diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7faec3c..fed61f43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,18 @@ jobs: ctest --test-dir .codex-build/map-tile-pipeline --output-on-failure python3 tests/map_tile_pipeline/test_ui_contract.py + - name: Verify Agenda, saved waypoints and SD session isolation + run: | + cmake -S modules/core_agenda -B .codex-build/agenda-core -DCMAKE_BUILD_TYPE=Debug + cmake --build .codex-build/agenda-core --parallel 2 + ctest --test-dir .codex-build/agenda-core --output-on-failure + cmake -S modules/core_waypoint -B .codex-build/waypoint -DCMAKE_BUILD_TYPE=Debug + cmake --build .codex-build/waypoint --parallel 2 + ctest --test-dir .codex-build/waypoint --output-on-failure + cmake -S tests/sd_record_io -B .codex-build/sd-record-io -DCMAKE_BUILD_TYPE=Debug + cmake --build .codex-build/sd-record-io --parallel 2 + ctest --test-dir .codex-build/sd-record-io --output-on-failure + - name: Verify Settings navigation contract run: python3 tests/settings_navigation/test_contract.py @@ -56,6 +68,9 @@ jobs: - name: Verify firmware version selection run: python3 tests/test_firmware_version.py + - name: Verify OTA partition size validation + run: python3 tests/test_ota_slot_size.py + - name: Verify Wi-Fi access policy run: python3 scripts/check_wifi_access_policy.py @@ -158,19 +173,13 @@ jobs: run: pio run -d ${{ matrix.project_dir }} -e ${{ matrix.env }} - name: Validate OTA slot size + if: matrix.env != 'gat562_mesh_evb_pro' && matrix.env != 't-echo-lite' run: | BUILD_DIR="${{ matrix.build_root }}/${{ matrix.env }}" if [ -f "${BUILD_DIR}/firmware.bin" ]; then - SIZE=$(stat -c%s "${BUILD_DIR}/firmware.bin") - MAX_SIZE=$((0x400000)) - if [ "${{ matrix.env }}" = "wio_tracker_l2" ]; then - MAX_SIZE=$((0x600000)) - fi - echo "firmware.bin size=${SIZE} max=${MAX_SIZE}" - if [ "${SIZE}" -gt "${MAX_SIZE}" ]; then - echo "firmware.bin exceeds OTA slot size" >&2 - exit 1 - fi + python scripts/check_ota_slot_size.py \ + --firmware "${BUILD_DIR}/firmware.bin" \ + --partitions "${BUILD_DIR}/partitions.bin" fi - name: Verify compact touch editor with LVGL @@ -182,6 +191,15 @@ jobs: cmake --build .codex-build/wio-touch --parallel 2 ctest --test-dir .codex-build/wio-touch --output-on-failure + - name: Verify Agenda layouts, reminders and input with LVGL + if: matrix.env == 'wio_tracker_l2' + run: | + cmake -S modules/ui_shared/tests/agenda -B .codex-build/agenda-ui \ + -DCMAKE_BUILD_TYPE=Debug \ + -DLVGL_SOURCE_DIR="$PWD/.pio/libdeps/wio_tracker_l2/lvgl" + cmake --build .codex-build/agenda-ui --parallel 2 + ctest --test-dir .codex-build/agenda-ui --output-on-failure + - name: Verify map gestures and settings navigation with LVGL if: matrix.env == 'wio_tracker_l2' run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e7aa947..c1d7696e 100644 Binary files a/CHANGELOG.md and b/CHANGELOG.md differ diff --git a/apps/esp32_lvgl/library.json b/apps/esp32_lvgl/library.json index bd128484..9f256ee3 100644 --- a/apps/esp32_lvgl/library.json +++ b/apps/esp32_lvgl/library.json @@ -9,6 +9,7 @@ "srcDir": "src", "srcFilter": [ "-<*>", + "+", "+", "+", "+", @@ -19,6 +20,7 @@ "flags": [ "-I../../modules/ui_shared/include", "-I../../modules/core_device/include", + "-I../../modules/core_agenda/include", "-I../../modules/core_mesh/include", "-I../../modules/core_phone/include", "-I../../modules/core_sys/include", @@ -38,6 +40,10 @@ ] }, "dependencies": [ + { "name": "core_waypoint" }, + { + "name": "core_agenda" + }, { "name": "product_composition" }, diff --git a/apps/esp32_lvgl/src/esp32_lvgl_arduino_agenda.cpp b/apps/esp32_lvgl/src/esp32_lvgl_arduino_agenda.cpp new file mode 100644 index 00000000..c9886720 --- /dev/null +++ b/apps/esp32_lvgl/src/esp32_lvgl_arduino_agenda.cpp @@ -0,0 +1,250 @@ +#include "esp32_lvgl_arduino_agenda.h" + +#if defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) +#include "esp32_lvgl_runtime_config.h" +#include "platform/esp/arduino_common/agenda_clock.h" +#include "platform/esp/arduino_common/storage/agenda_storage.h" +#include "platform/esp/arduino_common/storage/sd_record_file_io.h" +#include "platform/esp/arduino_common/storage/waypoint_storage.h" +#include "platform/esp/common/storage/agenda_file_store.h" +#include "platform/esp/common/storage/waypoint_file_store.h" +#include "platform/ui/device_runtime.h" +#include "platform/ui/screen_runtime.h" +#include "product_composition/agenda_composition.h" +#include "product_composition/agenda_target.h" +#include "ui/app_runtime.h" +#include "ui/callback_app_screen.h" +#include "ui/presentation_sources/runtime_gps_status_source.h" +#include "ui/screens/agenda/agenda_page_flow.h" +#include "ui/screens/agenda/agenda_page_runtime.h" +#include "ui/screens/agenda/agenda_page_shell.h" +#include "ui/screens/agenda/agenda_reminder_popup.h" +#include "ui_lvgl_ux_packs/packs/manifest_compatibility_ux_pack.h" +#include "ui_presentation/waypoint/waypoint_model.h" +#include +#if defined(ESP_PLATFORM) +#include "esp_heap_caps.h" +#else +#include +#endif + +namespace ui::assets +{ +// Keep the C asset's existing name without colliding with namespace agenda. +extern "C" const lv_image_dsc_t agenda; +} // namespace ui::assets + +namespace trailmate::apps::esp32_lvgl::arduino_agenda +{ +namespace +{ +namespace storage = platform::esp::arduino_common::storage; + +struct WaypointSession +{ + storage::SdRecordFileIo files; + platform::esp::storage::WaypointFileStore store{storage::kWaypointFile, storage::kWaypointInitializationFile, files}; + ui::waypoint::Model model{store}; + explicit WaypointSession(bool ready) + { + files.bindSession(storage::agenda_storage_session()); + model.setReady(ready && store.begin() == ::waypoint::Result::Ok); + } +}; +static_assert(sizeof(WaypointSession) < 256, "Waypoint session storage must remain bounded"); + +struct Root +{ + storage::SdRecordFileIo files; + platform::esp::storage::AgendaFileStore store{storage::kAgendaFile, storage::kAgendaInitializationFile, files}; + platform::esp::arduino_common::AgendaClock clock; + product_composition::AgendaComposition composition{store, clock}; + ui::agenda::GpsAgendaLocationSource locations{ui::presentation_sources::runtime_gps_status_source()}; + ui::agenda::page::Host page; + ui::agenda::page::Flow flow{page}; + ui::map::MapMarkerBinding markers{}; + WaypointSession* waypoint_session = nullptr; + ui::CallbackAppScreen app{"calendar", "Agenda", &ui::assets::agenda, + enter, exit, this}; + bool initialized = false; + bool storage_ready = false; + uint32_t storage_session = 0; + uint32_t sounded_reminder_revision = 0; + uint64_t last_storage_attempt = 0; + + void updateStorage(bool first = false) + { + const bool available = storage::agenda_storage_available(); + const auto session = storage::agenda_storage_session(); + const bool changed = session != storage_session; + storage_session = session; + if (!available || changed) + { + if (storage_ready) + { + storage_ready = false; + composition.setStorageReady(false); + if (waypoint_session) waypoint_session->model.setReady(false); + ui::agenda::reminder_popup::close(); + flow.invalidateStorage(); + } + if (!available) return; + } + if (storage_ready) return; + const auto now = clock.sample().monotonic_seconds; + if (!first && !changed && now == last_storage_attempt) return; + last_storage_attempt = now; + files.bindSession(storage_session); + storage_ready = storage::prepare_agenda_storage() && store.begin() == ::agenda::StoreResult::Ok; + composition.setStorageReady(storage_ready); + if (storage_ready) + { + if (waypoint_session) + { + waypoint_session->files.bindSession(storage_session); + waypoint_session->model.setReady(waypoint_session->store.begin() == ::waypoint::Result::Ok); + } + ui::agenda::page::runtime::refresh(); + } + } + + static void enter(void* context, lv_obj_t* parent) + { + auto& self = *static_cast(context); + self.updateStorage(); + if (!self.waypoint_session) self.waypoint_session = new (std::nothrow) WaypointSession(self.storage_ready); + if (self.waypoint_session) + { + self.page.waypoints = &self.waypoint_session->model; + self.page.waypoint_actions = &self.waypoint_session->model; + } + ui::agenda::page::Flow::enter(&self.flow, parent); + } + void releaseWaypoints() + { + updateStorage(); + // An accepted operation outlives its widgets and completes exactly once. + if (waypoint_session) waypoint_session->model.pump(); + page.waypoints = nullptr; + page.waypoint_actions = nullptr; + delete waypoint_session; + waypoint_session = nullptr; + } + static void exit(void* context, lv_obj_t* parent) + { + auto& self = *static_cast(context); + ui::agenda::page::Flow::exit(&self.flow, parent); + if (!ui_is_interruption_app_active()) self.releaseWaypoints(); + } + + Root() + { + // Bind the existing presentation graph without retaining another graph + // in idle RAM. The contracts and their owner outlive the page. + ui::workspace::PresentationWorkspace workspace; + composition.bind(workspace); + markers.source = workspace.agenda; + markers.allocate = [](std::size_t bytes) -> void* + { +#if defined(ESP_PLATFORM) + return heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +#else + return std::malloc(bytes); +#endif + }; + markers.release = [](void* memory) + { +#if defined(ESP_PLATFORM) + heap_caps_free(memory); +#else + std::free(memory); +#endif + }; + page.source = workspace.agenda; + page.actions = workspace.agenda; + page.locations = &locations; + page.navigation.request_exit = [](void*) + { ui_request_exit_to_menu(); }; + } +}; + +Root root; +// Charge both new registry objects and their ESP C++ local-static guards as +// well as the renderer's idle State*. LVGL objects exist only while open. +constexpr std::size_t kRegistrationBytes = 2 * (sizeof(ui_lvgl_ux::ManifestCompatibilityUxPack) + sizeof(uint64_t)); +// Selection extends the existing shared Map model. Charge its bounded result +// and a full alignment unit for the previous-tool field instead of hiding the +// feature's cost outside the Agenda root. +constexpr std::size_t kMapSelectionBytes = sizeof(ui::map::MapLocationSelection) + alignof(ui::map::MapLocationSelection) + 4 * sizeof(void*); +static_assert(sizeof(Root) + 2 * sizeof(void*) + kRegistrationBytes + kMapSelectionBytes < 1024, + "Agenda root and shared selection state exceeded the idle RAM budget"); +} // namespace + +void initialize() +{ + if (root.initialized || !product_composition::targetHasAgenda(esp32LvglRuntimeTargetProfile())) return; + root.updateStorage(true); + // Keep the page available to report storage failure, never silently format. + root.initialized = true; +} + +AppScreen* application() +{ + return root.initialized ? &root.app : nullptr; +} + +const ui::map::MapMarkerBinding* mapMarkers() +{ + return root.initialized ? &root.markers : nullptr; +} + +void tick() +{ + if (!root.initialized) return; + root.updateStorage(); + if (root.waypoint_session) root.waypoint_session->model.pump(); + namespace popup = ui::agenda::reminder_popup; + if (!ui_is_interruption_app_active() && !ui_is_transition_pending() && ui_get_active_app() != &root.app) + { + root.flow.discardSuspended(); + root.releaseWaypoints(); + } + if (root.flow.needsActivation() && main_screen && !popup::visible() && + !ui_is_overlay_active() && !ui_is_transition_pending() && !ui_is_interruption_app_active()) + ui_switch_to_app(&root.app, main_screen); + root.flow.tick(); + const bool may_present = !root.flow.mapActive() && popup::canPresent() && ui::agenda::page::canPresentReminder(); + // Do not replace the command result until the editor has observed its save. + // A visible popup also finishes/withdraws before another reminder is sent. + root.composition.tick(may_present && !popup::visible()); + ui::workspace::PresentationWorkspace workspace; + root.composition.bind(workspace); + popup::Host host{workspace.agenda, workspace.agenda, workspace.agenda_reminders, + platform::ui::screen::wake_for_modal}; + host.navigation_context = &root.flow; + host.prepare_navigation = [](void* context, int32_t latitude, int32_t longitude) -> const char* + { + if (!main_screen) return "Map is unavailable on this target."; + return static_cast(context)->prepareDestination(latitude, longitude); + }; + host.finish_navigation = [](void* context, bool accepted) + { static_cast(context)->finishDestination(accepted); }; + popup::tick(host, may_present); + const auto reminder = workspace.agenda_reminders->reminderSnapshot(); + if (popup::visible() && reminder.reminder.valid && + reminder.revision != root.sounded_reminder_revision) + { + root.sounded_reminder_revision = reminder.revision; + platform::ui::device::play_message_tone(); + } +} +} // namespace trailmate::apps::esp32_lvgl::arduino_agenda +#else +namespace trailmate::apps::esp32_lvgl::arduino_agenda +{ +void initialize() {} +AppScreen* application() { return nullptr; } +const ui::map::MapMarkerBinding* mapMarkers() { return nullptr; } +void tick() {} +} // namespace trailmate::apps::esp32_lvgl::arduino_agenda +#endif diff --git a/apps/esp32_lvgl/src/esp32_lvgl_arduino_agenda.h b/apps/esp32_lvgl/src/esp32_lvgl_arduino_agenda.h new file mode 100644 index 00000000..5f43bd01 --- /dev/null +++ b/apps/esp32_lvgl/src/esp32_lvgl_arduino_agenda.h @@ -0,0 +1,17 @@ +#pragma once + +class AppScreen; +namespace ui::map +{ +struct MapMarkerBinding; +} + +namespace trailmate::apps::esp32_lvgl::arduino_agenda +{ +// Target-owned composition, initialized before building the shell catalog. +// No domain services are exposed to the shell or placed in AppContext. +void initialize(); +AppScreen* application(); +const ui::map::MapMarkerBinding* mapMarkers(); +void tick(); +} // namespace trailmate::apps::esp32_lvgl::arduino_agenda diff --git a/apps/esp32_lvgl/src/esp32_lvgl_arduino_app_registry.cpp b/apps/esp32_lvgl/src/esp32_lvgl_arduino_app_registry.cpp index f9331171..83b56906 100644 --- a/apps/esp32_lvgl/src/esp32_lvgl_arduino_app_registry.cpp +++ b/apps/esp32_lvgl/src/esp32_lvgl_arduino_app_registry.cpp @@ -1,3 +1,4 @@ +#include "esp32_lvgl_arduino_agenda.h" #include "ui/app_registry.h" #include "app/app_config.h" @@ -22,6 +23,8 @@ namespace ui::app_catalog_builder::FeatureFlags buildFeatureFlags() { ui::app_catalog_builder::FeatureFlags flags{}; + flags.calendar_app = trailmate::apps::esp32_lvgl::arduino_agenda::application(); + flags.map_markers = trailmate::apps::esp32_lvgl::arduino_agenda::mapMarkers(); flags.profile = ui::app_catalog_builder::CatalogProfile::PioDefault; flags.include_gps_map = platform::ui::device::gps_supported(); flags.include_gnss_skyplot = platform::ui::device::gps_supported(); diff --git a/apps/esp32_lvgl/src/esp32_lvgl_arduino_loop_runtime.cpp b/apps/esp32_lvgl/src/esp32_lvgl_arduino_loop_runtime.cpp index 164719e8..475d628c 100644 --- a/apps/esp32_lvgl/src/esp32_lvgl_arduino_loop_runtime.cpp +++ b/apps/esp32_lvgl/src/esp32_lvgl_arduino_loop_runtime.cpp @@ -3,6 +3,7 @@ #include #include "board/BoardBase.h" +#include "esp32_lvgl_arduino_agenda.h" #include "esp32_lvgl_arduino_app_runtime_access.h" #include "platform/esp/arduino_common/display_runtime.h" #include "platform/esp/boards/board_runtime.h" @@ -78,6 +79,8 @@ void tick() hooks.idle_sleep_ms = 2; ui::widgets::reticulum_call_overlay::tick(); ui::loop_shell::tick(hooks); + // Also runs while an overlay is active; no Agenda page is required. + trailmate::apps::esp32_lvgl::arduino_agenda::tick(); } } // namespace trailmate::apps::esp32_lvgl::arduino_loop_runtime diff --git a/apps/esp32_lvgl/src/esp32_lvgl_arduino_startup_runtime.cpp b/apps/esp32_lvgl/src/esp32_lvgl_arduino_startup_runtime.cpp index e0497fa6..6090eefa 100644 --- a/apps/esp32_lvgl/src/esp32_lvgl_arduino_startup_runtime.cpp +++ b/apps/esp32_lvgl/src/esp32_lvgl_arduino_startup_runtime.cpp @@ -5,6 +5,7 @@ #include "app/app_facade_access.h" #include "board/BoardBase.h" #include "display/DisplayConfig.h" +#include "esp32_lvgl_arduino_agenda.h" #include "esp32_lvgl_arduino_app_runtime_access.h" #include "esp32_lvgl_runtime_config.h" #include "platform/esp/arduino_common/debug/sd_debug_log.h" @@ -202,6 +203,7 @@ void run() runtime_status.background_tasks_started ? 1 : 0); } + trailmate::apps::esp32_lvgl::arduino_agenda::initialize(); ui::startup_shell::setBootLogLine("Building main menu..."); if (!initializeShell()) return; ui::startup_shell::setBootLogLine("Startup complete"); diff --git a/apps/esp32_lvgl/src/esp32_lvgl_runtime_config.cpp b/apps/esp32_lvgl/src/esp32_lvgl_runtime_config.cpp index e7299c14..29c3eafb 100644 --- a/apps/esp32_lvgl/src/esp32_lvgl_runtime_config.cpp +++ b/apps/esp32_lvgl/src/esp32_lvgl_runtime_config.cpp @@ -22,6 +22,26 @@ const Esp32LvglRuntimeConfig& esp32LvglRuntimeConfig() 12288, 5, }; +#elif defined(ARDUINO_T_LORA_PAGER) + static const Esp32LvglRuntimeConfig kConfig = { + "tlora_pager", + "trail-mate-tlora-pager", + "T-LoRa Pager", + "pager_app_loop", + 10, + 12288, + 5, + }; +#elif defined(ARDUINO_T_DECK) + static const Esp32LvglRuntimeConfig kConfig = { + "tdeck", + "trail-mate-tdeck", + "T-Deck", + "tdeck_app_loop", + 10, + 12288, + 5, + }; #elif defined(TRAIL_MATE_ESP_BOARD_TAB5) static const Esp32LvglRuntimeConfig kConfig = { "tab5", diff --git a/apps/esp32_lvgl/tests/agenda/stubs/platform/esp/arduino_common/storage/agenda_storage.h b/apps/esp32_lvgl/tests/agenda/stubs/platform/esp/arduino_common/storage/agenda_storage.h new file mode 100644 index 00000000..d3be470a --- /dev/null +++ b/apps/esp32_lvgl/tests/agenda/stubs/platform/esp/arduino_common/storage/agenda_storage.h @@ -0,0 +1,12 @@ +#pragma once +#include + +// Only replace the mounted path, not the production file-store implementation. +namespace platform::esp::arduino_common::storage +{ +bool prepare_agenda_storage(); +bool agenda_storage_available(); +uint32_t agenda_storage_session(); +constexpr const char* kAgendaFile = "agenda-root-test.dat"; +constexpr const char* kAgendaInitializationFile = "agenda-root-test.init"; +} // namespace platform::esp::arduino_common::storage diff --git a/apps/esp32_lvgl/tests/agenda/stubs/platform/esp/arduino_common/storage/sd_record_file_io.h b/apps/esp32_lvgl/tests/agenda/stubs/platform/esp/arduino_common/storage/sd_record_file_io.h new file mode 100644 index 00000000..70322e34 --- /dev/null +++ b/apps/esp32_lvgl/tests/agenda/stubs/platform/esp/arduino_common/storage/sd_record_file_io.h @@ -0,0 +1,22 @@ +#pragma once +#include "platform/esp/common/storage/stdio_record_file_io.h" +namespace platform::esp::arduino_common::storage +{ +// Native root tests retain the production record stores and replace only I/O. +class SdRecordFileIo final : public platform::esp::storage::RecordFileIo +{ + public: + void bindSession(uint32_t) {} + OpenResult open(const char* path, Mode mode) override { return files_.open(path, mode); } + bool close(Handle file) override { return files_.close(file); } + bool size(Handle file, uint64_t& bytes) override { return files_.size(file, bytes); } + bool seek(Handle file, uint64_t offset) override { return files_.seek(file, offset); } + std::size_t read(Handle file, void* buffer, std::size_t bytes) override { return files_.read(file, buffer, bytes); } + std::size_t write(Handle file, const void* buffer, std::size_t bytes) override { return files_.write(file, buffer, bytes); } + bool sync(Handle file) override { return files_.sync(file); } + bool publish(const char* temporary, const char* destination) override { return files_.publish(temporary, destination); } + + private: + platform::esp::storage::StdioRecordFileIo files_; +}; +} // namespace platform::esp::arduino_common::storage diff --git a/apps/esp32_lvgl/tests/agenda/stubs/platform/esp/arduino_common/storage/waypoint_storage.h b/apps/esp32_lvgl/tests/agenda/stubs/platform/esp/arduino_common/storage/waypoint_storage.h new file mode 100644 index 00000000..831f1ed4 --- /dev/null +++ b/apps/esp32_lvgl/tests/agenda/stubs/platform/esp/arduino_common/storage/waypoint_storage.h @@ -0,0 +1,6 @@ +#pragma once +namespace platform::esp::arduino_common::storage +{ +constexpr const char* kWaypointFile = "agenda-root-waypoints.dat"; +constexpr const char* kWaypointInitializationFile = "agenda-root-waypoints.init"; +} // namespace platform::esp::arduino_common::storage diff --git a/apps/esp32_lvgl/tests/agenda/test_target_root.cpp b/apps/esp32_lvgl/tests/agenda/test_target_root.cpp new file mode 100644 index 00000000..c8b22b71 --- /dev/null +++ b/apps/esp32_lvgl/tests/agenda/test_target_root.cpp @@ -0,0 +1,478 @@ +#include "esp32_lvgl_arduino_agenda.h" +#include "esp32_lvgl_runtime_config.h" +#include "platform/esp/arduino_common/agenda_clock.h" +#include "platform/esp/arduino_common/storage/agenda_storage.h" +#include "platform/esp/arduino_common/storage/waypoint_storage.h" +#include "platform/esp/common/storage/agenda_file_store.h" +#include "popup_test_support.h" +#include "product_composition/agenda_target.h" +#include "ui/app_runtime.h" +#include "ui/app_screen.h" +#include "ui/presentation_sources/runtime_gps_status_source.h" +#include "ui/screens/agenda/agenda_page_runtime.h" +#include "ui/screens/agenda/agenda_reminder_popup.h" +#include "ui_presentation/waypoint/waypoint_model.h" + +#include +#include +#include + +void cancelTestMap(); +void returnFromTestMapTarget(); +void assertTestMapTarget(double latitude, double longitude); + +lv_obj_t* main_screen = nullptr; + +namespace +{ +bool menu_exit_requested = false; +unsigned reminder_tones = 0; +bool storage_available = false; +uint32_t storage_session = 1; +AppScreen* active_app = nullptr; +::agenda::ClockSample target_time{1776330000, 0, 0, true}; +lv_obj_t* popup_button(lv_obj_t* root, const char* text) +{ + if (lv_obj_check_type(root, &lv_label_class) && std::strcmp(lv_label_get_text(root), text) == 0) + { + auto* parent = lv_obj_get_parent(root); + if (lv_obj_check_type(parent, &lv_button_class)) return parent; + } + for (uint32_t i = 0; i < lv_obj_get_child_count(root); ++i) + if (auto* found = popup_button(lv_obj_get_child(root, i), text)) return found; + return nullptr; +} +} // namespace +void ui_request_exit_to_menu() { menu_exit_requested = true; } +namespace platform::ui::device +{ +void play_message_tone() { ++reminder_tones; } +} // namespace platform::ui::device +// Shell boundary substitute: keep exit-before-enter ordering explicit. The +// firmware still calls the real app_runtime implementation, not this helper. +AppScreen* ui_get_active_app() { return active_app; } +void ui_switch_to_app(AppScreen* app, lv_obj_t* parent) +{ + assert(!ui::agenda::reminder_popup::visible()); + assert(!agenda_test_ui::interruption && !agenda_test_ui::transition); + if (active_app && active_app != app) active_app->exit(parent); + if (app) app->enter(parent); + active_app = app; +} +namespace ui::presentation_sources +{ +bool RuntimeGpsStatusSource::buildGpsStatusSnapshot(ui::gps::GpsStatusSnapshot&) const { return false; } +RuntimeGpsStatusSource& runtime_gps_status_source() +{ + static RuntimeGpsStatusSource source; + return source; +} +} // namespace ui::presentation_sources +namespace platform::esp::arduino_common +{ +::agenda::ClockSample AgendaClock::sample() const { return target_time; } +namespace storage +{ +bool agenda_storage_available() { return storage_available; } +uint32_t agenda_storage_session() { return storage_session; } +bool prepare_agenda_storage() { return storage_available; } +} // namespace storage +} // namespace platform::esp::arduino_common + +void testTargetRoot() +{ + namespace runtime = trailmate::apps::esp32_lvgl::arduino_agenda; + namespace page = ui::agenda::page; + namespace storage = platform::esp::arduino_common::storage; + assert(!runtime::application()); + runtime::tick(); // Safe before startup, including an aborted boot. + assert(product_composition::targetHasAgenda(trailmate::apps::esp32_lvgl::esp32LvglRuntimeTargetProfile())); + std::remove(storage::kAgendaFile); + std::remove(storage::kAgendaInitializationFile); + std::remove(storage::kWaypointFile); + std::remove(storage::kWaypointInitializationFile); + runtime::initialize(); + auto* app = runtime::application(); + assert(app && std::strcmp(app->stable_id(), "calendar") == 0); + assert(std::strcmp(app->name(), "Agenda") == 0); + assert(app->icon() && app->icon()->header.w == 64 && app->icon()->header.h == 64); + runtime::initialize(); // Idempotent; does not replace services or storage. + assert(runtime::application() == app); + + auto* display = lv_display_create(320, 240); + auto* screen = lv_screen_active(); + main_screen = screen; + lv_obj_set_style_pad_all(screen, 0, 0); + lv_obj_set_style_border_width(screen, 0, 0); + app->enter(screen); + assert(page::state() && !page::state()->snapshot.storage_ready); + storage_available = true; + ++target_time.monotonic_seconds; + active_app = app; + runtime::tick(); + assert(page::state() && page::state()->snapshot.storage_ready); + assert(page::state()->host->waypoints && page::state()->host->waypoint_actions); + waypoint::Record waypoint_draft; + std::strcpy(waypoint_draft.name, "Session campsite"); + assert(page::state()->host->waypoint_actions->save(waypoint_draft).ok); + assert(page::state()->host->waypoints->commandResult().state == ui::waypoint::CommandState::Pending); + // Closing a page completes an accepted write before releasing its session. + app->exit(screen); + app->enter(screen); + waypoint::Page waypoint_page; + assert(page::state()->host->waypoints->page(0, 5, waypoint_page) == waypoint::Result::Ok); + assert(waypoint_page.count == 1 && !std::strcmp(waypoint_page.rows[0].name, "Session campsite")); + const auto waypoint_action = [](page::Action action) + { + page::runtime::request(action); + lv_tick_inc(60); + lv_timer_handler(); + }; + waypoint_action(page::Action::NewEvent); + waypoint_action(page::Action::PickLocation); + waypoint_action(page::Action::PickWaypoint); + assert(page::state()->view == page::View::WaypointPicker && page::state()->waypoint_page.count == 1); + waypoint_action(page::Action::SelectWaypoint); + assert(page::state()->view == page::View::Editor); + assert(page::state()->draft.event.location_type == agenda::LocationType::Waypoint); + assert(!std::strcmp(page::state()->draft.event.location_name, "Session campsite")); + assert(page::state()->host->source->commandResult().state == ui::agenda::CommandState::Idle); + waypoint_action(page::Action::PickLocation); + waypoint_action(page::Action::PickWaypoint); + waypoint_action(page::Action::NameWaypoint); + assert(page::state()->view == page::View::WaypointName); + lv_textarea_set_text(page::state()->editor_widgets.title, "Named through UI"); + agenda_test_ui::interruption = true; + app->exit(screen); + agenda_test_ui::interruption = false; + ui_switch_to_app(app, screen); + runtime::tick(); + assert(page::state()->view == page::View::WaypointName); + assert(!std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), "Named through UI")); + waypoint_action(page::Action::SaveWaypoint); + assert(page::state()->awaiting_command && page::state()->view == page::View::WaypointName); + agenda_test_ui::interruption = true; + app->exit(screen); + runtime::tick(); // Complete the submitted waypoint write during interruption. + agenda_test_ui::interruption = false; + ui_switch_to_app(app, screen); + runtime::tick(); + lv_tick_inc(60); + lv_timer_handler(); + assert(page::state()->view == page::View::Editor && !page::state()->awaiting_command); + assert(!std::strcmp(page::state()->draft.event.location_name, "Named through UI")); + assert(page::state()->host->source->commandResult().state == ui::agenda::CommandState::Idle); + active_app = nullptr; + waypoint_action(page::Action::Discard); + for (unsigned index = 0; index < 6; ++index) + { + std::snprintf(waypoint_draft.name, sizeof(waypoint_draft.name), "Extra %u", index); + assert(page::state()->host->waypoint_actions->save(waypoint_draft).ok); + app->exit(screen); + app->enter(screen); + } + waypoint_action(page::Action::NewEvent); + waypoint_action(page::Action::PickLocation); + waypoint_action(page::Action::PickWaypoint); + assert(page::state()->waypoint_page.has_more); + waypoint_action(page::Action::NextWaypoints); + const auto saved_cursor = page::state()->waypoint_page.after; + const auto saved_first = page::state()->waypoint_page.ids[0]; + assert(saved_cursor && saved_first > saved_cursor); + agenda_test_ui::interruption = true; + app->exit(screen); + assert(!page::state()); + agenda_test_ui::interruption = false; + ui_switch_to_app(app, screen); + runtime::tick(); + assert(page::state()->view == page::View::WaypointPicker); + assert(page::state()->waypoint_page.after == saved_cursor); + assert(page::state()->waypoint_page.ids[0] == saved_first); + waypoint_action(page::Action::FirstWaypoints); + assert(!page::state()->waypoint_page.after && page::state()->waypoint_page.ids[0] == 1); + waypoint_action(page::Action::Back); + assert(page::state()->view == page::View::LocationPicker); + waypoint_action(page::Action::Back); + waypoint_action(page::Action::Discard); + active_app = nullptr; + auto* source = page::state()->host->source; + auto* actions = page::state()->host->actions; + ui::agenda::AgendaEditorModel draft; + assert(source->newDraft(draft)); + std::strcpy(draft.event.title, "Persistent radio check"); + draft.event.start_time = target_time.calendar_seconds + 660; + draft.event.flags = agenda::HasReminder; + draft.event.reminder_offset_sec = 600; + assert(actions->save(draft.event).ok); + assert(source->commandResult().state == ui::agenda::CommandState::Pending); + app->exit(screen); + assert(!page::state()); + // The real target root, not a page timer, performs the durable command. + runtime::tick(); + assert(source->commandResult().state == ui::agenda::CommandState::Succeeded); + const auto id = source->commandResult().event_id; + { + platform::esp::storage::AgendaFileStore reopened(storage::kAgendaFile, storage::kAgendaInitializationFile); + assert(reopened.begin() == agenda::StoreResult::Ok); + agenda::EventRecord record; + assert(reopened.readSlot(0, record) == agenda::StoreResult::Ok); + assert(record.id == id && std::strcmp(record.title, draft.event.title) == 0); + } + app->enter(screen); + assert(page::state()->snapshot.page.count == 1); + page::state()->host->navigation.request_exit(nullptr); + assert(menu_exit_requested); + app->exit(screen); + namespace popup = ui::agenda::reminder_popup; + assert(!page::state() && !popup::visible()); + target_time.calendar_seconds += 60; + target_time.monotonic_seconds += 60; + agenda_test_ui::transition = true; + runtime::tick(); + assert(!popup::visible()); + assert(reminder_tones == 0); + agenda_test_ui::transition = false; + runtime::tick(); + assert(popup::visible() && !page::state()); + assert(reminder_tones == 1); + runtime::tick(); + assert(reminder_tones == 1); + popup::close(); + runtime::tick(); + assert(popup::visible() && reminder_tones == 1); + assert(!popup_button(lv_layer_top(), "Navigate")); + auto* snooze = popup_button(lv_layer_top(), "Snooze 10 min"); + assert(snooze); + lv_tick_inc(401); + lv_obj_send_event(snooze, LV_EVENT_CLICKED, nullptr); + runtime::tick(); + assert(source->commandResult().state == ui::agenda::CommandState::Pending); + runtime::tick(); + assert(!popup::visible()); + target_time.calendar_seconds += 599; + target_time.monotonic_seconds += 599; + runtime::tick(); + assert(!popup::visible()); + ++target_time.calendar_seconds; + ++target_time.monotonic_seconds; + runtime::tick(); + assert(popup::visible()); + assert(reminder_tones == 2); + lv_tick_inc(401); + auto* done = popup_button(lv_layer_top(), "Done"); + assert(done); + lv_obj_send_event(done, LV_EVENT_CLICKED, nullptr); + runtime::tick(); + runtime::tick(); + assert(!popup::visible() && !agenda_test_ui::overlay); + // A due reminder cannot take the shared command-result slot while the + // editor still needs to observe its completed save. + app->enter(screen); + page::runtime::request(page::Action::NewEvent); + lv_tick_inc(60); + lv_timer_handler(); + assert(page::state()->view == page::View::Editor); + lv_textarea_set_text(page::state()->editor_widgets.title, "Save before reminder"); + page::state()->draft.event.start_time = target_time.calendar_seconds + 601; + page::state()->draft.event.flags = agenda::HasReminder; + page::state()->draft.event.reminder_offset_sec = 600; + page::runtime::request(page::Action::Save); + runtime::tick(); + assert(!popup::visible()); // Queued page action has not run yet. + lv_tick_inc(60); + lv_timer_handler(); + assert(page::state()->awaiting_command); + runtime::tick(); + const auto saved_sequence = source->commandResult().sequence; + assert(source->commandResult().state == ui::agenda::CommandState::Succeeded); + ++target_time.calendar_seconds; + ++target_time.monotonic_seconds; + runtime::tick(); + assert(!popup::visible() && page::state()->awaiting_command); + assert(source->commandResult().sequence == saved_sequence); + lv_tick_inc(60); + lv_timer_handler(); + assert(!page::state()->awaiting_command && page::state()->view == page::View::Agenda); + runtime::tick(); + assert(popup::visible()); + lv_tick_inc(401); + lv_obj_send_event(popup_button(lv_layer_top(), "Done"), LV_EVENT_CLICKED, nullptr); + runtime::tick(); + runtime::tick(); + assert(!popup::visible() && lv_group_get_default() == page::state()->group); + // A reminder becoming due during a temporary Map visit remains scheduled, + // but cannot steal the Map's input group or the editor return context. + assert(source->newDraft(draft)); + std::strcpy(draft.event.title, "Reminder during map"); + draft.event.start_time = target_time.calendar_seconds + 60; + draft.event.flags = agenda::HasReminder; + assert(actions->save(draft.event).ok); + runtime::tick(); + page::runtime::request(page::Action::NewEvent); + lv_tick_inc(60); + lv_timer_handler(); + lv_textarea_set_text(page::state()->editor_widgets.title, "Uncommitted map draft"); + page::runtime::request(page::Action::PickLocation); + lv_tick_inc(60); + lv_timer_handler(); + page::runtime::request(page::Action::ChooseOnMap); + lv_tick_inc(60); + lv_timer_handler(); + runtime::tick(); + assert(!page::state() && !popup::visible()); + target_time.calendar_seconds += 60; + target_time.monotonic_seconds += 60; + runtime::tick(); + assert(!page::state() && !popup::visible()); + cancelTestMap(); + runtime::tick(); + assert(page::state() && page::state()->view == page::View::Editor && popup::visible()); + assert(std::strcmp(page::state()->draft.event.title, "Uncommitted map draft") == 0); + lv_tick_inc(401); + lv_obj_send_event(popup_button(lv_layer_top(), "Done"), LV_EVENT_CLICKED, nullptr); + runtime::tick(); + runtime::tick(); + assert(!popup::visible() && lv_group_get_default() == page::state()->group); + app->exit(screen); + + // A located reminder over another application must leave its tree alive + // until acceptance, then exit that app through the shell before opening Map. + struct OtherApp final : AppScreen + { + lv_obj_t* tree = nullptr; + unsigned exits = 0; + const char* stable_id() const override { return "test-other"; } + const char* name() const override { return "Other"; } + const lv_image_dsc_t* icon() const override { return nullptr; } + void enter(lv_obj_t* parent) override { tree = lv_obj_create(parent); } + void exit(lv_obj_t*) override + { + assert(!popup::visible()); + lv_obj_delete(tree); + tree = nullptr; + ++exits; + } + } other; + ui_switch_to_app(&other, screen); + assert(source->newDraft(draft)); + std::strcpy(draft.event.title, "Navigate from another app"); + draft.event.start_time = target_time.calendar_seconds + 1; + draft.event.flags = agenda::HasReminder | agenda::HasLocation; + draft.event.location_type = agenda::LocationType::Coordinate; + draft.event.latitude_e7 = 248731000; + draft.event.longitude_e7 = 1180000000; + assert(actions->save(draft.event).ok); + runtime::tick(); + ++target_time.calendar_seconds; + ++target_time.monotonic_seconds; + runtime::tick(); + assert(popup::visible() && popup_button(lv_layer_top(), "Navigate")); + lv_tick_inc(401); + lv_obj_send_event(popup_button(lv_layer_top(), "Navigate"), LV_EVENT_CLICKED, nullptr); + runtime::tick(); + assert(other.tree && other.exits == 0 && popup::visible()); + runtime::tick(); + assert(!popup::visible() && other.tree && other.exits == 0); + runtime::tick(); + assert(other.exits == 1 && !other.tree && ui_get_active_app() == app); + assert(!page::state()); + assertTestMapTarget(24.8731, 118.0); + returnFromTestMapTarget(); + runtime::tick(); + assert(page::state() && page::state()->view == page::View::Agenda); + assert(page::state()->request.day_start == page::state()->snapshot.today_start); + + // Navigation from a reminder must not use that reminder's event as the + // editor draft, or discard text that has not reached the draft yet. + draft.event.id = 0; + draft.event.start_time = target_time.calendar_seconds + 1; + assert(actions->save(draft.event).ok); + runtime::tick(); + page::runtime::request(page::Action::NewEvent); + lv_tick_inc(60); + lv_timer_handler(); + const char* oversized = u8"漢漢漢漢漢漢漢漢漢漢漢漢漢漢"; + lv_textarea_set_text(page::state()->editor_widgets.title, oversized); + ++target_time.calendar_seconds; + ++target_time.monotonic_seconds; + runtime::tick(); + lv_tick_inc(401); + lv_obj_send_event(popup_button(lv_layer_top(), "Navigate"), LV_EVENT_CLICKED, nullptr); + runtime::tick(); + assert(popup::visible() && page::state()); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), oversized) == 0); + popup::close(); // Withdraw presentation; the unconsumed reminder remains. + lv_textarea_set_text(page::state()->editor_widgets.title, "Keep my unfinished plan"); + lv_textarea_set_text(page::state()->editor_widgets.note, "Not the reminder event"); + runtime::tick(); + assert(popup::visible()); + lv_tick_inc(401); + lv_obj_send_event(popup_button(lv_layer_top(), "Navigate"), LV_EVENT_CLICKED, nullptr); + runtime::tick(); + runtime::tick(); + runtime::tick(); + assert(!page::state() && !popup::visible()); + assertTestMapTarget(24.8731, 118.0); + returnFromTestMapTarget(); + runtime::tick(); + assert(page::state()->view == page::View::Editor && page::state()->draft.dirty); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), "Keep my unfinished plan") == 0); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.note), "Not the reminder event") == 0); + assert(page::state()->draft.event.id == 0 && !(page::state()->draft.event.flags & agenda::HasLocation)); + // If the shell resumes Calendar, keep the recovery context until re-entry. + agenda_test_ui::interruption = true; + app->exit(screen); + active_app = nullptr; + runtime::tick(); + assert(!page::state()); + agenda_test_ui::interruption = false; + ui_switch_to_app(app, screen); + runtime::tick(); + assert(page::state()->view == page::View::Editor); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), "Keep my unfinished plan") == 0); + + // An interruption may supersede an already-pending exit-to-menu. In that + // case the shell does not resume Calendar: release recovery storage, and + // do not resurrect the discarded session on a later explicit app opening. + agenda_test_ui::interruption = true; + app->exit(screen); + other.enter(screen); + active_app = &other; + runtime::tick(); + agenda_test_ui::interruption = false; + runtime::tick(); + assert(!page::state() && active_app == &other); + ui_switch_to_app(app, screen); + runtime::tick(); + assert(page::state()->view == page::View::Agenda); + app->exit(screen); + active_app = nullptr; + // Loss must cancel commands before the composition pump. Recovery must not + // replay them, even if the same logical store becomes available again. + assert(source->newDraft(draft)); + std::strcpy(draft.event.title, "Cancelled by media loss"); + assert(actions->save(draft.event).ok); + storage_available = false; + runtime::tick(); + assert(source->commandResult().state == ui::agenda::CommandState::Failed); + assert(!source->newDraft(draft)); + storage_available = true; + ++target_time.monotonic_seconds; + runtime::tick(); + assert(source->commandResult().state == ui::agenda::CommandState::Failed); + assert(source->newDraft(draft)); + std::strcpy(draft.event.title, "Do not replay after rapid remount"); + assert(actions->save(draft.event).ok); + ++storage_session; // Available remained true across the observed ticks. + runtime::tick(); + assert(source->commandResult().state == ui::agenda::CommandState::Failed); + assert(source->newDraft(draft)); + storage_available = false; + runtime::tick(); + assert(!popup::visible()); + main_screen = nullptr; + lv_display_delete(display); + assert(std::remove(storage::kAgendaFile) == 0); + assert(std::remove(storage::kWaypointFile) == 0); + std::puts("Agenda target root: durable command and page-closed reminder/snooze/done passed"); +} diff --git a/apps/esp32_lvgl/tests/agenda_disabled_target_smoke.cpp b/apps/esp32_lvgl/tests/agenda_disabled_target_smoke.cpp new file mode 100644 index 00000000..c931508f --- /dev/null +++ b/apps/esp32_lvgl/tests/agenda_disabled_target_smoke.cpp @@ -0,0 +1,13 @@ +#include "esp32_lvgl_arduino_agenda.h" +#include + +int main() +{ + // Unsupported targets link the inert adapter, without LVGL, storage, + // the clock adapter or the Agenda composition as dependencies. + namespace agenda = trailmate::apps::esp32_lvgl::arduino_agenda; + assert(!agenda::application()); + agenda::initialize(); + agenda::tick(); + assert(!agenda::application()); +} diff --git a/apps/esp32_lvgl/tests/agenda_runtime_config_smoke.cpp b/apps/esp32_lvgl/tests/agenda_runtime_config_smoke.cpp new file mode 100644 index 00000000..3ce2e3d4 --- /dev/null +++ b/apps/esp32_lvgl/tests/agenda_runtime_config_smoke.cpp @@ -0,0 +1,25 @@ +#include "esp32_lvgl_runtime_config.h" +#include "product_composition/agenda_target.h" +#include "product_composition/target_ux_binding.h" +#include "ui_lvgl_ux_packs/ux/ux_pack_registry.h" + +#include +#include + +int main() +{ + using namespace trailmate::apps::esp32_lvgl; + assert(std::strcmp(esp32LvglRuntimeConfig().target_id, EXPECT_TARGET_ID) == 0); + const auto* profile = esp32LvglRuntimeTargetProfile(); + assert(product_composition::targetHasAgenda(profile)); + const auto* binding = esp32LvglRuntimeUxBinding(); + assert(binding && std::strcmp(binding->target_id, profile->target_id) == 0); + const auto* pack = ui_lvgl_ux::findUxPackById(binding->active_ux_pack_id); + assert(pack); + ui_lvgl_ux::ScreenRegistry registry; + pack->buildScreens(registry); + bool found = false; + for (std::size_t i = 0; i < registry.size(); ++i) + found |= registry.items()[i].id == ui_lvgl_ux::ScreenId::Calendar; + assert(found && registry.size() <= registry.kMaxScreens); +} diff --git a/docs/development/agenda-implementation.md b/docs/development/agenda-implementation.md new file mode 100644 index 00000000..21594ad2 --- /dev/null +++ b/docs/development/agenda-implementation.md @@ -0,0 +1,919 @@ +# Agenda implementation notes + +## Scope and status + +The authoritative requirements are in +`docs/specs/Agenda_Calendar_Implementation_Specification.md`. +This is a technical implementation record, not an acceptance report. +The sections below are chronological checkpoints, not the current storage design. +Registration, the main UI, reminder logic and local waypoint save/select have +implementation and native-test coverage. Internal-flash storage is not a supported +Agenda backend. SD lifecycle integration, device validation and final acceptance remain +incomplete. Earlier successful builds do not establish SD-backed acceptance. + +### Current storage contract + +- Agenda events and saved local waypoints reside on the SD card only. +- No available application-owned SD card means no event/waypoint operations or + Agenda reminder delivery. Cached rows or scheduled reminders do not provide + an offline mode. +- Removal or ownership loss invalidates storage readiness and cached results; + incomplete writes must not be reported successful. Remount requires store + validation and rebuilding queries/reminders from that card. Do not silently + save a retained draft to a different card. +- Use the existing shared SD runtime. SPI and SDMMC retain its existing access + policies; no device-specific Agenda storage path is permitted. +- Internal FFat/LittleFS/NVS are not record-store fallbacks. Agenda must not + format internal flash or the SD card to recover an error. + +The current UI media-loss policy ends the active Agenda editing/navigation +session and discards its unsaved draft, pending UI actions and suspended return +context. An active Map tree is closed before its request storage is released. +An active Agenda returns to the unavailable list; a suspended Agenda is not +opened over another application. Revalidation starts a new session rather than +restoring old-card IDs. Native LVGL tests cover active Map, queued Save and +suspended-editor invalidation on both display geometries. This establishes the +response to a storage-loss signal, not physical card-removal detection. + +The SD record adapter binds to the media session validated by its owner. The +shared runtime checks the expected session under its filesystem lock before +opening a file or renaming a staged file. Closing and reopening files within a +store operation therefore cannot implicitly adopt a replacement card. The native +`tests/sd_record_io` fixture compiles the production adapter and injects replacement +at open and publication boundaries. Its simulated runtime verifies adapter token +propagation; it does not prove hardware detection or runtime lock behavior. + +Internal-flash references in historical checkpoints below describe superseded +work, not instructions to retain or repair that backend. No SD migration or +on-device success is claimed by this documentation update. + +## Map location annotations + +Event locations remain part of `/agenda/events.dat`; there is no exported POI +file or duplicate waypoint record. Selecting a saved waypoint copies its location +into the event, so subsequent waypoint edits do not silently move the event. + +The target supplies an optional `MapMarkerBinding` through the catalog and Map +route. `IMapMarkerSource` exposes status and a streamed read-only visitor; the +shared map renderer knows neither Agenda storage paths nor recurrence rules. +Other map previews do not opt into this binding. + +All saved active records with locations are candidates, including past events. +At most 32 annotations are retained, ordered by viewport visibility, distance +from its center, then stable event ID. Empty and deleted records are excluded. +Unended event titles are red, ended titles gray; an invalid clock is neutral gray. +Recurring records project their next or ongoing occurrence without storing copies. +The event detail preview starts at zoom 16. + +The viewport owns a compact cache and one aligned record scratch buffer. On ESP +the target allocator requires PSRAM and never falls back to internal SRAM. +The cache is released on viewport destruction or storage loss. Failed allocation +or reads clear/defer annotations without disabling the base map; retries are +throttled. A failed scan never publishes a partial result. + +A 64-pixel query margin avoids rescanning during small pans. Overflowed queries +are reselected after movement so off-screen entries cannot starve visible ones. +Revision, storage readiness, clock validity, time transitions, zoom, coordinate +system and viewport coverage invalidate the cache. Drag previews only translate +annotations. Draw callbacks never access storage. The existing overlay layer +draws markers and titles directly, without per-event LVGL objects; LVGL still +uses transient draw-task allocations under the platform's normal allocator. + +## Repository audit (2026-09-21) + +| Product | Target profile | Declared / resolved UX pack | Page manifest | Layout | +| --- | --- | --- | --- | --- | +| Pager, SX1262 / LR1121 builds | `tlora_pager` | `pager_compact` / `compatibility` | `pager_compact_manifest` | `pager_compact`, 480 x 222 | +| T-Deck | `tdeck` | `deck_full` / `compatibility` | `deck_full_manifest` | `deck_wide`, 320 x 240 | +| Wio Tracker L2 | `wio_tracker_l2` | `deck_touch` / `deck_touch` | `deck_full_manifest` | `deck_wide`, 320 x 240 | + +Sources: `modules/product_composition/src/target_profile.cpp` and +`target_ux_binding.cpp`. Input must use the corresponding UX bindings; Wio is +touch-only, whereas Deck has keyboard/trackball as well. Resolution is not an +input capability test. Watch also resolves to `compatibility`; adding Calendar +unconditionally to that pack would incorrectly widen target scope. + +Current manifest counts: Pager 6, Deck 9. Current resolved screen counts: +compatibility 10, deck_touch 9. Both screen and binding registries have capacity +16. Calendar does not justify expanding either array. The separate app catalog +also has capacity 16 and must be checked with all optional apps enabled. + +Registration traverses PageId, manifest, UX pack, ScreenId, MenuScreenId, +screen/menu adapter, binding factory and catalog. Inspecting or changing only +`app_catalog_builder.cpp` is insufficient. Non-target packs must remain unchanged. + +### Ownership and existing integration points + +- Embedded app composition is under `apps/esp32_lvgl/src`. Arduino startup and + loop runtimes are separate. Agenda services/adapters will be owned here, not + by the page or `AppServicesBundle`. +- `PresentationWorkspace` is the existing typed graph of externally owned + models. Agenda source/sink contracts belong in `ui_presentation`. +- Tracker's shell/runtime/components/input/state files establish the current + page split. Existing Tracker filesystem calls are not a precedent for new + Agenda UI I/O. +- Global interruption precedent: `reticulum_call_overlay::tick()` is driven by + the app loop independently of an open page. Agenda reminders need equivalent + lifetime and safe focus restoration, with no interruption of an existing + modal until it can be presented safely. +- Clock: `sys::epoch_seconds_now()` and `platform::ui::time` expose UTC and the + configured timezone. The platform time runtime considers epochs before 2020 + invalid. Calendar arithmetic must not depend on LVGL or Arduino. +- Storage must use the shared SD runtime, with logical event path + `/agenda/events.dat` relative to the card. The unrelated internal `ffat` + partition at `/fs` is not an Agenda backend. Reuse SD file/ownership APIs, + not the chat database or NVS configuration store. Do not repartition or format. +- `MapWorkspaceModel` already owns viewport/tool actions; `gps_page_shell` + accepts a `RouteSpec` with a host and projection. There is currently no + SelectLocation mode in `MapToolKind`. Add a semantic selection/return contract + to that workflow, not another map renderer. Preserve only the small editor + draft while the map tree exists. +- A bounded saved-waypoint query is not exposed by the inspected Map workspace + contract. Locate its authoritative saved-item source and add a bounded adapter + before implementing the picker; the Team snapshot's vectors are not a suitable + Agenda-owned database. +- Use the supplied `modules/ui_shared/src/ui/assets/agenda.c` for the catalog icon. + +### Build ownership + +Reviewed entrypoints: `apps/esp32_lvgl/CMakeLists.txt`, +`cmake/TrailMateUxPacks.cmake`, `cmake/TrailMateLinuxSources.cmake`, +`builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake`, and `scripts/platformio-pre.py`. +Portable tests should compile the same core sources. Build integration must be +explicit for each enabled product; Linux/GTK/ASCII/headless/watch are not newly +enabled products. + +### Analysis limitation + +GitNexus reported an index 53 commits behind HEAD. `npx gitnexus analyze` was +attempted and exited 1 after native parser worker timeouts, including the large +GPS runtime. Do not interpret absent graph results as proof of zero impact. +Run symbol impact queries before modifying existing symbols, supplemented by +current source/caller checks and regression tests. + +## Verification still required + +- Portable record validation, recurrence boundaries, bounded queries, scheduler + simultaneous due events/snooze/clock changes. +- Fixed-slot persistence/reload, corrupted records, interrupted writes, full + store, deleted-slot reuse; no whole-database cache. +- Full target registration/capacity tests and explicit excluded-target tests. +- Actual 480 x 222 and 320 x 240 LVGL render/input tests, localization, editor + cancel/save, reminder while page closed, and map pick/return lifecycle. +- Size assertions and target RAM/build evidence, then hardware validation. + +## Portable core checkpoint + +Implemented in `modules/core_agenda` (not yet wired into firmware): + +- Fixed 256-byte, little-endian, versioned/CRC-protected record codec; 64 slots. +- Single-record CRUD service; tombstones retain IDs and vacant slots are reused. +- Calendar arithmetic and daily/weekly/monthly/yearly occurrence calculation. + Nonexistent dates are skipped (January 31 does not become February 28; + February 29 recurs in the next leap year). Start/end values represent local + calendar seconds; timezone conversion belongs to the platform clock adapter. +- Bounded sorted query with `(start,event_id)` pagination and explicit overflow; + month occupancy uses a 32-bit mask. Corrupt rows are reported as degraded in + agenda queries; write operations fail closed on unreadable slots. +- Reminder scheduler with one next candidate and one explicit snooze. Ordinary + ticks perform no store scans. Same-time reminders are ordered by ID; closing + one does not consume the others. An occupied presentation sink defers delivery. + Startup/material clock corrections skip already-past triggers. A second + concurrent snooze is rejected, not silently substituted; the renderer must + surface this outcome. Snooze/dismiss state is runtime-only in V1. + +Host CTest currently passes five executables: codec, CRUD, recurrence/query, +reminder scheduler, and calendar limits. Date round-trips cover every date from +1970 through 2400, including non-leap centuries. Host sizes: EventRecord 208 B, +AgendaPage 1048 B, ReminderScheduler 136 B, CivilTime 8 B, month mask 4 B. These +are structure sizes, not a claim about total embedded idle RAM or storage I/O. +Real filesystem recovery, platform clock/timezone binding, product registration, +LVGL rendering/input, map integration and hardware tests remain outstanding. + +## Persistence checkpoint (superseded internal-flash backend) + +At this historical checkpoint, `platform/esp/common/storage/AgendaFileStore` +implemented the slot port using standard file I/O over the mounted internal +filesystem. That backend is not compliant with the current SD-only contract. +The record layout uses two independently +CRC-protected 256-byte banks per logical slot, with slot identity and generation +inside the checksum. File size is 33,024 bytes (256-byte header + 64 dual-bank +slots). The RAM object is 488 bytes on the host. It opens files only for an +operation and retains no event table. New files are initialized via a separate +temporary file; existing invalid headers, truncated files and future versions +are reported rather than reformatted or downgraded. + +The former internal-filesystem mount policy is obsolete and must not be used as +an implementation recipe. Storage preparation must require an available, +application-owned SD card and validate the Agenda directory without formatting +any medium. `AgendaClock` uses the existing timezone policy and ESP monotonic timer. + +Seven host tests now pass, including actual temporary-file create/update/delete, +reopen, capacity, slot reuse, both-bank corruption, future-format refusal and +wrong-slot detection. Interrupted-record testing replaces every possible prefix +of a physical-bank update and verifies recovery of an old or new valid record. +The former internal-filesystem mount-policy tests do not establish compliance +with SD requirements. Verification must cover missing media, removal, ownership +transfer, validated remount and replacement cards. Record-bank recovery tests do +not prove whole-filesystem recovery from physical power loss; FAT metadata and +first-initialization failures still require device-level validation. + +## Presentation and composition checkpoint + +`AgendaWorkspaceModel` implements portable source/action contracts. A single +fixed command slot separates UI acceptance from durable completion; save/delete +are executed by the composition pump, not inside an LVGL callback. Failed writes +must leave the editor open with its draft. The CRUD service retains only a store +reference; its 208-byte operation scratch record is temporary rather than idle +state. Target stack depth still needs an embedded build check. + +`AgendaComposition` owns the service, scheduler, workspace model, and small +reminder presentation bridge. Its store/clock dependencies belong to the target +root. The typed `PresentationWorkspace` gains two nullable pointers; no Agenda +service was added to `AppServicesBundle` or AppContext. Existing non-Agenda +workspaces remain unbound and keep their previous interactive-status behavior. +The bridge holds only reminder identity/timing, not an EventRecord or UI tree. +The shell controls whether new reminders may be delivered while another modal +owns input. Popup actions carry a presentation revision, checked both when +queued and when executed, so stale callbacks cannot dismiss another occurrence. + +Nine host CTest executables pass. The added composition test exercises a draft +whose lifetime ends before reminder delivery, deferred presentation without +per-tick database reads, simultaneous reminders, snooze capacity, stale popup +actions, and withdrawal between enqueue and execution. The existing +`test_presentation_bundle_shape.cpp` was also compiled and run unchanged. + +Current MinGW host sizes are AgendaComposition 456 B, reminder bridge 48 B, +and the aggregate of composition + real file-store object + test clock 976 B. +The aggregate has a compile-time `< 1024` assertion; it does not instantiate or +cache an in-memory production event table. The two workspace binding pointers +cost an additional 8 B on this host. Embedded object sizes, filesystem runtime +costs, task stacks, and device behavior remain to be measured. + +The portable composition is implemented and tested, but firmware startup/loop +wiring, full target registration, LVGL pages/popups, localization, and map +integration are not yet complete. These host tests are not UI or device acceptance. + +## Target registration and first renderer checkpoint + +Calendar is declared only in `pager_compact_manifest` and `deck_full_manifest`. +WIO's existing DeckTouch pack projects the latter directly. Pager and T-Deck +now select manifest-backed compatibility packs: these preserve every existing +compatibility screen and append enabled manifest screens without duplicates. +They remain marked as transitional (`final_ux_pack_available == false`). The +shared compatibility pack is unchanged, so Watch and Tab5 do not inherit the +new page. No screen/binding array capacity was increased. + +Verified chain: TargetProfile -> PageManifest -> selected UX pack -> ScreenId +-> MenuScreenId -> ScreenBindingRegistry (`calendar`). Resulting screen/binding +counts are WIO 10, Pager 11, T-Deck 11; other target profiles exclude Calendar. +The AppCatalog descriptor and target runtime wiring are still outstanding. + +The first LVGL renderer now implements the bounded Agenda list, occurrence +detail, and confirmed asynchronous deletion. Sources/actions are injected in +the shell Host. Page state is allocated on enter and released on exit; it does +not own services or storage. Input callbacks only request transitions. A page +timer applies them after the input callback, and TopBar Back participates in +the same focus group as page controls. Pagination advances after the last +actually rendered row, including space consumed by date section headings. + +Native LVGL tests compile the production page, shared TopBar, and layout code. +The host stubs only locale-pack/font fallback, board power, and input-group +binding. Tests render actual 480 x 222 and 320 x 240 RGB565 framebuffers and +generate `agenda-home-480x222.bmp` / `agenda-home-320x240.bmp` in the UI build +directory. They verify button bounds, Back focus traversal, deferred page exit, +pagination, delete confirmation, persistence failure, and successful deletion. +The images were visually inspected. Pager uses the EncoderCompact profile to +select 24 px rows; the compact hybrid/touch layout uses 32 px rows. This does not +infer input capabilities from resolution. + +Verification commands: + +```text +cmake --build .codex-build/agenda-core +ctest --test-dir .codex-build/agenda-core --output-on-failure +cmake --build .codex-build/agenda-ui +ctest --test-dir .codex-build/agenda-ui --output-on-failure +``` + +The core build now runs 34 tests, including 24 existing UX/manifest/binding +regressions. The separate LVGL test passes both framebuffer profiles. Remaining +UI work includes creation/editing, date/time/reminder/repeat pickers, reminder +popup, location integration, and complete translation-pack entries. No firmware +or hardware validation has been performed for this checkpoint. + +## Editor and picker checkpoint + +The page now supports creation and editing through the injected presentation +source/action contracts, plus date, time, reminder and repeat pickers. Title +and note input is checked against the serialized UTF-8 byte limits without +silent truncation. Back can open a discard confirmation without rebuilding +the live text fields; Keep editing preserves even invalid oversized input. +Pending saves disable the actual textareas, and failed saves preserve the +draft and restore editing. Equal start/end clock times mean a 24-hour event; +an earlier end time falls on the next day. + +Calendar selection uses the receiving Calendar object for bubbled events, +not the originating buttonmatrix. Passing that matrix to Calendar APIs was +reproduced as a host segmentation fault before the fix. Keyboard/encoder +focus belongs to the editable matrix rather than its container. Directional +keys remain with the widget in edit mode; Tab and Back retain navigation +semantics. Tests cover the matrix's actual bubbling selection event and +directional-key event, in addition to presentation commands. + +The LVGL regression renders both real framebuffer geometries and covers CRUD, +picker transitions, duration preservation across date changes, UTF-8 overflow, +discard/keep editing, pending-save input state and write-failure recovery. +Calendar and time controls use the existing shared palette through local +styles; no global theme or LVGL library changes are required. The updated +time and date framebuffer images were visually inspected. + +Host touch text-editor attachment is currently stubbed: these tests do not +prove the production touch keyboard or device encoder driver. Translation +pack/font fallback and board power are also stubbed. Location selection is +not yet enabled. Target startup/AppCatalog wiring, the page-independent +reminder popup, full localization, Map integration, firmware builds and device +validation remain outstanding. + +## Target composition and catalog checkpoint + +At this checkpoint, `esp32_lvgl_arduino_agenda` owned the now-superseded +internal-flash adapter, clock, service, +scheduler, presentation model, injected page Host and ordinary CallbackAppScreen +descriptor. Startup initializes it before building the catalog. The app loop +pumps it after the common loop shell, including that shell's overlay branch; +durable commands therefore do not depend on the Calendar page being alive. +AppContext and AppServicesBundle remain unchanged. A temporary +PresentationWorkspace binds the contracts without adding another permanent +workspace graph. + +The descriptor is injected through `FeatureFlags::calendar_app`; callers that +leave it null retain their previous catalog. The production catalog test enables +all optional Pager entries: 15 existing apps plus Calendar equals the unchanged +capacity of 16, with Settings and Shutdown still present and no duplicate IDs. +`targetHasAgenda` checks the LVGL/display capability and the target's Calendar +manifest entry, not resolution. Unsupported Arduino targets compile an inert +adapter without linking Agenda storage, clock or UI dependencies. + +Pager and T-Deck now resolve their actual TargetProfile and TargetUxBinding +instead of falling through to the generic `esp_idf` runtime config. Native tests +compile each of WIO, Pager and T-Deck configurations separately and verify the +selected pack exposes Calendar. The root integration test links the real +`agenda.c` asset, page and file-store implementation, substitutes only the clock +and mounted directory, saves while the page is closed and reopens the file to +verify persistence. It does not simulate flash power loss. + +At this checkpoint reminder delivery remains disabled in the target adapter +until the global popup can safely own and restore input. This is intentionally +an incomplete runtime: successful compilation is not proof of the required +page-closed reminder scenario. Map/location, translation packs, production touch +keyboard testing and device acceptance are also still outstanding. + +### Storage scratch and linked RAM verification + +The file adapter no longer retains a second EventRecord. Both banks decode +into the caller's output; if bank 0 wins after reading bank 1, the adapter +rereads bank 0 and verifies its generation. That adds at most one 256-byte read +per logical slot and removes 208 bytes of permanent record storage (216 bytes +including target-root alignment). Initialization uses one bounded automatic +empty record. Slot layout, generation ordering, CRC, durable flush and alternate +bank writes are unchanged. Recovery tests also explicitly cover each bank +surviving corruption of the other. + +WIO PlatformIO build completed successfully after this change, exit code 0. +The final ELF reports: + +| Symbol / allocation | Bytes | Section | +| --- | ---: | --- | +| Agenda target root, including file adapter, composition, clock, Host and descriptor | 800 | `.dram0.bss` | +| Closed-page state pointer | 4 | `.dram0.bss` | +| Two manifest-backed UX pack objects | 48 | `.dram0.data` | +| Their two local-static initialization guards | 16 | `.dram0.bss` | +| Supplied Agenda bitmap | 12288 | `.flash.rodata` | + +The charged idle object subtotal is 868 bytes. A compile-time root assertion +includes the page pointer, both new pack objects and their guards. Making the +pack constructor constexpr did not remove those objects from target RAM; the +subtotal includes them rather than claiming they reside in flash. This is an +object/section measurement, not a runtime heap watermark; shared filesystem +mount overhead and transient LVGL allocations still require device measurement. + +Whole-firmware build output: static RAM 98,564 / 327,680 bytes; flash +4,107,425 / 6,291,456 bytes. These are current absolute totals, not a delta +against main. No firmware was uploaded. The native suites pass 38 core, +storage, registration and configuration cases plus 2 LVGL/catalog executables. + +## Page-independent reminder presentation checkpoint + +The target root now enables reminder delivery through a small injected popup +Host. The popup uses presentation source/action/reminder contracts; it neither +owns the scheduler nor accesses storage. Calendar may be closed throughout +delivery, Snooze and Done. A popup retains one event only while visible and +has a compile-time state limit below 512 bytes; its idle state is one pointer. + +Presentation waits for existing overlays, interruption apps, pending shell +transitions and unobserved Calendar commands. Button events enqueue actions; +the following root tick submits the command and observes its result before +closing. Input groups and editing mode are restored only while the popup still +owns input and the previous group still exists. A higher-priority interruption +closes the visual without consuming its reminder or clearing the interruption's +overlay marker. Activation is ignored for the first 400 ms to reject a release +already in flight when the modal opened. + +The native tests exercise the real composition, scheduler, presentation model +and LVGL popup at 480x222 and 320x240. Covered scenarios include simultaneous +reminders, monotonic 600-second snooze, refusal of a second concurrent snooze, +Done, interruption/reappearance, a destroyed previous focus group, a record +deleted after a click, and a save result consumed by the page before the due +popup may appear. The target-root integration also exercises persistence and +the complete page-closed reminder path. Shell activity flags, hardware power, +touch keyboard attachment and the clock are host substitutes; these tests do +not prove physical input drivers or the entire production shell lifecycle. + +The compact three-action footer allows a two-line caption and uses the taller +320x240 viewport rather than reducing font size. Tests assert caption bounds +and separation between the snooze-capacity error and action buttons. Actual +framebuffers, including this error state, were visually inspected. + +The Navigate callback is tested after successful dismissal and popup cleanup, +but the target's Map callback is not wired yet. Location editor selection, +production Map integration, translation pack coverage, production touch input +and device validation remain incomplete. No disabled Navigate button appears +on a location-free event. + +The WIO build with the popup linked completed successfully (exit 0, 143.29 s). +Whole-firmware static RAM is 98,564 / 327,680 bytes and flash is +4,111,089 / 6,291,456 bytes. The linked root remains 800 bytes; page and popup +idle pointers are 4 bytes each. Including both 24-byte manifest pack objects +and their two 8-byte guards gives an Agenda-charged object subtotal of 872 +bytes. Section alignment means the added pointer did not change the reported +whole-firmware RAM total; the subtotal still charges it explicitly. This does +not measure runtime filesystem heap or peak LVGL allocation. No upload was +performed. The native suites pass 38 core/regression cases and both UI/catalog +executables after the layout and command-handoff tests. + +## Location source and Map selection model checkpoint + +The Agenda page Host now receives an IAgendaLocationSource. The target owns a +small GpsAgendaLocationSource adapter over the existing IGpsStatusSource; +there is no page-to-hardware call, extra GPS timer or retained GPS snapshot. +Current position requires a valid presentation header, enabled/powered receiver, +valid fix and finite coordinates within geographic bounds. Conversion to E7 +occurs only after validation. A failed read leaves the output and editor's +existing location unchanged. + +The editor's Location field now opens an optional-location picker. Current +position sets a coordinate in the draft and clears any previous waypoint ID. +Its persisted label is a numeric coordinate rather than a translated phrase. +Remove location clears only location fields/flags. Both actions preserve the +other editor fields and require Save before changing the stored event. Native +UI tests at both geometries cover failure, replacement, Back, removal, save +and subsequent edit; the rendered picker framebuffers were inspected. + +MapWorkspaceModel now exposes beginLocationSelection, pickLocation and +cancelLocationSelection with the generic SelectLocation tool. The result is +a bounded structure no larger than 24 bytes. Selection uses the presentation +viewport centre, not the self-position overlay; it accepts (0, 0), rejects +non-finite/out-of-range coordinates, prevents unrelated tool changes while +selecting and restores the previous tool after a successful Pick/Cancel. +Rejected sink operations no longer commit local viewport/tool state. Tests +cover rejection and retry, busy selection, invalid snapshots/coordinates, +zero coordinates, and the pre-existing Map model behaviour. + +This is not yet the complete Map-selection workflow. The Map renderer's +Pick/Cancel controls, coordinate-system conversion, shell route handoff, +editor-tree destruction/draft-only retention and reconstruction still require +implementation and integration tests. No Calendar-specific renderer or tile +access has been introduced. Navigation remains unwired. Repository inspection +has not identified an independent saved-waypoint catalog; protocol waypoint +messages are not silently repurposed as such a catalog. The saved-waypoint +source remains unresolved. All 42 core/presentation/registration tests and +both LVGL/catalog test executables pass at this checkpoint. + +The added runtime Map adapter regression links the real RuntimeMapWorkspaceSource, +RuntimeMapActionSink and MapWorkspaceModel. It verifies that a committed (0, 0) +viewport is not replaced by a subsequent GPS fix, that the sink sees the +selection tool and its restoration, and that layers remain unchanged. + +The WIO location build completed with exit code 0 in 136.80 s: static RAM +98,596 / 327,680 bytes, flash 4,112,353 / 6,291,456 bytes. The root is now +808 bytes, closed page/popup pointers total 8 bytes, and the manifest objects +and guards remain 64 bytes. The existing Map model is 64 bytes, up by 24 from +its former 40-byte layout. Charging that growth yields 904 bytes of Agenda +idle object storage. The root assertion additionally reserves an alignment +unit beyond the selection result, conservatively charging 32 bytes for this +shared-model extension. No hardware upload or live GPS-fix validation was +performed; the reported RAM figures remain linked objects, not heap watermarks. + +## Map selection lifecycle checkpoint + +Agenda's target-owned UI Flow now connects Choose on map to the existing GPS +shell's Map projection. RouteSpec carries a caller-owned MapLocationRequest; +neither the Map contract nor the renderer depends on Agenda. Flow retains only +an editor draft and bounded return context during the visit. It destroys the +Agenda State, LVGL tree and input group before entering Map, then destroys Map +before reconstructing the editor. Pick updates the draft; Cancel preserves it. +Neither operation writes an event. Closing the app during a pending transition +or an active Map visit releases the return context. Entry failure restores the +draft with an error. Page transitions run outside widget callbacks. + +The existing Map runtime provides a fixed viewport-centre crosshair, Pick and +Cancel in selection mode. Route/track controls are not exposed in that mode. +The rendered centre is converted back to WGS84 before E7 storage. Existing +forward coordinate formulas are shared with a bounded inverse; tests pin an +existing forward result, check round trips across all three coordinate modes, +and reject invalid/non-converging input. An explicit initial (0, 0) viewport +receives a valid default zoom instead of being mistaken for an absent centre. + +Native lifecycle tests run the real Agenda page and Flow at 480x222 and +320x240, substituting only the Map shell. They assert editor destruction before +Map entry, draft and return-view preservation, Pick/Cancel, unavailable/failed +entry, input-group restoration, zero implicit writes and app exit in both +transition phases. A target-root test also verifies that a reminder due during +selection is presented after returning, without stealing Map input. These +tests do not validate the physical Map renderer, tile service or input drivers. +The updated location-picker framebuffers were inspected at both exact sizes. + +All 43 core/presentation/regression cases and both LVGL/catalog executables +pass. The WIO Map-flow build completed with exit 0 in 131.07 seconds. Static +RAM is 98,636 / 327,680 bytes and flash is 4,118,613 / 6,291,456 bytes. Linked +Root storage is 832 bytes. Charging page/popup pointers (8), manifest objects +and guards (64), shared Map-model growth (24), and the three Map selection +pointers (12) gives 940 bytes of idle Agenda-related object storage. The +compile-time guard conservatively charges another alignment unit. These are +object sizes, not heap watermarks. No firmware upload was performed. + +Navigation, a resolved saved-waypoint source, translation-pack coverage, +production input validation and the remaining target/device checks are still +incomplete; this checkpoint is not a Calendar V1 completion claim. + +## Locale source coverage and detail-return correction + +The 13 existing locale tables now contain 62 additional keys for Agenda, +Calendar's catalog label and the Map Pick action. Existing translation quality +statuses are unchanged: this change does not promote review locales. Seven +affected bundle versions receive a minor increment independently of firmware; +the minimum firmware compatibility bounds remain unchanged. Distribution +archives/catalogs have not been published, and the new translations still +require the normal language-quality and on-device layout review. + +UI charset/range metadata was regenerated with the existing generator for the +eight affected primary fonts, including the T-Deck Pro Simplified Chinese +variant. Existing non-ASCII charset glyphs were checked against HEAD: none +were removed. Latin, Cyrillic and Arabic build recipes now seed their existing +charsets so future pack builds incorporate added translations while retaining +their previous glyph coverage. Binary font generation and actual glyph lookup +remain separate verification steps; declared ranges alone are not proof of +rendering. Firmware-private dictionaries or embedded CJK font assets were not +added. + +The native UI CTest suite includes a Python source-coverage test using the +existing locale parser/validator. It checks Agenda's source keys in every +locale and advertised UI font ranges for the translated characters. All +three UI/catalog/locale tests and all 43 core/regression tests pass. + +A new regression reproduced a Map-return bug: detail timestamps were read +from a list row index which no longer referred to the selected occurrence +after page reconstruction. Detail now owns one bounded Occurrence value; +the temporary Map return context preserves it with the editor draft. The test +opens a non-first weekly occurrence, edits without saving, cancels Map, then +discards the edit. It verifies both the original title and the selected +occurrence's start/end display at both screen geometries. The regression was +observed failing before the fix and passing after it. No event is written. +This adds only open-page/temporary-context state, not an idle event table. +The latest WIO binary predates this small detail-state correction; no new +firmware build, upload, commit or push was performed for this checkpoint. + +## Detail metadata checkpoint + +Event Detail now displays configured reminder and recurrence values. The +editor and detail share the same preset-key functions; no second preset table +or retained presentation state was added. Absent reminder/repeat fields are +omitted. A regression renders all optional fields with a long note at both +480x222 and 320x240, checks that the note remains above the fixed action row, +and verifies that unconfigured metadata is absent. Both exact-size +framebuffers were inspected and the three UI/catalog/locale tests pass. + +Map navigation integration inspection found viewport positioning and existing +SelectedTarget overlays, but no generic destination-opening request in the +current shared Map contracts. The pending integration should extend that +shared boundary, not add Agenda-owned map state or imply that turn-by-turn +route planning already exists. Navigate remains unwired at this checkpoint. + +## Detail-to-Map target checkpoint + +The shared Map route now accepts a caller-owned `MapTargetRequest`, separate +from location selection. It focuses the existing workspace on a validated +WGS84 destination and adds an existing SelectedTarget annotation after optional +information filtering. A full annotation snapshot replaces the last non-self +item rather than dropping the destination or growing the fixed array. Normal +Map routes have no target request. Destination entry does not automatically +load a configured route which would recenter the viewport. This is destination +display, not turn-by-turn routing or route calculation. + +Event Detail now exposes Navigate only for located events with an available +host action. The existing Flow coordinator defers teardown until outside the +UI callback, destroys the Agenda tree, opens Map, and reconstructs Detail on +Back. It preserves the selected occurrence and does not write the event. +Selection continues to reconstruct the editor instead. Entry failure returns +to the original view with an error. The return context remains below 1 KiB; +only one additional shared Map pointer is retained while idle. + +Native tests cover zero/boundary/invalid coordinates, action-sink rejection, +selection conflicts, annotation capacity, both Detail return geometries, +entry failure, and no unintended event writes. The all-fields framebuffer +checks now include Navigate at 320x240 and 480x222. All 44 core/regression +tests and three UI/catalog/locale tests pass. The native Map-flow test still +substitutes the Map shell; it does not prove physical input or tile rendering. + +Expanded UI testing exposed a pre-existing Agenda input bug: LV_EVENT_KEY +holds a key-value pointer, but the handler passed it to lv_indev_stop_processing +as an input-device pointer. A debugger data watchpoint observed corruption of +an adjacent host field. Page key handling now uses the active input device; +the popup distinguishes key events from pointer/click events. Both paths have +guarded key-payload regressions. These are firmware fixes, not test-only changes. + +Reminder-popup navigation across application lifetimes remains unwired. +Saved-waypoint source integration and full device validation also remain open. + +WIO `wio_tracker_l2` was rebuilt after the input fix: PlatformIO exited 0 +after 132.33 seconds, static RAM 98,644/327,680 B and flash +4,120,177/6,291,456 B. The idle RAM static assertion remains satisfied. +No upload, commit, push or release was performed. Other target builds and +hardware behavior are not established by this WIO build. + +## Reminder navigation and approved-target build checkpoint + +Reminder Navigate now prepares a bounded return context before submitting +dismissal. Preparation or command failure leaves the reminder available and +cancels the prepared navigation. Successful dismissal closes the popup before +the target composition uses the existing application switch and Map route. +An already-open Agenda restores its view and draft; invocation from another +application returns to the default Agenda list. Picker values remain provisional. + +All four approved targets built successfully in the same PlatformIO run (exit +0). These results precede the content-font and interruption changes below. + +| Environment | Static RAM (B) | Flash (B) | +| --- | ---: | ---: | +| wio_tracker_l2 | 98,644 | 4,121,253 | +| tdeck | 96,040 | 4,043,773 | +| tlora_pager_sx1262 | 99,696 | 4,240,365 | +| tlora_pager_lr1121 | 100,112 | 4,242,033 | + +## Font artifact and scope verification + +Generated binaries for all eight affected primary fonts pass real LVGL glyph +lookup for the Agenda translations. Actual generation exposed missing Polish +letters, Arabic-pack punctuation and a mathematical comparison symbol. Latin +now uses Noto Sans with CJK fallback for missing symbols; Arabic retains Naskh +with Noto Sans fallback. The converter assigns each character to the first +source containing it, so fallback does not replace primary glyphs. Source and +pack licenses accompany the new font source. Distribution archives remain +unpublished; language quality and Arabic shaping are not proven by glyph lookup. +Full-charset lookup also passes for the other seven fonts. Arabic's existing +broad charset declaration fails at U+0605, despite Agenda's required characters +passing; full Arabic advertised-range coverage is not established. + +User-entered Agenda labels now use the existing Content font scope, while +translated controls use the Ui scope. A native regression failed on the old +helper and passes on both layout profiles, for body and caption fonts. + +The optional GNU font artifact test instruments the real LVGL loader, reports +retained allocation payload and load-time peak, and checks release on destroy. +The 32-bit retained payload measurements are: + +| Font | Retained payload (B) | Manifest estimate (B) | +| --- | ---: | ---: | +| ar-naskh | 50,939 | 52,224 | +| cyrillic-eu | 3,900 | 5,120 | +| latin-ext-eu | 2,582 | 4,096 | +| ja-cjk | 33,331 | 34,816 | +| ko-cjk | 24,770 | 26,624 | +| tdeckpro-zh-hans-core | 137,092 | 139,264 | +| zh-hans-core | 222,663 | 224,256 | +| zh-hant-cjk | 37,675 | 38,912 | + +`verify_font_artifacts.py` checks glyphs and requires a manifest estimate at +least the retained payload rounded up to 1 KiB plus a further 1 KiB allowance. +These are estimates, not physical-device heap watermarks or load-peak budgets; +allocator metadata, libc I/O and rendering allocations are not measured. +Ordinary CTest neither downloads dependencies nor regenerates fonts. + +## Interrupted-page recovery checkpoint + +The existing app runtime sets the interruption flag before calling the previous +application's exit callback. Agenda Flow now distinguishes that exit from a +normal close. It releases Agenda/Map widgets and input groups while retaining +one bounded recovery context. The shell must re-enter Calendar to resume it; +a suspended context cannot independently activate the application. +If an interruption superseded an exit-to-menu and the shell does not resume +Calendar, target composition discards the suspended context once the shell is +stable. Reopening Calendar then starts a new session. A root-level regression +reproduced stale-session resurrection before this cleanup was added. + +Recovery includes the draft, selected occurrence, list query, provisional +date/time values, delete/discard confirmation and pending command sequence. +Live text has a separate bounded UTF-8 representation because LVGL's character +limit can exceed the persistent record's byte limit. Invalid oversized text is +restored for correction, never silently truncated or written. The context is +reserved before accepting input and reused for Map returns; it remains below +1 KiB. Normal close releases the reserve. Idle ownership grows by one pointer, +not by a persistent draft buffer. + +Native regression first reproduced lost editor state after actual Flow exit +and enter callbacks. Both geometries now pass normal and maximum-size live text, +confirmation, picker, prepared-navigation cancellation, interrupted Map return, +and save success/failure during interruption. The test substitutes the external +interruption application and Map shell: it does not prove the production call +screen, keyboard integration or physical input. The latest native suites pass +44 core/regression and four UI/catalog/localization tests. + +Saved-waypoint integration, full production input/Map/device validation, +physical memory measurements and the complete specification audit remain open. + +Production touch-editor inspection identifies an additional recovery boundary: +the compact touch keyboard edits its own modal textarea until OK, whereas +Agenda's recovery snapshot currently reads the source field. Deleting the source +closes that modal without applying its unconfirmed text. A combined production +touch-editor/Agenda test and a shared editor recovery contract are still needed; +the page-field interruption tests do not cover this separate modal draft. + +WIO was rebuilt with the completed page-field recovery and abandoned-session +cleanup: PlatformIO exit 0, 122.63 seconds, static RAM 98,652/327,680 B and flash +4,122,157/6,291,456 B. The idle and recovery-context assertions pass on ESP. +ESP stack hygiene and `git diff --check` pass. No firmware upload was performed; +the latest Pager and T-Deck builds still predate these recovery changes. + +## Production touch-input integration checkpoint + +The Agenda native UI target now compiles the real compact touch editor, +ImeWidget, PinyinIme and input-layout adapter instead of replacing +`attach_touch_text_editor` with a no-op. Board keyboard-device lookup and the +external language-pack source remain host fixtures; the fixture does not enable +a Pinyin dictionary and does not establish language-specific composition recovery. + +At 320x240, the production DeckLandscape/Touch layout enables the compact +keyboard. A combined test opens Agenda's title and note editors, sends actual +buttonmatrix key events, checks keyboard bounds, verifies Cancel leaves the +source unchanged, verifies OK writes the field but not the store, and verifies +Save persists both fields without requiring a location. The four UI/catalog/ +localization tests pass with this stronger input boundary. + +Unconfirmed modal text across application interruption is still not restored. +The shared editor retains a separate draft and Cancel semantics; copying it into +the source on interruption would be an incorrect implicit commit. Recovery must +preserve that distinction and account for IME state and bounded temporary RAM. +This checkpoint changes tests only; it does not add a new firmware build result. + +## Shared modal recovery implementation checkpoint + +The compact touch editor now exposes capture/restore operations using caller- +provided text storage and a bounded `ImeEditState`. It retains no additional +global draft or widget tree. The IME metadata preserves mode, shift, cursor, +script selection, the eight-letter composition buffer and candidate state; +candidate collections are reconstructed rather than retained. + +Agenda Flow captures the modal independently of its source field and restores +it after rebuilding the source. A regression first failed with missing modal +text, then passed after integration. Combined tests cover Cancel/OK semantics, +numeric/symbol layout, shift and cursor restoration, a deterministic two-entry +Pinyin fixture, and maximum-length four-byte UTF-8 source and modal strings. +All four native UI/catalog/localization tests pass. Physical touchscreen and +full language-pack dictionary behavior remain outside this evidence. + +The initial integrated return context allocates 1,272 B on the 32-bit native +build (allocation immediate 0x4f8 in Flow::enter), exceeding the previous 1 KiB +recovery ceiling. A temporary 1.5 KiB ceiling keeps growth bounded; this is not +a completed RAM-budget acceptance. Compacting duplicated draft text and +mutually exclusive Map/modal state remains required before final validation. +No new firmware build or upload was performed for this checkpoint. + +## Compact recovery text checkpoint + +Agenda now stores transient source and modal drafts as bounded 21-bit Unicode +scalar snapshots. The persistent event format and input character limits are +unchanged. Invalid UTF-8 or excess characters reject capture without truncation; +restoration streams individual scalars into LVGL without a full decoded scratch +buffer. Shared touch-editor callbacks keep the encoding out of the shared IME. + +The 32-bit native recovery allocation is now 1,008 B (0x3f0), down from 1,272 B. +The compile-time ceiling is restored to strictly below 1 KiB. This is a native +context measurement, not an ESP heap-watermark or firmware static-RAM result. + +Five native UI/catalog/localization/snapshot tests pass, including the existing +maximum-length independent source/modal interruption cases. The snapshot test +round-trips every non-NUL Unicode scalar and checks invalid encodings, capacity +rejection, unchanged state on rejection, and early consumer termination. The +standalone shared touch-editor test and ESP stack-hygiene check also pass. +Firmware rebuild and hardware validation remain outstanding for these changes. + +The subsequent WIO PlatformIO build completed with exit 0 in 124.02 seconds. +It includes shared modal recovery and compact text snapshots; the ESP recovery +context size assertion passes. Static RAM is 98,652/327,680 B, unchanged from +the previous WIO checkpoint; flash is 4,124,481/6,291,456 B. No upload was done. +The 44-test core/regression suite was rerun and passed. Pager/T-Deck rebuilds, +hardware validation and saved-waypoint product integration remain outstanding. + +## Native framebuffer review checkpoint + +The core build was refreshed before rerunning all 44 passing tests, including +the rebuilt disabled-target executable. Actual native LVGL framebuffer outputs +were visually inspected at their original sizes: the 320x240 editor, date and +time pickers, and snooze-conflict reminder; the 480x222 Agenda, full-field detail +and located reminder. These English fixtures show no text/button overlap. The +compact editor scrolls its fields while Save remains outside the scroll area. +This limited review does not prove all translations, physical input behaviour, +production font-pack layout, or complete device acceptance. + +## T-Deck recovery build checkpoint + +The latest T-Deck firmware, including shared IME/modal recovery and compact +Agenda snapshots, builds successfully: PlatformIO exit 0, 107.16 seconds, +static RAM 96,048/327,680 B and flash 4,047,005/6,291,456 B. This validates the +compiled target path, not physical keyboard/trackball behaviour. No upload was +performed. The eight existing font binaries were rechecked for Agenda-required +glyph mappings and declared RAM estimates; all passed. Pager builds and the +remaining product/device acceptance items are still open. + +## Pager recovery build checkpoint + +Both Pager environments now build the latest recovery implementation successfully +(combined PlatformIO exit 0). SX1262: 113.42 seconds, static RAM 99,704/327,680 B, +flash 4,243,529/6,291,456 B. LR1121: 128.26 seconds, static RAM 100,120/327,680 B, +flash 4,245,185/6,291,456 B. Along with the preceding WIO and T-Deck builds, +all four approved firmware environments compile the compact recovery path. +No uploads were performed. These builds do not establish physical input, +power-loss behaviour or runtime heap-watermark acceptance; saved-waypoint +integration also remains incomplete. + +## Editor field focus recovery correction + +An interruption regression exposed that focusing Date without activating it +left the draft's remembered field unchanged. Textareas remembered focus, but +the five picker field buttons did not. Those buttons now use the same existing +focus callback; no additional state or shared focus-manager branch is needed. +The regression first failed, then passed for Date, Time, Reminder, Location +and Repeat across both native profiles. All five UI test targets pass. This +small correction postdates the four firmware build checkpoints above and has +not yet been rebuilt for ESP or tested with physical input. + +## Local waypoint integration checkpoint + +Local named waypoints use a separate portable `core_waypoint` module. This +checkpoint used an internal-flash file adapter, which must be replaced by an +SD runtime adapter under the current contract. The 64-slot store scans records on demand; the +picker retrieves at most five rows. Its presentation model retains one pending +write, not a database cache. The composition root creates the bounded waypoint +session when Calendar opens and releases it after ordinary exit. Interrupted +Calendar sessions preserve pending results until the UI can consume them. + +Selecting a waypoint copies its name, coordinates and identifier into the event +draft without saving the event. Naming and saving a location creates a waypoint +independently of event persistence. Compact touch-editor tests cover independent +source/modal text across interruption, Cancel without writeback, OK without +persistence, and explicit asynchronous Save followed by return to the editor. +Six waypoint tests and five UI tests pass. The 44-test core/registration suite +and ESP stack-hygiene check also pass. + +All four approved PlatformIO environments now build the waypoint integration +and editor focus recovery correction successfully (exit code 0): + +| Environment | Static RAM (bytes) | Flash (bytes) | +| --- | ---: | ---: | +| wio_tracker_l2 | 98,660 | 4,131,069 | +| tdeck | 96,056 | 4,053,221 | +| tlora_pager_sx1262 | 99,712 | 4,249,917 | +| tlora_pager_lr1121 | 100,128 | 4,251,561 | + +These are link-time measurements, not runtime heap measurements. No firmware +was uploaded. Physical input, on-device persistence/power-loss behavior, +runtime heap watermarks, and final waypoint UI wording/layout remain unverified. + +## Waypoint UI and recovery validation + +Native LVGL fixtures now render the waypoint list, name editor and failed-save +state at both 320x240 and 480x222. The English outputs were inspected without +scaling; rows, buttons and error status remain inside the framebuffer. Tests +cover five-row pagination, return navigation and retention of an unsaved name +after an asynchronous write failure. This is not multilingual layout or physical +input acceptance. + +The expanded lifecycle fixture exposed an aliasing bug in IME restoration: +`restoreEditState` accepted the textarea's internal text pointer, then changed +mode before copying it. Mode changes can replace that buffer. Restoration now +takes ownership through the existing `setText` path before changing mode. No +additional recovery buffer was added. The five Agenda UI tests and standalone +shared touch-editor test pass after this correction. + +Waypoint validation now checks bounded UTF-8 as well as record identity and +coordinate ranges. Tests reject overlong, surrogate, out-of-range and truncated +sequences, including the fixed-field boundary, while accepting valid multibyte +names. All six waypoint tests pass. The on-disk format is unchanged. + +Font cases were regenerated from current Agenda sources and all 13 locale files, +including the waypoint labels. All eight existing binary font artifacts contain +the required mapped glyphs and satisfy their declared retained-RAM estimates in +the 32-bit native loader probe. This does not establish shaping quality, visual +translation quality, or device heap watermarks. The firmware build table above +predates these latest validation and IME changes. diff --git a/docs/specs/Agenda_Calendar_Implementation_Specification.md b/docs/specs/Agenda_Calendar_Implementation_Specification.md new file mode 100644 index 00000000..46489e60 --- /dev/null +++ b/docs/specs/Agenda_Calendar_Implementation_Specification.md @@ -0,0 +1,1945 @@ +# Trail Mate Agenda / Calendar Implementation Specification + +**Status:** Implementation specification +**Audience:** AI coding agent / repository maintainer +**Canonical path:** `docs/specs/Agenda_Calendar_Implementation_Specification.md` + +--- + +# 1. Purpose + +Implement a lightweight Agenda / Calendar feature for Trail Mate. + +This document is an **implementation specification**, not a product brainstorm. + +An implementation agent MUST follow the existing Trail Mate architecture and MUST NOT introduce a parallel architecture for Calendar. + +The feature is intentionally small. + +Its core purpose is: + +```text +At a certain time, +remind the user about something. +``` + +Location is optional. + +An available SD card is required for Agenda and saved local waypoints. +Without an available card, Agenda is unavailable; internal flash is not a fallback. + +If an event has a location, Trail Mate may additionally provide: + +```text +Reminder + ↓ +Navigate + ↓ +Existing Map +``` + +But an event without a location is fully valid. + +Examples: + +```text +15:00 Drink water +18:00 Radio check +20:30 Check battery +``` + +are first-class events. + +--- + +# 2. Product definition + +The canonical model is: + +```text +Event +├── Title required +├── Date / Time required +├── Reminder optional +├── Location optional +├── Repeat optional +└── Note optional +``` + +Location MUST NOT be treated as a required part of event creation. + +The minimum useful event is: + +```text +Title + Time +``` + +Example: + +```text +Radio check +18:00 +``` + +The most common enhanced form is: + +```text +Radio check +18:00 +Reminder: 10 minutes before +``` + +Location is a secondary enhancement: + +```text +Leave campsite +07:00 +Location: Pine Creek +Reminder: 30 minutes before +``` + +--- + +# 3. Explicit non-goals + +V1 MUST NOT implement: + +```text +Week View +complex Day timeline +tasks / todo subsystem +multiple calendars +calendar colours/categories +meeting attendees +invitations +CalDAV +Google Calendar +Exchange +iCalendar synchronisation +natural-language parsing +attachments +per-event timezone +complex RRULE +recurrence exceptions +"This occurrence only" +"This and future" +event sharing +cloud synchronization +``` + +Do not implement functionality merely because desktop or mobile calendar applications normally have it. + +Trail Mate is not a PDA calendar replacement. + +--- + +# 4. Repository architecture that MUST be respected + +Current Trail Mate architecture is conceptually: + +```text +Target Manifest + ↓ +Product Composition + ↓ +App Services / domain services + ↓ +Presentation Models + ↓ +Shell / Renderer +``` + +Parallel concerns include: + +```text +Platform Runtime +Storage Backends +Capability Drivers +Protocol Cores +UX Packs +``` + +Agenda must fit this architecture. + +It MUST NOT create a new shortcut architecture such as: + +```text +LVGL page + ↓ +Agenda singleton + ↓ +LittleFS + ↓ +GPS + ↓ +Map globals +``` + +That is explicitly forbidden. + +--- + +# 5. Existing repository structures relevant to this implementation + +Before changing code, the implementation agent MUST inspect at least: + +```text +docs/specs/Cross_Platform_Product_Architecture_Specification.md + +modules/ui_presentation/include/ui_presentation/page/page_manifest.h +modules/ui_presentation/src/page/page_manifest.cpp + +modules/ui_presentation/include/ui_presentation/workspace/presentation_workspace.h + +modules/ui_lvgl_ux_packs/include/ui_lvgl_ux_packs/ux/screen_registry.h + +modules/product_composition/include/product_composition/target_profile.h +modules/product_composition/include/product_composition/presentation_bundle.h +modules/product_composition/include/product_composition/app_services_bundle.h + +modules/ui_shared/src/ui/app_catalog_builder.cpp + +modules/ui_shared/src/ui/screens/tracker/ +modules/ui_shared/src/ui/screens/gps/ + +docs/map/gps_page_feature_summary.md +docs/audits/UI_SHARED_SPLIT_AUDIT.md + +platform/esp/arduino_common/include/platform/esp/arduino_common/storage/storage_runtime.h +``` + +Do not implement Agenda based only on this specification without checking the current versions of those files. + +The repository may evolve after this document is written. + +--- + +# 6. Do not extend AppServicesBundle casually + +Current: + +```cpp +product_composition::AppServicesBundle +``` + +is intentionally small. + +Its source explicitly states that it must not become another global `AppContext`. + +Therefore: + +DO NOT automatically add: + +```cpp +AgendaService* agenda; +``` + +to `AppServicesBundle`. + +Agenda service lifetime should be owned by the appropriate **target composition root**. + +The target composition should wire: + +```text +Agenda domain/service +Agenda storage port +Agenda presentation source +Agenda action sink +Reminder runtime +``` + +The UI receives presentation contracts. + +The UI does not own service lifetime. + +Only add anything to `AppServicesBundle` if a repository-wide composition review shows that this is now the established pattern. + +--- + +# 7. Module placement + +Agenda is cross-platform product/domain behaviour. + +Therefore create: + +```text +modules/core_agenda/ +``` + +Do NOT create: + +```text +modules/agenda_runtime/ +modules/agenda_storage/ +modules/calendar_platform/ +modules/ui_agenda_runtime/ +``` + +unless later architecture work proves a reusable independent responsibility exists. + +V1 should remain structurally small. + +Suggested layout: + +```text +modules/core_agenda/ +├── include/agenda/ +│ ├── domain/ +│ │ ├── event.h +│ │ ├── recurrence.h +│ │ └── occurrence.h +│ │ +│ ├── ports/ +│ │ ├── agenda_store.h +│ │ ├── agenda_clock.h +│ │ └── reminder_sink.h +│ │ +│ └── usecase/ +│ ├── agenda_service.h +│ ├── agenda_query.h +│ └── reminder_scheduler.h +│ +├── src/ +│ ├── agenda_service.cpp +│ ├── agenda_query.cpp +│ ├── recurrence.cpp +│ └── reminder_scheduler.cpp +│ +├── tests/ +│ +└── library.json +``` + +No LVGL, Arduino, ESP-IDF, LittleFS, FreeRTOS or board header may appear inside `core_agenda`. + +--- + +# 8. UI presentation placement + +Portable presentation contracts belong in the existing: + +```text +modules/ui_presentation/ +``` + +Suggested: + +```text +modules/ui_presentation/include/ui_presentation/agenda/ +├── agenda_snapshot.h +├── agenda_source.h +├── agenda_action_sink.h +└── agenda_editor_model.h +``` + +If implementation requires `.cpp`: + +```text +modules/ui_presentation/src/agenda/ +``` + +Agenda presentation models MUST be: + +```text +small +bounded +renderer independent +non-LVGL +``` + +--- + +# 9. LVGL page placement + +Current embedded LVGL screens still live primarily under: + +```text +modules/ui_shared/src/ui/screens/ +``` + +and `ui_shared` remains a transitional umbrella while UX-pack migration proceeds. + +Therefore V1 Agenda page should follow the **current working repository structure**, not an imagined future structure. + +Suggested: + +```text +modules/ui_shared/include/ui/screens/agenda/ +modules/ui_shared/src/ui/screens/agenda/ +``` + +with approximately: + +```text +agenda_page_shell.cpp +agenda_page_runtime.cpp +agenda_page_components.cpp +agenda_page_input.cpp +agenda_state.cpp +``` + +Do not create one enormous: + +```text +calendar.cpp +``` + +The separation should follow existing Tracker/GPS patterns: + +```text +shell + enter / exit only + +runtime + source/sink binding + page lifecycle + refresh orchestration + +components + LVGL object construction/rendering + +input + keyboard/touch/focus semantics + +state + current page UI state only +``` + +Business state MUST NOT live in `agenda_state.cpp`. + +--- + +# 10. UX pack integration + +A new Calendar page is not complete merely because it appears in: + +```text +app_catalog_builder.cpp +``` + +Current registration is multi-stage. + +The implementation agent MUST inspect and update the full chain: + +```text +PageId + ↓ +PageManifest + ↓ +UX Pack + ↓ +ScreenId + ↓ +ScreenRegistry + ↓ +ScreenBindingRegistry + ↓ +App Catalog / Shell binding +``` + +At minimum inspect: + +```text +ui_presentation/page/page_manifest.* +ui_lvgl_ux_packs/ux/screen_registry.h +relevant *_ux_pack.cpp +app_catalog_builder.cpp +``` + +Add: + +```cpp +PageId::Calendar +``` + +and corresponding: + +```cpp +ScreenId::Calendar +``` + +only where appropriate. + +--- + +# 11. Registry capacity warning + +Current: + +```cpp +ScreenRegistry::kMaxScreens = 16 +``` + +and: + +```cpp +ScreenBindingRegistry::kMaxBindings = 16 +``` + +Adding Calendar consumes capacity. + +The implementation agent MUST count actual target entries before modifying these values. + +Do not silently increase capacity. + +If a target would exceed 16: + +1. determine why, +2. determine whether Calendar belongs on that target, +3. only then consider increasing capacity. + +Memory impact must be documented before changing these fixed arrays. + +--- + +# 12. Target enablement + +Do not enable Calendar on every target automatically. + +First determine which target profiles correspond to the intended embedded products. + +Initial requirement: + +```text +480×222 LVGL class +320×240 LVGL class +``` + +Resolution alone MUST NOT be used to infer product capability. + +Use: + +```cpp +product_composition::TargetProfile +``` + +including: + +```text +target_id +ux_pack_id +page_manifest_id +layout_profile_id +input capabilities +renderer +``` + +Enable Calendar through the appropriate PageManifest(s). + +Do not add Calendar to: + +```text +headless +ASCII +GTK +watch +other products +``` + +unless explicitly required. + +--- + +# 13. RAM policy + +This is a hard architectural constraint. + +Agenda must be: + +```text +disk-backed +scan-based +bounded-memory +allocation-light +``` + +There MUST NOT be a complete in-memory event database. + +Forbidden persistent/runtime patterns include: + +```cpp +std::vector events; +std::vector occurrences; +std::map; +std::unordered_map; +``` + +The implementation MUST NOT preload all calendar events into RAM. + +--- + +# 14. RAM budgets + +Additional Agenda business/runtime RAM targets: + +```text +Idle Agenda runtime: +< 1 KiB + +Agenda page business snapshot: +< 2 KiB + +Event editor draft: +< 1 KiB + +Reminder scheduler: +< 256 B preferred +< 512 B hard target + +Month picker domain/presentation state: +< 256 B + +Waypoint picker Agenda-owned state: +< 1 KiB +``` + +These numbers exclude existing shared LVGL engine memory and existing Map buffers. + +Agenda MUST NOT require PSRAM. + +If PSRAM is absent, Agenda must still function normally. + +--- + +# 15. Prefer SD I/O over permanent RAM + +Agenda is a low-frequency human interaction feature. + +Therefore the design explicitly chooses: + +```text +SD I/O +instead of +permanent RAM residency +``` + +Scanning several KiB or tens of KiB when the user opens Agenda is acceptable. + +Keeping tens of KiB permanently allocated is not acceptable. + +--- + +# 16. Event record + +Avoid dynamic strings in persistent records. + +Use a fixed-size record. + +Example conceptual structure: + +```cpp +struct EventRecord +{ + uint32_t id; + + int64_t start_time; + int64_t end_time; + + uint32_t reminder_offset_sec; + + int32_t latitude_e7; + int32_t longitude_e7; + + uint8_t state; + uint8_t flags; + uint8_t repeat_type; + uint8_t location_type; + + char title[40]; + char location_name[32]; + char waypoint_id[16]; + char note[80]; + + uint32_t crc32; +}; +``` + +Exact packing/alignment may be adjusted after static-size verification. + +Do not enlarge fields casually. + +--- + +# 17. Event flags + +Location, Reminder and Note are optional. + +Suggested flags: + +```cpp +enum EventFlags : uint8_t +{ + HasReminder = 1 << 0, + HasLocation = 1 << 1, + HasNote = 1 << 2, + HasEndTime = 1 << 3, +}; +``` + +A normal event may be: + +```text +HasReminder = false +HasLocation = false +HasNote = false +``` + +and is completely valid. + +--- + +# 18. Location storage + +Do not use `double` in the persisted Agenda record. + +Use: + +```cpp +int32_t latitude_e7; +int32_t longitude_e7; +``` + +Example: + +```text +24.8731000° +→ +248731000 +``` + +This provides more than sufficient precision for the feature while reducing record size and making serialization deterministic. + +--- + +# 19. Active event limit + +V1: + +```text +MAX_ACTIVE_EVENTS = 64 +``` + +This is intentional. + +Trail Mate is not intended to hold a lifetime calendar archive. + +Do not increase this limit unless actual use demonstrates the need. + +The storage format should permit future extension without changing the conceptual API. + +--- + +# 20. Storage format + +Use fixed-size slots rather than a heap-style variable-length event file. + +Logical file: + +```text +/agenda/events.dat +``` + +Conceptually: + +```text +Header +Slot 0 +Slot 1 +Slot 2 +... +Slot 63 +``` + +A record may be padded to a convenient fixed slot size if justified. + +Example: + +```text +256 bytes × 64 slots += +16 KiB +``` + +This is an acceptable use of SD storage. + +--- + +# 21. Storage states + +Record state should distinguish: + +```text +Empty +Active +Deleted +``` + +Deletion should mark a slot reusable. + +Creation should reuse empty/deleted slots before extending anything. + +V1 should not require compaction during ordinary operation. + +--- + +# 22. AgendaStore port + +Core Agenda must not know LittleFS. + +Define a port roughly equivalent to: + +```cpp +class IAgendaStore +{ +public: + virtual ~IAgendaStore() = default; + + virtual bool readSlot( + uint16_t slot, + EventRecord& out) = 0; + + virtual bool writeSlot( + uint16_t slot, + const EventRecord& record) = 0; + + virtual bool eraseSlot( + uint16_t slot) = 0; + + virtual uint16_t slotCount() const = 0; +}; +``` + +The exact interface may be adjusted to match existing repository port conventions. + +It MUST remain bounded and streaming-oriented. + +Do not expose: + +```cpp +loadAllEvents() +``` + +--- + +# 23. Platform storage adapter + +ESP implementation belongs under the existing ESP platform tree, for example: + +```text +platform/esp/arduino_common/ +``` + +in a feature-appropriate subdirectory. + +The exact path MUST follow nearby platform adapter conventions after repository inspection. + +The UI page MUST NOT directly call: + +```text +LittleFS.open() +SD.open() +fopen() +``` + +Agenda storage operations belong to the platform adapter. + +--- + +# 24. SD storage is mandatory + +Agenda events and saved local waypoints MUST reside on the SD card. +Use `/agenda/events.dat` for events and `/agenda/waypoints.dat` for saved local +waypoints. These are separate stores on the same required medium; neither is +an internal-flash cache or a copy synchronized to internal flash. +Their logical paths are relative to the existing SD runtime, not an internal +flash mount. Agenda MUST NOT initialize, format or use internal FFat, LittleFS +or NVS as its record store or as an automatic fallback. + +Without an available SD card, Agenda MUST report storage unavailable and MUST +NOT permit event queries, creation, editing, deletion or waypoint save/select. +Reminder delivery also requires available SD storage, even when a next reminder +was cached before card removal. A closed Calendar page does not disable reminders +while the card remains available. + +On card removal, mount loss or storage ownership transfer away from the +application, invalidate storage readiness and cached list/reminder results. +Dismiss an active Agenda reminder instead of allowing actions against an absent +store. Never acknowledge an incomplete write as successful. A bounded unsaved +editor draft may remain for explicit cancellation or retry, but MUST NOT be +silently written to a subsequently inserted card. + +After insertion and successful application-owned remount, reopen and validate +the stores before enabling operations. Rebuild queries and reminder scheduling +from the mounted card; do not reuse record IDs, command results or occurrences +from a previous card. A different card is a different store. + +Reuse the existing shared SD runtime for file operations, ownership, availability +and synchronization. Shared-SPI and SDMMC access policies remain responsibilities +of that runtime. Do not add a WIO-specific path or a separate Agenda SDMMC path. + +Agenda and waypoints have their own bounded record stores. Reusing SD runtime +infrastructure does not mean reusing chat databases or unrelated route storage. +Storage choice stays in platform adapters and target composition, not the core +domain or LVGL components. Never format a card to recover an Agenda error. + +--- + +# 25. Streaming queries + +Agenda query APIs MUST NOT return an unbounded vector. + +Prefer visitor/callback or fixed-capacity output. + +Conceptually: + +```cpp +bool scanEvents( + IEventVisitor& visitor); +``` + +or: + +```cpp +template +bool collectAgenda( + FixedAgendaSnapshot& out); +``` + +The essential rule is: + +> scan one persistent record at a time and retain only information required by the current view. + +--- + +# 26. Agenda page snapshot + +The Agenda page only needs a handful of rows. + +Example hard limits: + +```text +Today 5 rows +Tomorrow 3 rows +Upcoming 2 rows +``` + +Total: + +```text +10 rows maximum +``` + +Actual geometry may reduce this further. + +Do not allocate rows for events the user cannot see. + +--- + +# 27. AgendaRow presentation model + +Example: + +```cpp +struct AgendaRow +{ + uint32_t event_id; + int64_t occurrence_start; + + char time[6]; + char title[40]; + char location[24]; + + uint8_t flags; +}; +``` + +AgendaRow MUST NOT contain: + +```text +note +full EventRecord +repeat rule expansion +waypoint object +map object +``` + +--- + +# 28. Agenda home is the primary page + +Calendar opens directly to: + +```text +Agenda +``` + +not Month View. + +The Agenda home creation button uses the localized label `New`; `New Event` +may be used as the editor title, not the compact home button label. + +Typical 480×222 content: + +```text +TODAY + +09:30 Team meeting +14:30 Check campsite +18:00 Radio check + +TOMORROW + +07:00 Leave campsite +``` + +Only show location if present. + +Example with location: + +```text +14:30 Check campsite Pine Creek +``` + +Example without: + +```text +18:00 Radio check +``` + +No placeholder such as: + +```text +Location: none +``` + +should appear on the Agenda list. + +--- + +# 29. Information density + +480×222 is a very short landscape screen. + +The implementation MUST optimize for vertical density. + +Avoid: + +```text +large cards +large icons +large paddings +two-line rows by default +decorative whitespace +multiple nested panels +``` + +Use: + +```text +TopBar +section label +compact rows +thin separators +focused row highlight +minimal footer/help +``` + +For 480×222, ordinary event rows should target approximately one text line. + +320×240 may use different layout geometry. + +Do not scale the 480×222 design uniformly. + +--- + +# 30. Visual style + +Use the repository canonical firmware style: + +```text +Amber #EBA341 +AmberDark #C98118 +WarmBG #F6E6C6 +PanelBG #FAF0D8 +Line #E7C98F +Text #6B4A1E +TextDim #8A6A3A +Warn #B94A2C +Ok #3E7D3E +Info #2D6FB6 +``` + +Use existing shared theme/helpers where available. + +Do not introduce Calendar-specific dark themes or additional decorative colours. + +--- + +# 31. TopBar + +Use: + +```cpp +ui::widgets::TopBar +``` + +where the current target/page conventions support it. + +Do not implement a Calendar-specific TopBar. + +For the known 480×222 class, existing pages commonly reserve approximately 30 px for shared TopBar geometry. + +However: + +> geometry must be derived from the appropriate layout/profile and existing page convention, not from screen width alone. + +--- + +# 32. Pages / states + +V1 needs only the following user-facing states: + +```text +1. Agenda +2. Event Detail +3. Event Editor +4. Date Picker +5. Time Picker +6. Reminder Picker +7. Repeat Picker +8. Location Picker +9. Waypoint Picker +10. Reminder Popup +``` + +`Map Select` is NOT a new Calendar page. + +It is an operating mode of the existing Map workflow. + +--- + +# 33. Event editor field order + +Use: + +```text +Title +Date +Time +Reminder +Location +Repeat +Note +``` + +This ordering is deliberate. + +Location must not appear to be mandatory. + +Required fields: + +```text +Title +Date +Time +``` + +Everything else is optional. + +--- + +# 34. Reminder presets + +V1: + +```text +None +At time +10 min before +30 min before +1 hour before +1 day before +``` + +No custom reminder editor. + +--- + +# 35. Repeat presets + +V1: + +```text +None +Daily +Weekly +Monthly +Yearly +``` + +Do not implement recurrence exceptions. + +Editing a recurring Event changes the whole series. + +--- + +# 36. Recurrence must not expand into stored events + +A weekly recurring event remains one record. + +Never create: + +```text +52 records +``` + +for one year of weekly events. + +Queries calculate occurrences on demand. + +--- + +# 37. Month picker + +Month view is a picker, not a major browsing mode. + +It needs only: + +```text +current month +selected date +event-presence markers +``` + +Event-presence state for a month should preferably be represented as: + +```cpp +uint32_t occupied_days; +``` + +One bit per day. + +Do not construct 31 day objects containing event lists. + +--- + +# 38. Reminder runtime + +Reminder scheduling belongs to `core_agenda` use-case/runtime logic connected through target composition. + +It must remain alive independently of the Calendar page. + +The page may be destroyed while reminders continue to function, provided SD +storage remains available. Card removal suspends delivery and invalidates the +cached next reminder; validated remount triggers recalculation. + +--- + +# 39. Reminder scheduler RAM model + +The scheduler should retain only the next relevant reminder. + +Conceptually: + +```cpp +struct NextReminder +{ + bool valid; + + uint32_t event_id; + + int64_t occurrence_start; + int64_t trigger_time; +}; +``` + +Do not keep a queue of all future reminders. + +--- + +# 40. Reminder recalculation + +Rescan persistent events when: + +```text +startup +event created +event edited +event deleted +reminder dismissed +snooze completed +clock materially changes +SD storage becomes unavailable (invalidate; do not scan) +SD storage becomes available after validated remount +``` + +Do NOT continuously rescan the event database every second. + +Normal tick behaviour should be approximately: + +```cpp +if (next.valid && + now >= next.trigger_time) +{ + trigger(); +} +``` + +--- + +# 41. Reminder popup without location + +This is the normal case. + +Example: + +```text +RADIO CHECK + +18:00 + +[Snooze 10 min] [Done] +``` + +Do not display disabled Navigate controls. + +--- + +# 42. Reminder popup with location + +If: + +```text +HasLocation == true +``` + +show: + +```text +LEAVE CAMPSITE + +07:00 +Pine Creek + +[Navigate] [Snooze] [Done] +``` + +Navigate is conditional. + +--- + +# 43. Location priority + +Location is **P2 enhancement**, not the foundation of Calendar. + +Implementation priority: + +```text +P0 +Title +Date / Time +Agenda +Event CRUD +Reminder + +P1 +Repeat +Note + +P2 +Location +Waypoint +Map integration +Navigate +``` + +The feature should already be useful before P2 exists. + +--- + +# 44. Location picker + +When implemented, only: + +```text +Current position +Choose on map +Saved waypoint +``` + +No: + +```text +manual coordinates +recent places +address search +POI search +location history +``` + +in V1. + +--- + +# 45. Map reuse + +Current Map implementation already has a shared map viewport and MapWorkspaceModel. + +Do not create another map renderer. + +Do not reintroduce page-level tile ownership. + +Do not touch filesystem map tile paths from Calendar. + +Map integration must use the existing Map workspace/runtime boundaries. + +--- + +# 46. Map selection + +When Calendar needs a coordinate, existing Map should enter a temporary semantic mode equivalent to: + +```text +SelectLocation +``` + +Do not duplicate the GPS page. + +Selection UX: + +```text +fixed centre crosshair +map pans underneath it +Pick +Cancel +``` + +Selected coordinate is the current viewport centre. + +--- + +# 47. Map lifetime and RAM + +Do not keep Event Editor's full LVGL tree alive underneath the Map page solely to return from location selection. + +Before opening heavy Map UI, retain only a small draft: + +```text +EventDraft +focused field / editor state +return context +``` + +Recreate the editor UI on return if that better matches existing shell lifecycle. + +Prefer recomputation and reconstruction over simultaneously retaining multiple heavy object trees. + +--- + +# 48. Waypoint query + +Do not return all waypoints in: + +```cpp +std::vector +``` + +for Agenda. + +Consume the existing waypoint source via bounded/paged access if available. + +V1 includes saving named local waypoints and selecting them from SD storage. +The reusable waypoint store is independent of Agenda events and has no internal +flash fallback. Saving a waypoint does not implicitly save an event draft. + +If the current repository does not expose an appropriate bounded interface, add the smallest adapter necessary. + +A Waypoint picker should only keep one visible page in RAM. + +--- + +# 49. Input + +Input capability comes from target/UX profile. + +Do NOT infer input style from: + +```text +320×240 +480×222 +``` + +The same presentation semantics should support: + +```text +touch +keyboard +direction keys +trackball +``` + +through existing UX bindings and page input conventions. + +--- + +# 50. Localization + +All user-visible strings must use the existing localization mechanism. + +Do not hard-code final English UI labels directly into Agenda components. + +Examples: + +```text +Today +Tomorrow +Upcoming +New Event +Reminder +Repeat +Location +No plans today +Snooze +Done +Navigate +``` + +must enter the existing translation pipeline. + +--- + +# 51. Build integration + +Adding source files is not sufficient. + +The implementation agent MUST inspect and update all applicable build paths. + +At minimum inspect: + +```text +apps/esp32_lvgl/CMakeLists.txt + +cmake/TrailMateLinuxSources.cmake + +cmake/TrailMateUxPacks.cmake + +builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake + +scripts/platformio-pre.py +``` + +Only modify paths actually required by enabled targets. + +Do not assume PlatformIO, ESP-IDF and Linux automatically share new files. + +--- + +# 52. Phase 1 — repository impact audit + +Before adding production code: + +1. identify intended 480×222 targets, +2. identify intended 320×240 targets, +3. identify their `page_manifest_id`, +4. identify their `ux_pack_id`, +5. identify current screen registry counts, +6. identify composition roots, +7. identify shared SD storage adapters, ownership and card-availability lifecycle, +8. identify current clock source, +9. identify reminder/modal presentation mechanism, +10. identify Map selection/navigation integration points. + +Record findings in the implementation PR or development notes. + +Do not guess these points. + +--- + +# 53. Phase 2 — core Agenda + +Implement only: + +```text +EventRecord +IAgendaStore +AgendaService +occurrence calculation +bounded Agenda query +ReminderScheduler +``` + +Add unit tests. + +No LVGL. + +No Map integration. + +--- + +# 54. Phase 3 — storage adapter + +Implement the SD-backed platform adapter through the shared SD runtime. +Host filesystem fixtures are test adapters, not alternative firmware backends. + +Validate: + +```text +empty store +create +update +delete +restart/reload +invalid record +CRC failure +full store +SD absent at boot +card removal during query/write/reminder delivery +reinsertion and validated remount +replacement by a different card without cross-card state reuse +no internal-flash fallback or automatic formatting +``` + +Do not load all records during startup. + +--- + +# 55. Phase 4 — minimum UI + +Implement: + +```text +Agenda +Event Detail +Event Editor +Date Picker +Time Picker +Reminder Picker +Reminder Popup +``` + +At this point the complete feature must already work with: + +```text +no location +no map dependency +``` + +Required end-to-end scenario: + +```text +Calendar +↓ +New +↓ +Title = Radio check +↓ +18:00 +↓ +Reminder = 10 min before +↓ +Save +↓ +17:50 reminder appears +↓ +Snooze / Done +``` + +This is the primary V1 path. + +--- + +# 56. Phase 5 — Repeat + +Add: + +```text +Daily +Weekly +Monthly +Yearly +``` + +Keep storage unchanged except repeat metadata. + +Test recurrence thoroughly. + +--- + +# 57. Phase 6 — optional Location + +Only after the basic Agenda is stable: + +```text +Current position +Saved waypoint +Choose on map +Navigate +``` + +Location integration must not increase idle Agenda RAM meaningfully. + +--- + +# 58. Phase 7 — UX pack / device validation + +Validate separately on intended: + +```text +480×222 profile +320×240 profile +``` + +Do not use screenshots scaled to approximately the target aspect ratio. + +Actual screenshot/canvas validation must use the real framebuffer geometry. + +--- + +# 59. Unit tests + +Core tests must include: + +```text +single event +point event +event with end time + +today query +tomorrow query +upcoming query + +stable ordering + +daily recurrence +weekly recurrence +monthly recurrence +yearly recurrence + +January 31 rollover +February 29 rollover + +reminder calculation +reminder without location +reminder with location + +storage full +deleted-slot reuse +CRC failure +reload + +bounded result capacity +``` + +--- + +# 60. RAM tests + +Add explicit tests/assertions where practical for bounded structures. + +The implementation should make obvious at compile time that: + +```text +AgendaSnapshot has fixed capacity +EventRecord has fixed size +Reminder scheduler does not contain dynamic collections +Month occupancy is bounded +``` + +Record: + +```cpp +sizeof(EventRecord) +sizeof(AgendaSnapshot) +sizeof(ReminderScheduler state) +``` + +in a test or diagnostic assertion where appropriate. + +Unexpected growth should be treated as a regression. + +--- + +# 61. Forbidden implementation patterns + +The implementation agent MUST NOT introduce: + +```cpp +static std::vector g_events; +``` + +or equivalent. + +MUST NOT introduce: + +```cpp +std::vector +``` + +for ordinary query paths. + +MUST NOT create: + +```text +CalendarMapPage +``` + +MUST NOT make LVGL read/write LittleFS directly. + +MUST NOT make Calendar depend on PSRAM. + +MUST NOT make Location mandatory. + +MUST NOT keep all waypoint records in Agenda RAM. + +MUST NOT implement recurrence by copying event records. + +MUST NOT add Agenda service to arbitrary global contexts merely for convenience. + +MUST NOT enable Calendar on every PageManifest without target review. + +--- + +# 62. Definition of Done + +Calendar V1 is complete when all of the following are true: + +```text +Calendar is correctly registered through the PageManifest / UX Pack chain. + +Only approved target manifests expose it. + +Agenda is the default page. + +Events can be created, viewed, edited and deleted. + +An event requires only title + date/time. + +Location is optional. + +Reminder is optional. + +Events and saved waypoints survive restart on the same SD card. + +Agenda requires an available application-owned SD card. + +No card means no event/waypoint operations or reminder delivery. + +Removal invalidates cached state; validated remount rebuilds it from the card. + +No incomplete write is reported successful or silently retried on another card. + +No internal-flash fallback or automatic formatting exists. + +No full event table resides in RAM. + +Agenda queries are bounded. + +Reminder scheduler retains only bounded state. + +Reminder works while Calendar page is closed and SD storage remains available. + +480×222 has a dedicated usable layout. + +320×240 has a dedicated usable layout. + +Input follows target UX capabilities. + +All visible strings are localizable. + +Existing Map behaviour is unchanged before location integration. + +Map integration reuses the existing Map workspace. + +Core unit tests pass. + +Storage recovery tests pass. + +RAM budgets are respected. +``` + +--- + +# 63. Architectural intent + +The implementation must preserve this relationship: + +```text + ┌───────────────┐ + │ core_agenda │ + │ │ + │ Event │ + │ Query │ + │ Reminder │ + └───────┬───────┘ + │ + presentation source/sink + │ + ▼ + ┌───────────────────┐ + │ ui_presentation │ + │ bounded snapshot │ + └─────────┬─────────┘ + │ + ▼ + ┌───────────────────┐ + │ LVGL Agenda page │ + │ ui_shared / UX │ + └───────────────────┘ +``` + +Storage: + +```text +core_agenda + │ + IAgendaStore + │ + ▼ +platform adapter + │ + ▼ +shared SD runtime (SPI / SDMMC) + │ + ▼ +SD card (required; no internal-flash fallback) +``` + +Optional map path: + +```text +Agenda Event + │ +HasLocation + │ + ▼ +presentation action + │ + ▼ +existing Map workspace/runtime +``` + +The Map does not become part of the Agenda core. + +--- + +# 64. Final implementation principle + +When this document does not explicitly define a feature decision, choose the option with: + +```text +less permanent RAM +less dynamic allocation +fewer persistent states +fewer pages +fewer dependencies +smaller UI surface +better reuse of existing repository architecture +``` + +Do not use ambiguity as permission to add functionality. + +The feature should remain useful even if Map integration is entirely removed. + +The core product statement is: + +> Trail Mate Agenda reminds the user about something at the right time. Location and navigation are optional enhancements. diff --git a/modules/core_agenda/CMakeLists.txt b/modules/core_agenda/CMakeLists.txt new file mode 100644 index 00000000..703eefa7 --- /dev/null +++ b/modules/core_agenda/CMakeLists.txt @@ -0,0 +1,236 @@ +cmake_minimum_required(VERSION 3.20) +project(TrailMateAgenda LANGUAGES CXX) + +add_library(trailmate_core_agenda + src/event_codec.cpp src/agenda_service.cpp src/recurrence.cpp src/agenda_query.cpp + src/reminder_scheduler.cpp) +target_include_directories(trailmate_core_agenda PUBLIC include) +target_compile_features(trailmate_core_agenda PUBLIC cxx_std_17) + +include(CTest) +if(BUILD_TESTING) + add_executable(agenda_map_markers_test tests/test_map_markers.cpp + ../ui_presentation/src/agenda/agenda_workspace_model.cpp) + target_include_directories(agenda_map_markers_test PRIVATE ../ui_presentation/include) + target_link_libraries(agenda_map_markers_test PRIVATE trailmate_core_agenda) + if(MSVC) + target_compile_options(agenda_map_markers_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_map_markers_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_map_markers COMMAND agenda_map_markers_test) + add_executable(agenda_map_coordinates_test ../ui_map_runtime/tests/test_map_geo_coordinates.cpp) + target_include_directories(agenda_map_coordinates_test PRIVATE ../ui_map_runtime/include) + target_compile_features(agenda_map_coordinates_test PRIVATE cxx_std_17) + if(MSVC) + target_compile_options(agenda_map_coordinates_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_map_coordinates_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_map_coordinates COMMAND agenda_map_coordinates_test) + add_executable(agenda_map_runtime_test + ../ui_shared/tests/test_runtime_map_presentation_adapters.cpp + ../ui_shared/src/ui/presentation_sources/runtime_map_workspace_source.cpp + ../ui_shared/src/ui/team_presence/team_presence_model.cpp + ../ui_presentation/src/map/map_workspace_model.cpp + ../core_sys/src/sys/clock.cpp) + target_include_directories(agenda_map_runtime_test PRIVATE + ../ui_shared/include ../ui_presentation/include ../core_sys/include ../core_team/include) + target_compile_features(agenda_map_runtime_test PRIVATE cxx_std_17) + if(MSVC) + target_compile_options(agenda_map_runtime_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_map_runtime_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_map_runtime COMMAND agenda_map_runtime_test) + add_executable(agenda_location_test ../ui_presentation/tests/test_agenda_location.cpp) + target_include_directories(agenda_location_test PRIVATE ../ui_presentation/include include) + target_compile_features(agenda_location_test PRIVATE cxx_std_17) + if(MSVC) + target_compile_options(agenda_location_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_location_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_location COMMAND agenda_location_test) + foreach(map_test IN ITEMS map_workspace_model map_location_selection map_target_request) + add_executable(agenda_${map_test}_test + ../ui_presentation/tests/test_${map_test}.cpp + ../ui_presentation/src/map/map_workspace_model.cpp) + target_include_directories(agenda_${map_test}_test PRIVATE ../ui_presentation/include) + target_compile_features(agenda_${map_test}_test PRIVATE cxx_std_17) + if(MSVC) + target_compile_options(agenda_${map_test}_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_${map_test}_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_${map_test} COMMAND agenda_${map_test}_test) + endforeach() + add_executable(agenda_event_codec_test tests/test_event_codec.cpp) + target_link_libraries(agenda_event_codec_test PRIVATE trailmate_core_agenda) + if(MSVC) + target_compile_options(agenda_event_codec_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_event_codec_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_event_codec COMMAND agenda_event_codec_test) + add_executable(agenda_service_test tests/test_agenda_service.cpp) + target_link_libraries(agenda_service_test PRIVATE trailmate_core_agenda) + if(MSVC) + target_compile_options(agenda_service_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_service_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_service COMMAND agenda_service_test) + add_executable(agenda_recurrence_query_test tests/test_recurrence_query.cpp) + target_link_libraries(agenda_recurrence_query_test PRIVATE trailmate_core_agenda) + if(MSVC) + target_compile_options(agenda_recurrence_query_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_recurrence_query_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_recurrence_query COMMAND agenda_recurrence_query_test) + add_executable(agenda_reminder_scheduler_test tests/test_reminder_scheduler.cpp) + target_link_libraries(agenda_reminder_scheduler_test PRIVATE trailmate_core_agenda) + if(MSVC) + target_compile_options(agenda_reminder_scheduler_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_reminder_scheduler_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_reminder_scheduler COMMAND agenda_reminder_scheduler_test) + add_executable(agenda_calendar_limits_test tests/test_calendar_limits.cpp) + target_link_libraries(agenda_calendar_limits_test PRIVATE trailmate_core_agenda) + if(MSVC) + target_compile_options(agenda_calendar_limits_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_calendar_limits_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_calendar_limits COMMAND agenda_calendar_limits_test) + add_executable(agenda_file_store_test + tests/test_file_store.cpp + ../../platform/esp/common/src/storage/agenda_file_store.cpp) + target_include_directories(agenda_file_store_test PRIVATE ../../platform/esp/common/include) + target_link_libraries(agenda_file_store_test PRIVATE trailmate_core_agenda) + if(MSVC) + target_compile_options(agenda_file_store_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_file_store_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_file_store COMMAND agenda_file_store_test) + add_executable(agenda_sd_storage_test + tests/test_agenda_sd_storage.cpp + ../../platform/esp/arduino_common/src/storage/agenda_storage.cpp) + target_include_directories(agenda_sd_storage_test PRIVATE + tests/storage_stubs ../../platform/esp/arduino_common/include) + target_compile_features(agenda_sd_storage_test PRIVATE cxx_std_17) + if(MSVC) + target_compile_options(agenda_sd_storage_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_sd_storage_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_sd_storage COMMAND agenda_sd_storage_test) + add_executable(agenda_presentation_test + tests/test_agenda_presentation.cpp + ../ui_presentation/src/agenda/agenda_workspace_model.cpp) + target_include_directories(agenda_presentation_test PRIVATE ../ui_presentation/include) + target_link_libraries(agenda_presentation_test PRIVATE trailmate_core_agenda) + if(MSVC) + target_compile_options(agenda_presentation_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_presentation_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_presentation COMMAND agenda_presentation_test) + add_executable(agenda_composition_test + tests/test_agenda_composition.cpp + ../ui_presentation/src/agenda/agenda_workspace_model.cpp) + target_include_directories(agenda_composition_test PRIVATE + ../product_composition/include ../ui_presentation/include + ../../platform/esp/common/include) + target_link_libraries(agenda_composition_test PRIVATE trailmate_core_agenda) + if(MSVC) + target_compile_options(agenda_composition_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_composition_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_composition COMMAND agenda_composition_test) + + # Exercise the real target -> manifest -> UX -> menu -> binding chain. + set(TRAIL_MATE_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../..") + include("${TRAIL_MATE_REPO_ROOT}/cmake/TrailMateUxPacks.cmake") + trailmate_add_ui_lvgl_ux_packs(agenda_test_ux_packs) + add_executable(agenda_target_registration_test + ../ui_lvgl_ux_packs/tests/test_agenda_target_registration.cpp + ../product_composition/src/target_profile.cpp + ../product_composition/src/target_ux_binding.cpp) + target_link_libraries(agenda_target_registration_test PRIVATE agenda_test_ux_packs) + if(MSVC) + target_compile_options(agenda_target_registration_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_target_registration_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_target_registration COMMAND agenda_target_registration_test) + add_executable(agenda_disabled_target + ../../apps/esp32_lvgl/tests/agenda_disabled_target_smoke.cpp + ../../apps/esp32_lvgl/src/esp32_lvgl_arduino_agenda.cpp) + target_include_directories(agenda_disabled_target PRIVATE ../../apps/esp32_lvgl/src) + target_compile_features(agenda_disabled_target PRIVATE cxx_std_17) + if(MSVC) + target_compile_options(agenda_disabled_target PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_disabled_target PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_disabled_target COMMAND agenda_disabled_target) + foreach(target IN ITEMS wio_tracker_l2 tlora_pager tdeck) + if(target STREQUAL "wio_tracker_l2") + set(board_define ARDUINO_WIO_TRACKER_L2) + elseif(target STREQUAL "tlora_pager") + set(board_define ARDUINO_T_LORA_PAGER) + else() + set(board_define ARDUINO_T_DECK) + endif() + add_executable(agenda_config_${target} + ../../apps/esp32_lvgl/tests/agenda_runtime_config_smoke.cpp + ../../apps/esp32_lvgl/src/esp32_lvgl_runtime_config.cpp + ../product_composition/src/target_profile.cpp + ../product_composition/src/target_ux_binding.cpp) + target_include_directories(agenda_config_${target} PRIVATE ../../apps/esp32_lvgl/src) + target_compile_definitions(agenda_config_${target} PRIVATE ${board_define}=1 EXPECT_TARGET_ID="${target}") + target_link_libraries(agenda_config_${target} PRIVATE agenda_test_ux_packs) + if(MSVC) + target_compile_options(agenda_config_${target} PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_config_${target} PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_config_${target} COMMAND agenda_config_${target}) + endforeach() + foreach(case IN ITEMS + compatibility_menu_binding compatibility_screen_factory compatibility_ux_pack + cardputer_compact_ux_pack input_binding_set screen_registry simulator_full_ux_pack + t_display_p4_touch_ux_pack tiny_node_status_ux_pack uconsole_desktop_ux_pack + ux_menu_provider ux_pack_registry ux_screen_menu_adapter + lvgl_descriptor_menu_model lvgl_descriptor_renderer_probe lvgl_menu_runtime_adapter + lvgl_primary_screen_graph_runtime lvgl_runtime_adoption_probe lvgl_runtime_entry_adoption + lvgl_runtime_screen_graph_presenter lvgl_screen_graph_bridge lvgl_screen_host_adapter) + add_executable(agenda_regression_${case} ../ui_lvgl_ux_packs/tests/test_${case}.cpp) + target_link_libraries(agenda_regression_${case} PRIVATE agenda_test_ux_packs) + if(MSVC) + target_compile_options(agenda_regression_${case} PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_regression_${case} PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_regression_${case} COMMAND agenda_regression_${case}) + endforeach() + add_executable(agenda_regression_target_binding + ../product_composition/tests/test_target_ux_binding.cpp + ../product_composition/src/target_ux_binding.cpp) + target_link_libraries(agenda_regression_target_binding PRIVATE agenda_test_ux_packs) + add_executable(agenda_regression_page_manifest ../ui_presentation/tests/test_page_manifest.cpp) + target_link_libraries(agenda_regression_page_manifest PRIVATE agenda_test_ux_packs) + foreach(case IN ITEMS target_binding page_manifest) + if(MSVC) + target_compile_options(agenda_regression_${case} PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_regression_${case} PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_regression_${case} COMMAND agenda_regression_${case}) + endforeach() +endif() diff --git a/modules/core_agenda/include/agenda/domain/event.h b/modules/core_agenda/include/agenda/domain/event.h new file mode 100644 index 00000000..98c19bf6 --- /dev/null +++ b/modules/core_agenda/include/agenda/domain/event.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include + +namespace agenda +{ + +constexpr uint16_t kMaxActiveEvents = 64; +constexpr std::size_t kSlotBytes = 256; + +enum class RecordState : uint8_t +{ + Empty, + Active, + Deleted, +}; + +enum class Repeat : uint8_t +{ + None, + Daily, + Weekly, + Monthly, + Yearly, +}; + +enum class LocationType : uint8_t +{ + None, + Coordinate, + Waypoint, +}; + +enum EventFlags : uint8_t +{ + HasReminder = 1 << 0, + HasLocation = 1 << 1, + HasNote = 1 << 2, + HasEndTime = 1 << 3, +}; + +// Value object, never a serialized C++ memory image. The codec supplies the +// on-disk byte order, version and CRC independently of ABI/padding. +struct EventRecord +{ + int64_t start_time = 0; + int64_t end_time = 0; + uint32_t id = 0; + uint32_t reminder_offset_sec = 0; + int32_t latitude_e7 = 0; + int32_t longitude_e7 = 0; + RecordState state = RecordState::Empty; + uint8_t flags = 0; + Repeat repeat = Repeat::None; + LocationType location_type = LocationType::None; + char title[40]{}; + char location_name[32]{}; + char waypoint_id[16]{}; + char note[80]{}; +}; + +static_assert(sizeof(EventRecord) == 208, "Agenda record RAM budget changed"); + +bool validEvent(const EventRecord& record); + +} // namespace agenda diff --git a/modules/core_agenda/include/agenda/domain/event_codec.h b/modules/core_agenda/include/agenda/domain/event_codec.h new file mode 100644 index 00000000..8a26026c --- /dev/null +++ b/modules/core_agenda/include/agenda/domain/event_codec.h @@ -0,0 +1,21 @@ +#pragma once + +#include "agenda/domain/event.h" + +namespace agenda +{ + +enum class DecodeResult : uint8_t +{ + Ok, + UnsupportedVersion, + Corrupt, + InvalidRecord, +}; + +// Exactly one slot of caller-owned scratch storage. No heap allocation. +bool encodeEvent(const EventRecord& record, uint8_t (&out)[kSlotBytes]); +DecodeResult decodeEvent(const uint8_t (&bytes)[kSlotBytes], EventRecord& out); +uint32_t recordCrc32(const uint8_t* bytes, std::size_t size); + +} // namespace agenda diff --git a/modules/core_agenda/include/agenda/domain/recurrence.h b/modules/core_agenda/include/agenda/domain/recurrence.h new file mode 100644 index 00000000..e9cb172a --- /dev/null +++ b/modules/core_agenda/include/agenda/domain/recurrence.h @@ -0,0 +1,37 @@ +#pragma once + +#include "agenda/domain/event.h" + +namespace agenda +{ + +// Calendar-local seconds since 1970-01-01, not UTC. Platform clock adapters +// translate the product timezone. Repetition preserves the local time of day. +constexpr int64_t kLastCalendarSecond = 253402300799LL; // 9999-12-31 23:59:59 +struct CivilTime +{ + uint16_t year = 1970; + uint8_t month = 1; + uint8_t day = 1; + uint8_t hour = 0; + uint8_t minute = 0; + uint8_t second = 0; +}; + +uint8_t daysInMonth(uint16_t year, uint8_t month); +bool toCalendarSeconds(const CivilTime& civil, int64_t& seconds); +bool fromCalendarSeconds(int64_t seconds, CivilTime& civil); + +struct Occurrence +{ + int64_t start = 0; + int64_t end = 0; + uint32_t event_id = 0; + bool has_end = false; +}; + +// Inclusive lower bound. Invalid dates are skipped, never normalized: Jan 31 +// monthly -> Mar 31, Feb 29 yearly -> next leap year's Feb 29. No stored copies. +bool nextOccurrence(const EventRecord& event, int64_t not_before, Occurrence& out); + +} // namespace agenda diff --git a/modules/core_agenda/include/agenda/ports/agenda_clock.h b/modules/core_agenda/include/agenda/ports/agenda_clock.h new file mode 100644 index 00000000..0321c96f --- /dev/null +++ b/modules/core_agenda/include/agenda/ports/agenda_clock.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace agenda +{ +struct ClockSample +{ + int64_t calendar_seconds = 0; + uint64_t monotonic_seconds = 0; + uint32_t revision = 0; // Changes when timezone/time authority is reconfigured. + bool valid = false; +}; + +class IAgendaClock +{ + public: + virtual ~IAgendaClock() = default; + virtual ClockSample sample() const = 0; +}; +} // namespace agenda diff --git a/modules/core_agenda/include/agenda/ports/agenda_store.h b/modules/core_agenda/include/agenda/ports/agenda_store.h new file mode 100644 index 00000000..ef37a6c1 --- /dev/null +++ b/modules/core_agenda/include/agenda/ports/agenda_store.h @@ -0,0 +1,31 @@ +#pragma once + +#include "agenda/domain/event.h" + +namespace agenda +{ + +enum class StoreResult : uint8_t +{ + Ok, + IoError, + Corrupt, + UnsupportedVersion, + InvalidRecord, + OutOfRange, +}; + +// Composition supplies a single owner for calls. Implementations must not +// hydrate an in-memory table. Empty/deleted slots are readable records, not I/O +// failures. A successful write means the adapter's durable commit completed. +class IAgendaStore +{ + public: + virtual ~IAgendaStore() = default; + virtual StoreResult readSlot(uint16_t slot, EventRecord& out) = 0; + virtual StoreResult writeSlot(uint16_t slot, const EventRecord& record) = 0; + virtual StoreResult eraseSlot(uint16_t slot) = 0; + virtual uint16_t slotCount() const = 0; +}; + +} // namespace agenda diff --git a/modules/core_agenda/include/agenda/ports/reminder_sink.h b/modules/core_agenda/include/agenda/ports/reminder_sink.h new file mode 100644 index 00000000..0b91bcb3 --- /dev/null +++ b/modules/core_agenda/include/agenda/ports/reminder_sink.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace agenda +{ +struct Reminder +{ + int64_t occurrence_start = 0; + int64_t trigger_time = 0; + uint32_t event_id = 0; + bool valid = false; +}; + +class IReminderSink +{ + public: + virtual ~IReminderSink() = default; + // The sink may defer while another modal owns input. Never call scheduler + // actions reentrantly from present(). No LVGL type crosses this port. + virtual bool present(const Reminder& reminder) = 0; + virtual void withdraw() = 0; +}; +} // namespace agenda diff --git a/modules/core_agenda/include/agenda/usecase/agenda_query.h b/modules/core_agenda/include/agenda/usecase/agenda_query.h new file mode 100644 index 00000000..b504f46c --- /dev/null +++ b/modules/core_agenda/include/agenda/usecase/agenda_query.h @@ -0,0 +1,40 @@ +#pragma once + +#include "agenda/domain/recurrence.h" +#include "agenda/ports/agenda_store.h" + +namespace agenda +{ + +constexpr uint8_t kMaxAgendaRows = 10; +struct AgendaEntry +{ + Occurrence occurrence; + char title[40]{}; + char location[32]{}; + uint8_t flags = 0; +}; + +struct AgendaPage +{ + AgendaEntry rows[kMaxAgendaRows]{}; + uint8_t count = 0; + bool has_more = false; + bool degraded = false; +}; +static_assert(sizeof(AgendaPage) < 2048, "Agenda query snapshot exceeded RAM budget"); + +struct QueryCursor +{ + int64_t start = 0; + uint32_t event_id = 0; + bool valid = false; +}; + +// [begin,end), sorted by (occurrence start,event id), strictly after cursor. +// Caller selects a capacity based on visible geometry. No hidden full table. +StoreResult queryAgenda(IAgendaStore& store, int64_t begin, int64_t end, + uint8_t capacity, const QueryCursor& after, AgendaPage& out); +StoreResult queryMonth(IAgendaStore& store, uint16_t year, uint8_t month, uint32_t& occupied_days); + +} // namespace agenda diff --git a/modules/core_agenda/include/agenda/usecase/agenda_service.h b/modules/core_agenda/include/agenda/usecase/agenda_service.h new file mode 100644 index 00000000..39771091 --- /dev/null +++ b/modules/core_agenda/include/agenda/usecase/agenda_service.h @@ -0,0 +1,36 @@ +#pragma once + +#include "agenda/ports/agenda_store.h" + +namespace agenda +{ + +enum class AgendaResult : uint8_t +{ + Ok, + Invalid, + NotFound, + Full, + StorageError, + IdExhausted, +}; + +// Single-owner service. One bounded operation-local record, no resident table. The caller +// retains its draft until the durable write succeeds. IDs never change on edit. +class AgendaService +{ + public: + explicit AgendaService(IAgendaStore& store) : store_(store) {} + AgendaResult create(const EventRecord& draft, uint32_t& created_id); + AgendaResult update(const EventRecord& event); + AgendaResult remove(uint32_t id); + AgendaResult read(uint32_t id, EventRecord& out); + + private: + AgendaResult find(uint32_t id, uint16_t& slot, EventRecord& scratch_); + IAgendaStore& store_; +}; + +static_assert(sizeof(AgendaService) == sizeof(void*), "Agenda service must not retain records"); + +} // namespace agenda diff --git a/modules/core_agenda/include/agenda/usecase/reminder_scheduler.h b/modules/core_agenda/include/agenda/usecase/reminder_scheduler.h new file mode 100644 index 00000000..c1dcc1cc --- /dev/null +++ b/modules/core_agenda/include/agenda/usecase/reminder_scheduler.h @@ -0,0 +1,61 @@ +#pragma once + +#include "agenda/ports/agenda_clock.h" +#include "agenda/ports/agenda_store.h" +#include "agenda/ports/reminder_sink.h" + +namespace agenda +{ + +// Next reminder + at most one explicit snooze, never a queue of future events. +// Startup ignores already-past triggers. Dismissal consumes only one occurrence. +// All calls belong to the same composition-owned runtime, not the Agenda page. +class ReminderScheduler +{ + public: + ReminderScheduler(IAgendaStore& store, IAgendaClock& clock, IReminderSink& sink) + : store_(store), clock_(clock), sink_(sink) {} + void tick(); + void eventsChanged(); + // Media identity/lifetime boundary: no reminder state may cross it. + void resetStorage() + { + sink_.withdraw(); + next_ = {}; + snoozed_ = {}; + consumed_ = {}; + previous_ = {}; + floor_ = 0; + snooze_deadline_ = 0; + status_ = StoreResult::Ok; + initialized_ = false; + dirty_ = true; + showing_ = false; + showing_snooze_ = false; + } + bool dismiss(); + bool snooze(); + bool showing() const { return showing_; } + StoreResult status() const { return status_; } + + private: + void scan(int64_t now); + void consume(); + IAgendaStore& store_; + IAgendaClock& clock_; + IReminderSink& sink_; + Reminder next_{}; + Reminder snoozed_{}; + Reminder consumed_{}; + ClockSample previous_{}; + int64_t floor_ = 0; + uint64_t snooze_deadline_ = 0; + StoreResult status_ = StoreResult::Ok; + bool initialized_ = false; + bool dirty_ = true; + bool showing_ = false; + bool showing_snooze_ = false; +}; + +static_assert(sizeof(ReminderScheduler) < 256, "Reminder scheduler exceeded preferred RAM budget"); +} // namespace agenda diff --git a/modules/core_agenda/library.json b/modules/core_agenda/library.json new file mode 100644 index 00000000..a02f9ce0 --- /dev/null +++ b/modules/core_agenda/library.json @@ -0,0 +1,8 @@ +{ + "name": "core_agenda", + "version": "0.1.0", + "build": { + "includeDir": "include", + "srcDir": "src" + } +} diff --git a/modules/core_agenda/src/agenda_query.cpp b/modules/core_agenda/src/agenda_query.cpp new file mode 100644 index 00000000..231f6f95 --- /dev/null +++ b/modules/core_agenda/src/agenda_query.cpp @@ -0,0 +1,96 @@ +#include "agenda/usecase/agenda_query.h" + +#include + +namespace agenda +{ +namespace +{ +bool before(const Occurrence& left, const Occurrence& right) +{ + return left.start < right.start || (left.start == right.start && left.event_id < right.event_id); +} +} // namespace + +StoreResult queryAgenda(IAgendaStore& store, int64_t begin, int64_t end, + uint8_t capacity, const QueryCursor& after, AgendaPage& out) +{ + out = {}; + if (capacity == 0 || capacity > kMaxAgendaRows || begin < 0 || end <= begin || + end > kLastCalendarSecond + 1 || store.slotCount() > kMaxActiveEvents) + return StoreResult::OutOfRange; + EventRecord event; + for (uint16_t slot = 0; slot < store.slotCount(); ++slot) + { + const auto status = store.readSlot(slot, event); + if (status == StoreResult::Corrupt || status == StoreResult::InvalidRecord) + { + out.degraded = true; + continue; + } + if (status != StoreResult::Ok) + { + out = {}; + return status; + } + int64_t lower = begin; + if (after.valid && after.start >= lower) + { + if (after.start >= end) continue; + lower = after.start + (event.id <= after.event_id ? 1 : 0); + } + Occurrence occurrence; + // At most capacity+1 occurrences per persistent event are needed to + // determine the top N and the continuation bit, even for distant dates. + for (uint8_t n = 0; n <= capacity && nextOccurrence(event, lower, occurrence); ++n) + { + if (occurrence.start >= end) break; + uint8_t index = 0; + while (index < out.count && !before(occurrence, out.rows[index].occurrence)) ++index; + if (out.count == capacity) out.has_more = true; + if (index == capacity) break; + if (out.count < capacity) ++out.count; + for (uint8_t pos = out.count - 1; pos > index; --pos) out.rows[pos] = out.rows[pos - 1]; + auto& row = out.rows[index]; + row = {}; + row.occurrence = occurrence; + row.flags = event.flags; + std::memcpy(row.title, event.title, sizeof(row.title)); + if (event.flags & HasLocation) std::memcpy(row.location, event.location_name, sizeof(row.location)); + lower = occurrence.start + 1; + } + } + return StoreResult::Ok; +} + +StoreResult queryMonth(IAgendaStore& store, uint16_t year, uint8_t month, uint32_t& occupied_days) +{ + occupied_days = 0; + CivilTime first; + first.year = year; + first.month = month; + int64_t begin = 0; + if (!toCalendarSeconds(first, begin) || store.slotCount() > kMaxActiveEvents) return StoreResult::OutOfRange; + const int64_t end = begin + daysInMonth(year, month) * 86400; + EventRecord event; + for (uint16_t slot = 0; slot < store.slotCount(); ++slot) + { + const auto status = store.readSlot(slot, event); + if (status != StoreResult::Ok) + { + occupied_days = 0; + return status; + } + int64_t lower = begin; + Occurrence occurrence; + while (nextOccurrence(event, lower, occurrence) && occurrence.start < end) + { + const auto day_index = static_cast((occurrence.start - begin) / 86400); + occupied_days |= uint32_t{1} << day_index; + lower = occurrence.start + 1; + } + } + return StoreResult::Ok; +} + +} // namespace agenda diff --git a/modules/core_agenda/src/agenda_service.cpp b/modules/core_agenda/src/agenda_service.cpp new file mode 100644 index 00000000..6f1f819f --- /dev/null +++ b/modules/core_agenda/src/agenda_service.cpp @@ -0,0 +1,83 @@ +#include "agenda/usecase/agenda_service.h" + +#include + +namespace agenda +{ + +AgendaResult AgendaService::find(uint32_t id, uint16_t& slot, EventRecord& scratch_) +{ + if (id == 0) return AgendaResult::Invalid; + if (store_.slotCount() > kMaxActiveEvents) return AgendaResult::StorageError; + for (slot = 0; slot < store_.slotCount(); ++slot) + { + if (store_.readSlot(slot, scratch_) != StoreResult::Ok) + return AgendaResult::StorageError; + if (scratch_.state == RecordState::Active && scratch_.id == id) + return AgendaResult::Ok; + } + return AgendaResult::NotFound; +} + +AgendaResult AgendaService::create(const EventRecord& draft, uint32_t& created_id) +{ + created_id = 0; + EventRecord scratch_ = draft; + scratch_.state = RecordState::Active; + scratch_.id = 1; + if (!validEvent(scratch_)) return AgendaResult::Invalid; + const uint16_t count = store_.slotCount(); + if (count == 0 || count > kMaxActiveEvents) return AgendaResult::StorageError; + uint16_t available = count; + uint32_t highest_id = 0; + for (uint16_t slot = 0; slot < count; ++slot) + { + if (store_.readSlot(slot, scratch_) != StoreResult::Ok) + return AgendaResult::StorageError; + // Tombstones retain IDs, including the highest deleted event. Reusing + // that slot writes a still higher ID, preserving the high-water mark. + if (scratch_.id > highest_id) highest_id = scratch_.id; + if (scratch_.state != RecordState::Active && available == count) available = slot; + } + if (available == count) return AgendaResult::Full; + if (highest_id == std::numeric_limits::max()) return AgendaResult::IdExhausted; + scratch_ = draft; + scratch_.state = RecordState::Active; + scratch_.id = highest_id + 1; + if (store_.writeSlot(available, scratch_) != StoreResult::Ok) return AgendaResult::StorageError; + created_id = scratch_.id; + return AgendaResult::Ok; +} + +AgendaResult AgendaService::update(const EventRecord& event) +{ + if (event.state != RecordState::Active || !validEvent(event)) return AgendaResult::Invalid; + uint16_t slot = 0; + EventRecord scratch_; + const auto result = find(event.id, slot, scratch_); + if (result != AgendaResult::Ok) return result; + return store_.writeSlot(slot, event) == StoreResult::Ok ? AgendaResult::Ok : AgendaResult::StorageError; +} + +AgendaResult AgendaService::remove(uint32_t id) +{ + uint16_t slot = 0; + EventRecord scratch_; + const auto result = find(id, slot, scratch_); + if (result != AgendaResult::Ok) return result; + scratch_ = {}; + scratch_.id = id; + scratch_.state = RecordState::Deleted; + return store_.writeSlot(slot, scratch_) == StoreResult::Ok ? AgendaResult::Ok : AgendaResult::StorageError; +} + +AgendaResult AgendaService::read(uint32_t id, EventRecord& out) +{ + out = {}; + uint16_t slot = 0; + const auto result = find(id, slot, out); + if (result != AgendaResult::Ok) out = {}; + return result; +} + +} // namespace agenda diff --git a/modules/core_agenda/src/event_codec.cpp b/modules/core_agenda/src/event_codec.cpp new file mode 100644 index 00000000..0367f41c --- /dev/null +++ b/modules/core_agenda/src/event_codec.cpp @@ -0,0 +1,158 @@ +#include "agenda/domain/event_codec.h" +#include "agenda/domain/recurrence.h" + +#include + +namespace agenda +{ +namespace +{ +constexpr uint8_t kVersion = 1; +constexpr std::size_t kCrcOffset = kSlotBytes - 4; + +void put32(uint8_t* out, uint32_t value) +{ + for (unsigned i = 0; i < 4; ++i) + out[i] = static_cast(value >> (8 * i)); +} + +uint32_t get32(const uint8_t* in) +{ + uint32_t value = 0; + for (unsigned i = 0; i < 4; ++i) + value |= static_cast(in[i]) << (8 * i); + return value; +} + +void put64(uint8_t* out, int64_t value) +{ + const auto bits = static_cast(value); + for (unsigned i = 0; i < 8; ++i) + out[i] = static_cast(bits >> (8 * i)); +} + +int64_t get64(const uint8_t* in) +{ + uint64_t bits = 0; + for (unsigned i = 0; i < 8; ++i) + bits |= static_cast(in[i]) << (8 * i); + // Avoid implementation-defined conversion of unsigned values above INT64_MAX. + return (bits & (uint64_t{1} << 63)) ? -1 - static_cast(~bits) : static_cast(bits); +} + +int32_t signed32(uint32_t bits) +{ + return (bits & 0x80000000U) ? -1 - static_cast(~bits) : static_cast(bits); +} + +template +bool terminated(const char (&text)[N]) +{ + return std::memchr(text, '\0', N) != nullptr; +} + +bool hasTitle(const char* title) +{ + for (; *title; ++title) + if (*title != ' ' && *title != '\t' && *title != '\r' && *title != '\n') return true; + return false; +} + +bool reminderPreset(uint32_t offset) +{ + return offset == 0 || offset == 600 || offset == 1800 || offset == 3600 || offset == 86400; +} +} // namespace + +bool validEvent(const EventRecord& record) +{ + if (record.state > RecordState::Deleted) return false; + if (record.state != RecordState::Active) return true; + if (record.id == 0 || record.start_time < 0 || record.start_time > kLastCalendarSecond || + record.repeat > Repeat::Yearly || record.location_type > LocationType::Waypoint || + (record.flags & ~uint8_t{HasReminder | HasLocation | HasNote | HasEndTime}) != 0) + return false; + if (!terminated(record.title) || !terminated(record.location_name) || + !terminated(record.waypoint_id) || !terminated(record.note) || !hasTitle(record.title)) + return false; + if ((record.flags & HasEndTime) && + (record.end_time <= record.start_time || record.end_time > kLastCalendarSecond)) return false; + if ((record.flags & HasReminder) && !reminderPreset(record.reminder_offset_sec)) return false; + if (record.flags & HasLocation) + { + if (record.location_type == LocationType::None || + record.latitude_e7 < -900000000 || record.latitude_e7 > 900000000 || + record.longitude_e7 < -1800000000 || record.longitude_e7 > 1800000000) + return false; + if (record.location_type == LocationType::Waypoint && record.waypoint_id[0] == '\0') return false; + } + return true; +} + +uint32_t recordCrc32(const uint8_t* bytes, std::size_t size) +{ + uint32_t crc = 0xffffffffU; + for (std::size_t i = 0; i < size; ++i) + { + crc ^= bytes[i]; + for (unsigned bit = 0; bit < 8; ++bit) + crc = (crc >> 1) ^ (0xedb88320U & (0U - (crc & 1U))); + } + return ~crc; +} + +bool encodeEvent(const EventRecord& record, uint8_t (&out)[kSlotBytes]) +{ + if (!validEvent(record)) return false; + std::memset(out, 0, sizeof(out)); + out[0] = 'A'; + out[1] = 'G'; + out[2] = kVersion; + out[3] = static_cast(record.state); + put32(out + 4, record.id); + if (record.state == RecordState::Active) + { + put64(out + 8, record.start_time); + put64(out + 16, record.end_time); + put32(out + 24, record.reminder_offset_sec); + put32(out + 28, static_cast(record.latitude_e7)); + put32(out + 32, static_cast(record.longitude_e7)); + out[36] = record.flags; + out[37] = static_cast(record.repeat); + out[38] = static_cast(record.location_type); + std::memcpy(out + 40, record.title, sizeof(record.title)); + std::memcpy(out + 80, record.location_name, sizeof(record.location_name)); + std::memcpy(out + 112, record.waypoint_id, sizeof(record.waypoint_id)); + std::memcpy(out + 128, record.note, sizeof(record.note)); + } + put32(out + kCrcOffset, recordCrc32(out, kCrcOffset)); + return true; +} + +DecodeResult decodeEvent(const uint8_t (&bytes)[kSlotBytes], EventRecord& out) +{ + out = {}; + if (bytes[0] != 'A' || bytes[1] != 'G' || + recordCrc32(bytes, kCrcOffset) != get32(bytes + kCrcOffset)) + return DecodeResult::Corrupt; + if (bytes[2] != kVersion) return DecodeResult::UnsupportedVersion; + out.state = static_cast(bytes[3]); + out.id = get32(bytes + 4); + out.start_time = get64(bytes + 8); + out.end_time = get64(bytes + 16); + out.reminder_offset_sec = get32(bytes + 24); + out.latitude_e7 = signed32(get32(bytes + 28)); + out.longitude_e7 = signed32(get32(bytes + 32)); + out.flags = bytes[36]; + out.repeat = static_cast(bytes[37]); + out.location_type = static_cast(bytes[38]); + std::memcpy(out.title, bytes + 40, sizeof(out.title)); + std::memcpy(out.location_name, bytes + 80, sizeof(out.location_name)); + std::memcpy(out.waypoint_id, bytes + 112, sizeof(out.waypoint_id)); + std::memcpy(out.note, bytes + 128, sizeof(out.note)); + if (validEvent(out)) return DecodeResult::Ok; + out = {}; + return DecodeResult::InvalidRecord; +} + +} // namespace agenda diff --git a/modules/core_agenda/src/recurrence.cpp b/modules/core_agenda/src/recurrence.cpp new file mode 100644 index 00000000..366ed414 --- /dev/null +++ b/modules/core_agenda/src/recurrence.cpp @@ -0,0 +1,112 @@ +#include "agenda/domain/recurrence.h" + +#include + +namespace agenda +{ +namespace +{ +int64_t daysBeforeYear(uint16_t year) +{ + const int64_t previous = year - 1; + return previous * 365 + previous / 4 - previous / 100 + previous / 400; +} + +bool emit(const EventRecord& event, int64_t start, Occurrence& out) +{ + const bool has_end = (event.flags & HasEndTime) != 0; + const int64_t duration = has_end ? event.end_time - event.start_time : 0; + if (start > kLastCalendarSecond - duration) return false; + out.start = start; + out.end = start + duration; + out.event_id = event.id; + out.has_end = has_end; + return true; +} +} // namespace + +uint8_t daysInMonth(uint16_t year, uint8_t month) +{ + constexpr uint8_t lengths[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (month < 1 || month > 12) return 0; + const bool leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + return lengths[month - 1] + (month == 2 && leap ? 1 : 0); +} + +bool toCalendarSeconds(const CivilTime& civil, int64_t& seconds) +{ + seconds = 0; + if (civil.year < 1970 || civil.year > 9999 || civil.month < 1 || civil.month > 12 || + civil.day < 1 || civil.day > daysInMonth(civil.year, civil.month) || + civil.hour > 23 || civil.minute > 59 || civil.second > 59) + return false; + int64_t days = daysBeforeYear(civil.year) - daysBeforeYear(1970) + civil.day - 1; + for (uint8_t month = 1; month < civil.month; ++month) days += daysInMonth(civil.year, month); + seconds = days * 86400 + civil.hour * 3600 + civil.minute * 60 + civil.second; + return true; +} + +bool fromCalendarSeconds(int64_t seconds, CivilTime& civil) +{ + civil = {}; + if (seconds < 0 || seconds > kLastCalendarSecond) return false; + const int64_t absolute_days = seconds / 86400 + daysBeforeYear(1970); + uint16_t low = 1970; + uint16_t high = 10000; + while (high - low > 1) + { + const auto middle = static_cast(low + (high - low) / 2); + if (daysBeforeYear(middle) <= absolute_days) low = middle; + else high = middle; + } + civil.year = low; + int64_t day = absolute_days - daysBeforeYear(low); + while (day >= daysInMonth(low, civil.month)) day -= daysInMonth(low, civil.month++); + civil.day = static_cast(day + 1); + const int64_t within_day = seconds % 86400; + civil.hour = static_cast(within_day / 3600); + civil.minute = static_cast(within_day % 3600 / 60); + civil.second = static_cast(within_day % 60); + return true; +} + +bool nextOccurrence(const EventRecord& event, int64_t not_before, Occurrence& out) +{ + out = {}; + if (event.state != RecordState::Active || !validEvent(event) || + event.start_time > kLastCalendarSecond || not_before > kLastCalendarSecond || + ((event.flags & HasEndTime) && event.end_time > kLastCalendarSecond)) + return false; + const int64_t lower = std::max(event.start_time, not_before); + if (lower == event.start_time) return emit(event, lower, out); + if (event.repeat == Repeat::None) return false; + if (event.repeat == Repeat::Daily || event.repeat == Repeat::Weekly) + { + const int64_t period = event.repeat == Repeat::Daily ? 86400 : 7 * 86400; + const int64_t elapsed = lower - event.start_time; + const int64_t steps = elapsed / period + (elapsed % period != 0); + return emit(event, event.start_time + steps * period, out); + } + CivilTime anchor; + CivilTime candidate; + if (!fromCalendarSeconds(event.start_time, anchor) || !fromCalendarSeconds(lower, candidate)) return false; + candidate.day = anchor.day; + candidate.hour = anchor.hour; + candidate.minute = anchor.minute; + candidate.second = anchor.second; + if (event.repeat == Repeat::Yearly) candidate.month = anchor.month; + while (candidate.year <= 9999) + { + int64_t start = 0; + if (toCalendarSeconds(candidate, start) && start >= lower) return emit(event, start, out); + if (event.repeat == Repeat::Yearly) ++candidate.year; + else if (++candidate.month > 12) + { + candidate.month = 1; + ++candidate.year; + } + } + return false; +} + +} // namespace agenda diff --git a/modules/core_agenda/src/reminder_scheduler.cpp b/modules/core_agenda/src/reminder_scheduler.cpp new file mode 100644 index 00000000..72f250c9 --- /dev/null +++ b/modules/core_agenda/src/reminder_scheduler.cpp @@ -0,0 +1,147 @@ +#include "agenda/usecase/reminder_scheduler.h" + +#include "agenda/domain/recurrence.h" + +#include + +namespace agenda +{ +namespace +{ +bool earlier(const Reminder& a, const Reminder& b) +{ + return a.trigger_time < b.trigger_time || + (a.trigger_time == b.trigger_time && a.event_id < b.event_id); +} +} // namespace + +void ReminderScheduler::scan(int64_t now) +{ + next_ = {}; + status_ = StoreResult::Ok; + dirty_ = false; + if (store_.slotCount() > kMaxActiveEvents) + { + status_ = StoreResult::OutOfRange; + return; + } + EventRecord event; + bool snooze_exists = !snoozed_.valid; + for (uint16_t slot = 0; slot < store_.slotCount(); ++slot) + { + const auto result = store_.readSlot(slot, event); + if (result != StoreResult::Ok) + { + status_ = result; + // Corrupt slots do not disable healthy reminders; I/O failure is + // reported, without hammering flash again on every normal tick. + continue; + } + if (event.state != RecordState::Active || !(event.flags & HasReminder)) continue; + Occurrence occurrence; + if (snoozed_.valid && event.id == snoozed_.event_id && + nextOccurrence(event, snoozed_.occurrence_start, occurrence) && + occurrence.start == snoozed_.occurrence_start) + snooze_exists = true; + int64_t lower = floor_; + if (consumed_.valid && consumed_.trigger_time >= lower) + lower = consumed_.trigger_time + (event.id <= consumed_.event_id ? 1 : 0); + if (lower > kLastCalendarSecond - event.reminder_offset_sec) continue; + if (!nextOccurrence(event, lower + event.reminder_offset_sec, occurrence)) continue; + Reminder candidate; + candidate.event_id = event.id; + candidate.occurrence_start = occurrence.start; + candidate.trigger_time = occurrence.start - event.reminder_offset_sec; + candidate.valid = true; + if (!next_.valid || earlier(candidate, next_)) next_ = candidate; + } + if (!snooze_exists) snoozed_ = {}; + (void)now; +} + +void ReminderScheduler::eventsChanged() +{ + if (showing_) + { + sink_.withdraw(); + showing_ = false; + } + dirty_ = true; +} + +void ReminderScheduler::tick() +{ + const auto current = clock_.sample(); + if (!current.valid || current.calendar_seconds < 0 || current.calendar_seconds > kLastCalendarSecond) + { + if (showing_) sink_.withdraw(); + showing_ = false; + previous_ = current; + dirty_ = true; + return; + } + if (!initialized_) + { + initialized_ = true; + floor_ = current.calendar_seconds; + } + else if (!previous_.valid) + { + floor_ = current.calendar_seconds; + dirty_ = true; + } + else + { + const int64_t wall_delta = current.calendar_seconds - previous_.calendar_seconds; + const uint64_t monotonic_delta = current.monotonic_seconds - previous_.monotonic_seconds; + if (current.revision != previous_.revision || current.monotonic_seconds < previous_.monotonic_seconds || + monotonic_delta > static_cast(kLastCalendarSecond) || + wall_delta < static_cast(monotonic_delta) - 2 || + wall_delta > static_cast(monotonic_delta) + 2) + { + // Time corrections do not replay the entire missed calendar. + floor_ = current.calendar_seconds; + dirty_ = true; + } + } + previous_ = current; + if (showing_) return; + if (dirty_) scan(current.calendar_seconds); + if (snoozed_.valid && current.monotonic_seconds >= snooze_deadline_) + { + showing_snooze_ = true; + showing_ = sink_.present(snoozed_); + } + else if (next_.valid && current.calendar_seconds >= next_.trigger_time) + { + showing_snooze_ = false; + showing_ = sink_.present(next_); + } +} + +void ReminderScheduler::consume() +{ + if (showing_snooze_) snoozed_ = {}; + else consumed_ = next_; + showing_ = false; + sink_.withdraw(); + dirty_ = true; +} + +bool ReminderScheduler::dismiss() +{ + if (!showing_) return false; + consume(); + return true; +} + +bool ReminderScheduler::snooze() +{ + if (!showing_ || (snoozed_.valid && !showing_snooze_)) return false; + const auto reminder = showing_snooze_ ? snoozed_ : next_; + consume(); + snoozed_ = reminder; + snooze_deadline_ = previous_.monotonic_seconds + 600; + return true; +} +} // namespace agenda diff --git a/modules/core_agenda/tests/storage_stubs/FFat.h b/modules/core_agenda/tests/storage_stubs/FFat.h new file mode 100644 index 00000000..8fcf8532 --- /dev/null +++ b/modules/core_agenda/tests/storage_stubs/FFat.h @@ -0,0 +1,8 @@ +#pragma once +#include +struct FakeFat +{ + bool begin(bool format, const char* path, uint8_t files, const char* label); +}; +extern FakeFat FFat; +void yield(); diff --git a/modules/core_agenda/tests/storage_stubs/esp_partition.h b/modules/core_agenda/tests/storage_stubs/esp_partition.h new file mode 100644 index 00000000..a567843e --- /dev/null +++ b/modules/core_agenda/tests/storage_stubs/esp_partition.h @@ -0,0 +1,12 @@ +#pragma once +#include +#include +struct esp_partition_t +{ + uint32_t size; +}; +constexpr int ESP_OK = 0; +constexpr int ESP_PARTITION_TYPE_DATA = 1; +constexpr int ESP_PARTITION_SUBTYPE_DATA_FAT = 0x81; +const esp_partition_t* esp_partition_find_first(int type, int subtype, const char* label); +int esp_partition_read(const esp_partition_t* partition, std::size_t offset, void* out, std::size_t size); diff --git a/modules/core_agenda/tests/storage_stubs/platform/esp/arduino_common/storage/sd_card_runtime.h b/modules/core_agenda/tests/storage_stubs/platform/esp/arduino_common/storage/sd_card_runtime.h new file mode 100644 index 00000000..bfd7796c --- /dev/null +++ b/modules/core_agenda/tests/storage_stubs/platform/esp/arduino_common/storage/sd_card_runtime.h @@ -0,0 +1,19 @@ +#pragma once +#include +namespace platform::esp::arduino_common::storage +{ +bool sd_card_ready(); +uint32_t sd_media_session(); +enum class SdMediaStatus : uint8_t +{ + Ready, + Busy, + Unavailable, + IoError +}; +SdMediaStatus sd_probe_media(); +bool sd_recover_media(); +bool sd_external_block_owner_active(); +bool sd_is_directory(const char* path); +bool sd_mkdir(const char* path); +} // namespace platform::esp::arduino_common::storage diff --git a/modules/core_agenda/tests/storage_stubs/sys/stat.h b/modules/core_agenda/tests/storage_stubs/sys/stat.h new file mode 100644 index 00000000..072a90a4 --- /dev/null +++ b/modules/core_agenda/tests/storage_stubs/sys/stat.h @@ -0,0 +1,13 @@ +#pragma once +#define stat agenda_test_stat +#define mkdir agenda_test_mkdir +#ifdef S_ISDIR +#undef S_ISDIR +#endif +struct stat +{ + unsigned st_mode; +}; +#define S_ISDIR(mode) (((mode)&040000) != 0) +int stat(const char* path, struct stat* info); +int mkdir(const char* path, unsigned mode); diff --git a/modules/core_agenda/tests/test_agenda_composition.cpp b/modules/core_agenda/tests/test_agenda_composition.cpp new file mode 100644 index 00000000..0cc8c622 --- /dev/null +++ b/modules/core_agenda/tests/test_agenda_composition.cpp @@ -0,0 +1,222 @@ +#include "platform/esp/common/storage/agenda_file_store.h" +#include "product_composition/agenda_composition.h" + +#include +#include +#include + +namespace +{ +class Store final : public agenda::IAgendaStore +{ + public: + agenda::EventRecord slots[2]{}; // Test-only in-memory store. + unsigned reads = 0; + agenda::StoreResult readSlot(uint16_t slot, agenda::EventRecord& out) override + { + ++reads; + out = slots[slot]; + return agenda::StoreResult::Ok; + } + agenda::StoreResult writeSlot(uint16_t slot, const agenda::EventRecord& event) override + { + slots[slot] = event; + return agenda::StoreResult::Ok; + } + agenda::StoreResult eraseSlot(uint16_t) override { return agenda::StoreResult::IoError; } + uint16_t slotCount() const override { return 2; } +}; + +class Clock final : public agenda::IAgendaClock +{ + public: + agenda::ClockSample value{100000, 0, 0, true}; + agenda::ClockSample sample() const override { return value; } + void advance(uint32_t seconds) + { + value.calendar_seconds += seconds; + value.monotonic_seconds += seconds; + } +}; + +// Match the production ownership shape, including adapter memory rather than +// testing only the scheduler in isolation. Clock's test sample storage is also +// charged here (the production clock adapter itself is stateless). +struct TargetOwnedAgenda +{ + platform::esp::storage::AgendaFileStore store{"unused", "unused.tmp"}; + Clock clock; + product_composition::AgendaComposition composition{store, clock}; +}; +static_assert(sizeof(TargetOwnedAgenda) < 1024, "Total idle Agenda runtime exceeded 1 KiB"); + +void test_reminder_bridge() +{ + ui::agenda::AgendaReminderModel bridge; + agenda::Reminder reminder{101000, 100400, 1, true}; + assert(!bridge.present(reminder)); // No live shell yet. + bridge.setDeliveryEnabled(true); + assert(bridge.present(reminder)); + const auto first = bridge.reminderSnapshot(); + assert(first.reminder.valid && first.revision != 0); + assert(!bridge.present(reminder)); // Cannot overwrite an unhandled reminder. + bridge.setDeliveryEnabled(false); + assert(bridge.reminderSnapshot().revision == first.revision); + bridge.withdraw(); + assert(!bridge.reminderSnapshot().reminder.valid); + assert(bridge.reminderSnapshot().revision != first.revision); + const auto cleared = bridge.reminderSnapshot().revision; + bridge.withdraw(); + assert(bridge.reminderSnapshot().revision == cleared); +} + +void test_page_independent_lifecycle() +{ + Store store; + Clock clock; + product_composition::AgendaComposition composition(store, clock); + ui::workspace::PresentationWorkspace workspace; + assert(!workspace.hasAgenda()); + assert(!ui::workspace::hasInteractiveWorkspaceModels(workspace)); + composition.bind(workspace); + assert(workspace.hasAgenda()); + assert(ui::workspace::hasInteractiveWorkspaceModels(workspace)); + composition.setStorageReady(true); + auto& model = *workspace.agenda; + auto& reminders = *workspace.agenda_reminders; + // The page/draft ends its lifetime before any reminder is delivered. + { + ui::agenda::AgendaEditorModel draft; + assert(model.newDraft(draft)); + std::strcpy(draft.event.title, "Radio check"); + draft.event.start_time = clock.value.calendar_seconds + 660; + draft.event.reminder_offset_sec = 600; + draft.event.flags = agenda::HasReminder; // No location needed. + assert(model.save(draft.event).ok); + composition.tick(true); + assert(model.commandResult().state == ui::agenda::CommandState::Succeeded); + // A second event at the same trigger exercises serial presentation. + std::strcpy(draft.event.title, "Check battery"); + assert(model.save(draft.event).ok); + composition.tick(true); + } + const auto reads = store.reads; + for (unsigned i = 0; i < 60; ++i) + { + clock.advance(1); + composition.tick(false); // Another modal owns input. + assert(!reminders.reminderSnapshot().reminder.valid); + } + assert(store.reads == reads); // No per-tick flash scan or retry queue. + composition.tick(true); + auto first = reminders.reminderSnapshot(); + assert(first.reminder.valid && first.reminder.event_id == 1); + assert(!model.dismissReminder(0).ok); + assert(model.snoozeReminder(first.revision).ok); + assert(reminders.reminderSnapshot().revision == first.revision); // Deferred action. + composition.tick(true); + assert(model.commandResult().state == ui::agenda::CommandState::Succeeded); + auto second = reminders.reminderSnapshot(); + assert(second.reminder.valid && second.reminder.event_id == 2); + assert(second.revision != first.revision); + assert(!model.dismissReminder(first.revision).ok); // Old popup cannot dismiss the next event. + assert(model.snoozeReminder(second.revision).ok); + composition.tick(true); + assert(model.commandResult().state == ui::agenda::CommandState::Failed); + assert(model.commandResult().result == agenda::AgendaResult::Full); + assert(reminders.reminderSnapshot().revision == second.revision); // No silent replacement. + assert(model.dismissReminder(second.revision).ok); + composition.tick(true); + assert(!reminders.reminderSnapshot().reminder.valid); + clock.advance(600); + composition.tick(true); + auto snoozed = reminders.reminderSnapshot(); + assert(snoozed.reminder.valid && snoozed.reminder.event_id == 1); + assert(snoozed.revision != first.revision); + assert(!model.dismissReminder(first.revision).ok); // Same ID/occurrence, new presentation. + assert(model.dismissReminder(snoozed.revision).ok); + composition.tick(true); + assert(!reminders.reminderSnapshot().reminder.valid); +} + +void test_queued_action_invalidated() +{ + Store store; + Clock clock; + agenda::AgendaService service(store); + ui::agenda::AgendaReminderModel reminders; + agenda::ReminderScheduler scheduler(store, clock, reminders); + ui::agenda::AgendaWorkspaceModel model(store, service, clock, scheduler, reminders); + model.setStorageReady(true); + reminders.setDeliveryEnabled(true); + auto& event = store.slots[0]; + event.state = agenda::RecordState::Active; + event.id = 1; + std::strcpy(event.title, "Due now"); + event.start_time = clock.value.calendar_seconds; + event.flags = agenda::HasReminder; + model.pump(); + const auto first = reminders.reminderSnapshot(); + assert(first.reminder.valid); + assert(model.dismissReminder(first.revision).ok); + // Model action was accepted, but the reminder was withdrawn before pump. + scheduler.eventsChanged(); + scheduler.tick(); + const auto replacement = reminders.reminderSnapshot(); + assert(replacement.reminder.valid && replacement.revision != first.revision); + model.pump(); + assert(model.commandResult().state == ui::agenda::CommandState::Failed); + assert(model.commandResult().result == agenda::AgendaResult::NotFound); + assert(reminders.reminderSnapshot().revision == replacement.revision); +} +} // namespace + +void test_storage_lifetime_boundary() +{ + Store store; + Clock clock; + product_composition::AgendaComposition composition(store, clock); + ui::workspace::PresentationWorkspace workspace; + composition.bind(workspace); + auto& model = *workspace.agenda; + auto& reminders = *workspace.agenda_reminders; + ui::agenda::AgendaEditorModel draft; + assert(!model.newDraft(draft)); + composition.setStorageReady(true); + assert(model.newDraft(draft)); + std::strcpy(draft.event.title, "Must not cross cards"); + assert(model.save(draft.event).ok); + composition.setStorageReady(false); + assert(model.commandResult().state == ui::agenda::CommandState::Failed); + composition.setStorageReady(true); // No pump between removal and insertion. + composition.tick(true); + assert(store.slots[0].id == 0 && store.slots[1].id == 0); + + draft.event.start_time = clock.value.calendar_seconds; + draft.event.flags = agenda::HasReminder; + assert(model.save(draft.event).ok); + composition.tick(true); + auto shown = reminders.reminderSnapshot(); + assert(shown.reminder.valid); + assert(model.snoozeReminder(shown.revision).ok); + composition.tick(true); + composition.setStorageReady(false); + assert(!reminders.reminderSnapshot().reminder.valid); + store.slots[0] = {}; // Replacement medium contains no events. + composition.setStorageReady(true); + clock.advance(600); + composition.tick(true); + assert(!reminders.reminderSnapshot().reminder.valid); + assert(!model.dismissReminder(shown.revision).ok); +} + +int main() +{ + test_reminder_bridge(); + test_page_independent_lifecycle(); + test_queued_action_invalidated(); + test_storage_lifetime_boundary(); + std::printf("AgendaComposition=%zu ReminderBridge=%zu TargetOwnedAgenda=%zu\n", + sizeof(product_composition::AgendaComposition), sizeof(ui::agenda::AgendaReminderModel), + sizeof(TargetOwnedAgenda)); +} diff --git a/modules/core_agenda/tests/test_agenda_flash_mount.cpp b/modules/core_agenda/tests/test_agenda_flash_mount.cpp new file mode 100644 index 00000000..8a2ed002 --- /dev/null +++ b/modules/core_agenda/tests/test_agenda_flash_mount.cpp @@ -0,0 +1,103 @@ +#include "platform/esp/arduino_common/storage/agenda_storage.h" + +#include +#include +#include + +#include +#include +#include + +namespace +{ +bool mounted = false; +bool agenda_exists = false; +bool present = true; +bool mountable = false; +bool readable = true; +bool writable = true; +unsigned mounts = 0; +unsigned formats = 0; +unsigned reads = 0; +uint8_t flash[1024]; +esp_partition_t partition{sizeof(flash)}; + +void reset() +{ + mounted = agenda_exists = mountable = false; + present = readable = writable = true; + mounts = formats = reads = 0; + std::memset(flash, 0xff, sizeof(flash)); +} +} // namespace + +FakeFat FFat; +void yield() {} +bool FakeFat::begin(bool format, const char* path, uint8_t files, const char* label) +{ + assert(std::strcmp(path, "/fs") == 0 && files == 10 && std::strcmp(label, "ffat") == 0); + ++mounts; + if (format) ++formats; + // Simulate wear-level metadata written by the first mount attempt. + flash[0] = 0; + mounted = mountable || (format && writable); + return mounted; +} +const esp_partition_t* esp_partition_find_first(int type, int subtype, const char* label) +{ + assert(type == ESP_PARTITION_TYPE_DATA && subtype == ESP_PARTITION_SUBTYPE_DATA_FAT); + assert(std::strcmp(label, "ffat") == 0); + return present ? &partition : nullptr; +} +int esp_partition_read(const esp_partition_t* source, std::size_t offset, void* out, std::size_t size) +{ + ++reads; + assert(source == &partition && offset + size <= sizeof(flash)); + if (!readable) return -1; + std::memcpy(out, flash + offset, size); + return ESP_OK; +} +int stat(const char* path, struct stat* info) +{ + const bool exists = std::strcmp(path, "/fs") == 0 ? mounted : agenda_exists; + info->st_mode = exists ? 040000 : 0; + return exists ? 0 : -1; +} +int mkdir(const char* path, unsigned mode) +{ + assert(std::strcmp(path, "/fs/agenda") == 0 && mode == 0700); + if (!mounted || !writable) + { + errno = EACCES; + return -1; + } + agenda_exists = true; + return 0; +} + +int main() +{ + using platform::esp::arduino_common::storage::prepare_agenda_storage; + reset(); + assert(prepare_agenda_storage()); + assert(mounts == 2 && formats == 1 && reads == 8 && agenda_exists); + assert(prepare_agenda_storage() && mounts == 2); // Shared existing mount. + + reset(); + flash[1023] = 0; // Must inspect the whole partition, not just its header. + assert(!prepare_agenda_storage() && formats == 0); + reset(); + readable = false; + assert(!prepare_agenda_storage() && formats == 0); + reset(); + present = false; + assert(!prepare_agenda_storage() && mounts == 0); + reset(); + flash[0] = 0; + mountable = true; + assert(prepare_agenda_storage() && formats == 0); + reset(); + mounted = true; + writable = false; + assert(!prepare_agenda_storage() && mounts == 0 && formats == 0); +} diff --git a/modules/core_agenda/tests/test_agenda_presentation.cpp b/modules/core_agenda/tests/test_agenda_presentation.cpp new file mode 100644 index 00000000..8ecbeb62 --- /dev/null +++ b/modules/core_agenda/tests/test_agenda_presentation.cpp @@ -0,0 +1,102 @@ +#include "ui_presentation/agenda/agenda_workspace_model.h" + +#include +#include +#include + +namespace +{ +class Store final : public agenda::IAgendaStore +{ + public: + agenda::EventRecord slots[2]{}; + unsigned writes = 0; + bool fail = false; + agenda::StoreResult readSlot(uint16_t slot, agenda::EventRecord& out) override + { + out = slots[slot]; + return agenda::StoreResult::Ok; + } + agenda::StoreResult writeSlot(uint16_t slot, const agenda::EventRecord& event) override + { + ++writes; + if (fail) return agenda::StoreResult::IoError; + slots[slot] = event; + return agenda::StoreResult::Ok; + } + agenda::StoreResult eraseSlot(uint16_t) override { return agenda::StoreResult::IoError; } + uint16_t slotCount() const override { return 2; } +}; +class Clock final : public agenda::IAgendaClock +{ + public: + agenda::ClockSample value{100000, 0, 0, true}; + agenda::ClockSample sample() const override { return value; } +}; +} // namespace + +int main() +{ + using namespace ui::agenda; + Store store; + Clock clock; + AgendaReminderModel sink; + ::agenda::AgendaService service(store); + ::agenda::ReminderScheduler scheduler(store, clock, sink); + AgendaWorkspaceModel model(store, service, clock, scheduler, sink); + AgendaEditorModel draft; + assert(!model.newDraft(draft)); + assert(!model.save(draft.event).ok && store.writes == 0); + model.setStorageReady(true); + assert(model.newDraft(draft)); + assert(draft.event.id == 0 && draft.event.start_time == 100020); + assert(!model.save(draft.event).ok); // Required title. + std::strcpy(draft.event.title, "Radio check"); + assert(model.save(draft.event).ok); + assert(store.writes == 0 && model.commandResult().state == CommandState::Pending); + assert(model.remove(123).failure == ui::UiActionFailure::Busy); + // The command owns a copy; later UI edits cannot mutate a queued save. + std::strcpy(draft.event.title, "Unsaved edit"); + model.pump(); + assert(store.writes == 1 && model.commandResult().state == CommandState::Succeeded); + assert(model.commandResult().event_id == 1); + ::agenda::EventRecord detail; + assert(model.detail(1, detail) == ::agenda::AgendaResult::Ok); + assert(std::strcmp(detail.title, "Radio check") == 0); + + AgendaSnapshot snapshot; + AgendaRequest request; + request.day_start = 86400; + model.snapshot(request, snapshot); + assert(snapshot.clock_valid && snapshot.storage_ready && snapshot.page.count == 1); + assert(snapshot.revision == 2 && snapshot.today_start == 86400); + MonthSnapshot month; + model.month(1970, 1, month); + assert(month.occupied_days == 2); // January 2. + + store.fail = true; + detail.flags = ::agenda::HasNote; + std::strcpy(detail.note, "New note"); + assert(model.save(detail).ok); + model.pump(); + assert(model.commandResult().state == CommandState::Failed); + assert(model.commandResult().result == ::agenda::AgendaResult::StorageError); + assert(model.detail(1, detail) == ::agenda::AgendaResult::Ok && detail.note[0] == '\0'); + store.fail = false; + assert(model.remove(1).ok); + assert(model.detail(1, detail) == ::agenda::AgendaResult::Ok); + model.pump(); + assert(model.detail(1, detail) == ::agenda::AgendaResult::NotFound); + + // A write queued before storage becomes unavailable must terminate Failed. + assert(model.save(draft.event).ok); + model.setStorageReady(false); + model.pump(); + assert(model.commandResult().state == CommandState::Failed); + model.snapshot(request, snapshot); + assert(!snapshot.storage_ready && snapshot.page.count == 0); + clock.value.valid = false; + assert(!model.newDraft(draft)); + std::printf("AgendaWorkspaceModel=%zu AgendaSnapshot=%zu EditorDraft=%zu AgendaService=%zu\n", + sizeof(AgendaWorkspaceModel), sizeof(AgendaSnapshot), sizeof(AgendaEditorModel), sizeof(service)); +} diff --git a/modules/core_agenda/tests/test_agenda_sd_storage.cpp b/modules/core_agenda/tests/test_agenda_sd_storage.cpp new file mode 100644 index 00000000..825ca083 --- /dev/null +++ b/modules/core_agenda/tests/test_agenda_sd_storage.cpp @@ -0,0 +1,73 @@ +#include "platform/esp/arduino_common/storage/agenda_storage.h" +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include +#include + +namespace +{ +bool ready = false; +bool external = false; +bool directory = false; +bool writable = true; +unsigned creates = 0; +unsigned recoveries = 0; +auto media_status = platform::esp::arduino_common::storage::SdMediaStatus::Ready; +} // namespace +namespace platform::esp::arduino_common::storage +{ +bool sd_card_ready() { return ready; } +uint32_t sd_media_session() { return 1; } +SdMediaStatus sd_probe_media() { return media_status; } +bool sd_recover_media() +{ + ++recoveries; + return false; +} +bool sd_external_block_owner_active() { return external; } +bool sd_is_directory(const char* path) +{ + assert(!std::strcmp(path, "/agenda")); + return directory; +} +bool sd_mkdir(const char* path) +{ + assert(!std::strcmp(path, "/agenda")); + ++creates; + if (!writable) return false; + directory = true; + return true; +} +} // namespace platform::esp::arduino_common::storage +int main() +{ + using platform::esp::arduino_common::storage::prepare_agenda_storage; + assert(!prepare_agenda_storage() && creates == 0); + ready = true; + external = true; + assert(!prepare_agenda_storage() && creates == 0); + external = false; + writable = false; + assert(!prepare_agenda_storage() && creates == 1); + writable = true; + assert(prepare_agenda_storage() && creates == 2); + assert(prepare_agenda_storage() && creates == 2); + ready = false; + assert(!prepare_agenda_storage() && creates == 2); + ready = true; + assert(prepare_agenda_storage() && creates == 2); + using namespace platform::esp::arduino_common::storage; + assert(agenda_storage_available()); + media_status = SdMediaStatus::Busy; + assert(agenda_storage_available()); // A busy transport is not card removal. + assert(recoveries == 0); + media_status = SdMediaStatus::IoError; + assert(!agenda_storage_available()); + media_status = SdMediaStatus::Unavailable; + assert(!agenda_storage_available()); + assert(recoveries == 2); + external = true; + assert(!agenda_storage_available() && recoveries == 2); + external = false; + ready = false; + assert(!agenda_storage_available() && recoveries == 3); +} diff --git a/modules/core_agenda/tests/test_agenda_service.cpp b/modules/core_agenda/tests/test_agenda_service.cpp new file mode 100644 index 00000000..ebb57729 --- /dev/null +++ b/modules/core_agenda/tests/test_agenda_service.cpp @@ -0,0 +1,95 @@ +#include "agenda/domain/event_codec.h" +#include "agenda/usecase/agenda_service.h" + +#include +#include +#include + +namespace +{ +// The test double emulates persistent bytes. Production stores must not keep +// this table in RAM. A fresh service over these bytes models process reload. +class Store final : public agenda::IAgendaStore +{ + public: + uint8_t slots[agenda::kMaxActiveEvents][agenda::kSlotBytes]{}; + bool fail_write = false; + bool fail_read = false; + + Store() + { + agenda::EventRecord empty{}; + for (auto& slot : slots) assert(agenda::encodeEvent(empty, slot)); + } + agenda::StoreResult readSlot(uint16_t slot, agenda::EventRecord& out) override + { + if (fail_read) return agenda::StoreResult::IoError; + if (slot >= slotCount()) return agenda::StoreResult::OutOfRange; + return agenda::decodeEvent(slots[slot], out) == agenda::DecodeResult::Ok + ? agenda::StoreResult::Ok + : agenda::StoreResult::Corrupt; + } + agenda::StoreResult writeSlot(uint16_t slot, const agenda::EventRecord& record) override + { + if (fail_write) return agenda::StoreResult::IoError; + if (slot >= slotCount()) return agenda::StoreResult::OutOfRange; + return agenda::encodeEvent(record, slots[slot]) ? agenda::StoreResult::Ok : agenda::StoreResult::InvalidRecord; + } + agenda::StoreResult eraseSlot(uint16_t slot) override + { + agenda::EventRecord record{}; + auto result = readSlot(slot, record); + if (result != agenda::StoreResult::Ok) return result; + record.state = agenda::RecordState::Deleted; + return writeSlot(slot, record); + } + uint16_t slotCount() const override { return agenda::kMaxActiveEvents; } +}; +} // namespace + +int main() +{ + using namespace agenda; + Store store; + AgendaService service(store); + EventRecord draft{}; + draft.start_time = 1790000000; + uint32_t id = 999; + assert(service.create(draft, id) == AgendaResult::Invalid && id == 0); + std::strcpy(draft.title, "Radio check"); + assert(service.create(draft, id) == AgendaResult::Ok && id == 1); + assert(draft.id == 0 && draft.state == RecordState::Empty); + EventRecord loaded; + assert(service.read(1, loaded) == AgendaResult::Ok); + loaded.flags = HasReminder; + loaded.reminder_offset_sec = 600; + assert(service.update(loaded) == AgendaResult::Ok); + AgendaService reloaded(store); + assert(reloaded.read(1, loaded) == AgendaResult::Ok && loaded.reminder_offset_sec == 600); + assert(reloaded.remove(1) == AgendaResult::Ok); + assert(reloaded.read(1, loaded) == AgendaResult::NotFound); + assert(reloaded.create(draft, id) == AgendaResult::Ok && id == 2); + assert(store.readSlot(0, loaded) == StoreResult::Ok && loaded.id == 2); + for (uint32_t expected = 3; expected <= 65; ++expected) + assert(reloaded.create(draft, id) == AgendaResult::Ok && id == expected); + assert(reloaded.create(draft, id) == AgendaResult::Full && id == 0); + assert(reloaded.remove(65) == AgendaResult::Ok); + assert(reloaded.create(draft, id) == AgendaResult::Ok && id == 66); + assert(reloaded.remove(66) == AgendaResult::Ok); + store.fail_write = true; + assert(reloaded.create(draft, id) == AgendaResult::StorageError && id == 0); + store.fail_write = false; + assert(reloaded.create(draft, id) == AgendaResult::Ok && id == 67); + store.fail_read = true; + assert(reloaded.remove(2) == AgendaResult::StorageError); + store.fail_read = false; + store.slots[0][100] ^= 1; + assert(reloaded.create(draft, id) == AgendaResult::StorageError); + assert(reloaded.read(2, loaded) == AgendaResult::StorageError); + store.slots[0][100] ^= 1; + assert(reloaded.remove(67) == AgendaResult::Ok); + assert(store.readSlot(63, loaded) == StoreResult::Ok); + loaded.id = std::numeric_limits::max(); + assert(store.writeSlot(63, loaded) == StoreResult::Ok); + assert(reloaded.create(draft, id) == AgendaResult::IdExhausted); +} diff --git a/modules/core_agenda/tests/test_calendar_limits.cpp b/modules/core_agenda/tests/test_calendar_limits.cpp new file mode 100644 index 00000000..824b2ba3 --- /dev/null +++ b/modules/core_agenda/tests/test_calendar_limits.cpp @@ -0,0 +1,76 @@ +#include "agenda/domain/recurrence.h" +#include "agenda/usecase/reminder_scheduler.h" + +#include +#include + +namespace +{ +class Clock final : public agenda::IAgendaClock +{ + public: + agenda::ClockSample value{1000, 0, 0, true}; + agenda::ClockSample sample() const override { return value; } +}; +class Store final : public agenda::IAgendaStore +{ + public: + agenda::EventRecord record; + agenda::StoreResult readSlot(uint16_t, agenda::EventRecord& out) override + { + out = record; + return agenda::StoreResult::Ok; + } + agenda::StoreResult writeSlot(uint16_t, const agenda::EventRecord&) override { return agenda::StoreResult::IoError; } + agenda::StoreResult eraseSlot(uint16_t) override { return agenda::StoreResult::IoError; } + uint16_t slotCount() const override { return 1; } +}; +class Sink final : public agenda::IReminderSink +{ + public: + bool present(const agenda::Reminder&) override { return true; } + void withdraw() override {} +}; +} // namespace + +int main() +{ + using namespace agenda; + Store store; + auto& record = store.record; + record.id = 1; + record.state = RecordState::Active; + std::strcpy(record.title, "Radio check"); + record.start_time = kLastCalendarSecond; + assert(validEvent(record)); + record.start_time++; + assert(!validEvent(record)); + record.start_time = 1000; + record.flags = HasEndTime; + record.end_time = kLastCalendarSecond + 1; + assert(!validEvent(record)); + record.end_time = kLastCalendarSecond; + assert(validEvent(record)); + + record.flags = HasReminder; + record.start_time = 1100; + Clock clock; + Sink sink; + ReminderScheduler scheduler(store, clock, sink); + scheduler.tick(); + assert(!scheduler.showing()); + clock.value.valid = false; + scheduler.tick(); + clock.value.calendar_seconds = 1200; + clock.value.monotonic_seconds = 200; + clock.value.valid = true; + scheduler.tick(); + assert(!scheduler.showing()); // No stale popup when time authority returns. + record.start_time = 1300; + scheduler.eventsChanged(); + scheduler.tick(); + clock.value.calendar_seconds = 1300; + clock.value.monotonic_seconds = 300; + scheduler.tick(); + assert(scheduler.showing()); +} diff --git a/modules/core_agenda/tests/test_event_codec.cpp b/modules/core_agenda/tests/test_event_codec.cpp new file mode 100644 index 00000000..e24e0cfd --- /dev/null +++ b/modules/core_agenda/tests/test_event_codec.cpp @@ -0,0 +1,78 @@ +#include "agenda/domain/event_codec.h" + +#include +#include +#include + +int main() +{ + using namespace agenda; + static_assert(kMaxActiveEvents == 64); + uint8_t bytes[kSlotBytes]{}; + EventRecord event{}; + EventRecord decoded{}; + assert(encodeEvent(event, bytes)); + assert(decodeEvent(bytes, decoded) == DecodeResult::Ok); + assert(decoded.state == RecordState::Empty); + + event.state = RecordState::Active; + event.id = 42; + event.start_time = 1790000000; + std::strcpy(event.title, "Radio check"); + assert(validEvent(event)); // Point event: no location, reminder or note. + assert(encodeEvent(event, bytes)); + assert(bytes[4] == 42 && bytes[5] == 0); + assert(decodeEvent(bytes, decoded) == DecodeResult::Ok); + assert(decoded.id == 42 && decoded.start_time == event.start_time); + assert(std::strcmp(decoded.title, event.title) == 0 && decoded.flags == 0); + + event.flags = HasReminder | HasLocation | HasEndTime | HasNote; + event.end_time = event.start_time + 3600; + event.reminder_offset_sec = 600; + event.location_type = LocationType::Coordinate; + event.latitude_e7 = -900000000; + event.longitude_e7 = -1800000000; + std::strcpy(event.note, "Verify equipment"); + event.repeat = Repeat::Weekly; + assert(encodeEvent(event, bytes)); + assert(decodeEvent(bytes, decoded) == DecodeResult::Ok); + assert(decoded.latitude_e7 == event.latitude_e7); + assert(decoded.longitude_e7 == event.longitude_e7); + assert(decoded.end_time == event.end_time && decoded.repeat == Repeat::Weekly); + for (std::size_t i = 0; i < kSlotBytes; ++i) + { + bytes[i] ^= 0x01; + assert(decodeEvent(bytes, decoded) == DecodeResult::Corrupt); + assert(decoded.state == RecordState::Empty); + bytes[i] ^= 0x01; + } + + event.end_time = event.start_time; + assert(!validEvent(event)); + event.end_time += 3600; + event.latitude_e7 = 900000001; + assert(!validEvent(event)); + event.latitude_e7 = 0; + event.reminder_offset_sec = 601; + assert(!validEvent(event)); + event.reminder_offset_sec = 0; + std::memset(event.title, 'a', sizeof(event.title)); + assert(!validEvent(event)); + std::strcpy(event.title, " \t"); + assert(!validEvent(event)); + std::strcpy(event.title, "Radio check"); + event.location_type = LocationType::Waypoint; + assert(!validEvent(event)); + std::strcpy(event.waypoint_id, "camp"); + assert(validEvent(event)); + + event.state = RecordState::Deleted; + assert(encodeEvent(event, bytes)); + assert(decodeEvent(bytes, decoded) == DecodeResult::Ok); + assert(decoded.state == RecordState::Deleted && decoded.id == 42); + assert(decoded.title[0] == '\0'); + + constexpr char known[] = "123456789"; + assert(recordCrc32(reinterpret_cast(known), 9) == 0xcbf43926U); + std::printf("EventRecord=%zu slot=%zu max_events=%u\n", sizeof(EventRecord), kSlotBytes, kMaxActiveEvents); +} diff --git a/modules/core_agenda/tests/test_file_store.cpp b/modules/core_agenda/tests/test_file_store.cpp new file mode 100644 index 00000000..c4527957 --- /dev/null +++ b/modules/core_agenda/tests/test_file_store.cpp @@ -0,0 +1,163 @@ +#include "agenda/domain/event_codec.h" +#include "agenda/usecase/agenda_service.h" +#include "platform/esp/common/storage/agenda_file_store.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using FileStore = platform::esp::storage::AgendaFileStore; +using namespace agenda; + +struct TemporaryDirectory +{ + std::filesystem::path path; + TemporaryDirectory() + { + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + path = std::filesystem::temp_directory_path() / ("trailmate-agenda-" + std::to_string(stamp)); + assert(std::filesystem::create_directory(path)); + } + ~TemporaryDirectory() { std::filesystem::remove_all(path); } +}; + +void readBytes(const std::string& path, std::size_t offset, uint8_t* data, std::size_t size) +{ + std::ifstream stream(path, std::ios::binary); + stream.seekg(static_cast(offset)); + stream.read(reinterpret_cast(data), static_cast(size)); + assert(stream.good()); +} +void writeBytes(const std::string& path, std::size_t offset, const uint8_t* data, std::size_t size) +{ + std::fstream stream(path, std::ios::binary | std::ios::in | std::ios::out); + stream.seekp(static_cast(offset)); + stream.write(reinterpret_cast(data), static_cast(size)); + stream.flush(); + assert(stream.good()); +} +void refreshCrc(uint8_t (&bank)[kSlotBytes]) +{ + const auto crc = recordCrc32(bank, kSlotBytes - 4); + for (unsigned i = 0; i < 4; ++i) bank[kSlotBytes - 4 + i] = static_cast(crc >> (8 * i)); +} +} // namespace + +int main() +{ + TemporaryDirectory directory; + const auto path = (directory.path / "events.dat").string(); + const auto init = (directory.path / "events.init").string(); + FileStore store(path.c_str(), init.c_str()); + assert(store.begin() == StoreResult::Ok); + assert(std::filesystem::file_size(path) == FileStore::kFileBytes); + EventRecord out; + for (uint16_t slot = 0; slot < 64; ++slot) + assert(store.readSlot(slot, out) == StoreResult::Ok && out.state == RecordState::Empty); + assert(store.readSlot(64, out) == StoreResult::OutOfRange); + + AgendaService service(store); + EventRecord draft; + draft.start_time = 100000; + std::strcpy(draft.title, "Radio check"); + uint32_t id = 0; + assert(service.create(draft, id) == AgendaResult::Ok && id == 1); + FileStore reload(path.c_str(), init.c_str()); + assert(reload.begin() == StoreResult::Ok); + assert(reload.readSlot(0, out) == StoreResult::Ok && out.id == 1); + assert(std::strcmp(out.title, "Radio check") == 0); + + // Preserve a committed bank and model power interruption after every + // possible byte prefix of the alternate-bank update. + uint8_t old_bank[kSlotBytes]; + uint8_t new_bank[kSlotBytes]; + readBytes(path, FileStore::kHeaderBytes, old_bank, sizeof(old_bank)); + std::strcpy(out.title, "Check campsite"); + out.flags = HasReminder; + out.reminder_offset_sec = 600; + assert(reload.writeSlot(0, out) == StoreResult::Ok); + readBytes(path, FileStore::kHeaderBytes, new_bank, sizeof(new_bank)); + for (std::size_t prefix = 0; prefix <= kSlotBytes; ++prefix) + { + writeBytes(path, FileStore::kHeaderBytes, old_bank, sizeof(old_bank)); + writeBytes(path, FileStore::kHeaderBytes, new_bank, prefix); + FileStore crash_reload(path.c_str(), init.c_str()); + assert(crash_reload.begin() == StoreResult::Ok); + assert(crash_reload.readSlot(0, out) == StoreResult::Ok); + assert(out.id == 1 && out.state == RecordState::Active); + assert(std::strcmp(out.title, "Radio check") == 0 || std::strcmp(out.title, "Check campsite") == 0); + } + assert(std::strcmp(out.title, "Check campsite") == 0); + assert(reload.eraseSlot(0) == StoreResult::Ok); + FileStore after_delete(path.c_str(), init.c_str()); + assert(after_delete.begin() == StoreResult::Ok); + assert(after_delete.readSlot(0, out) == StoreResult::Ok && out.state == RecordState::Deleted && out.id == 1); + AgendaService reloaded_service(after_delete); + assert(reloaded_service.create(draft, id) == AgendaResult::Ok && id == 2); + for (uint32_t next = 3; next <= 65; ++next) + assert(reloaded_service.create(draft, id) == AgendaResult::Ok && id == next); + assert(reloaded_service.create(draft, id) == AgendaResult::Full); + + // Both copies corrupt: fail closed, never silently recreate the database. + readBytes(path, FileStore::kHeaderBytes, old_bank, sizeof(old_bank)); + readBytes(path, FileStore::kHeaderBytes + kSlotBytes, new_bank, sizeof(new_bank)); + uint8_t corrupt[kSlotBytes]; + // Reusing one decode destination must preserve either surviving bank, + // including bank 0 after the corrupt bank 1 decode has cleared the output. + EventRecord surviving; + assert(decodeEvent(old_bank, surviving) == DecodeResult::Ok); + std::memcpy(corrupt, new_bank, sizeof(corrupt)); + corrupt[40] ^= 1; + writeBytes(path, FileStore::kHeaderBytes + kSlotBytes, corrupt, sizeof(corrupt)); + assert(after_delete.readSlot(0, out) == StoreResult::Ok); + assert(out.id == surviving.id && out.state == surviving.state); + assert(std::strcmp(out.title, surviving.title) == 0); + writeBytes(path, FileStore::kHeaderBytes + kSlotBytes, new_bank, sizeof(new_bank)); + + std::memcpy(corrupt, old_bank, sizeof(corrupt)); + corrupt[40] ^= 1; + writeBytes(path, FileStore::kHeaderBytes, corrupt, sizeof(corrupt)); + assert(decodeEvent(new_bank, surviving) == DecodeResult::Ok); + assert(after_delete.readSlot(0, out) == StoreResult::Ok); + assert(out.id == surviving.id && out.state == surviving.state); + assert(std::strcmp(out.title, surviving.title) == 0); + std::memcpy(corrupt, new_bank, sizeof(corrupt)); + corrupt[40] ^= 1; + writeBytes(path, FileStore::kHeaderBytes + kSlotBytes, corrupt, sizeof(corrupt)); + assert(after_delete.readSlot(0, out) == StoreResult::Corrupt && out.state == RecordState::Empty); + assert(after_delete.readSlot(1, out) == StoreResult::Ok && out.id == 3); + writeBytes(path, FileStore::kHeaderBytes, old_bank, sizeof(old_bank)); + writeBytes(path, FileStore::kHeaderBytes + kSlotBytes, new_bank, sizeof(new_bank)); + + // A future-format bank must not be silently downgraded to an older copy. + std::memcpy(corrupt, old_bank, sizeof(corrupt)); + corrupt[2] = 2; + refreshCrc(corrupt); + writeBytes(path, FileStore::kHeaderBytes, corrupt, sizeof(corrupt)); + assert(after_delete.readSlot(0, out) == StoreResult::UnsupportedVersion); + writeBytes(path, FileStore::kHeaderBytes, old_bank, sizeof(old_bank)); + + // A valid bank copied to the wrong logical slot must be rejected. + writeBytes(path, FileStore::kHeaderBytes + 2 * kSlotBytes, old_bank, sizeof(old_bank)); + writeBytes(path, FileStore::kHeaderBytes + 3 * kSlotBytes, old_bank, sizeof(old_bank)); + assert(after_delete.readSlot(1, out) == StoreResult::Corrupt); + + // Invalid header and short file are reported, not reformatted. + uint8_t header[kSlotBytes]; + readBytes(path, 0, header, sizeof(header)); + header[0] ^= 1; + writeBytes(path, 0, header, sizeof(header)); + assert(after_delete.begin() == StoreResult::Corrupt); + assert(std::filesystem::file_size(path) == FileStore::kFileBytes); + header[0] ^= 1; + writeBytes(path, 0, header, sizeof(header)); + std::filesystem::resize_file(path, FileStore::kFileBytes - 1); + assert(after_delete.begin() == StoreResult::Corrupt); + std::printf("AgendaFileStore=%zu file=%zu logical_slots=%u\n", sizeof(FileStore), FileStore::kFileBytes, kMaxActiveEvents); +} diff --git a/modules/core_agenda/tests/test_map_markers.cpp b/modules/core_agenda/tests/test_map_markers.cpp new file mode 100644 index 00000000..bc2b66b0 --- /dev/null +++ b/modules/core_agenda/tests/test_map_markers.cpp @@ -0,0 +1,115 @@ +#include "ui_presentation/agenda/agenda_workspace_model.h" +#include "ui_presentation/map/map_marker_cache.h" +#include +#include +#include + +namespace +{ +class Store final : public agenda::IAgendaStore +{ + public: + agenda::EventRecord records[64]{}; + unsigned reads = 0; + int fail_slot = -1; + agenda::StoreResult readSlot(uint16_t slot, agenda::EventRecord& out) override + { + ++reads; + if (slot == fail_slot) return agenda::StoreResult::IoError; + out = records[slot]; + return agenda::StoreResult::Ok; + } + agenda::StoreResult writeSlot(uint16_t slot, const agenda::EventRecord& value) override + { + records[slot] = value; + return agenda::StoreResult::Ok; + } + agenda::StoreResult eraseSlot(uint16_t) override { return agenda::StoreResult::IoError; } + uint16_t slotCount() const override { return 64; } +}; +class Clock final : public agenda::IAgendaClock +{ + public: + agenda::ClockSample value{100000, 0, 0, true}; + agenda::ClockSample sample() const override { return value; } +}; +void collect(const ui::map::MapMarker& marker, void* context) +{ + static_cast(context)->offer(marker, marker.id); +} +} // namespace + +int main() +{ + Store store; + Clock clock; + ui::agenda::AgendaReminderModel reminders; + agenda::AgendaService service(store); + agenda::ReminderScheduler scheduler(store, clock, reminders); + ui::agenda::AgendaWorkspaceModel model(store, service, clock, scheduler, reminders); + ui::map::MapMarkerCache cache; + assert(!model.visitMarkers(collect, &cache, cache.scratch, sizeof(cache.scratch))); + assert(store.reads == 0); + model.setStorageReady(true); + for (unsigned i = 0; i < 64; ++i) + { + auto& event = store.records[i]; + event.id = 64 - i; // Reverse storage order must not affect selection. + event.state = agenda::RecordState::Active; + event.flags = agenda::HasLocation; + event.location_type = agenda::LocationType::Coordinate; + event.latitude_e7 = 300000000; + event.longitude_e7 = 1200000000; + event.start_time = 90000 + i * 1000; + std::strcpy(event.title, "Map event"); + } + assert(model.visitMarkers(collect, &cache, cache.scratch, sizeof(cache.scratch))); + assert(store.reads == 64 && cache.count == 32 && cache.truncated); + assert(cache.entries[0].marker.id == 1 && cache.entries[31].marker.id == 32); + cache.commit(model.markerStatus()); + assert(!cache.stale(model.markerStatus())); + assert(store.reads == 64); // Status and cache hits never access storage. + clock.value.valid = false; + assert(cache.stale(model.markerStatus())); + clock.value.valid = true; + --clock.value.calendar_seconds; + assert(cache.stale(model.markerStatus())); + ++clock.value.calendar_seconds; + model.setStorageReady(false); + assert(cache.stale(model.markerStatus())); + model.setStorageReady(true); + assert(cache.stale(model.markerStatus())); + + for (auto& event : store.records) event.state = agenda::RecordState::Empty; + auto& event = store.records[0]; + event.id = 1; + event.state = agenda::RecordState::Active; + event.start_time = 90000; + cache.reset(); + assert(model.visitMarkers(collect, &cache, cache.scratch, sizeof(cache.scratch))); + assert(cache.count == 1 && cache.entries[0].marker.active_until < clock.value.calendar_seconds); + event.flags |= agenda::HasEndTime; + event.end_time = 110000; + cache.reset(); + assert(model.visitMarkers(collect, &cache, cache.scratch, sizeof(cache.scratch))); + assert(cache.entries[0].marker.active_until == 110000); // Ongoing stays red. + cache.commit(model.markerStatus()); + clock.value.calendar_seconds = 110001; + assert(cache.stale(model.markerStatus())); + event.repeat = agenda::Repeat::Daily; + cache.reset(); + assert(model.visitMarkers(collect, &cache, cache.scratch, sizeof(cache.scratch))); + assert(cache.count == 1 && cache.entries[0].marker.active_until > clock.value.calendar_seconds); + event.state = agenda::RecordState::Deleted; + cache.reset(); + assert(model.visitMarkers(collect, &cache, cache.scratch, sizeof(cache.scratch)) && cache.count == 0); + event.state = agenda::RecordState::Active; + event.flags = 0; + cache.reset(); + assert(model.visitMarkers(collect, &cache, cache.scratch, sizeof(cache.scratch)) && cache.count == 0); + store.fail_slot = 20; + assert(!model.visitMarkers(collect, &cache, cache.scratch, sizeof(cache.scratch))); + assert(!model.visitMarkers(collect, &cache, cache.scratch, 1)); + std::printf("MapMarker=%zu cache=%zu scratch=%zu; no persistent event mirror\n", + sizeof(ui::map::MapMarker), sizeof(cache), sizeof(cache.scratch)); +} diff --git a/modules/core_agenda/tests/test_recurrence_query.cpp b/modules/core_agenda/tests/test_recurrence_query.cpp new file mode 100644 index 00000000..d65f479c --- /dev/null +++ b/modules/core_agenda/tests/test_recurrence_query.cpp @@ -0,0 +1,135 @@ +#include "agenda/usecase/agenda_query.h" + +#include +#include +#include +#include + +namespace +{ +int64_t at(uint16_t year, uint8_t month, uint8_t day, uint8_t hour = 9) +{ + agenda::CivilTime civil; + civil.year = year; + civil.month = month; + civil.day = day; + civil.hour = hour; + int64_t result = 0; + assert(agenda::toCalendarSeconds(civil, result)); + return result; +} + +agenda::EventRecord event(uint32_t id, int64_t start, agenda::Repeat repeat = agenda::Repeat::None) +{ + agenda::EventRecord result; + result.id = id; + result.start_time = start; + result.repeat = repeat; + result.state = agenda::RecordState::Active; + std::strcpy(result.title, "Radio check"); + return result; +} + +class Store final : public agenda::IAgendaStore +{ + public: + agenda::EventRecord records[agenda::kMaxActiveEvents]{}; + int corrupt = -1; + agenda::StoreResult readSlot(uint16_t slot, agenda::EventRecord& out) override + { + if (slot == corrupt) return agenda::StoreResult::Corrupt; + out = records[slot]; + return agenda::StoreResult::Ok; + } + agenda::StoreResult writeSlot(uint16_t, const agenda::EventRecord&) override { return agenda::StoreResult::IoError; } + agenda::StoreResult eraseSlot(uint16_t) override { return agenda::StoreResult::IoError; } + uint16_t slotCount() const override { return agenda::kMaxActiveEvents; } +}; +} // namespace + +int main() +{ + using namespace agenda; + // Round-trip every civil date across ordinary, leap and non-leap centuries. + for (uint16_t year = 1970; year <= 2400; ++year) + for (uint8_t month = 1; month <= 12; ++month) + for (uint8_t day = 1; day <= daysInMonth(year, month); ++day) + { + CivilTime value; + assert(fromCalendarSeconds(at(year, month, day), value)); + assert(value.year == year && value.month == month && value.day == day && value.hour == 9); + } + CivilTime civil; + assert(fromCalendarSeconds(0, civil) && civil.year == 1970 && civil.day == 1); + assert(fromCalendarSeconds(kLastCalendarSecond, civil) && civil.year == 9999 && civil.day == 31); + assert(!fromCalendarSeconds(-1, civil)); + assert(!fromCalendarSeconds(kLastCalendarSecond + 1, civil)); + assert(daysInMonth(2000, 2) == 29 && daysInMonth(2100, 2) == 28); + + auto single = event(1, at(2026, 9, 21)); + Occurrence occurrence; + assert(nextOccurrence(single, 0, occurrence) && occurrence.start == single.start_time && !occurrence.has_end); + assert(nextOccurrence(single, single.start_time, occurrence)); + assert(!nextOccurrence(single, single.start_time + 1, occurrence)); + single.flags = HasEndTime; + single.end_time = single.start_time + 3600; + single.repeat = Repeat::Daily; + assert(nextOccurrence(single, single.start_time + 1, occurrence)); + assert(occurrence.start == at(2026, 9, 22) && occurrence.end == at(2026, 9, 22, 10)); + single.repeat = Repeat::Weekly; + assert(nextOccurrence(single, single.start_time + 1, occurrence) && occurrence.start == at(2026, 9, 28)); + + auto monthly = event(2, at(2024, 1, 31), Repeat::Monthly); + assert(nextOccurrence(monthly, at(2024, 2, 1), occurrence) && occurrence.start == at(2024, 3, 31)); + assert(nextOccurrence(monthly, at(2024, 4, 1), occurrence) && occurrence.start == at(2024, 5, 31)); + auto yearly = event(3, at(2096, 2, 29), Repeat::Yearly); + assert(nextOccurrence(yearly, yearly.start_time + 1, occurrence) && occurrence.start == at(2104, 2, 29)); + assert(!nextOccurrence(yearly, std::numeric_limits::max(), occurrence)); + yearly.start_time = at(9999, 12, 31); + assert(!nextOccurrence(yearly, yearly.start_time + 1, occurrence)); + + Store store; + const int64_t today = at(2026, 9, 21, 0); + store.records[0] = event(9, today + 3600); + store.records[1] = event(4, today + 3600); + store.records[2] = event(7, today + 86400 + 3600); + store.records[3] = event(8, today + 2 * 86400 + 3600); + AgendaPage page; + QueryCursor cursor; + assert(queryAgenda(store, today, today + 86400, 10, cursor, page) == StoreResult::Ok); + assert(page.count == 2 && !page.has_more && page.rows[0].occurrence.event_id == 4); + assert(page.rows[1].occurrence.event_id == 9 && page.rows[0].location[0] == '\0'); + assert(queryAgenda(store, today + 86400, today + 2 * 86400, 10, cursor, page) == StoreResult::Ok); + assert(page.count == 1 && page.rows[0].occurrence.event_id == 7); + assert(queryAgenda(store, today + 2 * 86400, today + 7 * 86400, 10, cursor, page) == StoreResult::Ok); + assert(page.count == 1 && page.rows[0].occurrence.event_id == 8); + + // Equal-time records paginate without skips despite reverse slot order. + for (uint16_t i = 0; i < 64; ++i) store.records[i] = event(64 - i, today + 3600); + uint32_t expected = 1; + do + { + assert(queryAgenda(store, today, today + 86400, 10, cursor, page) == StoreResult::Ok); + assert(page.count > 0 && page.count <= 10); + for (uint8_t i = 0; i < page.count; ++i) assert(page.rows[i].occurrence.event_id == expected++); + const auto& last = page.rows[page.count - 1].occurrence; + cursor = {last.start, last.event_id, true}; + } while (page.has_more); + assert(expected == 65); + assert(queryAgenda(store, today, today + 86400, 11, {}, page) == StoreResult::OutOfRange); + store.corrupt = 0; + assert(queryAgenda(store, today, today + 86400, 10, {}, page) == StoreResult::Ok && page.degraded); + store.corrupt = -1; + for (auto& record : store.records) record = {}; + store.records[0] = event(1, at(2026, 9, 1), Repeat::Daily); + uint32_t days = 0; + assert(queryMonth(store, 2026, 9, days) == StoreResult::Ok && days == 0x3fffffffU); + assert(queryAgenda(store, today, today + 30 * 86400, 10, {}, page) == StoreResult::Ok); + assert(page.count == 10 && page.has_more); + for (uint8_t i = 0; i < page.count; ++i) assert(page.rows[i].occurrence.start == today + i * 86400 + 9 * 3600); + store.records[0] = monthly; + assert(queryMonth(store, 2024, 2, days) == StoreResult::Ok && days == 0); + assert(queryMonth(store, 2024, 3, days) == StoreResult::Ok && days == (uint32_t{1} << 30)); + std::printf("AgendaPage=%zu Occurrence=%zu CivilTime=%zu month_mask=%zu\n", + sizeof(AgendaPage), sizeof(Occurrence), sizeof(CivilTime), sizeof(days)); +} diff --git a/modules/core_agenda/tests/test_reminder_scheduler.cpp b/modules/core_agenda/tests/test_reminder_scheduler.cpp new file mode 100644 index 00000000..e3c17d6f --- /dev/null +++ b/modules/core_agenda/tests/test_reminder_scheduler.cpp @@ -0,0 +1,158 @@ +#include "agenda/usecase/reminder_scheduler.h" + +#include +#include +#include + +namespace +{ +class Clock final : public agenda::IAgendaClock +{ + public: + agenda::ClockSample value{100000, 0, 0, true}; + agenda::ClockSample sample() const override { return value; } + void advance(uint64_t seconds) + { + value.calendar_seconds += static_cast(seconds); + value.monotonic_seconds += seconds; + } +}; + +class Sink final : public agenda::IReminderSink +{ + public: + agenda::Reminder shown; + unsigned presentations = 0; + bool busy = false; + bool present(const agenda::Reminder& reminder) override + { + if (busy) return false; + ++presentations; + shown = reminder; + return true; + } + void withdraw() override { shown = {}; } +}; + +class Store final : public agenda::IAgendaStore +{ + public: + agenda::EventRecord events[3]{}; + unsigned reads = 0; + agenda::StoreResult readSlot(uint16_t slot, agenda::EventRecord& out) override + { + ++reads; + out = events[slot]; + return agenda::StoreResult::Ok; + } + agenda::StoreResult writeSlot(uint16_t, const agenda::EventRecord&) override { return agenda::StoreResult::IoError; } + agenda::StoreResult eraseSlot(uint16_t) override { return agenda::StoreResult::IoError; } + uint16_t slotCount() const override { return 3; } +}; + +agenda::EventRecord event(uint32_t id, int64_t start) +{ + agenda::EventRecord value; + value.id = id; + value.state = agenda::RecordState::Active; + value.start_time = start; + value.flags = agenda::HasReminder; + value.reminder_offset_sec = 600; + std::strcpy(value.title, "Radio check"); + return value; +} +} // namespace + +int main() +{ + using namespace agenda; + Store store; + Clock clock; + Sink sink; + store.events[0] = event(2, 101200); + store.events[1] = event(1, 101200); + store.events[1].flags |= HasLocation; + store.events[1].location_type = LocationType::Coordinate; + store.events[2] = event(3, 101000); + store.events[2].flags = 0; // No reminder is a valid ordinary event. + ReminderScheduler scheduler(store, clock, sink); + scheduler.tick(); + assert(store.reads == 3 && !scheduler.showing()); + for (unsigned i = 0; i < 599; ++i) + { + clock.advance(1); + scheduler.tick(); + } + assert(store.reads == 3 && sink.presentations == 0); + clock.advance(1); + sink.busy = true; + scheduler.tick(); + assert(!scheduler.showing() && store.reads == 3); + sink.busy = false; + scheduler.tick(); + assert(scheduler.showing() && sink.shown.event_id == 1); + scheduler.tick(); + assert(sink.presentations == 1); // Never duplicate a visible popup. + assert(scheduler.snooze()); + scheduler.tick(); + assert(scheduler.showing() && sink.shown.event_id == 2); // Same-time tie not lost. + assert(!scheduler.snooze()); // Do not silently replace an existing snooze. + assert(scheduler.dismiss()); + scheduler.tick(); + const unsigned reads = store.reads; + clock.advance(599); + scheduler.tick(); + assert(!scheduler.showing() && store.reads == reads); + clock.advance(1); + scheduler.tick(); + assert(scheduler.showing() && sink.shown.event_id == 1); + assert(scheduler.dismiss()); + scheduler.tick(); + assert(!scheduler.showing()); + + // New and edited events cause scans, even while the Agenda page is absent. + store.events[0] = event(4, clock.value.calendar_seconds + 600); + scheduler.eventsChanged(); + scheduler.tick(); + assert(scheduler.showing() && sink.shown.event_id == 4); + store.events[0].state = RecordState::Deleted; + scheduler.eventsChanged(); + assert(!scheduler.showing()); + scheduler.tick(); + assert(!scheduler.showing()); + + store.events[0] = event(5, clock.value.calendar_seconds + 1000); + scheduler.eventsChanged(); + scheduler.tick(); + const unsigned before_jump = store.reads; + clock.value.calendar_seconds += 10000; // Material wall-clock correction. + scheduler.tick(); + assert(store.reads == before_jump + 3 && !scheduler.showing()); + + // Startup does not replay old triggers; daily series advances after Done. + store.events[0] = event(6, clock.value.calendar_seconds + 600); + store.events[0].repeat = Repeat::Daily; + ReminderScheduler restarted(store, clock, sink); + restarted.tick(); + assert(restarted.showing() && sink.shown.event_id == 6); + assert(restarted.dismiss()); + restarted.tick(); + clock.advance(86400); + restarted.tick(); + assert(restarted.showing() && sink.shown.occurrence_start == store.events[0].start_time + 86400); + assert(restarted.snooze()); + store.events[0].state = RecordState::Deleted; + restarted.eventsChanged(); + restarted.tick(); + clock.advance(600); + restarted.tick(); + assert(!restarted.showing()); + + clock.value.valid = false; + restarted.tick(); + const unsigned invalid_reads = store.reads; + restarted.tick(); + assert(store.reads == invalid_reads); + std::printf("ReminderScheduler=%zu Reminder=%zu ClockSample=%zu\n", + sizeof(ReminderScheduler), sizeof(Reminder), sizeof(ClockSample)); +} diff --git a/modules/core_waypoint/CMakeLists.txt b/modules/core_waypoint/CMakeLists.txt new file mode 100644 index 00000000..94d56f8e --- /dev/null +++ b/modules/core_waypoint/CMakeLists.txt @@ -0,0 +1,62 @@ +cmake_minimum_required(VERSION 3.20) +project(TrailMateWaypoint LANGUAGES CXX) +add_library(trailmate_core_waypoint src/waypoint.cpp src/record_codec.cpp) +target_include_directories(trailmate_core_waypoint PUBLIC include) +target_compile_features(trailmate_core_waypoint PUBLIC cxx_std_17) +include(CTest) +if(BUILD_TESTING) + add_executable(waypoint_test tests/test_waypoint.cpp) + target_link_libraries(waypoint_test PRIVATE trailmate_core_waypoint) + if(MSVC) + target_compile_options(waypoint_test PRIVATE /UNDEBUG) + else() + target_compile_options(waypoint_test PRIVATE -UNDEBUG) + endif() + add_test(NAME waypoint_query COMMAND waypoint_test) + add_executable(waypoint_codec_test tests/test_record_codec.cpp) + target_link_libraries(waypoint_codec_test PRIVATE trailmate_core_waypoint) + if(MSVC) + target_compile_options(waypoint_codec_test PRIVATE /UNDEBUG) + else() + target_compile_options(waypoint_codec_test PRIVATE -UNDEBUG) + endif() + add_test(NAME waypoint_codec COMMAND waypoint_codec_test) + add_executable(waypoint_file_test tests/test_file_store.cpp + ../../platform/esp/common/src/storage/waypoint_file_store.cpp) + target_include_directories(waypoint_file_test PRIVATE ../../platform/esp/common/include) + target_link_libraries(waypoint_file_test PRIVATE trailmate_core_waypoint) + if(MSVC) + target_compile_options(waypoint_file_test PRIVATE /UNDEBUG) + else() + target_compile_options(waypoint_file_test PRIVATE -UNDEBUG) + endif() + add_test(NAME waypoint_file COMMAND waypoint_file_test) + add_executable(waypoint_torn_test tests/test_torn_write.cpp + ../../platform/esp/common/src/storage/waypoint_file_store.cpp) + target_include_directories(waypoint_torn_test PRIVATE ../../platform/esp/common/include) + target_link_libraries(waypoint_torn_test PRIVATE trailmate_core_waypoint) + if(MSVC) + target_compile_options(waypoint_torn_test PRIVATE /UNDEBUG) + else() + target_compile_options(waypoint_torn_test PRIVATE -UNDEBUG) + endif() + add_test(NAME waypoint_torn_write COMMAND waypoint_torn_test) + add_executable(waypoint_presentation_test tests/test_presentation.cpp) + target_include_directories(waypoint_presentation_test PRIVATE ../ui_presentation/include) + target_link_libraries(waypoint_presentation_test PRIVATE trailmate_core_waypoint) + if(MSVC) + target_compile_options(waypoint_presentation_test PRIVATE /UNDEBUG) + else() + target_compile_options(waypoint_presentation_test PRIVATE -UNDEBUG) + endif() + add_test(NAME waypoint_presentation COMMAND waypoint_presentation_test) + add_executable(agenda_waypoint_test tests/test_agenda_waypoint.cpp) + target_include_directories(agenda_waypoint_test PRIVATE ../ui_presentation/include ../core_agenda/include) + target_link_libraries(agenda_waypoint_test PRIVATE trailmate_core_waypoint) + if(MSVC) + target_compile_options(agenda_waypoint_test PRIVATE /UNDEBUG) + else() + target_compile_options(agenda_waypoint_test PRIVATE -UNDEBUG) + endif() + add_test(NAME agenda_waypoint_selection COMMAND agenda_waypoint_test) +endif() diff --git a/modules/core_waypoint/include/waypoint/record_codec.h b/modules/core_waypoint/include/waypoint/record_codec.h new file mode 100644 index 00000000..0f2c45e2 --- /dev/null +++ b/modules/core_waypoint/include/waypoint/record_codec.h @@ -0,0 +1,24 @@ +#pragma once +#include "waypoint/waypoint.h" +#include + +namespace waypoint +{ +constexpr std::size_t kEncodedBytes = 64; +enum class SlotState : uint8_t +{ + Empty, + Active, + Deleted +}; +struct Slot +{ + Record record; + uint64_t generation = 0; + SlotState state = SlotState::Empty; +}; +// Exactly 64 bytes, little endian, versioned and CRC protected. Deleted slots +// retain their id; a store can recover the high-water mark by scanning slots. +bool encode(const Slot& slot, uint8_t* bytes, std::size_t capacity); +bool decode(const uint8_t* bytes, std::size_t size, Slot& out); +} // namespace waypoint diff --git a/modules/core_waypoint/include/waypoint/waypoint.h b/modules/core_waypoint/include/waypoint/waypoint.h new file mode 100644 index 00000000..e86fb8ab --- /dev/null +++ b/modules/core_waypoint/include/waypoint/waypoint.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +namespace waypoint +{ +constexpr uint16_t kMaxWaypoints = 64; +constexpr uint8_t kPageCapacity = 5; + +// Product data, not the on-disk representation. Zero id denotes an empty slot. +struct Record +{ + uint32_t id = 0; + int32_t latitude_e7 = 0; + int32_t longitude_e7 = 0; + char name[32]{}; +}; +static_assert(sizeof(Record) == 44); + +enum class Result : uint8_t +{ + Ok, + Invalid, + NotFound, + Full, + Corrupt, + IoError +}; + +class IStore +{ + public: + virtual ~IStore() = default; + virtual uint16_t slotCount() const = 0; + virtual Result read(uint16_t slot, Record& out) = 0; + // Allocate a durable, non-reused id on successful creation. Failed writes + // must not publish an id. Updates preserve the existing id. + virtual Result create(const Record& value, uint32_t& id) = 0; + virtual Result update(const Record& value) = 0; + virtual Result remove(uint32_t id) = 0; +}; + +struct Page +{ + Record rows[kPageCapacity]{}; + uint8_t count = 0; + bool has_more = false; +}; +static_assert(sizeof(Page) < 256, "Waypoint picker retains only one visible page"); + +bool valid(const Record& record); +// Stable ascending id order, independent of slot reuse. No retained database. +Result query(IStore& store, uint32_t after_id, uint8_t capacity, Page& out); +Result find(IStore& store, uint32_t id, Record& out); +} // namespace waypoint diff --git a/modules/core_waypoint/library.json b/modules/core_waypoint/library.json new file mode 100644 index 00000000..50b195bf --- /dev/null +++ b/modules/core_waypoint/library.json @@ -0,0 +1,5 @@ +{ + "name": "core_waypoint", + "version": "0.1.0", + "build": { "includeDir": "include", "srcDir": "src" } +} diff --git a/modules/core_waypoint/src/record_codec.cpp b/modules/core_waypoint/src/record_codec.cpp new file mode 100644 index 00000000..d5281f80 --- /dev/null +++ b/modules/core_waypoint/src/record_codec.cpp @@ -0,0 +1,88 @@ +#include "waypoint/record_codec.h" +#include + +namespace waypoint +{ +namespace +{ +uint32_t crc(const uint8_t* bytes) +{ + uint32_t value = 0xFFFFFFFF; + for (unsigned i = 0; i < 60; ++i) + { + value ^= bytes[i]; + for (unsigned bit = 0; bit < 8; ++bit) + value = (value >> 1) ^ (0xEDB88320u & (0u - (value & 1))); + } + return ~value; +} +void put(uint8_t* bytes, uint64_t value, unsigned count) +{ + for (unsigned i = 0; i < count; ++i) bytes[i] = static_cast(value >> (8 * i)); +} +uint64_t get(const uint8_t* bytes, unsigned count) +{ + uint64_t value = 0; + for (unsigned i = 0; i < count; ++i) value |= uint64_t{bytes[i]} << (8 * i); + return value; +} +int32_t signedCoordinate(const uint8_t* bytes) +{ + const auto value = get(bytes, 4); + return static_cast(value <= 0x7FFFFFFF ? static_cast(value) + : static_cast(value) - 0x100000000LL); +} +bool validSlot(const Slot& slot) +{ + if (!slot.generation) return false; + if (slot.state == SlotState::Active) return valid(slot.record); + if (slot.state == SlotState::Deleted) return slot.record.id != 0; + return slot.state == SlotState::Empty && slot.record.id == 0; +} +} // namespace + +bool encode(const Slot& slot, uint8_t* bytes, std::size_t capacity) +{ + if (!bytes || capacity < kEncodedBytes || !validSlot(slot)) return false; + std::memset(bytes, 0, kEncodedBytes); + bytes[0] = 'W'; + bytes[1] = 'P'; + bytes[2] = 1; + bytes[3] = static_cast(slot.state); + put(bytes + 4, slot.generation, 8); + put(bytes + 12, slot.record.id, 4); + if (slot.state == SlotState::Active) + { + put(bytes + 16, static_cast(slot.record.latitude_e7), 4); + put(bytes + 20, static_cast(slot.record.longitude_e7), 4); + std::memcpy(bytes + 24, slot.record.name, std::strlen(slot.record.name)); + } + put(bytes + 60, crc(bytes), 4); + return true; +} + +bool decode(const uint8_t* bytes, std::size_t size, Slot& out) +{ + out = {}; + if (!bytes || size != kEncodedBytes || bytes[0] != 'W' || bytes[1] != 'P' || bytes[2] != 1 || + bytes[3] > static_cast(SlotState::Deleted) || get(bytes + 60, 4) != crc(bytes)) return false; + for (unsigned i = 56; i < 60; ++i) + if (bytes[i]) return false; + Slot slot; + slot.state = static_cast(bytes[3]); + slot.generation = get(bytes + 4, 8); + slot.record.id = static_cast(get(bytes + 12, 4)); + if (slot.state == SlotState::Active) + { + slot.record.latitude_e7 = signedCoordinate(bytes + 16); + slot.record.longitude_e7 = signedCoordinate(bytes + 20); + std::memcpy(slot.record.name, bytes + 24, sizeof(slot.record.name)); + } + else + for (unsigned i = 16; i < 56; ++i) + if (bytes[i]) return false; + if (!validSlot(slot)) return false; + out = slot; + return true; +} +} // namespace waypoint diff --git a/modules/core_waypoint/src/waypoint.cpp b/modules/core_waypoint/src/waypoint.cpp new file mode 100644 index 00000000..6cbec62d --- /dev/null +++ b/modules/core_waypoint/src/waypoint.cpp @@ -0,0 +1,101 @@ +#include "waypoint/waypoint.h" +#include + +namespace waypoint +{ +namespace +{ +bool validName(const char (&name)[32]) +{ + if (!name[0]) return false; + unsigned index = 0; + while (index < sizeof(name)) + { + const auto lead = static_cast(name[index++]); + if (!lead) return true; + if (lead < 0x80) continue; + unsigned remaining; + uint32_t scalar; + uint32_t minimum; + if (lead >= 0xc2 && lead <= 0xdf) + { + remaining = 1; + scalar = lead & 0x1f; + minimum = 0x80; + } + else if (lead >= 0xe0 && lead <= 0xef) + { + remaining = 2; + scalar = lead & 0x0f; + minimum = 0x800; + } + else if (lead >= 0xf0 && lead <= 0xf4) + { + remaining = 3; + scalar = lead & 7; + minimum = 0x10000; + } + else return false; + if (index + remaining >= sizeof(name)) return false; // Reserve the terminator. + while (remaining--) + { + const auto next = static_cast(name[index++]); + if ((next & 0xc0) != 0x80) return false; + scalar = (scalar << 6) | (next & 0x3f); + } + if (scalar < minimum || scalar > 0x10ffff || (scalar >= 0xd800 && scalar <= 0xdfff)) return false; + } + return false; +} +} // namespace +bool valid(const Record& record) +{ + return record.id != 0 && validName(record.name) && + record.latitude_e7 >= -900000000 && record.latitude_e7 <= 900000000 && + record.longitude_e7 >= -1800000000 && record.longitude_e7 <= 1800000000; +} + +Result query(IStore& store, uint32_t after_id, uint8_t capacity, Page& out) +{ + out = {}; + if (!capacity || capacity > kPageCapacity || store.slotCount() > kMaxWaypoints) return Result::Invalid; + Record record; + for (uint16_t slot = 0; slot < store.slotCount(); ++slot) + { + const auto result = store.read(slot, record); + if (result != Result::Ok || (record.id && !valid(record))) + { + out = {}; + return result == Result::Ok ? Result::Corrupt : result; + } + if (!record.id || record.id <= after_id) continue; + uint8_t index = 0; + while (index < out.count && out.rows[index].id < record.id) ++index; + if (out.count == capacity) out.has_more = true; + if (index == capacity) continue; + if (out.count < capacity) ++out.count; + for (uint8_t n = out.count - 1; n > index; --n) out.rows[n] = out.rows[n - 1]; + out.rows[index] = record; + } + return Result::Ok; +} + +Result find(IStore& store, uint32_t id, Record& out) +{ + out = {}; + if (!id || store.slotCount() > kMaxWaypoints) return Result::Invalid; + Record record; + for (uint16_t slot = 0; slot < store.slotCount(); ++slot) + { + const auto result = store.read(slot, record); + if (result != Result::Ok) return result; + if (record.id && !valid(record)) return Result::Corrupt; + if (record.id == id) + { + out = record; + return Result::Ok; + } + } + return Result::NotFound; +} +} // namespace waypoint diff --git a/modules/core_waypoint/tests/test_agenda_waypoint.cpp b/modules/core_waypoint/tests/test_agenda_waypoint.cpp new file mode 100644 index 00000000..ad8e701e --- /dev/null +++ b/modules/core_waypoint/tests/test_agenda_waypoint.cpp @@ -0,0 +1,26 @@ +#include "ui_presentation/agenda/agenda_waypoint.h" +#include +#include + +int main() +{ + ui::agenda::AgendaEditorModel draft; + std::strcpy(draft.event.title, "Leave camp"); + waypoint::Record waypoint; + waypoint.id = 0xFFFFFFFFu; + waypoint.latitude_e7 = -900000000; + waypoint.longitude_e7 = 1800000000; + std::memset(waypoint.name, 'a', 31); + assert(ui::agenda::applyWaypoint(draft, waypoint)); + assert(draft.dirty && draft.focus == ui::agenda::EditorField::Location); + assert(draft.event.location_type == agenda::LocationType::Waypoint); + assert(draft.event.flags & agenda::HasLocation); + assert(!std::strcmp(draft.event.waypoint_id, "4294967295")); + assert(!std::strcmp(draft.event.location_name, waypoint.name)); + assert(!std::strcmp(draft.event.title, "Leave camp")); + waypoint = {}; // Source deletion cannot mutate the event snapshot. + assert(draft.event.latitude_e7 == -900000000 && draft.event.longitude_e7 == 1800000000); + const auto before = draft; + assert(!ui::agenda::applyWaypoint(draft, waypoint)); + assert(!std::memcmp(&before, &draft, sizeof(draft))); +} diff --git a/modules/core_waypoint/tests/test_file_store.cpp b/modules/core_waypoint/tests/test_file_store.cpp new file mode 100644 index 00000000..04f4fa5a --- /dev/null +++ b/modules/core_waypoint/tests/test_file_store.cpp @@ -0,0 +1,45 @@ +#include "platform/esp/common/storage/waypoint_file_store.h" +#include +#include +#include + +int main() +{ + using platform::esp::storage::WaypointFileStore; + using waypoint::Result; + const char* path = "waypoint-test.dat"; + const char* staging = "waypoint-test.init"; + std::remove(path); + std::remove(staging); + WaypointFileStore store(path, staging); + assert(store.begin() == Result::Ok); + waypoint::Record record; + std::strcpy(record.name, "Camp"); + record.latitude_e7 = -100; + uint32_t id = 0; + assert(store.create(record, id) == Result::Ok && id == 1); + record.id = id; + std::strcpy(record.name, "Updated"); + assert(store.update(record) == Result::Ok); + WaypointFileStore reopened(path, staging); + assert(reopened.begin() == Result::Ok); + waypoint::Record loaded; + assert(waypoint::find(reopened, id, loaded) == Result::Ok && !std::strcmp(loaded.name, "Updated")); + // Initial bank 0 gen1; create bank1 gen2; update bank0 gen3. + // Damage only the newest copy: the last intact record must survive. + auto* file = std::fopen(path, "r+b"); + assert(file && !std::fseek(file, 64 + 24, SEEK_SET)); + assert(std::fputc('X', file) != EOF && !std::fclose(file)); + assert(waypoint::find(reopened, id, loaded) == Result::Ok && !std::strcmp(loaded.name, "Camp")); + assert(reopened.remove(id) == Result::Ok); + assert(waypoint::find(reopened, id, loaded) == Result::NotFound); + record.id = 0; + assert(reopened.create(record, id) == Result::Ok && id == 2); + for (unsigned i = 1; i < waypoint::kMaxWaypoints; ++i) assert(reopened.create(record, id) == Result::Ok); + assert(reopened.create(record, id) == Result::Full && id == 0); + file = std::fopen(path, "r+b"); + assert(file && std::fputc('X', file) != EOF && !std::fclose(file)); + assert(reopened.begin() == Result::Corrupt); // Never reset/format a damaged file. + assert(!std::remove(path)); + std::remove(staging); +} diff --git a/modules/core_waypoint/tests/test_presentation.cpp b/modules/core_waypoint/tests/test_presentation.cpp new file mode 100644 index 00000000..1e4050d0 --- /dev/null +++ b/modules/core_waypoint/tests/test_presentation.cpp @@ -0,0 +1,77 @@ +#include "ui_presentation/waypoint/waypoint_model.h" +#include +#include + +class Store final : public waypoint::IStore +{ + public: + unsigned writes = 0; + bool fail = false; + waypoint::Record record; + uint16_t slotCount() const override { return 1; } + waypoint::Result read(uint16_t, waypoint::Record& out) override + { + out = record; + return waypoint::Result::Ok; + } + waypoint::Result create(const waypoint::Record& value, uint32_t& id) override + { + ++writes; + if (fail) return waypoint::Result::IoError; + record = value; + record.id = id = 7; + return waypoint::Result::Ok; + } + waypoint::Result update(const waypoint::Record& value) override + { + uint32_t id; + return create(value, id); + } + waypoint::Result remove(uint32_t) override + { + ++writes; + record = {}; + return waypoint::Result::Ok; + } +}; +int main() +{ + Store store; + ui::waypoint::Model model(store); + waypoint::Record draft; + std::strcpy(draft.name, "Camp"); + assert(model.save(draft).failure == ui::UiActionFailure::NotReady); + model.setReady(true); + assert(model.save(draft).ok && store.writes == 0); + auto sequence = model.commandResult().sequence; + assert(model.save(draft).failure == ui::UiActionFailure::Busy); + assert(model.commandResult().sequence == sequence); + model.pump(); + assert(store.writes == 1 && model.commandResult().id == 7); + assert(model.commandResult().state == ui::waypoint::CommandState::Succeeded); + model.pump(); + assert(store.writes == 1); + waypoint::Page page; + assert(model.page(0, 5, page) == waypoint::Result::Ok && page.count == 1); + store.fail = true; + assert(model.save(draft).ok); + model.pump(); + assert(model.commandResult().state == ui::waypoint::CommandState::Failed && !model.commandResult().id); + assert(model.commandResult().sequence != sequence); + assert(model.remove(7).ok); + model.pump(); + assert(model.page(0, 5, page) == waypoint::Result::Ok && !page.count); + assert(model.save(draft).ok); + model.setReady(false); + const auto writes = store.writes; + assert(model.commandResult().state == ui::waypoint::CommandState::Failed); + assert(model.page(0, 5, page) == waypoint::Result::IoError && !page.count); + model.setReady(true); // Reinsert before the next pump: never replay the save. + model.pump(); + assert(store.writes == writes && model.commandResult().state == ui::waypoint::CommandState::Failed); + assert(model.remove(7).ok); + model.setReady(false); + model.setReady(true); + model.pump(); + assert(store.writes == writes && model.commandResult().state == ui::waypoint::CommandState::Failed); +} diff --git a/modules/core_waypoint/tests/test_record_codec.cpp b/modules/core_waypoint/tests/test_record_codec.cpp new file mode 100644 index 00000000..ad94cd0f --- /dev/null +++ b/modules/core_waypoint/tests/test_record_codec.cpp @@ -0,0 +1,41 @@ +#include "waypoint/record_codec.h" +#include +#include + +int main() +{ + waypoint::Slot slot; + slot.state = waypoint::SlotState::Active; + slot.generation = 0x123456789ABCDEF0ULL; + slot.record.id = 42; + slot.record.latitude_e7 = -900000000; + slot.record.longitude_e7 = 1800000000; + std::strcpy(slot.record.name, "Camp"); + uint8_t bytes[64]; + assert(waypoint::encode(slot, bytes, sizeof(bytes))); + assert(bytes[4] == 0xF0 && bytes[11] == 0x12 && bytes[12] == 42); + waypoint::Slot result; + assert(waypoint::decode(bytes, sizeof(bytes), result)); + assert(result.record.latitude_e7 == slot.record.latitude_e7); + assert(result.record.longitude_e7 == slot.record.longitude_e7); + assert(result.generation == slot.generation && !std::strcmp(result.record.name, "Camp")); + for (unsigned i = 0; i < 64; ++i) + for (unsigned bit = 0; bit < 8; ++bit) + { + bytes[i] ^= 1u << bit; + assert(!waypoint::decode(bytes, sizeof(bytes), result) && !result.record.id); + bytes[i] ^= 1u << bit; + } + for (unsigned size = 0; size < 64; ++size) assert(!waypoint::decode(bytes, size, result)); + slot.state = waypoint::SlotState::Deleted; + assert(waypoint::encode(slot, bytes, sizeof(bytes))); + assert(waypoint::decode(bytes, sizeof(bytes), result)); + assert(result.record.id == 42 && !result.record.name[0] && !result.record.latitude_e7); + slot.state = waypoint::SlotState::Empty; + assert(!waypoint::encode(slot, bytes, sizeof(bytes))); + slot.record.id = 0; + assert(waypoint::encode(slot, bytes, sizeof(bytes))); + assert(waypoint::decode(bytes, sizeof(bytes), result) && result.state == waypoint::SlotState::Empty); + assert(!waypoint::encode(slot, nullptr, 64)); + assert(!waypoint::encode(slot, bytes, 63)); +} diff --git a/modules/core_waypoint/tests/test_torn_write.cpp b/modules/core_waypoint/tests/test_torn_write.cpp new file mode 100644 index 00000000..1cdcc810 --- /dev/null +++ b/modules/core_waypoint/tests/test_torn_write.cpp @@ -0,0 +1,88 @@ +#include "platform/esp/common/storage/waypoint_file_store.h" +#include +#include +#include +#include + +namespace +{ +using Store = platform::esp::storage::WaypointFileStore; +using waypoint::Result; +constexpr const char* path = "waypoint-torn-test.dat"; +constexpr const char* staging = "waypoint-torn-test.init"; +using Image = std::array; +Image readImage() +{ + Image image{}; + auto* file = std::fopen(path, "rb"); + assert(file && std::fread(image.data(), 1, image.size(), file) == image.size()); + assert(!std::fclose(file)); + return image; +} +void restoreImage(const Image& image) +{ + auto* file = std::fopen(path, "wb"); + assert(file && std::fwrite(image.data(), 1, image.size(), file) == image.size()); + assert(!std::fclose(file)); +} +bool equal(const waypoint::Record& a, const waypoint::Record& b) +{ + return a.id == b.id && a.latitude_e7 == b.latitude_e7 && a.longitude_e7 == b.longitude_e7 && + !std::strcmp(a.name, b.name); +} +void verifyCuts(const Image& before, const Image& after, unsigned bank, + const waypoint::Record& oldRecord, const waypoint::Record& newRecord) +{ + const unsigned offset = 64 + bank * 64; + for (unsigned cut = 0; cut <= 64; ++cut) + { + Image torn = before; + std::memcpy(torn.data() + offset, after.data() + offset, cut); + restoreImage(torn); + Store reopened(path, staging); + assert(reopened.begin() == Result::Ok); + waypoint::Record recovered; + assert(reopened.read(0, recovered) == Result::Ok); + assert(equal(recovered, oldRecord) || equal(recovered, newRecord)); + if (!cut) assert(equal(recovered, oldRecord)); + if (cut == 64) assert(equal(recovered, newRecord)); + // Recovery must leave the store writable, not merely readable. + if (recovered.id) assert(reopened.update(recovered) == Result::Ok); + } + restoreImage(after); +} +} // namespace + +int main() +{ + std::remove(path); + std::remove(staging); + Store store(path, staging); + assert(store.begin() == Result::Ok); + const auto emptyImage = readImage(); + waypoint::Record record; + record.latitude_e7 = 123456; + record.longitude_e7 = -654321; + std::strcpy(record.name, "Camp before"); + uint32_t id = 0; + assert(store.create(record, id) == Result::Ok); + record.id = id; + auto createdImage = readImage(); + verifyCuts(emptyImage, createdImage, 1, {}, record); + auto original = record; + record.latitude_e7 = -789012; + std::strcpy(record.name, "Camp after"); + assert(store.update(record) == Result::Ok); + auto updatedImage = readImage(); + verifyCuts(createdImage, updatedImage, 0, original, record); + assert(store.remove(id) == Result::Ok); + auto deletedImage = readImage(); + verifyCuts(updatedImage, deletedImage, 1, record, {}); + // Tombstones survive reopen and slot reuse assigns a different identity. + Store reopened(path, staging); + assert(reopened.begin() == Result::Ok); + uint32_t replacement = 0; + assert(reopened.create(record, replacement) == Result::Ok && replacement > id); + assert(!std::remove(path)); + std::remove(staging); +} diff --git a/modules/core_waypoint/tests/test_waypoint.cpp b/modules/core_waypoint/tests/test_waypoint.cpp new file mode 100644 index 00000000..c1576d26 --- /dev/null +++ b/modules/core_waypoint/tests/test_waypoint.cpp @@ -0,0 +1,80 @@ +#include "waypoint/waypoint.h" +#include +#include + +class Store final : public waypoint::IStore +{ + public: + waypoint::Record rows[64]{}; // Test fixture only, never a production cache. + int failing = -1; + uint16_t slotCount() const override { return 64; } + waypoint::Result read(uint16_t slot, waypoint::Record& out) override + { + if (slot == failing) return waypoint::Result::IoError; + out = rows[slot]; + return waypoint::Result::Ok; + } + waypoint::Result create(const waypoint::Record&, uint32_t&) override { return waypoint::Result::IoError; } + waypoint::Result update(const waypoint::Record&) override { return waypoint::Result::IoError; } + waypoint::Result remove(uint32_t) override { return waypoint::Result::IoError; } +}; + +int main() +{ + Store store; + waypoint::Page page; + using waypoint::Result; + assert(waypoint::query(store, 0, 5, page) == Result::Ok && !page.count && !page.has_more); + for (unsigned i = 0; i < 64; ++i) + { + store.rows[i].id = 64 - i; + std::strcpy(store.rows[i].name, "Campsite"); + } + uint32_t after = 0; + unsigned total = 0; + do + { + assert(waypoint::query(store, after, 5, page) == Result::Ok); + for (unsigned i = 0; i < page.count; ++i) assert(page.rows[i].id == ++after); + total += page.count; + } while (page.has_more); + assert(total == 64); + assert(waypoint::query(store, 0, 0, page) == Result::Invalid && !page.count); + assert(waypoint::query(store, 0, 6, page) == Result::Invalid); + waypoint::Record record; + assert(waypoint::find(store, 10, record) == Result::Ok && record.id == 10); + assert(waypoint::find(store, 100, record) == Result::NotFound && !record.id); + store.failing = 12; + assert(waypoint::query(store, 0, 5, page) == Result::IoError && !page.count); + store.failing = -1; + store.rows[12].latitude_e7 = 900000001; + assert(waypoint::query(store, 0, 5, page) == Result::Corrupt && !page.count); + store.rows[12].latitude_e7 = -900000000; + store.rows[12].longitude_e7 = 1800000000; + assert(waypoint::valid(store.rows[12])); + std::memset(store.rows[12].name, 'x', sizeof(store.rows[12].name)); + assert(!waypoint::valid(store.rows[12])); + const char* invalid_names[] = {"", "\x80", "\xc0\xaf", "\xe0\x80\xaf", "\xed\xa0\x80", + "\xf0\x80\x80\xaf", "\xf4\x90\x80\x80", "\xf5\x80\x80\x80", + "\xc2", "\xe4\xb8", "\xf0\x9f\x8f", "\xc2x"}; + for (const auto* name : invalid_names) + { + std::strcpy(store.rows[12].name, name); + assert(!waypoint::valid(store.rows[12])); + assert(waypoint::query(store, 0, 5, page) == Result::Corrupt && !page.count); + } + const char* valid_names[] = {"Camp", "\xc2\x80", "\xe0\xa0\x80", "\xed\x9f\xbf", + "\xee\x80\x80", "\xf0\x90\x80\x80", "\xf4\x8f\xbf\xbf", + u8"山间营地"}; + for (const auto* name : valid_names) + { + std::strcpy(store.rows[12].name, name); + assert(waypoint::valid(store.rows[12])); + } + std::memset(store.rows[12].name, 'x', 27); + std::memcpy(store.rows[12].name + 27, "\xf0\x9f\x8f\x95", 5); + assert(waypoint::valid(store.rows[12])); // Four-byte scalar ending at byte 31. + std::memset(store.rows[12].name, 'x', 28); + std::memcpy(store.rows[12].name + 28, "\xf0\x9f\x8f", 4); + assert(!waypoint::valid(store.rows[12])); // Truncated at the field boundary. +} diff --git a/modules/product_composition/include/product_composition/agenda_composition.h b/modules/product_composition/include/product_composition/agenda_composition.h new file mode 100644 index 00000000..01fef92b --- /dev/null +++ b/modules/product_composition/include/product_composition/agenda_composition.h @@ -0,0 +1,44 @@ +#pragma once + +#include "ui_presentation/agenda/agenda_workspace_model.h" +#include "ui_presentation/workspace/presentation_workspace.h" + +namespace product_composition +{ +// An owned component of an enabled target's composition root, never a global +// service locator or a page singleton. The target owns store/clock adapters and +// calls tick from its normal application loop, even while Calendar is closed. +class AgendaComposition +{ + public: + AgendaComposition(::agenda::IAgendaStore& store, ::agenda::IAgendaClock& clock) + : service_(store), scheduler_(store, clock, reminders_), + model_(store, service_, clock, scheduler_, reminders_) {} + AgendaComposition(const AgendaComposition&) = delete; + AgendaComposition& operator=(const AgendaComposition&) = delete; + + void bind(ui::workspace::PresentationWorkspace& workspace) + { + workspace.agenda = &model_; + workspace.agenda_reminders = &reminders_; + } + void setStorageReady(bool ready) + { + model_.setStorageReady(ready); + if (!ready) reminders_.setDeliveryEnabled(false); + } + void tick(bool can_present_reminder) + { + reminders_.setDeliveryEnabled(can_present_reminder); + model_.pump(); + } + + private: + ::agenda::AgendaService service_; + ui::agenda::AgendaReminderModel reminders_; + ::agenda::ReminderScheduler scheduler_; + ui::agenda::AgendaWorkspaceModel model_; +}; + +static_assert(sizeof(AgendaComposition) <= 512, "Leave at least half the idle budget for storage and clock"); +} // namespace product_composition diff --git a/modules/product_composition/include/product_composition/agenda_target.h b/modules/product_composition/include/product_composition/agenda_target.h new file mode 100644 index 00000000..25c7d372 --- /dev/null +++ b/modules/product_composition/include/product_composition/agenda_target.h @@ -0,0 +1,22 @@ +#pragma once + +#include "product_composition/target_profile.h" +#include "ui_presentation/page/page_manifest.h" + +namespace product_composition +{ +// Capability comes from the product manifest, never from framebuffer size. +inline bool targetHasAgenda(const TargetProfile* target) +{ + if (!target || target->renderer != TargetRenderer::Lvgl || !target->has_display) return false; + const auto* manifest = ui::presentation::findPageManifest(target->page_manifest_id); + if (!manifest) return false; + for (std::size_t i = 0; i < manifest->item_count; ++i) + { + const auto& item = manifest->items[i]; + if (item.page_id == ui::presentation::PageId::Calendar) + return item.enabled && item.visible_in_menu; + } + return false; +} +} // namespace product_composition diff --git a/modules/product_composition/src/target_ux_binding.cpp b/modules/product_composition/src/target_ux_binding.cpp index d309e0da..a6c2e2e1 100644 --- a/modules/product_composition/src/target_ux_binding.cpp +++ b/modules/product_composition/src/target_ux_binding.cpp @@ -11,8 +11,8 @@ constexpr TargetUxBinding kTargetUxBindings[] = { {"tab5", "tab5_touch", "compatibility", "compatibility", false}, {"t_display_p4_tft", "t_display_p4_touch", "t_display_p4_touch", nullptr, true}, {"t_display_p4_amoled", "t_display_p4_touch", "t_display_p4_touch", nullptr, true}, - {"tlora_pager", "pager_compact", "compatibility", "compatibility", false}, - {"tdeck", "deck_full", "compatibility", "compatibility", false}, + {"tlora_pager", "pager_compact", "pager_compact", "compatibility", false}, + {"tdeck", "deck_full", "deck_full", "compatibility", false}, {"wio_tracker_l2", "deck_touch", "deck_touch", nullptr, true}, {"twatch", "watch_compact", "compatibility", "compatibility", false}, {"uconsole", "uconsole_desktop", "uconsole_desktop", nullptr, true}, diff --git a/modules/product_composition/tests/test_target_ux_binding.cpp b/modules/product_composition/tests/test_target_ux_binding.cpp index 73d628ed..9366dfb1 100644 --- a/modules/product_composition/tests/test_target_ux_binding.cpp +++ b/modules/product_composition/tests/test_target_ux_binding.cpp @@ -22,7 +22,12 @@ int main() assert(std::strcmp(wio->desired_ux_pack_id, "deck_touch") == 0); assert(std::strcmp(wio->active_ux_pack_id, "deck_touch") == 0); assert(wio->final_ux_pack_available && wio->fallback_ux_pack_id == nullptr); - assert(std::strcmp(deck->active_ux_pack_id, "compatibility") == 0); + assert(std::strcmp(deck->active_ux_pack_id, "deck_full") == 0); + assert(!deck->final_ux_pack_available); // Manifest-backed compatibility renderer. + const auto* pager = product_composition::findTargetUxBinding("tlora_pager"); + assert(pager && std::strcmp(pager->active_ux_pack_id, "pager_compact") == 0); + const auto* watch = product_composition::findTargetUxBinding("twatch"); + assert(watch && std::strcmp(watch->active_ux_pack_id, "compatibility") == 0); const auto* tft = product_composition::findTargetUxBinding("t_display_p4_tft"); assert(tft != nullptr); diff --git a/modules/ui_lvgl_ux_packs/include/ui_lvgl_ux_packs/common/touch_text_editor.h b/modules/ui_lvgl_ux_packs/include/ui_lvgl_ux_packs/common/touch_text_editor.h index 93cced9d..631bdf70 100644 --- a/modules/ui_lvgl_ux_packs/include/ui_lvgl_ux_packs/common/touch_text_editor.h +++ b/modules/ui_lvgl_ux_packs/include/ui_lvgl_ux_packs/common/touch_text_editor.h @@ -1,13 +1,37 @@ #pragma once #include "lvgl.h" +#include namespace ui::widgets { class ImeWidget; +struct ImeEditState; +enum class TouchEditorCapture +{ + Inactive, + Captured, + InsufficientCapacity, +}; // No-op unless the active page profile selects a compact touch-only keyboard. // An existing IME owner receives committed text so its composition buffer // stays synchronized. Plain text fields use the same editor with no owner. void attach_touch_text_editor(lv_obj_t* textarea, ImeWidget* owner = nullptr); + +// Capture without applying the modal draft to its source or retaining widgets. +// The caller must keep text/state alive only until restore returns. +TouchEditorCapture capture_touch_text_editor(lv_obj_t* source, char* text, + std::size_t capacity, ImeEditState& state); +bool restore_touch_text_editor(lv_obj_t* source, const char* text, + const ImeEditState& state, ImeWidget* owner = nullptr); + +// Streaming variants let bounded callers encode directly from the live draft +// and restore directly into its replacement without a full scratch string. +using TouchTextCapture = bool (*)(void*, const char*); +using TouchTextRestore = bool (*)(const void*, lv_obj_t*); +TouchEditorCapture capture_touch_text_editor(lv_obj_t* source, TouchTextCapture capture, + void* context, ImeEditState& state); +bool restore_touch_text_editor(lv_obj_t* source, const ImeEditState& state, + TouchTextRestore restore, const void* context, ImeWidget* owner = nullptr); } // namespace ui::widgets diff --git a/modules/ui_lvgl_ux_packs/include/ui_lvgl_ux_packs/packs/manifest_compatibility_ux_pack.h b/modules/ui_lvgl_ux_packs/include/ui_lvgl_ux_packs/packs/manifest_compatibility_ux_pack.h new file mode 100644 index 00000000..245e41f3 --- /dev/null +++ b/modules/ui_lvgl_ux_packs/include/ui_lvgl_ux_packs/packs/manifest_compatibility_ux_pack.h @@ -0,0 +1,97 @@ +#pragma once + +#include "ui_lvgl_ux_packs/packs/compatibility_ux_pack.h" +#include "ui_presentation/page/page_manifest.h" + +namespace ui_lvgl_ux +{ +// Transitional target pack: preserve working compatibility screens while +// admitting new screens only through this target's manifest. It is not a +// second app catalog and is not the target's final native renderer pack. +class ManifestCompatibilityUxPack final : public IUxPack +{ + public: + constexpr ManifestCompatibilityUxPack(const char* pack_id, const char* manifest_id, + ScreenClass screen_class, InputModel input_model) + : profile_{pack_id, screen_class, input_model, MapMode::Full, ChatMode::Full, + true, true, true, true}, + manifest_id_(manifest_id) {} + const char* id() const override { return profile_.id; } + const DeviceUxProfile& profile() const override { return profile_; } + const UxFeatureSet& features() const override { return compatibility_.features(); } + void buildInputBindings(InputBindingSet& out) const override { compatibility_.buildInputBindings(out); } + void buildScreens(ScreenRegistry& out) const override + { + compatibility_.buildScreens(out); + const auto* manifest = ui::presentation::findPageManifest(manifest_id_); + if (!manifest) return; + for (std::size_t i = 0; i < manifest->item_count; ++i) + { + const auto& item = manifest->items[i]; + if (!item.enabled || !item.visible_in_menu) continue; + ScreenId screen{}; + if (!screenForPage(item.page_id, screen)) continue; + bool exists = false; + for (std::size_t j = 0; j < out.size(); ++j) + if (out.items()[j].id == screen) exists = true; + if (!exists && !out.add({screen, item.binding_id, true})) return; + } + } + + private: + static bool screenForPage(ui::presentation::PageId page, ScreenId& out) + { + using ui::presentation::PageId; + switch (page) + { + case PageId::Dashboard: + out = ScreenId::Dashboard; + return true; + case PageId::Chat: + out = ScreenId::Chat; + return true; + case PageId::Contacts: + out = ScreenId::Contacts; + return true; + case PageId::Map: + out = ScreenId::Map; + return true; + case PageId::Gps: + out = ScreenId::Gps; + return true; + case PageId::SkyPlot: + out = ScreenId::SkyPlot; + return true; + case PageId::Team: + out = ScreenId::Team; + return true; + case PageId::Tracker: + out = ScreenId::Tracker; + return true; + case PageId::EnergySweep: + out = ScreenId::EnergySweep; + return true; + case PageId::Settings: + out = ScreenId::Settings; + return true; + case PageId::WalkieTalkie: + out = ScreenId::WalkieTalkie; + return true; + case PageId::Sstv: + out = ScreenId::Sstv; + return true; + case PageId::Extensions: + out = ScreenId::Extensions; + return true; + case PageId::Calendar: + out = ScreenId::Calendar; + return true; + default: + return false; + } + } + CompatibilityUxPack compatibility_; + DeviceUxProfile profile_; + const char* manifest_id_; +}; +} // namespace ui_lvgl_ux diff --git a/modules/ui_lvgl_ux_packs/include/ui_lvgl_ux_packs/ux/screen_registry.h b/modules/ui_lvgl_ux_packs/include/ui_lvgl_ux_packs/ux/screen_registry.h index e4411350..2f9842e6 100644 --- a/modules/ui_lvgl_ux_packs/include/ui_lvgl_ux_packs/ux/screen_registry.h +++ b/modules/ui_lvgl_ux_packs/include/ui_lvgl_ux_packs/ux/screen_registry.h @@ -21,6 +21,7 @@ enum class ScreenId : uint8_t WalkieTalkie, Sstv, Extensions, + Calendar, }; struct ScreenDescriptor diff --git a/modules/ui_lvgl_ux_packs/src/common/touch_text_editor.cpp b/modules/ui_lvgl_ux_packs/src/common/touch_text_editor.cpp index 05e39f7e..ef23875b 100644 --- a/modules/ui_lvgl_ux_packs/src/common/touch_text_editor.cpp +++ b/modules/ui_lvgl_ux_packs/src/common/touch_text_editor.cpp @@ -138,4 +138,45 @@ void attach_touch_text_editor(lv_obj_t* textarea, ImeWidget* owner) lv_obj_remove_event_cb(textarea, on_field_event); lv_obj_add_event_cb(textarea, on_field_event, LV_EVENT_ALL, owner); } + +TouchEditorCapture capture_touch_text_editor(lv_obj_t* source, char* text, + std::size_t capacity, ImeEditState& state) +{ + if (!source || s_source != source || !s_root || !s_draft) return TouchEditorCapture::Inactive; + return s_keyboard.captureEditState(text, capacity, state) ? TouchEditorCapture::Captured + : TouchEditorCapture::InsufficientCapacity; +} + +bool restore_touch_text_editor(lv_obj_t* source, const char* text, + const ImeEditState& state, ImeWidget* owner) +{ + if (!text) return false; + return restore_touch_text_editor( + source, state, [](const void* value, lv_obj_t* draft) + { + lv_textarea_set_text(draft, static_cast(value)); + return true; }, + text, owner); +} + +TouchEditorCapture capture_touch_text_editor(lv_obj_t* source, TouchTextCapture capture, + void* context, ImeEditState& state) +{ + if (!source || s_source != source || !s_root || !s_draft) return TouchEditorCapture::Inactive; + return capture && s_keyboard.captureEditState(nullptr, 0, state) && capture(context, lv_textarea_get_text(s_draft)) + ? TouchEditorCapture::Captured + : TouchEditorCapture::InsufficientCapacity; +} + +bool restore_touch_text_editor(lv_obj_t* source, const ImeEditState& state, + TouchTextRestore restore, const void* context, ImeWidget* owner) +{ + if (!restore || s_root || !source || !lv_obj_is_valid(source) || + !::ui::page_profile::current().compact_touch_keyboard) return false; + open_editor(source, owner); + if (s_source == source && s_draft && restore(context, s_draft) && + s_keyboard.restoreEditState(lv_textarea_get_text(s_draft), state)) return true; + finish_editor(false); + return false; +} } // namespace ui::widgets diff --git a/modules/ui_lvgl_ux_packs/src/packs/deck_touch_ux_pack.cpp b/modules/ui_lvgl_ux_packs/src/packs/deck_touch_ux_pack.cpp index 7ee6681f..a6384fa2 100644 --- a/modules/ui_lvgl_ux_packs/src/packs/deck_touch_ux_pack.cpp +++ b/modules/ui_lvgl_ux_packs/src/packs/deck_touch_ux_pack.cpp @@ -25,6 +25,9 @@ bool screenId(PageId id, ScreenId& screen) { switch (id) { + case PageId::Calendar: + screen = ScreenId::Calendar; + return true; case PageId::Dashboard: screen = ScreenId::Dashboard; return true; diff --git a/modules/ui_lvgl_ux_packs/src/runtime/compatibility_screen_factory.cpp b/modules/ui_lvgl_ux_packs/src/runtime/compatibility_screen_factory.cpp index cc38f630..638be2bd 100644 --- a/modules/ui_lvgl_ux_packs/src/runtime/compatibility_screen_factory.cpp +++ b/modules/ui_lvgl_ux_packs/src/runtime/compatibility_screen_factory.cpp @@ -9,6 +9,8 @@ const char* bindingIdForScreen(ui::menu::MenuScreenId screen_id) { switch (screen_id) { + case ui::menu::MenuScreenId::Calendar: + return "calendar"; case ui::menu::MenuScreenId::Dashboard: return "dashboard"; case ui::menu::MenuScreenId::Chat: diff --git a/modules/ui_lvgl_ux_packs/src/ux/ux_pack_registry.cpp b/modules/ui_lvgl_ux_packs/src/ux/ux_pack_registry.cpp index b52ec023..42f1bb30 100644 --- a/modules/ui_lvgl_ux_packs/src/ux/ux_pack_registry.cpp +++ b/modules/ui_lvgl_ux_packs/src/ux/ux_pack_registry.cpp @@ -3,6 +3,7 @@ #include "ui_lvgl_ux_packs/packs/cardputer_compact_ux_pack.h" #include "ui_lvgl_ux_packs/packs/compatibility_ux_pack.h" #include "ui_lvgl_ux_packs/packs/deck_touch_ux_pack.h" +#include "ui_lvgl_ux_packs/packs/manifest_compatibility_ux_pack.h" #include "ui_lvgl_ux_packs/packs/simulator_full_ux_pack.h" #include "ui_lvgl_ux_packs/packs/t_display_p4_touch_ux_pack.h" #include "ui_lvgl_ux_packs/packs/tiny_node_status_ux_pack.h" @@ -22,6 +23,10 @@ const IUxPack* findUxPackById(const char* id) static const CompatibilityUxPack compatibility_pack; static const DeckTouchUxPack deck_touch_pack; + static const ManifestCompatibilityUxPack pager_pack{ + "pager_compact", "pager_compact_manifest", ScreenClass::CompactHandheld, InputModel::Keyboard}; + static const ManifestCompatibilityUxPack deck_pack{ + "deck_full", "deck_full_manifest", ScreenClass::DeckLandscape, InputModel::RotaryOrTrackball}; static const CardputerCompactUxPack cardputer_compact_pack; static const UConsoleDesktopUxPack uconsole_desktop_pack; static const TinyNodeStatusUxPack tiny_node_status_pack; @@ -31,6 +36,8 @@ const IUxPack* findUxPackById(const char* id) const IUxPack* const packs[] = { &compatibility_pack, &deck_touch_pack, + &pager_pack, + &deck_pack, &cardputer_compact_pack, &uconsole_desktop_pack, &tiny_node_status_pack, diff --git a/modules/ui_lvgl_ux_packs/src/ux/ux_screen_menu_adapter.cpp b/modules/ui_lvgl_ux_packs/src/ux/ux_screen_menu_adapter.cpp index 741f3c89..405934f7 100644 --- a/modules/ui_lvgl_ux_packs/src/ux/ux_screen_menu_adapter.cpp +++ b/modules/ui_lvgl_ux_packs/src/ux/ux_screen_menu_adapter.cpp @@ -9,6 +9,8 @@ ui::menu::MenuScreenId toMenuScreenId(ScreenId id) { switch (id) { + case ScreenId::Calendar: + return ui::menu::MenuScreenId::Calendar; case ScreenId::Dashboard: return ui::menu::MenuScreenId::Dashboard; case ScreenId::Chat: diff --git a/modules/ui_lvgl_ux_packs/tests/test_agenda_target_registration.cpp b/modules/ui_lvgl_ux_packs/tests/test_agenda_target_registration.cpp new file mode 100644 index 00000000..71bbb60c --- /dev/null +++ b/modules/ui_lvgl_ux_packs/tests/test_agenda_target_registration.cpp @@ -0,0 +1,68 @@ +#include "product_composition/agenda_target.h" +#include "product_composition/target_profile.h" +#include "product_composition/target_ux_binding.h" +#include "ui_lvgl_ux_packs/runtime/compatibility_screen_factory.h" +#include "ui_lvgl_ux_packs/ux/ux_pack_registry.h" +#include "ui_lvgl_ux_packs/ux/ux_screen_menu_adapter.h" +#include "ui_presentation/page/page_manifest.h" + +#include +#include +#include + +int main() +{ + static_assert(ui_lvgl_ux::ScreenRegistry::kMaxScreens == 16); + static_assert(ui::screen::ScreenBindingRegistry::kMaxBindings == 16); + std::size_t count = 0; + const auto* targets = product_composition::allTargetProfiles(&count); + for (std::size_t i = 0; i < count; ++i) + { + const auto& target = targets[i]; + const bool expected = std::strcmp(target.target_id, "wio_tracker_l2") == 0 || + std::strcmp(target.target_id, "tdeck") == 0 || + std::strcmp(target.target_id, "tlora_pager") == 0; + const auto* manifest = ui::presentation::findPageManifest(target.page_manifest_id); + bool declared = false; + if (manifest) + for (std::size_t j = 0; j < manifest->item_count; ++j) + { + const auto& item = manifest->items[j]; + if (item.page_id == ui::presentation::PageId::Calendar) + { + assert(item.enabled && item.visible_in_menu); + assert(std::strcmp(item.binding_id, "calendar") == 0); + declared = true; + } + } + assert(declared == expected); + assert(product_composition::targetHasAgenda(&target) == expected); + const auto* binding = product_composition::findTargetUxBinding(target.target_id); + assert(binding); + const auto* pack = ui_lvgl_ux::findUxPackById(binding->active_ux_pack_id); + assert(pack); + ui_lvgl_ux::ScreenRegistry screens; + pack->buildScreens(screens); + assert(screens.size() <= screens.kMaxScreens); + for (std::size_t a = 0; a < screens.size(); ++a) + for (std::size_t b = a + 1; b < screens.size(); ++b) + assert(screens.items()[a].id != screens.items()[b].id); + ui::menu::MenuModel menu; + ui_lvgl_ux::UxScreenMenuAdapter{}.buildMenu(screens, menu); + assert(menu.size() == screens.size()); + ui::screen::ScreenBindingRegistry bindings; + ui_lvgl_ux::CompatibilityScreenFactory{}.buildBindingsForMenu(menu, bindings); + assert(bindings.size() == menu.size()); + const auto* calendar = bindings.find(ui::menu::MenuScreenId::Calendar); + assert((calendar != nullptr) == expected); + if (calendar) + { + assert(calendar->available && std::strcmp(calendar->binding_id, "calendar") == 0); + const bool touch_only = std::strcmp(target.target_id, "wio_tracker_l2") == 0; + assert(screens.size() == (touch_only ? 10 : 11)); + assert(target.renderer == product_composition::TargetRenderer::Lvgl); + std::printf("%s: manifest=%zu screens=%zu bindings=%zu\n", + target.target_id, manifest->item_count, screens.size(), bindings.size()); + } + } +} diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_geo_coordinates.h b/modules/ui_map_runtime/include/ui_map_runtime/map_geo_coordinates.h new file mode 100644 index 00000000..c18b8dcb --- /dev/null +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_geo_coordinates.h @@ -0,0 +1,109 @@ +#pragma once + +#include +#include + +namespace ui::map_geo +{ +constexpr double kCoordPi = 3.14159265358979323846; +constexpr double kCoordA = 6378245.0; +constexpr double kCoordEe = 0.00669342162296594323; + +inline bool coord_out_of_china(double lat, double lon) +{ + return (lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271); +} +inline double coord_transform_lat(double x, double y) +{ + double ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + + 0.2 * std::sqrt(std::fabs(x)); + ret += (20.0 * std::sin(6.0 * x * kCoordPi) + 20.0 * std::sin(2.0 * x * kCoordPi)) * 2.0 / 3.0; + ret += (20.0 * std::sin(y * kCoordPi) + 40.0 * std::sin(y / 3.0 * kCoordPi)) * 2.0 / 3.0; + ret += (160.0 * std::sin(y / 12.0 * kCoordPi) + 320 * std::sin(y * kCoordPi / 30.0)) * 2.0 / 3.0; + return ret; +} +inline double coord_transform_lon(double x, double y) +{ + double ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + + 0.1 * std::sqrt(std::fabs(x)); + ret += (20.0 * std::sin(6.0 * x * kCoordPi) + 20.0 * std::sin(2.0 * x * kCoordPi)) * 2.0 / 3.0; + ret += (20.0 * std::sin(x * kCoordPi) + 40.0 * std::sin(x / 3.0 * kCoordPi)) * 2.0 / 3.0; + ret += (150.0 * std::sin(x / 12.0 * kCoordPi) + 300.0 * std::sin(x / 30.0 * kCoordPi)) * 2.0 / 3.0; + return ret; +} +inline void wgs84_to_gcj02(double lat, double lon, double& out_lat, double& out_lon) +{ + if (coord_out_of_china(lat, lon)) + { + out_lat = lat; + out_lon = lon; + return; + } + double dlat = coord_transform_lat(lon - 105.0, lat - 35.0); + double dlon = coord_transform_lon(lon - 105.0, lat - 35.0); + double radlat = lat / 180.0 * kCoordPi; + double magic = std::sin(radlat); + magic = 1 - kCoordEe * magic * magic; + double sqrt_magic = std::sqrt(magic); + dlat = (dlat * 180.0) / ((kCoordA * (1 - kCoordEe)) / (magic * sqrt_magic) * kCoordPi); + dlon = (dlon * 180.0) / (kCoordA / sqrt_magic * std::cos(radlat) * kCoordPi); + out_lat = lat + dlat; + out_lon = lon + dlon; +} +inline void gcj02_to_bd09(double lat, double lon, double& out_lat, double& out_lon) +{ + double z = std::sqrt(lon * lon + lat * lat) + 0.00002 * std::sin(lat * kCoordPi); + double theta = std::atan2(lat, lon) + 0.000003 * std::cos(lon * kCoordPi); + out_lon = z * std::cos(theta) + 0.0065; + out_lat = z * std::sin(theta) + 0.006; +} +inline bool valid(double latitude, double longitude) +{ + return std::isfinite(latitude) && std::isfinite(longitude) && + latitude >= -90.0 && latitude <= 90.0 && longitude >= -180.0 && longitude <= 180.0; +} +inline double wrap_longitude(double longitude) +{ + double wrapped = std::fmod(longitude + 180.0, 360.0); + if (wrapped < 0.0) wrapped += 360.0; + return wrapped - 180.0; +} + +// The forward formulas are the existing viewport formulas, shared unchanged +// with inverse selection so drawing and returned coordinates cannot drift. +inline bool transform(double latitude, double longitude, uint8_t system, double& out_lat, double& out_lon) +{ + if (!valid(latitude, longitude) || system > 2) return false; + out_lat = latitude; + out_lon = longitude; + if (system == 0) return true; + wgs84_to_gcj02(latitude, longitude, out_lat, out_lon); + if (system == 2) gcj02_to_bd09(out_lat, out_lon, out_lat, out_lon); + return true; +} + +inline bool inverse(double latitude, double longitude, uint8_t system, double& out_lat, double& out_lon) +{ + if (!valid(latitude, longitude) || system > 2) return false; + double candidate_lat = latitude; + double candidate_lon = longitude; + // Fixed bound: a coordinate conversion must never stall the UI loop. + for (unsigned iteration = 0; iteration < 12; ++iteration) + { + double projected_lat = 0.0; + double projected_lon = 0.0; + if (!transform(candidate_lat, candidate_lon, system, projected_lat, projected_lon)) return false; + const double delta_lat = latitude - projected_lat; + const double delta_lon = wrap_longitude(longitude - projected_lon); + if (std::fabs(delta_lat) < 1e-8 && std::fabs(delta_lon) < 1e-8) + { + out_lat = candidate_lat; + out_lon = candidate_lon; + return true; + } + candidate_lat += delta_lat; + candidate_lon = wrap_longitude(candidate_lon + delta_lon); + } + return false; // Discontinuities / non-convergence must not yield a wrong fix. +} +} // namespace ui::map_geo diff --git a/modules/ui_map_runtime/tests/test_map_geo_coordinates.cpp b/modules/ui_map_runtime/tests/test_map_geo_coordinates.cpp new file mode 100644 index 00000000..a2acf028 --- /dev/null +++ b/modules/ui_map_runtime/tests/test_map_geo_coordinates.cpp @@ -0,0 +1,33 @@ +#include "ui_map_runtime/map_geo_coordinates.h" + +#include +#include + +int main() +{ + namespace geo = ui::map_geo; + const double points[][2] = {{24.8731, 118.0}, {39.9, 116.4}, {31.23, 121.47}, {51.5, -0.12}, {-33.86, 151.2}, {0.0, 0.0}, {20.0, 179.999}, {-20.0, -179.999}, {85.0, 0.0}}; + for (uint8_t system = 0; system <= 2; ++system) + for (const auto& point : points) + { + double lat, lon, restored_lat, restored_lon; + assert(geo::transform(point[0], point[1], system, lat, lon)); + assert(geo::inverse(lat, geo::wrap_longitude(lon), system, restored_lat, restored_lon)); + assert(std::fabs(restored_lat - point[0]) < 1e-7); + assert(std::fabs(geo::wrap_longitude(restored_lon - point[1])) < 1e-7); + } + // Known forward output pins the existing formula, independently of inverse. + double lat, lon; + assert(geo::transform(39.9, 116.4, 1, lat, lon)); + assert(std::fabs(lat - 39.9014035298494) < 1e-10); + assert(std::fabs(lon - 116.406242784911) < 1e-10); + assert(geo::transform(51.5, -0.12, 1, lat, lon)); + assert(lat == 51.5 && lon == -0.12); + lat = 123; + lon = 456; + assert(!geo::inverse(std::numeric_limits::quiet_NaN(), 0, 0, lat, lon)); + assert(!geo::inverse(91, 0, 0, lat, lon)); + assert(!geo::inverse(0, 181, 0, lat, lon)); + assert(!geo::inverse(0, 0, 3, lat, lon)); + assert(lat == 123 && lon == 456); +} diff --git a/modules/ui_presentation/include/ui_presentation/agenda/agenda_action_sink.h b/modules/ui_presentation/include/ui_presentation/agenda/agenda_action_sink.h new file mode 100644 index 00000000..1b699253 --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/agenda/agenda_action_sink.h @@ -0,0 +1,19 @@ +#pragma once + +#include "agenda/domain/event.h" +#include "ui_presentation/common/ui_action_result.h" + +namespace ui::agenda +{ +class IAgendaActionSink +{ + public: + virtual ~IAgendaActionSink() = default; + // Success means accepted, not persisted. Observe commandResult before + // closing the editor or reporting success. A second command returns Busy. + virtual UiActionResult save(const ::agenda::EventRecord& draft) = 0; + virtual UiActionResult remove(uint32_t event_id) = 0; + virtual UiActionResult dismissReminder(uint32_t reminder_revision) = 0; + virtual UiActionResult snoozeReminder(uint32_t reminder_revision) = 0; +}; +} // namespace ui::agenda diff --git a/modules/ui_presentation/include/ui_presentation/agenda/agenda_countdown.h b/modules/ui_presentation/include/ui_presentation/agenda/agenda_countdown.h new file mode 100644 index 00000000..57e8ea83 --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/agenda/agenda_countdown.h @@ -0,0 +1,28 @@ +#pragma once +#include +#include +#include + +namespace ui::agenda +{ +// T is this occurrence's start, not its reminder deadline. Compact SI-style +// units avoid allocating translated duration strings on each timer tick. +inline void formatCountdown(int64_t start, int64_t now, bool valid, char* out, std::size_t size) +{ + if (!valid) + { + std::snprintf(out, size, "--"); + return; + } + const bool future = start > now; + const uint64_t seconds = future ? uint64_t(start) - uint64_t(now) : uint64_t(now) - uint64_t(start); + const uint64_t minutes = seconds / 60 + (future && seconds % 60 ? 1 : 0); + const char sign = future ? '-' : '+'; + if (minutes >= 1440) + std::snprintf(out, size, "T%c%llud%lluh", sign, (unsigned long long)(minutes / 1440), (unsigned long long)(minutes / 60 % 24)); + else if (minutes >= 60) + std::snprintf(out, size, "T%c%lluh%llum", sign, (unsigned long long)(minutes / 60), (unsigned long long)(minutes % 60)); + else + std::snprintf(out, size, "T%c%llum", sign, (unsigned long long)minutes); +} +} // namespace ui::agenda diff --git a/modules/ui_presentation/include/ui_presentation/agenda/agenda_editor_model.h b/modules/ui_presentation/include/ui_presentation/agenda/agenda_editor_model.h new file mode 100644 index 00000000..f045ae98 --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/agenda/agenda_editor_model.h @@ -0,0 +1,28 @@ +#pragma once + +#include "agenda/domain/event.h" + +namespace ui::agenda +{ +enum class EditorField : uint8_t +{ + Title, + Date, + Time, + Reminder, + Location, + Repeat, + Note, +}; + +// Caller-owned draft survives reconstruction of the editor after map selection. +// No widget pointers, service ownership, or implicit writes while editing. +struct AgendaEditorModel +{ + ::agenda::EventRecord event; + EditorField focus = EditorField::Title; + bool dirty = false; + bool editing_existing = false; +}; +static_assert(sizeof(AgendaEditorModel) < 1024, "Editor draft budget exceeded"); +} // namespace ui::agenda diff --git a/modules/ui_presentation/include/ui_presentation/agenda/agenda_location_source.h b/modules/ui_presentation/include/ui_presentation/agenda/agenda_location_source.h new file mode 100644 index 00000000..69130d62 --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/agenda/agenda_location_source.h @@ -0,0 +1,84 @@ +#pragma once + +#include "ui_presentation/agenda/agenda_editor_model.h" +#include "ui_presentation/gps/gps_status_source.h" + +#include +#include +#include + +namespace ui::agenda +{ +struct AgendaCoordinate +{ + int32_t latitude_e7 = 0; + int32_t longitude_e7 = 0; +}; + +inline bool coordinateFromDegrees(double latitude, double longitude, AgendaCoordinate& out) +{ + if (!std::isfinite(latitude) || !std::isfinite(longitude) || + latitude < -90.0 || latitude > 90.0 || longitude < -180.0 || longitude > 180.0) + return false; + out.latitude_e7 = static_cast(std::lround(latitude * 1e7)); + out.longitude_e7 = static_cast(std::lround(longitude * 1e7)); + return true; +} + +class IAgendaLocationSource +{ + public: + virtual ~IAgendaLocationSource() = default; + virtual bool currentLocation(AgendaCoordinate& out) const = 0; +}; + +// Portable adapter: the target supplies the existing GPS presentation source. +// No GPS hardware dependency and no retained snapshot or polling timer. +class GpsAgendaLocationSource final : public IAgendaLocationSource +{ + public: + explicit GpsAgendaLocationSource(const ::ui::gps::IGpsStatusSource& source) : source_(source) {} + bool currentLocation(AgendaCoordinate& out) const override + { + ::ui::gps::GpsStatusSnapshot snapshot; + if (!source_.buildGpsStatusSnapshot(snapshot) || !snapshot.header.valid || + !snapshot.receiver_enabled || !snapshot.receiver_powered || !snapshot.fix_valid) + return false; + return coordinateFromDegrees(snapshot.latitude, snapshot.longitude, out); + } + + private: + const ::ui::gps::IGpsStatusSource& source_; +}; + +inline bool applyCoordinate(AgendaEditorModel& draft, const AgendaCoordinate& coordinate) +{ + if (coordinate.latitude_e7 < -900000000 || coordinate.latitude_e7 > 900000000 || + coordinate.longitude_e7 < -1800000000 || coordinate.longitude_e7 > 1800000000) + return false; + auto& event = draft.event; + event.latitude_e7 = coordinate.latitude_e7; + event.longitude_e7 = coordinate.longitude_e7; + event.location_type = ::agenda::LocationType::Coordinate; + event.flags |= ::agenda::HasLocation; + std::memset(event.waypoint_id, 0, sizeof(event.waypoint_id)); + // Persist data, not a translation of "Current position" tied to one locale. + std::snprintf(event.location_name, sizeof(event.location_name), "%.5f, %.5f", + coordinate.latitude_e7 / 1e7, coordinate.longitude_e7 / 1e7); + draft.dirty = true; + draft.focus = EditorField::Location; + return true; +} + +inline void clearLocation(AgendaEditorModel& draft) +{ + auto& event = draft.event; + draft.dirty |= (event.flags & ::agenda::HasLocation) != 0; + event.flags &= ~::agenda::HasLocation; + event.latitude_e7 = event.longitude_e7 = 0; + event.location_type = ::agenda::LocationType::None; + std::memset(event.location_name, 0, sizeof(event.location_name)); + std::memset(event.waypoint_id, 0, sizeof(event.waypoint_id)); + draft.focus = EditorField::Location; +} +} // namespace ui::agenda diff --git a/modules/ui_presentation/include/ui_presentation/agenda/agenda_reminder_source.h b/modules/ui_presentation/include/ui_presentation/agenda/agenda_reminder_source.h new file mode 100644 index 00000000..e4049927 --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/agenda/agenda_reminder_source.h @@ -0,0 +1,55 @@ +#pragma once + +#include "agenda/ports/reminder_sink.h" + +namespace ui::agenda +{ +struct ReminderSnapshot +{ + ::agenda::Reminder reminder{}; + // A popup action must name the presentation it was rendered from. Event ID + // alone is insufficient for repeated events and snoozed occurrences. + uint32_t revision = 0; +}; + +class IAgendaReminderSource +{ + public: + virtual ~IAgendaReminderSource() = default; + virtual ReminderSnapshot reminderSnapshot() const = 0; +}; + +// Composition-owned bridge, not a popup or event cache. Renderer and scheduler +// are pumped on the same owner thread. Delivery is enabled only after the shell +// can present reminders; another modal may temporarily disable new deliveries. +class AgendaReminderModel final : public ::agenda::IReminderSink, public IAgendaReminderSource +{ + public: + void setDeliveryEnabled(bool enabled) { delivery_enabled_ = enabled; } + ReminderSnapshot reminderSnapshot() const override { return current_; } + bool present(const ::agenda::Reminder& reminder) override + { + if (!delivery_enabled_ || !reminder.valid || reminder.event_id == 0 || current_.reminder.valid) + return false; + current_.reminder = reminder; + advanceRevision(); + return true; + } + void withdraw() override + { + if (!current_.reminder.valid) return; + current_.reminder = {}; + advanceRevision(); + } + + private: + void advanceRevision() + { + if (++current_.revision == 0) ++current_.revision; + } + ReminderSnapshot current_{}; + bool delivery_enabled_ = false; +}; + +static_assert(sizeof(AgendaReminderModel) <= 64, "Reminder bridge must not cache an event record"); +} // namespace ui::agenda diff --git a/modules/ui_presentation/include/ui_presentation/agenda/agenda_snapshot.h b/modules/ui_presentation/include/ui_presentation/agenda/agenda_snapshot.h new file mode 100644 index 00000000..0f32409b --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/agenda/agenda_snapshot.h @@ -0,0 +1,52 @@ +#pragma once + +#include "agenda/usecase/agenda_query.h" +#include "agenda/usecase/agenda_service.h" + +namespace ui::agenda +{ + +struct AgendaRequest +{ + int64_t day_start = 0; + uint8_t visible_rows = 5; + ::agenda::QueryCursor after; +}; + +struct AgendaSnapshot +{ + ::agenda::AgendaPage page; + int64_t today_start = 0; + uint32_t revision = 0; + bool clock_valid = false; + bool storage_ready = false; + ::agenda::StoreResult result = ::agenda::StoreResult::Ok; +}; + +enum class CommandState : uint8_t +{ + Idle, + Pending, + Succeeded, + Failed, +}; + +struct CommandResult +{ + CommandState state = CommandState::Idle; + ::agenda::AgendaResult result = ::agenda::AgendaResult::Ok; + uint32_t event_id = 0; + uint32_t sequence = 0; +}; + +struct MonthSnapshot +{ + uint32_t occupied_days = 0; + uint16_t year = 1970; + uint8_t month = 1; + ::agenda::StoreResult result = ::agenda::StoreResult::Ok; +}; + +static_assert(sizeof(AgendaSnapshot) < 2048, "Agenda presentation budget exceeded"); +static_assert(sizeof(MonthSnapshot) < 256, "Month picker budget exceeded"); +} // namespace ui::agenda diff --git a/modules/ui_presentation/include/ui_presentation/agenda/agenda_source.h b/modules/ui_presentation/include/ui_presentation/agenda/agenda_source.h new file mode 100644 index 00000000..e4f8023f --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/agenda/agenda_source.h @@ -0,0 +1,20 @@ +#pragma once +#include "agenda/ports/agenda_clock.h" + +#include "ui_presentation/agenda/agenda_editor_model.h" +#include "ui_presentation/agenda/agenda_snapshot.h" + +namespace ui::agenda +{ +class IAgendaSource +{ + public: + virtual ~IAgendaSource() = default; + virtual ::agenda::ClockSample currentTime() const { return {}; } + virtual void snapshot(const AgendaRequest& request, AgendaSnapshot& out) = 0; + virtual ::agenda::AgendaResult detail(uint32_t id, ::agenda::EventRecord& out) = 0; + virtual bool newDraft(AgendaEditorModel& out) const = 0; + virtual void month(uint16_t year, uint8_t month, MonthSnapshot& out) = 0; + virtual CommandResult commandResult() const = 0; +}; +} // namespace ui::agenda diff --git a/modules/ui_presentation/include/ui_presentation/agenda/agenda_waypoint.h b/modules/ui_presentation/include/ui_presentation/agenda/agenda_waypoint.h new file mode 100644 index 00000000..a70dbcb2 --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/agenda/agenda_waypoint.h @@ -0,0 +1,27 @@ +#pragma once +#include "ui_presentation/agenda/agenda_editor_model.h" +#include "waypoint/waypoint.h" +#include +#include + +namespace ui::agenda +{ +// Copy a durable waypoint into a draft. Its location remains valid if the +// saved waypoint is later renamed or deleted. This never persists the event. +inline bool applyWaypoint(AgendaEditorModel& draft, const ::waypoint::Record& waypoint) +{ + if (!::waypoint::valid(waypoint)) return false; + auto& event = draft.event; + event.latitude_e7 = waypoint.latitude_e7; + event.longitude_e7 = waypoint.longitude_e7; + event.location_type = ::agenda::LocationType::Waypoint; + event.flags |= ::agenda::HasLocation; + std::memset(event.location_name, 0, sizeof(event.location_name)); + std::memcpy(event.location_name, waypoint.name, std::strlen(waypoint.name)); + std::memset(event.waypoint_id, 0, sizeof(event.waypoint_id)); + std::snprintf(event.waypoint_id, sizeof(event.waypoint_id), "%lu", static_cast(waypoint.id)); + draft.dirty = true; + draft.focus = EditorField::Location; + return true; +} +} // namespace ui::agenda diff --git a/modules/ui_presentation/include/ui_presentation/agenda/agenda_workspace_model.h b/modules/ui_presentation/include/ui_presentation/agenda/agenda_workspace_model.h new file mode 100644 index 00000000..c7594538 --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/agenda/agenda_workspace_model.h @@ -0,0 +1,78 @@ +#pragma once + +#include "agenda/ports/agenda_clock.h" +#include "agenda/usecase/reminder_scheduler.h" +#include "ui_presentation/agenda/agenda_action_sink.h" +#include "ui_presentation/agenda/agenda_reminder_source.h" +#include "ui_presentation/agenda/agenda_source.h" +#include "ui_presentation/map/map_marker_source.h" + +namespace ui::agenda +{ +// Constructed and pumped by the target composition. Requests enqueue a single +// bounded command; durable writes happen from pump(), outside LVGL callbacks. +class AgendaWorkspaceModel final : public IAgendaSource, public IAgendaActionSink, public ui::map::IMapMarkerSource +{ + public: + AgendaWorkspaceModel(::agenda::IAgendaStore& store, ::agenda::AgendaService& service, + ::agenda::IAgendaClock& clock, ::agenda::ReminderScheduler& scheduler, + const IAgendaReminderSource& reminders) + : store_(store), service_(service), clock_(clock), scheduler_(scheduler), reminders_(reminders) {} + void setStorageReady(bool ready) + { + if (ready_ != ready) ++revision_; + ready_ = ready; + if (!ready) + { + // Cancel now, even if another card becomes ready before pump(). + if (command_ != Command::None) + { + result_.state = CommandState::Failed; + result_.result = ::agenda::AgendaResult::StorageError; + result_.event_id = 0; + } + command_ = Command::None; + pending_ = {}; + scheduler_.resetStorage(); + } + } + void pump(); + ui::map::MapMarkerStatus markerStatus() const override; + bool visitMarkers(ui::map::MapMarkerVisitor visitor, void* context, + void* scratch, std::size_t scratch_bytes) override; + ::agenda::ClockSample currentTime() const override { return clock_.sample(); } + void snapshot(const AgendaRequest& request, AgendaSnapshot& out) override; + ::agenda::AgendaResult detail(uint32_t id, ::agenda::EventRecord& out) override; + bool newDraft(AgendaEditorModel& out) const override; + void month(uint16_t year, uint8_t month, MonthSnapshot& out) override; + CommandResult commandResult() const override { return result_; } + UiActionResult save(const ::agenda::EventRecord& draft) override; + UiActionResult remove(uint32_t event_id) override; + UiActionResult dismissReminder(uint32_t reminder_revision) override; + UiActionResult snoozeReminder(uint32_t reminder_revision) override; + + private: + enum class Command : uint8_t + { + None, + Save, + Delete, + Dismiss, + Snooze + }; + UiActionResult enqueue(Command command); + UiActionResult enqueueReminder(Command command, uint32_t revision); + bool matchesReminder(uint32_t revision) const; + ::agenda::IAgendaStore& store_; + ::agenda::AgendaService& service_; + ::agenda::IAgendaClock& clock_; + ::agenda::ReminderScheduler& scheduler_; + const IAgendaReminderSource& reminders_; + ::agenda::EventRecord pending_{}; + CommandResult result_{}; + uint32_t revision_ = 0; + Command command_ = Command::None; + bool ready_ = false; +}; + +} // namespace ui::agenda diff --git a/modules/ui_presentation/include/ui_presentation/map/map_location_request.h b/modules/ui_presentation/include/ui_presentation/map/map_location_request.h new file mode 100644 index 00000000..e41fbe42 --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/map/map_location_request.h @@ -0,0 +1,16 @@ +#pragma once + +#include "ui_presentation/map/map_workspace_snapshot.h" + +namespace ui::map +{ +// Caller-owned route context. Must outlive the temporary Map page; no widgets, +// storage or Agenda dependencies. Result coordinates are always WGS84. +struct MapLocationRequest +{ + MapViewport initial_viewport{}; + MapLocationSelection result{}; + bool has_initial_viewport = false; +}; +static_assert(sizeof(MapLocationRequest) <= 64, "Map request must remain a small return context"); +} // namespace ui::map diff --git a/modules/ui_presentation/include/ui_presentation/map/map_marker_cache.h b/modules/ui_presentation/include/ui_presentation/map/map_marker_cache.h new file mode 100644 index 00000000..0aa29fdc --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/map/map_marker_cache.h @@ -0,0 +1,64 @@ +#pragma once + +#include "ui_presentation/map/map_marker_source.h" +#include +#include + +namespace ui::map +{ +// Construct only in the allocator supplied by the platform. No static table, +// full-record mirror, or per-entry allocation. The renderer borrows this data. +struct MapMarkerCache +{ + static constexpr std::size_t kCapacity = 32; + struct Entry + { + MapMarker marker; + uint64_t distance = 0; + }; + Entry entries[kCapacity]{}; + alignas(std::max_align_t) unsigned char scratch[256]{}; + std::size_t count = 0; + bool truncated = false; + bool valid = false; + MapMarkerStatus seen{}; + int64_t next_transition = std::numeric_limits::max(); + + void reset() + { + count = 0; + truncated = false; + valid = false; + next_transition = std::numeric_limits::max(); + } + void offer(const MapMarker& marker, uint64_t distance) + { + std::size_t position = 0; + while (position < count && (entries[position].distance < distance || + (entries[position].distance == distance && entries[position].marker.id < marker.id))) ++position; + if (count == kCapacity) + { + truncated = true; + if (position == kCapacity) return; + } + else ++count; + for (std::size_t i = count - 1; i > position; --i) entries[i] = entries[i - 1]; + entries[position] = {marker, distance}; + } + bool stale(const MapMarkerStatus& status) const + { + return !valid || seen.revision != status.revision || seen.ready != status.ready || + seen.clock_valid != status.clock_valid || status.now < seen.now || status.now >= next_transition; + } + void commit(const MapMarkerStatus& status) + { + seen = status; + valid = true; + for (std::size_t i = 0; i < count; ++i) + if (entries[i].marker.active_until >= status.now && + entries[i].marker.active_until < std::numeric_limits::max()) + next_transition = std::min(next_transition, entries[i].marker.active_until + 1); + } +}; +static_assert(sizeof(MapMarkerCache) <= 2816, "Viewport marker cache must stay compact"); +} // namespace ui::map diff --git a/modules/ui_presentation/include/ui_presentation/map/map_marker_source.h b/modules/ui_presentation/include/ui_presentation/map/map_marker_source.h new file mode 100644 index 00000000..f1cfe74b --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/map/map_marker_source.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +namespace ui::map +{ +// Optional, read-only annotations. Storage and calendar semantics belong to the +// source; projection and bounded viewport selection belong to the consumer. +struct MapMarker +{ + int64_t active_until = 0; + uint32_t id = 0; + int32_t latitude_e7 = 0; + int32_t longitude_e7 = 0; + char title[40]{}; +}; +struct MapMarkerStatus +{ + int64_t now = 0; + uint32_t revision = 0; + bool ready = false; + bool clock_valid = false; +}; +using MapMarkerVisitor = void (*)(const MapMarker&, void*); +class IMapMarkerSource +{ + public: + virtual ~IMapMarkerSource() = default; + virtual MapMarkerStatus markerStatus() const = 0; + // Scratch is aligned to max_align_t and owned by the viewport session. + virtual bool visitMarkers(MapMarkerVisitor visitor, void* context, + void* scratch, std::size_t scratch_bytes) = 0; +}; +struct MapMarkerBinding +{ + IMapMarkerSource* source = nullptr; + void* (*allocate)(std::size_t) = nullptr; + void (*release)(void*) = nullptr; +}; +} // namespace ui::map diff --git a/modules/ui_presentation/include/ui_presentation/map/map_target_request.h b/modules/ui_presentation/include/ui_presentation/map/map_target_request.h new file mode 100644 index 00000000..ff49a946 --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/map/map_target_request.h @@ -0,0 +1,63 @@ +#pragma once + +#include "ui_presentation/map/map_overlay_snapshot.h" +#include "ui_presentation/map/map_workspace_model.h" + +#include + +namespace ui::map +{ +// Caller-owned context for displaying a WGS84 destination in the existing Map. +// Must outlive that page. It does not calculate a route or own renderer state. +struct MapTargetRequest +{ + MapViewport viewport{}; + ui::FixedText<32> label{}; + bool entered = false; + + bool valid() const + { + return std::isfinite(viewport.center_lat) && std::isfinite(viewport.center_lon) && + viewport.center_lat >= -90.0 && viewport.center_lat <= 90.0 && + viewport.center_lon >= -180.0 && viewport.center_lon <= 180.0; + } + + ui::UiActionResult focus(MapWorkspaceModel& model, uint8_t default_zoom) const + { + if (!valid()) return ui::UiActionResult::fail(ui::UiActionFailure::InvalidInput); + if (model.locationSelection().state == MapLocationSelectionState::Selecting) + return ui::UiActionResult::fail(ui::UiActionFailure::Busy); + auto initial = viewport; + if (initial.zoom == 0) initial.zoom = default_zoom; + return model.setViewport(initial); + } + + // Append after optional information filtering. The requested destination + // has priority over the last non-self annotation if the fixed list is full. + void appendOverlay(MapOverlaySnapshot& snapshot) const + { + if (!valid()) return; + std::size_t index = snapshot.item_count; + if (index >= MapOverlaySnapshot::kMaxItems) + { + index = MapOverlaySnapshot::kMaxItems - 1; + while (index > 0 && snapshot.items[index].kind == MapOverlayKind::CurrentPosition) --index; + snapshot.item_count = MapOverlaySnapshot::kMaxItems; + snapshot.truncated = true; + } + else + { + ++snapshot.item_count; + } + auto& item = snapshot.items[index]; + item = {}; + item.kind = MapOverlayKind::SelectedTarget; + item.style = MapOverlayStyle::Warning; + item.point = {viewport.center_lat, viewport.center_lon, true}; + item.label = label; + item.label.data[sizeof(item.label.data) - 1] = '\0'; + item.selected = true; + } +}; +static_assert(sizeof(MapTargetRequest) <= 64, "Destination route context must remain bounded"); +} // namespace ui::map diff --git a/modules/ui_presentation/include/ui_presentation/map/map_workspace_model.h b/modules/ui_presentation/include/ui_presentation/map/map_workspace_model.h index ea628b27..5560542d 100644 --- a/modules/ui_presentation/include/ui_presentation/map/map_workspace_model.h +++ b/modules/ui_presentation/include/ui_presentation/map/map_workspace_model.h @@ -22,12 +22,21 @@ class MapWorkspaceModel MapViewport viewport() const; MapToolKind activeTool() const; + // Temporary workspace mode, shared by any caller that needs a coordinate. + // The renderer must synchronize its visible centre before confirming. + ui::UiActionResult beginLocationSelection(); + ui::UiActionResult pickLocation(); + ui::UiActionResult cancelLocationSelection(); + MapLocationSelection locationSelection() const; + private: IMapPresentationSource& source_; IMapActionSink& sink_; MapViewport viewport_{}; MapToolKind active_tool_ = MapToolKind::Pan; + MapToolKind previous_tool_ = MapToolKind::Pan; + MapLocationSelection selection_{}; }; } // namespace ui::map diff --git a/modules/ui_presentation/include/ui_presentation/map/map_workspace_snapshot.h b/modules/ui_presentation/include/ui_presentation/map/map_workspace_snapshot.h index e7ae1382..4888f541 100644 --- a/modules/ui_presentation/include/ui_presentation/map/map_workspace_snapshot.h +++ b/modules/ui_presentation/include/ui_presentation/map/map_workspace_snapshot.h @@ -22,8 +22,25 @@ enum class MapToolKind : uint8_t Pan, CenterOnSelf, MeasureDistance, + SelectLocation, }; +enum class MapLocationSelectionState : uint8_t +{ + Idle, + Selecting, + Picked, + Cancelled, +}; + +struct MapLocationSelection +{ + double latitude = 0.0; + double longitude = 0.0; + MapLocationSelectionState state = MapLocationSelectionState::Idle; +}; +static_assert(sizeof(MapLocationSelection) <= 24, "Location selection must remain bounded"); + struct MapViewport { double center_lat = 0.0; diff --git a/modules/ui_presentation/include/ui_presentation/menu/menu_model.h b/modules/ui_presentation/include/ui_presentation/menu/menu_model.h index 5faf3af7..8dc2d500 100644 --- a/modules/ui_presentation/include/ui_presentation/menu/menu_model.h +++ b/modules/ui_presentation/include/ui_presentation/menu/menu_model.h @@ -23,6 +23,7 @@ enum class MenuScreenId : uint8_t WalkieTalkie, Sstv, Extensions, + Calendar, }; struct MenuItem diff --git a/modules/ui_presentation/include/ui_presentation/page/page_manifest.h b/modules/ui_presentation/include/ui_presentation/page/page_manifest.h index d4ebafb1..a7e6b1a1 100644 --- a/modules/ui_presentation/include/ui_presentation/page/page_manifest.h +++ b/modules/ui_presentation/include/ui_presentation/page/page_manifest.h @@ -22,6 +22,7 @@ enum class PageId Extensions, NodeStatus, Diagnostics, + Calendar, }; struct PageManifestItem diff --git a/modules/ui_presentation/include/ui_presentation/waypoint/waypoint_model.h b/modules/ui_presentation/include/ui_presentation/waypoint/waypoint_model.h new file mode 100644 index 00000000..0c4e4ad7 --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/waypoint/waypoint_model.h @@ -0,0 +1,118 @@ +#pragma once +#include "ui_presentation/common/ui_action_result.h" +#include "waypoint/waypoint.h" + +namespace ui::waypoint +{ +enum class CommandState : uint8_t +{ + Idle, + Pending, + Succeeded, + Failed +}; +struct CommandResult +{ + uint32_t sequence = 0; + uint32_t id = 0; + ::waypoint::Result result = ::waypoint::Result::Ok; + CommandState state = CommandState::Idle; +}; +class ISource +{ + public: + virtual ~ISource() = default; + virtual ::waypoint::Result page(uint32_t after, uint8_t capacity, ::waypoint::Page& out) = 0; + virtual ::waypoint::Result detail(uint32_t id, ::waypoint::Record& out) = 0; + virtual CommandResult commandResult() const = 0; +}; +class IActionSink +{ + public: + virtual ~IActionSink() = default; + // Success acknowledges enqueueing only. Wait for the command sequence to + // finish before closing an editor or applying a newly created waypoint. + virtual UiActionResult save(const ::waypoint::Record& record) = 0; + virtual UiActionResult remove(uint32_t id) = 0; +}; +// Root-owned, renderer-independent, one pending operation. No list cache. +class Model final : public ISource, public IActionSink +{ + public: + explicit Model(::waypoint::IStore& store) : store_(store) {} + void setReady(bool ready) + { + ready_ = ready; + if (!ready) + { + // A pending write belongs to the old media session, not to the + // next card that happens to become available before pump(). + if (result_.state == CommandState::Pending) + { + result_.state = CommandState::Failed; + result_.result = ::waypoint::Result::IoError; + result_.id = 0; + } + pending_ = {}; + deleting_ = false; + } + } + void pump() + { + if (result_.state != CommandState::Pending) return; + uint32_t id = pending_.id; + result_.result = !ready_ ? ::waypoint::Result::IoError + : deleting_ ? store_.remove(id) + : id ? store_.update(pending_) + : store_.create(pending_, id); + result_.state = result_.result == ::waypoint::Result::Ok ? CommandState::Succeeded : CommandState::Failed; + result_.id = result_.state == CommandState::Succeeded ? id : 0; + pending_ = {}; + } + ::waypoint::Result page(uint32_t after, uint8_t capacity, ::waypoint::Page& out) override + { + out = {}; + return ready_ ? ::waypoint::query(store_, after, capacity, out) : ::waypoint::Result::IoError; + } + ::waypoint::Result detail(uint32_t id, ::waypoint::Record& out) override + { + out = {}; + return ready_ ? ::waypoint::find(store_, id, out) : ::waypoint::Result::IoError; + } + CommandResult commandResult() const override { return result_; } + UiActionResult save(const ::waypoint::Record& record) override + { + auto validation = record; + if (!validation.id) validation.id = 1; + if (!::waypoint::valid(validation)) return UiActionResult::fail(UiActionFailure::InvalidInput); + return enqueue(record, false); + } + UiActionResult remove(uint32_t id) override + { + if (!id) return UiActionResult::fail(UiActionFailure::InvalidInput); + ::waypoint::Record record; + record.id = id; + return enqueue(record, true); + } + + private: + UiActionResult enqueue(const ::waypoint::Record& record, bool deleting) + { + if (!ready_) return UiActionResult::fail(UiActionFailure::NotReady); + if (result_.state == CommandState::Pending) return UiActionResult::fail(UiActionFailure::Busy); + pending_ = record; + deleting_ = deleting; + if (!++result_.sequence) ++result_.sequence; + result_.id = 0; + result_.result = ::waypoint::Result::Ok; + result_.state = CommandState::Pending; + return UiActionResult::success(); + } + ::waypoint::IStore& store_; + ::waypoint::Record pending_{}; + CommandResult result_{}; + bool ready_ = false; + bool deleting_ = false; +}; +static_assert(sizeof(Model) < 96, "Waypoint model must remain bounded"); +} // namespace ui::waypoint diff --git a/modules/ui_presentation/include/ui_presentation/workspace/presentation_workspace.h b/modules/ui_presentation/include/ui_presentation/workspace/presentation_workspace.h index ee477a14..094cfe27 100644 --- a/modules/ui_presentation/include/ui_presentation/workspace/presentation_workspace.h +++ b/modules/ui_presentation/include/ui_presentation/workspace/presentation_workspace.h @@ -7,6 +7,12 @@ #include "ui_presentation/mesh/mesh_status_model.h" #include "ui_presentation/settings/settings_model.h" +namespace ui::agenda +{ +class AgendaWorkspaceModel; +class IAgendaReminderSource; +} // namespace ui::agenda + namespace ui::workspace { @@ -21,6 +27,8 @@ struct PresentationWorkspace ui::chat::ChatWorkspaceModel* chat = nullptr; ui::chat::ChatWorkspaceModel* team_chat = nullptr; ui::map::MapWorkspaceModel* map = nullptr; + ui::agenda::AgendaWorkspaceModel* agenda = nullptr; + ui::agenda::IAgendaReminderSource* agenda_reminders = nullptr; bool hasDevice() const { return device != nullptr; } bool hasGps() const { return gps != nullptr; } @@ -29,6 +37,7 @@ struct PresentationWorkspace bool hasChat() const { return chat != nullptr; } bool hasTeamChat() const { return team_chat != nullptr; } bool hasMap() const { return map != nullptr; } + bool hasAgenda() const { return agenda != nullptr && agenda_reminders != nullptr; } }; inline bool hasCoreStatusModels(const PresentationWorkspace& workspace) @@ -44,7 +53,8 @@ inline bool hasInteractiveWorkspaceModels( return workspace.settings != nullptr || workspace.chat != nullptr || workspace.team_chat != nullptr || - workspace.map != nullptr; + workspace.map != nullptr || + workspace.hasAgenda(); } } // namespace ui::workspace diff --git a/modules/ui_presentation/library.json b/modules/ui_presentation/library.json index c4a8da30..4cae9411 100644 --- a/modules/ui_presentation/library.json +++ b/modules/ui_presentation/library.json @@ -4,5 +4,6 @@ "build": { "includeDir": "include", "srcDir": "src" - } + }, + "dependencies": [{ "name": "core_agenda" }, { "name": "core_waypoint" }] } diff --git a/modules/ui_presentation/src/agenda/agenda_workspace_model.cpp b/modules/ui_presentation/src/agenda/agenda_workspace_model.cpp new file mode 100644 index 00000000..faf3a48b --- /dev/null +++ b/modules/ui_presentation/src/agenda/agenda_workspace_model.cpp @@ -0,0 +1,217 @@ +#include "ui_presentation/agenda/agenda_workspace_model.h" +#include +#include +#include + +namespace ui::agenda +{ +ui::map::MapMarkerStatus AgendaWorkspaceModel::markerStatus() const +{ + const auto time = clock_.sample(); + return {time.calendar_seconds, revision_, ready_, time.valid}; +} + +bool AgendaWorkspaceModel::visitMarkers(ui::map::MapMarkerVisitor visitor, void* context, + void* scratch, std::size_t scratch_bytes) +{ + if (!ready_ || !visitor || !scratch || scratch_bytes < sizeof(::agenda::EventRecord)) return false; + auto* record = new (scratch)::agenda::EventRecord{}; + const auto time = clock_.sample(); + bool success = true; + for (uint16_t slot = 0; slot < store_.slotCount(); ++slot) + { + if (store_.readSlot(slot, *record) != ::agenda::StoreResult::Ok) + { + success = false; + break; + } + if (record->state != ::agenda::RecordState::Active || !(record->flags & ::agenda::HasLocation)) continue; + ui::map::MapMarker marker{}; + marker.id = record->id; + marker.latitude_e7 = record->latitude_e7; + marker.longitude_e7 = record->longitude_e7; + std::memcpy(marker.title, record->title, sizeof(marker.title)); + marker.title[sizeof(marker.title) - 1] = '\0'; + marker.active_until = (record->flags & ::agenda::HasEndTime) ? record->end_time : record->start_time; + if (record->repeat != ::agenda::Repeat::None && time.valid) + { + ::agenda::Occurrence occurrence; + const auto duration = marker.active_until - record->start_time; + if (::agenda::nextOccurrence(*record, std::max(0, time.calendar_seconds - duration), occurrence)) + marker.active_until = occurrence.has_end ? occurrence.end : occurrence.start; + } + visitor(marker, context); + } + record->~EventRecord(); + return success; +} + +void AgendaWorkspaceModel::snapshot(const AgendaRequest& request, AgendaSnapshot& out) +{ + out = {}; + const auto time = clock_.sample(); + out.clock_valid = time.valid; + out.storage_ready = ready_; + out.revision = revision_; + if (time.valid) out.today_start = time.calendar_seconds / 86400 * 86400; + if (!ready_) + { + out.result = ::agenda::StoreResult::IoError; + return; + } + if (!time.valid) return; + // Upcoming is streamed through the same bounded cursor, not cut off after + // an arbitrary week or month. The renderer labels sections relative to today. + out.result = ::agenda::queryAgenda(store_, request.day_start, ::agenda::kLastCalendarSecond + 1, + request.visible_rows, request.after, out.page); +} + +::agenda::AgendaResult AgendaWorkspaceModel::detail(uint32_t id, ::agenda::EventRecord& out) +{ + out = {}; + return ready_ ? service_.read(id, out) : ::agenda::AgendaResult::StorageError; +} + +bool AgendaWorkspaceModel::newDraft(AgendaEditorModel& out) const +{ + out = {}; + if (!ready_) return false; + const auto time = clock_.sample(); + if (!time.valid || time.calendar_seconds < 0 || time.calendar_seconds > ::agenda::kLastCalendarSecond - 60) + return false; + out.event.start_time = (time.calendar_seconds / 60 + 1) * 60; + out.event.state = ::agenda::RecordState::Active; + return true; +} + +void AgendaWorkspaceModel::month(uint16_t year, uint8_t month, MonthSnapshot& out) +{ + out = {}; + out.year = year; + out.month = month; + out.result = ready_ ? ::agenda::queryMonth(store_, year, month, out.occupied_days) : ::agenda::StoreResult::IoError; +} + +UiActionResult AgendaWorkspaceModel::enqueue(Command command) +{ + if (!ready_) return UiActionResult::fail(UiActionFailure::NotReady); + if (command_ != Command::None) return UiActionResult::fail(UiActionFailure::Busy); + command_ = command; + ++result_.sequence; + result_.state = CommandState::Pending; + result_.result = ::agenda::AgendaResult::Ok; + result_.event_id = 0; + return UiActionResult::success(); +} + +UiActionResult AgendaWorkspaceModel::save(const ::agenda::EventRecord& draft) +{ + // Validate without altering the caller's draft or using a second record. + if (!ready_) return UiActionResult::fail(UiActionFailure::NotReady); + if (command_ != Command::None) return UiActionResult::fail(UiActionFailure::Busy); + pending_ = draft; + pending_.state = ::agenda::RecordState::Active; + if (!pending_.id) pending_.id = 1; + if (!::agenda::validEvent(pending_)) + { + pending_ = {}; + return UiActionResult::fail(UiActionFailure::InvalidInput); + } + pending_.id = draft.id; + return enqueue(Command::Save); +} + +UiActionResult AgendaWorkspaceModel::remove(uint32_t event_id) +{ + if (event_id == 0) return UiActionResult::fail(UiActionFailure::InvalidInput); + const auto result = enqueue(Command::Delete); + if (result.ok) + { + pending_ = {}; + pending_.id = event_id; + } + return result; +} + +bool AgendaWorkspaceModel::matchesReminder(uint32_t revision) const +{ + const auto current = reminders_.reminderSnapshot(); + return revision != 0 && current.reminder.valid && current.revision == revision && scheduler_.showing(); +} + +UiActionResult AgendaWorkspaceModel::enqueueReminder(Command command, uint32_t revision) +{ + if (!matchesReminder(revision)) return UiActionResult::fail(UiActionFailure::InvalidInput); + const auto result = enqueue(command); + if (result.ok) + { + pending_ = {}; + // The single command slot is reused; no second pending action buffer. + pending_.id = revision; + } + return result; +} + +UiActionResult AgendaWorkspaceModel::dismissReminder(uint32_t revision) +{ + return enqueueReminder(Command::Dismiss, revision); +} +UiActionResult AgendaWorkspaceModel::snoozeReminder(uint32_t revision) +{ + return enqueueReminder(Command::Snooze, revision); +} + +void AgendaWorkspaceModel::pump() +{ + if (!ready_) + { + if (command_ != Command::None) + { + result_.state = CommandState::Failed; + result_.result = ::agenda::AgendaResult::StorageError; + command_ = Command::None; + pending_ = {}; + } + return; + } + if (command_ != Command::None) + { + bool changed = false; + result_.event_id = pending_.id; + switch (command_) + { + case Command::Save: + result_.result = pending_.id ? service_.update(pending_) : service_.create(pending_, result_.event_id); + changed = result_.result == ::agenda::AgendaResult::Ok; + break; + case Command::Delete: + result_.result = service_.remove(pending_.id); + changed = result_.result == ::agenda::AgendaResult::Ok; + break; + case Command::Dismiss: + result_.event_id = 0; + result_.result = matchesReminder(pending_.id) && scheduler_.dismiss() + ? ::agenda::AgendaResult::Ok + : ::agenda::AgendaResult::NotFound; + break; + case Command::Snooze: + result_.event_id = 0; + result_.result = !matchesReminder(pending_.id) ? ::agenda::AgendaResult::NotFound + : scheduler_.snooze() ? ::agenda::AgendaResult::Ok + : ::agenda::AgendaResult::Full; + break; + default: + break; + } + result_.state = result_.result == ::agenda::AgendaResult::Ok ? CommandState::Succeeded : CommandState::Failed; + pending_ = {}; + command_ = Command::None; + if (changed) + { + ++revision_; + scheduler_.eventsChanged(); + } + } + scheduler_.tick(); +} +} // namespace ui::agenda diff --git a/modules/ui_presentation/src/map/map_workspace_model.cpp b/modules/ui_presentation/src/map/map_workspace_model.cpp index d7b4222b..b7294d2d 100644 --- a/modules/ui_presentation/src/map/map_workspace_model.cpp +++ b/modules/ui_presentation/src/map/map_workspace_model.cpp @@ -1,5 +1,7 @@ #include "ui_presentation/map/map_workspace_model.h" +#include + namespace ui::map { @@ -41,8 +43,9 @@ ui::UiActionResult MapWorkspaceModel::centerOnSelf() ui::UiActionResult MapWorkspaceModel::setViewport(const MapViewport& viewport) { - viewport_ = viewport; - return sink_.setViewport(viewport); + const auto result = sink_.setViewport(viewport); + if (result.ok) viewport_ = viewport; + return result; } ui::UiActionResult MapWorkspaceModel::setLayer(MapLayerKind layer, bool enabled) @@ -52,8 +55,15 @@ ui::UiActionResult MapWorkspaceModel::setLayer(MapLayerKind layer, bool enabled) ui::UiActionResult MapWorkspaceModel::setActiveTool(MapToolKind tool) { - active_tool_ = tool; - return sink_.setActiveTool(tool); + // Selection owns the tool until confirmed or cancelled. Ordinary pan and + // zoom still use setViewport and must not silently abandon this mode. + if (selection_.state == MapLocationSelectionState::Selecting && tool != MapToolKind::SelectLocation) + return ui::UiActionResult::fail(ui::UiActionFailure::Busy); + if (tool == MapToolKind::SelectLocation && selection_.state != MapLocationSelectionState::Selecting) + return ui::UiActionResult::fail(ui::UiActionFailure::InvalidInput); + const auto result = sink_.setActiveTool(tool); + if (result.ok) active_tool_ = tool; + return result; } ui::UiActionResult MapWorkspaceModel::clearMeasurement() @@ -71,4 +81,53 @@ MapToolKind MapWorkspaceModel::activeTool() const return active_tool_; } +ui::UiActionResult MapWorkspaceModel::beginLocationSelection() +{ + if (selection_.state == MapLocationSelectionState::Selecting) + return ui::UiActionResult::fail(ui::UiActionFailure::Busy); + const auto result = sink_.setActiveTool(MapToolKind::SelectLocation); + if (!result.ok) return result; + previous_tool_ = active_tool_; + active_tool_ = MapToolKind::SelectLocation; + selection_ = {}; + selection_.state = MapLocationSelectionState::Selecting; + return result; +} + +ui::UiActionResult MapWorkspaceModel::pickLocation() +{ + if (selection_.state != MapLocationSelectionState::Selecting) + return ui::UiActionResult::fail(ui::UiActionFailure::NotReady); + const auto view = snapshot(); + if (!view.header.valid) return ui::UiActionResult::fail(ui::UiActionFailure::NotReady); + const double lat = view.viewport.center_lat; + const double lon = view.viewport.center_lon; + if (!std::isfinite(lat) || !std::isfinite(lon) || lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) + return ui::UiActionResult::fail(ui::UiActionFailure::InvalidInput); + const auto result = sink_.setActiveTool(previous_tool_); + if (!result.ok) return result; + active_tool_ = previous_tool_; + selection_.latitude = lat; + selection_.longitude = lon; + selection_.state = MapLocationSelectionState::Picked; + return result; +} + +ui::UiActionResult MapWorkspaceModel::cancelLocationSelection() +{ + if (selection_.state != MapLocationSelectionState::Selecting) + return ui::UiActionResult::fail(ui::UiActionFailure::NotReady); + const auto result = sink_.setActiveTool(previous_tool_); + if (!result.ok) return result; + active_tool_ = previous_tool_; + selection_ = {}; + selection_.state = MapLocationSelectionState::Cancelled; + return result; +} + +MapLocationSelection MapWorkspaceModel::locationSelection() const +{ + return selection_; +} + } // namespace ui::map diff --git a/modules/ui_presentation/src/page/page_manifest.cpp b/modules/ui_presentation/src/page/page_manifest.cpp index 614a548e..4297c949 100644 --- a/modules/ui_presentation/src/page/page_manifest.cpp +++ b/modules/ui_presentation/src/page/page_manifest.cpp @@ -36,6 +36,7 @@ constexpr PageManifestItem kTDisplayP4TouchItems[] = { }; constexpr PageManifestItem kPagerCompactItems[] = { + {PageId::Calendar, "calendar", true, true}, {PageId::Dashboard, "dashboard", true, true}, {PageId::Chat, "chat", true, true}, {PageId::Gps, "gps", true, true}, @@ -45,6 +46,7 @@ constexpr PageManifestItem kPagerCompactItems[] = { }; constexpr PageManifestItem kDeckFullItems[] = { + {PageId::Calendar, "calendar", true, true}, {PageId::Dashboard, "dashboard", true, true}, {PageId::Chat, "chat", true, true}, {PageId::Team, "team", true, true}, diff --git a/modules/ui_presentation/tests/test_agenda_location.cpp b/modules/ui_presentation/tests/test_agenda_location.cpp new file mode 100644 index 00000000..841306a6 --- /dev/null +++ b/modules/ui_presentation/tests/test_agenda_location.cpp @@ -0,0 +1,60 @@ +#include "fake/fake_gps_status_source.h" +#include "ui_presentation/agenda/agenda_location_source.h" + +#include +#include +#include + +int main() +{ + using namespace ui::agenda; + ui::tests::FakeGpsStatusSource gps; + GpsAgendaLocationSource source(gps); + AgendaCoordinate out{123, 456}; + assert(!source.currentLocation(out)); + assert(out.latitude_e7 == 123 && out.longitude_e7 == 456); + gps.snapshot_value.header.valid = true; + gps.snapshot_value.receiver_enabled = true; + gps.snapshot_value.receiver_powered = true; + gps.snapshot_value.fix_valid = true; + gps.snapshot_value.latitude = 24.87310004; + gps.snapshot_value.longitude = 118.00000006; + assert(source.currentLocation(out)); + assert(out.latitude_e7 == 248731000 && out.longitude_e7 == 1180000001); + gps.snapshot_value.receiver_powered = false; + assert(!source.currentLocation(out)); + gps.snapshot_value.receiver_powered = true; + gps.available = false; + assert(!source.currentLocation(out)); + gps.available = true; + gps.snapshot_value.latitude = std::numeric_limits::quiet_NaN(); + assert(!source.currentLocation(out)); + assert(!coordinateFromDegrees(91, 0, out)); + assert(!coordinateFromDegrees(0, -181, out)); + assert(coordinateFromDegrees(-90, 180, out)); + assert(out.latitude_e7 == -900000000 && out.longitude_e7 == 1800000000); + AgendaEditorModel draft; + std::strcpy(draft.event.title, "Keep title"); + std::strcpy(draft.event.note, "Keep note"); + std::strcpy(draft.event.waypoint_id, "old-waypoint"); + draft.event.flags = ::agenda::HasNote | ::agenda::HasReminder; + draft.event.start_time = 1776330000; + assert(applyCoordinate(draft, out)); + assert(draft.dirty && draft.focus == EditorField::Location); + assert(draft.event.waypoint_id[0] == '\0'); + assert(draft.event.location_type == ::agenda::LocationType::Coordinate); + assert(std::strcmp(draft.event.location_name, "-90.00000, 180.00000") == 0); + assert(!applyCoordinate(draft, {900000001, 0})); + assert(draft.event.latitude_e7 == -900000000); + clearLocation(draft); + assert(draft.event.flags == (::agenda::HasNote | ::agenda::HasReminder)); + assert(draft.event.location_type == ::agenda::LocationType::None); + assert(draft.event.latitude_e7 == 0 && draft.event.longitude_e7 == 0); + assert(draft.event.location_name[0] == '\0' && draft.event.waypoint_id[0] == '\0'); + assert(draft.event.start_time == 1776330000); + assert(std::strcmp(draft.event.title, "Keep title") == 0); + assert(std::strcmp(draft.event.note, "Keep note") == 0); + assert(coordinateFromDegrees(0, 0, out)); + assert(applyCoordinate(draft, out)); + assert(draft.event.flags & ::agenda::HasLocation); +} diff --git a/modules/ui_presentation/tests/test_map_location_selection.cpp b/modules/ui_presentation/tests/test_map_location_selection.cpp new file mode 100644 index 00000000..634c3fd2 --- /dev/null +++ b/modules/ui_presentation/tests/test_map_location_selection.cpp @@ -0,0 +1,70 @@ +#include "fake/fake_map_action_sink.h" +#include "fake/fake_map_presentation_source.h" +#include "ui_presentation/map/map_workspace_model.h" + +#include +#include + +int main() +{ + using namespace ui::map; + ui::tests::FakeMapPresentationSource source; + source.snapshot_value.header.valid = true; + ui::tests::FakeMapActionSink sink; + MapWorkspaceModel model(source, sink); + assert(model.locationSelection().state == MapLocationSelectionState::Idle); + assert(!model.pickLocation().ok && !model.cancelLocationSelection().ok); + assert(!model.setActiveTool(MapToolKind::SelectLocation).ok); + assert(model.setActiveTool(MapToolKind::MeasureDistance).ok); + sink.set_active_tool_result = ui::UiActionResult::fail(ui::UiActionFailure::Rejected); + assert(!model.beginLocationSelection().ok); + assert(model.locationSelection().state == MapLocationSelectionState::Idle); + assert(model.activeTool() == MapToolKind::MeasureDistance); + assert(!model.setActiveTool(MapToolKind::Pan).ok); + assert(model.activeTool() == MapToolKind::MeasureDistance); + sink.set_active_tool_result = ui::UiActionResult::success(); + assert(model.beginLocationSelection().ok); + assert(!model.beginLocationSelection().ok); + assert(!model.setActiveTool(MapToolKind::Pan).ok); + assert(model.snapshot().active_tool == MapToolKind::SelectLocation); + assert(model.setViewport({24.8731, 118.0, 12}).ok); + sink.set_viewport_result = ui::UiActionResult::fail(ui::UiActionFailure::Rejected); + assert(!model.setViewport({50.0, 60.0, 7}).ok); + assert(model.viewport().center_lat == 24.8731); + sink.set_viewport_result = ui::UiActionResult::success(); + // Confirm the viewport, not the GPS fix, including zero coordinates. + source.snapshot_value.self.valid = true; + source.snapshot_value.self.lat = 50.0; + source.snapshot_value.self.lon = 60.0; + assert(model.pickLocation().ok); + const auto picked = model.locationSelection(); + assert(picked.state == MapLocationSelectionState::Picked); + assert(picked.latitude == 24.8731 && picked.longitude == 118.0); + assert(model.activeTool() == MapToolKind::MeasureDistance); + assert(!model.pickLocation().ok); + assert(model.beginLocationSelection().ok); + assert(model.locationSelection().latitude == 0.0); + source.available = false; + assert(!model.pickLocation().ok); + source.available = true; + const double invalid[] = {91.0, -91.0, std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity()}; + for (double lat : invalid) + { + assert(model.setViewport({lat, 0.0, 12}).ok); + assert(!model.pickLocation().ok); + } + assert(model.setViewport({0.0, 181.0, 12}).ok); + assert(!model.pickLocation().ok); + assert(model.setViewport({0.0, 0.0, 12}).ok); + sink.set_active_tool_result = ui::UiActionResult::fail(ui::UiActionFailure::Rejected); + assert(!model.pickLocation().ok && !model.cancelLocationSelection().ok); + assert(model.locationSelection().state == MapLocationSelectionState::Selecting); + sink.set_active_tool_result = ui::UiActionResult::success(); + assert(model.pickLocation().ok); + assert(model.locationSelection().latitude == 0.0 && model.locationSelection().longitude == 0.0); + assert(model.beginLocationSelection().ok); + assert(model.cancelLocationSelection().ok); + assert(model.locationSelection().state == MapLocationSelectionState::Cancelled); + assert(model.activeTool() == MapToolKind::MeasureDistance); +} diff --git a/modules/ui_presentation/tests/test_map_target_request.cpp b/modules/ui_presentation/tests/test_map_target_request.cpp new file mode 100644 index 00000000..91845800 --- /dev/null +++ b/modules/ui_presentation/tests/test_map_target_request.cpp @@ -0,0 +1,66 @@ +#include "fake/fake_map_action_sink.h" +#include "fake/fake_map_presentation_source.h" +#include "ui_presentation/map/map_target_request.h" + +#include +#include +#include + +int main() +{ + using namespace ui::map; + ui::tests::FakeMapPresentationSource source; + ui::tests::FakeMapActionSink sink; + MapWorkspaceModel model(source, sink); + MapTargetRequest request; + // Zero is a real destination, not a request to follow the GPS fix. + assert(request.valid() && request.focus(model, 12).ok); + assert(model.viewport().center_lat == 0.0 && model.viewport().zoom == 12); + request.viewport = {24.8731, 118.0, 14}; + ui::copyText(request.label, "Campsite"); + assert(request.focus(model, 12).ok); + assert(model.viewport().center_lat == 24.8731 && model.viewport().zoom == 14); + sink.set_viewport_result = ui::UiActionResult::fail(ui::UiActionFailure::Rejected); + request.viewport.center_lat = 25.0; + assert(!request.focus(model, 12).ok && model.viewport().center_lat == 24.8731); + sink.set_viewport_result = ui::UiActionResult::success(); + assert(model.beginLocationSelection().ok); + assert(!request.focus(model, 12).ok); + assert(model.locationSelection().state == MapLocationSelectionState::Selecting); + assert(model.cancelLocationSelection().ok); + + MapOverlaySnapshot snapshot; + snapshot.item_count = 1; + snapshot.items[0].kind = MapOverlayKind::CurrentPosition; + request.appendOverlay(snapshot); + assert(snapshot.item_count == 2 && !snapshot.truncated); + assert(snapshot.items[0].kind == MapOverlayKind::CurrentPosition); + const auto& target = snapshot.items[1]; + assert(target.kind == MapOverlayKind::SelectedTarget && target.selected && target.visible); + assert(target.point.valid && target.point.lat == 25.0 && target.point.lon == 118.0); + assert(std::strcmp(target.label.c_str(), "Campsite") == 0); + // Full snapshots stay bounded and retain the self marker at the tail. + snapshot.item_count = MapOverlaySnapshot::kMaxItems; + for (auto& item : snapshot.items) item.kind = MapOverlayKind::TeamMember; + snapshot.items[MapOverlaySnapshot::kMaxItems - 1].kind = MapOverlayKind::CurrentPosition; + request.appendOverlay(snapshot); + assert(snapshot.item_count == MapOverlaySnapshot::kMaxItems && snapshot.truncated); + assert(snapshot.items[MapOverlaySnapshot::kMaxItems - 2].kind == MapOverlayKind::SelectedTarget); + assert(snapshot.items[MapOverlaySnapshot::kMaxItems - 1].kind == MapOverlayKind::CurrentPosition); + const double invalid[] = {-91.0, 91.0, std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}; + for (double lat : invalid) + { + request.viewport.center_lat = lat; + assert(!request.valid() && !request.focus(model, 12).ok); + snapshot.item_count = 0; + request.appendOverlay(snapshot); + assert(snapshot.item_count == 0); + } + request.viewport = {0.0, 181.0, 12}; + assert(!request.valid()); + request.viewport = {-90.0, -180.0, 12}; + assert(request.valid()); + request.viewport = {90.0, 180.0, 12}; + assert(request.valid()); +} diff --git a/modules/ui_presentation/tests/test_page_manifest.cpp b/modules/ui_presentation/tests/test_page_manifest.cpp index a2d7d719..8ad5fd1b 100644 --- a/modules/ui_presentation/tests/test_page_manifest.cpp +++ b/modules/ui_presentation/tests/test_page_manifest.cpp @@ -51,8 +51,12 @@ int main() assert(pager != nullptr); for (std::size_t index = 0; index < pager->item_count; ++index) { - assert(contains(*t_display_p4, pager->items[index].page_id)); + const auto page = pager->items[index].page_id; + // Calendar is intentionally restricted to the approved handhelds. + assert(page == ui::presentation::PageId::Calendar || contains(*t_display_p4, page)); } + assert(contains(*pager, ui::presentation::PageId::Calendar)); + assert(!contains(*t_display_p4, ui::presentation::PageId::Calendar)); const auto* watch = ui::presentation::findPageManifest("watch_compact_manifest"); assert(watch != nullptr); diff --git a/modules/ui_shared/include/ui/app_catalog_builder.h b/modules/ui_shared/include/ui/app_catalog_builder.h index d12807f9..fd834a6b 100644 --- a/modules/ui_shared/include/ui/app_catalog_builder.h +++ b/modules/ui_shared/include/ui/app_catalog_builder.h @@ -2,6 +2,11 @@ #include "ui/app_catalog.h" +namespace ui::map +{ +struct MapMarkerBinding; +} + namespace ui::app_catalog_builder { @@ -13,6 +18,10 @@ enum class CatalogProfile : uint8_t struct FeatureFlags { + // Optional descriptor supplied by the owning target composition. The + // catalog does not construct or locate Agenda services. + AppScreen* calendar_app = nullptr; + const ui::map::MapMarkerBinding* map_markers = nullptr; CatalogProfile profile = CatalogProfile::PioDefault; bool include_chat = true; bool include_gps_map = true; diff --git a/modules/ui_shared/include/ui/app_runtime.h b/modules/ui_shared/include/ui/app_runtime.h index 0dbbfc61..840ca6d3 100644 --- a/modules/ui_shared/include/ui/app_runtime.h +++ b/modules/ui_shared/include/ui/app_runtime.h @@ -15,6 +15,8 @@ void ui_exit_active_app(lv_obj_t* parent); bool ui_present_interruption_app(AppScreen* app, lv_obj_t* parent); void ui_dismiss_interruption_app(lv_obj_t* parent); bool ui_is_interruption_app_active(); +// Global modal renderers must wait until a queued exit/rebuild has completed. +bool ui_is_transition_pending(); void ui_request_exit_to_menu(); void ui_request_rebuild_active_app(); void ui_set_overlay_active(bool active); diff --git a/modules/ui_shared/include/ui/screens/agenda/agenda_editor_runtime.h b/modules/ui_shared/include/ui/screens/agenda/agenda_editor_runtime.h new file mode 100644 index 00000000..2fb4aeb8 --- /dev/null +++ b/modules/ui_shared/include/ui/screens/agenda/agenda_editor_runtime.h @@ -0,0 +1,21 @@ +#pragma once +#include "ui/screens/agenda/agenda_state.h" + +namespace ui::agenda::page::editor +{ +enum class Transition +{ + Unhandled, + Render, + KeepWidgets +}; +Transition handle(Action action, uint8_t value); +bool captureText(); +void render(lv_coord_t width, lv_coord_t height); +void showStatus(); +void updateDiscard(); +const char* title(); +// Stable localization keys shared by editor fields and event detail. +const char* reminderText(const ::agenda::EventRecord& event); +const char* repeatText(::agenda::Repeat repeat); +} // namespace ui::agenda::page::editor diff --git a/modules/ui_shared/include/ui/screens/agenda/agenda_page_components.h b/modules/ui_shared/include/ui/screens/agenda/agenda_page_components.h new file mode 100644 index 00000000..8e11ebe6 --- /dev/null +++ b/modules/ui_shared/include/ui/screens/agenda/agenda_page_components.h @@ -0,0 +1,12 @@ +#pragma once +#include "ui/screens/agenda/agenda_state.h" + +namespace ui::agenda::page::components +{ +void create(lv_obj_t* parent); +void render(); +void updateCountdowns(); +void destroy(); +lv_obj_t* addLabel(lv_obj_t* parent, const char* text, bool translated = true, bool caption = false); +lv_obj_t* addButton(lv_obj_t* parent, const char* text, Action action, uint8_t value = 0); +} // namespace ui::agenda::page::components diff --git a/modules/ui_shared/include/ui/screens/agenda/agenda_page_flow.h b/modules/ui_shared/include/ui/screens/agenda/agenda_page_flow.h new file mode 100644 index 00000000..e11fa14a --- /dev/null +++ b/modules/ui_shared/include/ui/screens/agenda/agenda_page_flow.h @@ -0,0 +1,46 @@ +#pragma once + +#include "ui/screens/agenda/agenda_page_shell.h" + +namespace ui::agenda::page +{ +// UI navigation lifetime only. No store, scheduler, tile or GPS ownership. +// A bounded return context exists during a Map visit or app interruption. +class Flow +{ + public: + explicit Flow(Host& host); + ~Flow(); + Flow(const Flow&) = delete; + Flow& operator=(const Flow&) = delete; + static void enter(void* context, lv_obj_t* parent); + static void exit(void* context, lv_obj_t* parent); + void tick(); + bool mapActive() const { return return_ != nullptr; } + // Prepare without destroying widgets; commit only after reminder dismissal. + // nullptr means prepared, otherwise a localization key describes failure. + const char* prepareDestination(int32_t latitude_e7, int32_t longitude_e7); + void finishDestination(bool accepted); + bool needsActivation() const; + // Composition calls this when the shell chose not to resume Calendar. + void discardSuspended(); + // Called outside widget callbacks after the source becomes unavailable. + // End this media session, including any Map or suspended editor context. + void invalidateStorage(); + + private: + struct ReturnContext; + static bool requestMap(void*, const AgendaEditorModel&, bool return_detail); + static bool requestTarget(void*, const AgendaEditorModel&); + bool queueMap(const AgendaEditorModel&, bool return_detail, bool show_target); + ReturnContext* captureReturn(); + static void requestReturn(void*); + void close(); + void suspend(); + void restoreEditor(); + Host& host_; + lv_obj_t* parent_ = nullptr; + ReturnContext* return_ = nullptr; + ReturnContext* reserve_ = nullptr; // Allocated before editing, not during interruption. +}; +} // namespace ui::agenda::page diff --git a/modules/ui_shared/include/ui/screens/agenda/agenda_page_input.h b/modules/ui_shared/include/ui/screens/agenda/agenda_page_input.h new file mode 100644 index 00000000..86cf258a --- /dev/null +++ b/modules/ui_shared/include/ui/screens/agenda/agenda_page_input.h @@ -0,0 +1,10 @@ +#pragma once +#include "ui/screens/agenda/agenda_state.h" + +namespace ui::agenda::page::input +{ +void begin(); +void bind(lv_obj_t* object); +void end(); +void activate(lv_event_t* event); +} // namespace ui::agenda::page::input diff --git a/modules/ui_shared/include/ui/screens/agenda/agenda_page_runtime.h b/modules/ui_shared/include/ui/screens/agenda/agenda_page_runtime.h new file mode 100644 index 00000000..315c1090 --- /dev/null +++ b/modules/ui_shared/include/ui/screens/agenda/agenda_page_runtime.h @@ -0,0 +1,10 @@ +#pragma once +#include "ui/screens/agenda/agenda_state.h" + +namespace ui::agenda::page::runtime +{ +void enter(const Host* host, lv_obj_t* parent); +void exit(); +void request(Action action, uint8_t row = 0); +void refresh(); +} // namespace ui::agenda::page::runtime diff --git a/modules/ui_shared/include/ui/screens/agenda/agenda_page_shell.h b/modules/ui_shared/include/ui/screens/agenda/agenda_page_shell.h new file mode 100644 index 00000000..a7e5a320 --- /dev/null +++ b/modules/ui_shared/include/ui/screens/agenda/agenda_page_shell.h @@ -0,0 +1,35 @@ +#pragma once + +#include "lvgl.h" +#include "ui/page/page_host.h" +#include "ui_presentation/agenda/agenda_action_sink.h" +#include "ui_presentation/agenda/agenda_location_source.h" +#include "ui_presentation/agenda/agenda_source.h" + +namespace ui::waypoint +{ +class ISource; +class IActionSink; +} // namespace ui::waypoint + +namespace ui::agenda::page +{ +// Supplied by target composition. Only portable presentation contracts cross +// this boundary; the page never mounts storage or constructs an Agenda service. +struct Host +{ + ::ui::page::Host navigation{}; + IAgendaSource* source = nullptr; + IAgendaActionSink* actions = nullptr; + IAgendaLocationSource* locations = nullptr; + void* map_context = nullptr; + bool (*request_map)(void*, const AgendaEditorModel&, bool return_detail) = nullptr; + bool (*request_target)(void*, const AgendaEditorModel&) = nullptr; + ::ui::waypoint::ISource* waypoints = nullptr; + ::ui::waypoint::IActionSink* waypoint_actions = nullptr; +}; + +void enter(void* user_data, lv_obj_t* parent); +void exit(void* user_data, lv_obj_t* parent); +bool canPresentReminder(); +} // namespace ui::agenda::page diff --git a/modules/ui_shared/include/ui/screens/agenda/agenda_reminder_popup.h b/modules/ui_shared/include/ui/screens/agenda/agenda_reminder_popup.h new file mode 100644 index 00000000..14f6ad65 --- /dev/null +++ b/modules/ui_shared/include/ui/screens/agenda/agenda_reminder_popup.h @@ -0,0 +1,29 @@ +#pragma once + +#include "ui_presentation/agenda/agenda_action_sink.h" +#include "ui_presentation/agenda/agenda_reminder_source.h" +#include "ui_presentation/agenda/agenda_source.h" + +namespace ui::agenda::reminder_popup +{ +struct Host +{ + IAgendaSource* source = nullptr; + IAgendaActionSink* actions = nullptr; + const IAgendaReminderSource* reminders = nullptr; + void (*wake)() = nullptr; + void* navigation_context = nullptr; + // Prepare only reserves a bounded request; it must not switch UI trees. + // nullptr = success, otherwise a localization key. Finish is called once + // with acceptance, or cancellation on dismissal failure/interruption. + const char* (*prepare_navigation)(void*, int32_t latitude_e7, int32_t longitude_e7) = nullptr; + void (*finish_navigation)(void*, bool accepted) = nullptr; +}; + +// Called on the UI owner thread, outside LVGL input callbacks. No page, +// storage backend or platform clock is owned by this renderer. +bool visible(); +bool canPresent(); +void tick(const Host& host, bool may_present); +void close(); +} // namespace ui::agenda::reminder_popup diff --git a/modules/ui_shared/include/ui/screens/agenda/agenda_state.h b/modules/ui_shared/include/ui/screens/agenda/agenda_state.h new file mode 100644 index 00000000..70411b3c --- /dev/null +++ b/modules/ui_shared/include/ui/screens/agenda/agenda_state.h @@ -0,0 +1,121 @@ +#pragma once + +#include "ui/screens/agenda/agenda_page_shell.h" +#include "ui/widgets/map/map_viewport.h" +#include "ui/widgets/top_bar.h" + +namespace ui::agenda::page +{ +enum class View : uint8_t +{ + Agenda, + Detail, + Editor, + DatePicker, + TimePicker, + ReminderPicker, + RepeatPicker, + LocationPicker, + WaypointPicker, + WaypointName +}; +enum class Action : uint8_t +{ + None, + Back, + Today, + NextPage, + OpenRow, + Delete, + ConfirmDelete, + CancelDelete, + NewEvent, + EditEvent, + Save, + Discard, + KeepEditing, + PickDate, + JumpToDate, + PreviousMonth, + NextMonth, + SelectDay, + PickTime, + AcceptTime, + PickReminder, + SelectReminder, + PickRepeat, + SelectRepeat, + PickLocation, + CurrentLocation, + ChooseOnMap, + ClearLocation, + Navigate, + PickWaypoint, + NextWaypoints, + FirstWaypoints, + SelectWaypoint, + NameWaypoint, + SaveWaypoint +}; + +struct DatePickerState +{ + ::agenda::CivilTime date{}; + MonthSnapshot month{}; + lv_calendar_date_t markers[31]{}; + const char* day_names[7]{}; + bool jump = false; +}; +static_assert(sizeof(DatePickerState) < 256, "Month picker state exceeded budget"); + +struct EditorWidgets +{ + lv_obj_t* title = nullptr; + lv_obj_t* note = nullptr; + lv_obj_t* clock_fields[4]{}; + lv_obj_t* end_enabled = nullptr; + lv_obj_t* status = nullptr; + lv_obj_t* focus = nullptr; + lv_obj_t* confirm_root = nullptr; +}; + +struct State +{ + const Host* host = nullptr; + lv_obj_t* root = nullptr; + lv_obj_t* body = nullptr; + lv_group_t* group = nullptr; + lv_group_t* previous_group = nullptr; + lv_timer_t* timer = nullptr; + ::ui::widgets::map::Runtime* detail_map = nullptr; + lv_obj_t* map_notice = nullptr; + ::ui::widgets::TopBar top_bar{}; + AgendaSnapshot snapshot{}; + AgendaRequest request{}; + AgendaEditorModel draft{}; // Only the selected record, never a table. + ::agenda::Occurrence detail_occurrence{}; // Independent of rebuilt list rows. + DatePickerState picker{}; + struct WaypointPage + { + uint32_t after = 0; + uint32_t ids[5]{}; + uint8_t count = 0; + bool has_more = false; + } waypoint_page; + EditorWidgets editor_widgets{}; + View view = View::Agenda; + View editor_return = View::Agenda; + Action pending = Action::None; + uint8_t selected_row = 0; + uint8_t action_value = 0; + uint32_t pending_sequence = 0; + bool awaiting_command = false; + bool confirm_delete = false; + bool confirm_discard = false; + const char* error = nullptr; // Translation key, never a heap string. +}; + +State* state(); +bool createState(); +void destroyState(); +} // namespace ui::agenda::page diff --git a/modules/ui_shared/include/ui/screens/agenda/agenda_text_snapshot.h b/modules/ui_shared/include/ui/screens/agenda/agenda_text_snapshot.h new file mode 100644 index 00000000..e4da6a88 --- /dev/null +++ b/modules/ui_shared/include/ui/screens/agenda/agenda_text_snapshot.h @@ -0,0 +1,123 @@ +#pragma once + +#include +#include +#include + +namespace ui::agenda::page +{ +// Transient text only, never a disk format. LVGL limits Unicode characters, +// while UTF-8 needs up to four bytes per character. Unicode scalars fit in 21 +// bits, giving a fixed worst-case bound without lowering the editor limit. +template +class TextSnapshot +{ + static_assert(Capacity > 0 && Capacity <= 255); + + public: + bool assign(const char* text) + { + if (!text) return false; + auto* cursor = reinterpret_cast(text); + std::size_t count = 0; + uint32_t scalar; + // Validate before writing: rejected input leaves the snapshot intact. + while (*cursor) + { + if (count == Capacity || !next(cursor, scalar)) return false; + ++count; + } + bits_.fill(0); + count_ = static_cast(count); + cursor = reinterpret_cast(text); + for (std::size_t index = 0; index < count; ++index) + { + next(cursor, scalar); + for (unsigned bit = 0; bit < 21; ++bit) + { + const auto offset = index * 21 + bit; + if (scalar & (uint32_t{1} << bit)) bits_[offset / 8] |= uint8_t{1} << (offset % 8); + } + } + return true; + } + + template + bool forEachUtf8(Consumer consume) const + { + for (std::size_t index = 0; index < count_; ++index) + { + uint32_t scalar = 0; + for (unsigned bit = 0; bit < 21; ++bit) + { + const auto offset = index * 21 + bit; + if (bits_[offset / 8] & (uint8_t{1} << (offset % 8))) scalar |= uint32_t{1} << bit; + } + char text[5]{}; // One scalar, not a second full decoded draft. + if (scalar < 0x80) text[0] = static_cast(scalar); + else if (scalar < 0x800) + { + text[0] = static_cast(0xC0 | (scalar >> 6)); + text[1] = static_cast(0x80 | (scalar & 0x3F)); + } + else if (scalar < 0x10000) + { + text[0] = static_cast(0xE0 | (scalar >> 12)); + text[1] = static_cast(0x80 | ((scalar >> 6) & 0x3F)); + text[2] = static_cast(0x80 | (scalar & 0x3F)); + } + else + { + text[0] = static_cast(0xF0 | (scalar >> 18)); + text[1] = static_cast(0x80 | ((scalar >> 12) & 0x3F)); + text[2] = static_cast(0x80 | ((scalar >> 6) & 0x3F)); + text[3] = static_cast(0x80 | (scalar & 0x3F)); + } + if (!consume(text)) return false; + } + return true; + } + + private: + static bool next(const unsigned char*& cursor, uint32_t& scalar) + { + const unsigned char first = *cursor++; + if (first < 0x80) + { + scalar = first; + return true; + } + unsigned remaining; + uint32_t minimum; + if (first >= 0xC2 && first <= 0xDF) + { + scalar = first & 0x1F; + remaining = 1; + minimum = 0x80; + } + else if (first >= 0xE0 && first <= 0xEF) + { + scalar = first & 0x0F; + remaining = 2; + minimum = 0x800; + } + else if (first >= 0xF0 && first <= 0xF4) + { + scalar = first & 7; + remaining = 3; + minimum = 0x10000; + } + else return false; + while (remaining--) + { + if ((*cursor & 0xC0) != 0x80) return false; + scalar = (scalar << 6) | (*cursor++ & 0x3F); + } + return scalar >= minimum && scalar <= 0x10FFFF && !(scalar >= 0xD800 && scalar <= 0xDFFF); + } + + std::array bits_{}; + uint8_t count_ = 0; +}; +static_assert(sizeof(TextSnapshot<39>) == 104 && sizeof(TextSnapshot<79>) == 209); +} // namespace ui::agenda::page diff --git a/modules/ui_shared/include/ui/screens/gps/gps_page_runtime.h b/modules/ui_shared/include/ui/screens/gps/gps_page_runtime.h index b6666baa..ba341091 100644 --- a/modules/ui_shared/include/ui/screens/gps/gps_page_runtime.h +++ b/modules/ui_shared/include/ui/screens/gps/gps_page_runtime.h @@ -8,9 +8,12 @@ namespace gps::ui::runtime { bool is_available(); +void set_marker_binding(const ::ui::map::MapMarkerBinding* binding); void enter(const shell::Host* host, lv_obj_t* parent, - shell::Projection projection = shell::Projection::Map); + shell::Projection projection = shell::Projection::Map, + ::ui::map::MapLocationRequest* location = nullptr, + ::ui::map::MapTargetRequest* target = nullptr); void exit(lv_obj_t* parent); void remember_gps_view_state(); bool restore_gps_view_state(); diff --git a/modules/ui_shared/include/ui/screens/gps/gps_page_shell.h b/modules/ui_shared/include/ui/screens/gps/gps_page_shell.h index 3c4fed3e..4a4b18c9 100644 --- a/modules/ui_shared/include/ui/screens/gps/gps_page_shell.h +++ b/modules/ui_shared/include/ui/screens/gps/gps_page_shell.h @@ -10,6 +10,13 @@ #include +namespace ui::map +{ +struct MapLocationRequest; +struct MapTargetRequest; +struct MapMarkerBinding; +} // namespace ui::map + namespace gps::ui::shell { @@ -25,6 +32,9 @@ struct RouteSpec { const Host* host = nullptr; Projection projection = Projection::Map; + ::ui::map::MapLocationRequest* location = nullptr; + ::ui::map::MapTargetRequest* target = nullptr; + const ::ui::map::MapMarkerBinding* markers = nullptr; }; void enter(void* user_data, lv_obj_t* parent); diff --git a/modules/ui_shared/include/ui/widgets/ime/ime_widget.h b/modules/ui_shared/include/ui/widgets/ime/ime_widget.h index 4dcc035f..af4e843f 100644 --- a/modules/ui_shared/include/ui/widgets/ime/ime_widget.h +++ b/modules/ui_shared/include/ui/widgets/ime/ime_widget.h @@ -24,6 +24,20 @@ namespace ui namespace widgets { +// Transient UI recovery metadata. Text storage belongs to the caller; this is +// not a persistent format and does not retain widgets or candidate collections. +struct ImeEditState +{ + uint32_t cursor = 0; + int script_index = 0; + int candidate_index = 0; + int candidate_window = 0; + char composition[9]{}; + uint8_t mode = 0; + bool shift = false; +}; +static_assert(sizeof(ImeEditState) <= 32, "IME recovery metadata must remain bounded"); + class ImeWidget { public: @@ -45,6 +59,8 @@ class ImeWidget bool handle_key(lv_event_t* e); void setText(const char* text); + bool captureEditState(char* text, std::size_t capacity, ImeEditState& out) const; + bool restoreEditState(const char* text, const ImeEditState& state); lv_obj_t* container() const { return container_; } lv_obj_t* toggle_btn() const { return toggle_btn_; } diff --git a/modules/ui_shared/include/ui/widgets/map/map_viewport.h b/modules/ui_shared/include/ui/widgets/map/map_viewport.h index fa31bb29..aa7dc93e 100644 --- a/modules/ui_shared/include/ui/widgets/map/map_viewport.h +++ b/modules/ui_shared/include/ui/widgets/map/map_viewport.h @@ -6,6 +6,7 @@ #pragma once #include "lvgl.h" +#include "ui_presentation/map/map_marker_source.h" #include "ui_presentation/map/map_overlay_snapshot.h" #include @@ -120,6 +121,7 @@ class Runtime friend void apply_model_lightweight(Runtime& runtime, const Model& model); friend void apply_overlay(Runtime& runtime, const ui::map::MapOverlaySnapshot& overlay); friend void clear(Runtime& runtime); + friend void set_marker_binding(Runtime& runtime, const ui::map::MapMarkerBinding* binding); friend bool project_point(const Runtime& runtime, const GeoPoint& point, lv_point_t& out_screen_point); friend bool screen_center(const Runtime& runtime, GeoPoint& out_center); friend Status status(const Runtime& runtime); @@ -137,6 +139,7 @@ void apply_model(Runtime& runtime, const Model& model); void apply_model_lightweight(Runtime& runtime, const Model& model); void apply_overlay(Runtime& runtime, const ui::map::MapOverlaySnapshot& overlay); void clear(Runtime& runtime); +void set_marker_binding(Runtime& runtime, const ui::map::MapMarkerBinding* binding); bool project_point(const Runtime& runtime, const GeoPoint& point, lv_point_t& out_screen_point); bool screen_center(const Runtime& runtime, GeoPoint& out_center); bool preview_project_point(lv_obj_t* viewport_root, const Model& model, const GeoPoint& point, lv_point_t& out_screen_point); diff --git a/modules/ui_shared/src/ui/app_catalog_builder.cpp b/modules/ui_shared/src/ui/app_catalog_builder.cpp index 2a4cdeab..65773442 100644 --- a/modules/ui_shared/src/ui/app_catalog_builder.cpp +++ b/modules/ui_shared/src/ui/app_catalog_builder.cpp @@ -196,10 +196,14 @@ ui::CallbackAppScreen s_chat_app("chat", "Chat", CATALOG_ICON(Chat), chat::ui::shell::enter, chat::ui::shell::exit, &s_menu_host); -ui::CallbackAppScreen s_gps_app("map", "Map", CATALOG_ICON(gps_icon), - gps::ui::shell::enter, - gps::ui::shell::exit, - &s_menu_host); +gps::ui::shell::RouteSpec s_map_route{&s_menu_host}; +ui::CallbackAppScreen s_gps_app( + "map", "Map", CATALOG_ICON(gps_icon), + [](void* context, lv_obj_t* parent) + { gps::ui::shell::enter_route(static_cast(context), parent); }, + [](void* context, lv_obj_t* parent) + { gps::ui::shell::exit_route(static_cast(context), parent); }, + &s_map_route); ui::CallbackAppScreen s_skyplot_app("sky_plot", "Sky Plot", CATALOG_ICON(Satellite), gnss::ui::shell::enter, gnss::ui::shell::exit, @@ -452,6 +456,9 @@ namespace ui::app_catalog_builder AppCatalog build(const FeatureFlags& flags) { +#if !TRAIL_MATE_USE_MONO_SCREEN_240X320 + s_map_route.markers = flags.map_markers; +#endif size_t count = 0; auto add = [&](AppScreen* app) { @@ -464,6 +471,10 @@ AppCatalog build(const FeatureFlags& flags) } }; + // Calendar plus the 15 existing optional entries fits the fixed capacity. + // A null descriptor leaves every other target's catalog unchanged. + add(flags.calendar_app); + const auto add_common_tail = [&]() { if (flags.include_contacts) diff --git a/modules/ui_shared/src/ui/app_runtime.cpp b/modules/ui_shared/src/ui/app_runtime.cpp index de37c456..1ff05afb 100644 --- a/modules/ui_shared/src/ui/app_runtime.cpp +++ b/modules/ui_shared/src/ui/app_runtime.cpp @@ -608,6 +608,11 @@ bool ui_is_overlay_active() return s_overlay_active; } +bool ui_is_transition_pending() +{ + return s_pending_exit != nullptr || s_exit_timer != nullptr || s_rebuild_timer != nullptr; +} + lv_obj_t* create_menu(lv_obj_t* parent, lv_event_cb_t event_cb) { lv_obj_t* menu = lv_menu_create(parent); diff --git a/modules/ui_shared/src/ui/assets/agenda.c b/modules/ui_shared/src/ui/assets/agenda.c new file mode 100644 index 00000000..5e2a1e24 --- /dev/null +++ b/modules/ui_shared/src/ui/assets/agenda.c @@ -0,0 +1,165 @@ +#ifdef __has_include + #if __has_include("lvgl.h") + #ifndef LV_LVGL_H_INCLUDE_SIMPLE + #define LV_LVGL_H_INCLUDE_SIMPLE + #endif + #endif +#endif + +#if defined(LV_LVGL_H_INCLUDE_SIMPLE) + #include "lvgl.h" +#else + #include "lvgl/lvgl.h" +#endif + + +#ifndef LV_ATTRIBUTE_MEM_ALIGN +#define LV_ATTRIBUTE_MEM_ALIGN +#endif + +#ifndef LV_ATTRIBUTE_IMAGE_AGENDA +#define LV_ATTRIBUTE_IMAGE_AGENDA +#endif + +const LV_ATTRIBUTE_MEM_ALIGN LV_ATTRIBUTE_LARGE_CONST LV_ATTRIBUTE_IMAGE_AGENDA uint8_t agenda_map[] = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0xc4, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc4, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xc4, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe4, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe4, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xc4, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc4, 0xa9, 0xe4, 0xa9, 0xe5, 0xa9, 0xe5, 0xa1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc4, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc4, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc4, 0xa9, 0xa4, 0x91, 0xa4, 0x91, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x41, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x41, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x81, 0x20, 0xa2, 0x30, 0x81, 0x20, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x08, 0x21, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x08, 0x21, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x08, 0x21, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x08, 0x21, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x47, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x65, 0xb2, 0xe5, 0xa9, 0x65, 0xb2, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0xc5, 0xb2, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x43, 0x81, 0x43, 0x81, 0x44, 0x81, 0x23, 0x71, 0x23, 0x71, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x43, 0x81, 0x02, 0x69, 0x41, 0x18, 0x81, 0x20, 0x43, 0x81, 0x43, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x43, 0x81, 0x43, 0x79, 0xc2, 0x40, 0xc2, 0x48, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x43, 0x81, 0x43, 0x81, 0x43, 0x79, 0xc2, 0x40, 0xc2, 0x48, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x69, 0x41, 0x18, 0x81, 0x20, 0x43, 0x81, 0x43, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x44, 0x81, 0x43, 0x81, 0x44, 0x81, 0x23, 0x71, 0x23, 0x71, 0x43, 0x81, 0x43, 0x81, 0x43, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x61, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x26, 0xcc, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x21, 0x08, 0x21, 0x08, 0x41, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x45, 0xbb, 0xe5, 0xa9, 0x45, 0xbb, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x21, 0x08, 0x21, 0x08, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x67, 0xee, 0x65, 0xb2, 0xe5, 0xa9, 0xe5, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x61, 0x18, 0x21, 0x08, 0xa2, 0x28, 0x81, 0x20, 0x81, 0x20, 0x81, 0x20, 0x81, 0x20, 0x81, 0x20, 0x81, 0x20, 0x81, 0x20, 0xc2, 0x30, 0x02, 0x39, 0x02, 0x39, 0x02, 0x39, 0x02, 0x39, 0x02, 0x39, 0x02, 0x39, 0x02, 0x39, 0x02, 0x39, 0x02, 0x39, 0x02, 0x39, 0xc2, 0x30, 0xa2, 0x30, 0xc2, 0x30, 0x02, 0x39, 0x02, 0x39, 0x02, 0x39, 0x02, 0x39, 0x02, 0x39, 0x21, 0x08, 0x21, 0x08, 0x86, 0xc3, 0x86, 0xc3, 0x86, 0xc3, 0xe5, 0xa9, 0xe5, 0xa9, 0x65, 0xb2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x51, 0x61, 0x18, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0x21, 0x08, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0x25, 0xaa, 0x26, 0xcc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc4, 0xa9, 0x82, 0x30, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0x03, 0x59, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa9, 0xe5, 0xa1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xff, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xdf, 0xff, 0xff, 0xdf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xdf, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xc7, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x20, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xff, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xbf, 0xeb, 0xff, 0xeb, 0xbf, 0xbf, 0xbf, 0xbf, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaf, 0xff, 0xaf, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaf, 0xff, 0xaf, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaf, 0xff, 0xaf, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaf, 0xff, 0xaf, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaf, 0xff, 0xaf, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x34, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0x43, 0xc4, 0xff, 0xc4, 0x43, 0x43, 0x43, 0x43, 0x43, 0xff, 0xff, 0x43, 0x43, 0x43, 0x43, 0xa1, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xdf, 0xe7, 0xff, 0xfe, 0xdf, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x54, 0xff, 0xf8, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0x9d, 0xff, 0xfb, 0x7f, 0x7f, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x7f, 0x9d, 0xff, 0xfb, 0x7f, 0x7f, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x54, 0xff, 0xf8, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xdf, 0xe7, 0xff, 0xfe, 0xdf, 0xdf, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xf7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf7, 0xff, 0xf7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xf4, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xeb, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x58, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x60, 0x60, 0x60, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +const lv_image_dsc_t agenda = { + .header.magic = LV_IMAGE_HEADER_MAGIC, + .header.cf = LV_COLOR_FORMAT_RGB565A8, + .header.flags = 0, + .header.w = 64, + .header.h = 64, + .header.stride = 128, + .data_size = sizeof(agenda_map), + .data = agenda_map, +}; \ No newline at end of file diff --git a/modules/ui_shared/src/ui/screens/agenda/agenda_editor_components.cpp b/modules/ui_shared/src/ui/screens/agenda/agenda_editor_components.cpp new file mode 100644 index 00000000..4d7b3df0 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/agenda/agenda_editor_components.cpp @@ -0,0 +1,467 @@ +#include "ui/assets/fonts/font_utils.h" +#include "ui/localization.h" +#include "ui/page/page_profile.h" +#include "ui/screens/agenda/agenda_editor_runtime.h" +#include "ui/screens/agenda/agenda_page_components.h" +#include "ui/screens/agenda/agenda_page_input.h" +#include "ui/screens/agenda/agenda_page_runtime.h" +#include "ui/ui_theme.h" +#include "ui_lvgl_ux_packs/common/touch_text_editor.h" +#include "ui_presentation/waypoint/waypoint_model.h" + +#include +#include + +namespace ui::agenda::page::editor +{ +namespace +{ +constexpr const char* kReminderLabels[] = {"None", "At time", "10 min before", "30 min before", "1 hour before", "1 day before"}; +constexpr const char* kRepeatLabels[] = {"None", "Daily", "Weekly", "Monthly", "Yearly"}; + +void box(lv_obj_t* object) +{ + lv_obj_set_style_bg_color(object, ::ui::theme::page_bg(), 0); + lv_obj_set_style_border_width(object, 0, 0); + lv_obj_set_style_pad_all(object, 0, 0); + lv_obj_set_style_radius(object, 0, 0); +} +void put(lv_obj_t* object, int x, int y, int width, int height = LV_SIZE_CONTENT) +{ + lv_obj_set_pos(object, x, y); + lv_obj_set_size(object, width, height); +} +void selected(lv_obj_t* object, EditorField field) +{ + if (state()->draft.focus == field) state()->editor_widgets.focus = object; +} +void remember_field(lv_event_t* event) +{ + if (auto* s = state()) s->draft.focus = static_cast(reinterpret_cast(lv_event_get_user_data(event))); +} +lv_obj_t* text_field(lv_obj_t* parent, const char* text, int bytes, EditorField field) +{ + auto* object = lv_textarea_create(parent); + lv_textarea_set_one_line(object, true); + lv_textarea_set_max_length(object, bytes - 1); + lv_textarea_set_text(object, text); + lv_obj_set_style_pad_all(object, 3, 0); + lv_obj_set_style_bg_color(object, ::ui::theme::surface(), 0); + lv_obj_set_style_border_color(object, ::ui::theme::border(), 0); + ::ui::fonts::apply_content_font(object, text, ::ui::page_profile::resolve_body_font()); + input::bind(object); + ::ui::widgets::attach_touch_text_editor(object); + lv_obj_add_event_cb(object, remember_field, LV_EVENT_FOCUSED, reinterpret_cast(static_cast(field))); + selected(object, field); + return object; +} +void field(lv_obj_t* parent, int width, int y, const char* name, const char* value, + Action action, EditorField focus, bool raw = false) +{ + auto* caption = components::addLabel(parent, name, true, true); + put(caption, 3, y + 6, 82); + auto* button = components::addButton(parent, raw ? nullptr : value, action); + put(button, 88, y, width - 94, 28); + if (raw) + { + auto* label = components::addLabel(button, value, false); + lv_obj_set_width(label, LV_PCT(100)); + lv_obj_center(label); + } + if (action == Action::None) lv_obj_add_state(button, LV_STATE_DISABLED); + lv_obj_add_event_cb(button, remember_field, LV_EVENT_FOCUSED, reinterpret_cast(static_cast(focus))); + selected(button, focus); +} + +void form(int width, int height) +{ + auto& s = *state(); + if (s.confirm_discard) + { + put(components::addLabel(s.body, "Discard changes?"), 4, 8, width - 8); + put(components::addButton(s.body, "Keep editing", Action::KeepEditing), 4, 48, width - 8, 32); + put(components::addButton(s.body, "Discard", Action::Discard), 4, 88, width - 8, 32); + return; + } + auto* form = lv_obj_create(s.body); + box(form); + put(form, 0, 0, width, height - 34); + lv_obj_set_scroll_dir(form, LV_DIR_VER); + lv_obj_set_scrollbar_mode(form, LV_SCROLLBAR_MODE_AUTO); + put(components::addLabel(form, "Title", true, true), 3, 0, width - 6); + s.editor_widgets.title = text_field(form, s.draft.event.title, sizeof(s.draft.event.title), EditorField::Title); + put(s.editor_widgets.title, 3, 20, width - 9, 30); + ::agenda::CivilTime date; + ::agenda::fromCalendarSeconds(s.draft.event.start_time, date); + char buffer[48]; + std::snprintf(buffer, sizeof(buffer), "%04u-%02u-%02u", date.year, date.month, date.day); + field(form, width, 54, "Date", buffer, Action::PickDate, EditorField::Date, true); + std::snprintf(buffer, sizeof(buffer), "%02u:%02u", date.hour, date.minute); + field(form, width, 86, "Time", buffer, Action::PickTime, EditorField::Time, true); + field(form, width, 118, "Reminder", reminderText(s.draft.event), Action::PickReminder, EditorField::Reminder); + const bool location = (s.draft.event.flags & ::agenda::HasLocation) != 0; + field(form, width, 150, "Location", location ? s.draft.event.location_name : "Not set", + Action::PickLocation, EditorField::Location, location); + field(form, width, 182, "Repeat", repeatText(s.draft.event.repeat), Action::PickRepeat, EditorField::Repeat); + put(components::addLabel(form, "Note", true, true), 3, 216, width - 6); + s.editor_widgets.note = text_field(form, s.draft.event.note, sizeof(s.draft.event.note), EditorField::Note); + put(s.editor_widgets.note, 3, 236, width - 9, 30); + if (s.draft.event.repeat != ::agenda::Repeat::None) + put(components::addLabel(form, "Changes apply to the entire series", true, true), 3, 270, width - 9); + auto* save = components::addButton(s.body, "Save", Action::Save); + put(save, 4, height - 30, width - 8, 28); + if (s.awaiting_command) + { + // Disabled state is not inherited by child widgets. Freeze the actual + // editors while the captured command is pending, not just their panel. + lv_obj_add_state(s.editor_widgets.title, LV_STATE_DISABLED); + lv_obj_add_state(s.editor_widgets.note, LV_STATE_DISABLED); + } +} + +void calendar_draw(lv_event_t* event) +{ + // Calendar's own draw callback overrides styles with the global theme's + // primary colour. Adapt only this calendar, after its built-in callback. + auto* task = static_cast(lv_event_get_param(event)); + auto* fill = lv_draw_task_get_fill_dsc(task); + auto* border = lv_draw_task_get_border_dsc(task); + if (fill && fill->base.part == LV_PART_ITEMS) fill->color = ::ui::theme::accent(); + if (border && border->base.part == LV_PART_ITEMS) border->color = ::ui::theme::border(); +} + +void date_chosen(lv_event_t* event) +{ + lv_calendar_date_t selected_date; + // VALUE_CHANGED bubbles from Calendar's internal buttonmatrix. The + // original target is not a calendar and cannot be passed to its API. + auto* calendar = static_cast(lv_event_get_current_target(event)); + if (lv_calendar_get_pressed_date(calendar, &selected_date) != LV_RESULT_OK) return; + auto& date = state()->picker.date; + date.year = selected_date.year; + date.month = selected_date.month; + date.day = selected_date.day; + runtime::request(Action::SelectDay, date.day); +} +void date_picker(int width, int height) +{ + auto& s = *state(); + auto& picker = s.picker; + put(components::addButton(s.body, LV_SYMBOL_LEFT, Action::PreviousMonth), 3, 0, 40, 26); + put(components::addButton(s.body, LV_SYMBOL_RIGHT, Action::NextMonth), width - 43, 0, 40, 26); + char month[20]; + std::snprintf(month, sizeof(month), "%04u-%02u", picker.date.year, picker.date.month); + auto* caption = components::addLabel(s.body, month, false); + put(caption, 46, 3, width - 92); + lv_obj_set_style_text_align(caption, LV_TEXT_ALIGN_CENTER, 0); + auto* calendar = lv_calendar_create(s.body); + put(calendar, 3, 29, width - 6, height - 29); + lv_obj_set_style_bg_color(calendar, ::ui::theme::surface(), 0); + lv_obj_set_style_border_color(calendar, ::ui::theme::border(), 0); + lv_obj_set_style_text_font(calendar, ::ui::page_profile::resolve_body_font(), 0); + lv_obj_set_style_pad_all(calendar, 1, 0); + auto* days_grid = lv_calendar_get_btnmatrix(calendar); + lv_obj_set_style_bg_color(days_grid, ::ui::theme::surface(), 0); + lv_obj_set_style_text_color(days_grid, ::ui::theme::text(), LV_PART_ITEMS); + lv_obj_set_style_text_color(days_grid, ::ui::theme::text(), LV_PART_ITEMS | LV_STATE_PRESSED); + lv_obj_set_style_text_color(days_grid, ::ui::theme::text(), LV_PART_ITEMS | LV_STATE_FOCUSED); + lv_obj_set_style_outline_color(days_grid, ::ui::theme::accent(), LV_STATE_FOCUS_KEY); + lv_obj_add_event_cb(days_grid, calendar_draw, LV_EVENT_DRAW_TASK_ADDED, nullptr); + lv_calendar_set_month_shown(calendar, picker.date.year, picker.date.month); + ::agenda::CivilTime today; + ::agenda::fromCalendarSeconds(s.snapshot.today_start, today); + lv_calendar_set_today_date(calendar, today.year, today.month, today.day); + const char* days[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; + for (unsigned i = 0; i < 7; ++i) picker.day_names[i] = ::ui::i18n::tr(days[i]); + lv_calendar_set_day_names(calendar, picker.day_names); + s.host->source->month(picker.date.year, picker.date.month, picker.month); + unsigned count = 0; + for (unsigned day = 1; day <= 31; ++day) + if (picker.month.occupied_days & (uint32_t{1} << (day - 1))) + picker.markers[count++] = {picker.date.year, picker.date.month, static_cast(day)}; + lv_calendar_set_highlighted_dates(calendar, picker.markers, count); + lv_obj_add_event_cb(calendar, date_chosen, LV_EVENT_VALUE_CHANGED, nullptr); + // Calendar is a container; its buttonmatrix owns keyboard/encoder editing. + // Do not leave the non-editable container as a second focus stop. + lv_group_remove_obj(calendar); + input::bind(days_grid); + char selected_day[4]; + std::snprintf(selected_day, sizeof(selected_day), "%u", picker.date.day); + for (unsigned id = 7; id < 49; ++id) + { + if (!lv_buttonmatrix_has_button_ctrl(days_grid, id, LV_BUTTONMATRIX_CTRL_DISABLED) && + std::strcmp(lv_buttonmatrix_get_button_text(days_grid, id), selected_day) == 0) + { + lv_buttonmatrix_set_selected_button(days_grid, id); + break; + } + } + s.editor_widgets.focus = days_grid; + if (picker.month.result != ::agenda::StoreResult::Ok) s.error = "Could not read events"; +} + +void step_time(lv_event_t* event) +{ + const auto data = reinterpret_cast(lv_event_get_user_data(event)); + auto* field = state()->editor_widgets.clock_fields[data >> 1]; + if (data & 1) lv_spinbox_increment(field); + else lv_spinbox_decrement(field); +} +void clock_field(int index, int x, int y, int value, int maximum) +{ + auto& s = *state(); + auto* spin = lv_spinbox_create(s.body); + lv_spinbox_set_range(spin, 0, maximum); + lv_spinbox_set_digit_format(spin, 2, 0); + lv_spinbox_set_rollover(spin, true); + lv_spinbox_set_step(spin, 1); + lv_spinbox_set_value(spin, value); + put(spin, x + 26, y, 46, 30); + lv_obj_set_style_pad_all(spin, 4, 0); + lv_obj_set_style_bg_color(spin, ::ui::theme::surface(), 0); + lv_obj_set_style_border_color(spin, ::ui::theme::border(), 0); + lv_obj_set_style_text_color(spin, ::ui::theme::text(), 0); + lv_obj_set_style_text_font(spin, ::ui::page_profile::resolve_body_font(), 0); + lv_obj_set_style_outline_color(spin, ::ui::theme::accent(), LV_STATE_FOCUS_KEY); + lv_obj_set_style_bg_color(spin, ::ui::theme::accent(), LV_PART_CURSOR); + lv_obj_set_style_text_color(spin, ::ui::theme::text(), LV_PART_CURSOR); + s.editor_widgets.clock_fields[index] = spin; + input::bind(spin); + for (unsigned increment = 0; increment < 2; ++increment) + { + auto* button = components::addButton(s.body, increment ? "+" : "-", Action::None); + lv_obj_remove_event_cb(button, input::activate); + put(button, x + (increment ? 74 : 0), y, 24, 30); + lv_obj_add_event_cb(button, step_time, LV_EVENT_CLICKED, + reinterpret_cast((static_cast(index) << 1) | increment)); + } +} +void time_picker(int width, int height) +{ + auto& s = *state(); + ::agenda::CivilTime start, end; + ::agenda::fromCalendarSeconds(s.draft.event.start_time, start); + ::agenda::fromCalendarSeconds(s.draft.event.flags & ::agenda::HasEndTime ? s.draft.event.end_time : s.draft.event.start_time, end); + put(components::addLabel(s.body, "Start time", true, true), 4, 0, width - 8); + clock_field(0, width / 2 - 110, 21, start.hour, 23); + clock_field(1, width / 2 + 10, 21, start.minute, 59); + s.editor_widgets.end_enabled = lv_checkbox_create(s.body); + lv_checkbox_set_text(s.editor_widgets.end_enabled, ::ui::i18n::tr("End time (optional)")); + put(s.editor_widgets.end_enabled, 4, 60, width - 8, 24); + lv_obj_set_style_text_color(s.editor_widgets.end_enabled, ::ui::theme::text(), 0); + lv_obj_set_style_text_font(s.editor_widgets.end_enabled, ::ui::page_profile::resolve_body_font(), 0); + lv_obj_set_style_bg_color(s.editor_widgets.end_enabled, ::ui::theme::surface(), LV_PART_INDICATOR); + lv_obj_set_style_border_color(s.editor_widgets.end_enabled, ::ui::theme::border(), LV_PART_INDICATOR); + lv_obj_set_style_bg_color(s.editor_widgets.end_enabled, ::ui::theme::accent(), LV_PART_INDICATOR | LV_STATE_CHECKED); + lv_obj_set_style_border_color(s.editor_widgets.end_enabled, ::ui::theme::border(), LV_PART_INDICATOR | LV_STATE_CHECKED); + lv_obj_set_style_text_color(s.editor_widgets.end_enabled, ::ui::theme::text(), LV_PART_INDICATOR | LV_STATE_CHECKED); + lv_obj_set_style_anim_duration(s.editor_widgets.end_enabled, 0, LV_PART_INDICATOR); + if (s.draft.event.flags & ::agenda::HasEndTime) lv_obj_add_state(s.editor_widgets.end_enabled, LV_STATE_CHECKED); + input::bind(s.editor_widgets.end_enabled); + clock_field(2, width / 2 - 110, 90, end.hour, 23); + clock_field(3, width / 2 + 10, 90, end.minute, 59); + // Keep the helper above the footer even on the 222-pixel-tall profile. + put(components::addLabel(s.body, "End <= start: next day", true, true), 4, 121, width - 8, 14); + put(components::addButton(s.body, "OK", Action::AcceptTime), 4, height - 28, width - 8, 28); + s.editor_widgets.focus = s.editor_widgets.clock_fields[0]; +} +void presets(int width, int height, bool reminder) +{ + auto& s = *state(); + const unsigned count = reminder ? 6 : 5; + const auto* labels = reminder ? kReminderLabels : kRepeatLabels; + const int row_height = height / count; + for (unsigned i = 0; i < count; ++i) + { + auto* button = components::addButton(s.body, labels[i], reminder ? Action::SelectReminder : Action::SelectRepeat, i); + put(button, 4, i * row_height, width - 8, row_height - 2); + if (i == 0) s.editor_widgets.focus = button; + } +} +void location_picker(int width, int height) +{ + auto& s = *state(); + put(components::addLabel(s.body, "Location is optional", true, true), 4, 0, width - 8); + auto* current = components::addButton(s.body, "Current position", Action::CurrentLocation); + put(current, 4, 24, width - 8, 32); + if (!s.host->locations) lv_obj_add_state(current, LV_STATE_DISABLED); + s.editor_widgets.focus = current; + auto* map = components::addButton(s.body, "Choose on map", Action::ChooseOnMap); + put(map, 4, 60, width - 8, 32); + if (!s.host->request_map) lv_obj_add_state(map, LV_STATE_DISABLED); + auto* saved = components::addButton(s.body, "Saved waypoint", Action::PickWaypoint); + put(saved, 4, 96, width - 8, 28); + if (!s.host->waypoints) lv_obj_add_state(saved, LV_STATE_DISABLED); + auto* remove = components::addButton(s.body, "Remove location", Action::ClearLocation); + put(remove, 4, height - 32, width - 8, 30); + if (!(s.draft.event.flags & ::agenda::HasLocation)) lv_obj_add_state(remove, LV_STATE_DISABLED); +} +void waypoint_picker(int width, int height) +{ + auto& s = *state(); + ::waypoint::Page page; + const auto capacity = static_cast((height - 32) / 24 < 5 ? (height - 32) / 24 : 5); + s.waypoint_page.count = 0; + s.waypoint_page.has_more = false; + if (!s.host->waypoints || s.host->waypoints->page(s.waypoint_page.after, capacity, page) != ::waypoint::Result::Ok) + s.error = "Could not read waypoints"; + else + { + s.waypoint_page.count = page.count; + s.waypoint_page.has_more = page.has_more; + for (uint8_t i = 0; i < page.count; ++i) + { + s.waypoint_page.ids[i] = page.rows[i].id; + auto* button = components::addButton(s.body, nullptr, Action::SelectWaypoint, i); + put(button, 4, i * 24, width - 8, 23); + auto* label = components::addLabel(button, page.rows[i].name, false); + lv_obj_set_width(label, LV_PCT(100)); + lv_obj_center(label); + if (!i) s.editor_widgets.focus = button; + } + if (!page.count) put(components::addLabel(s.body, "No saved waypoints"), 4, 4, width - 8); + } + auto* first = components::addButton(s.body, "First", Action::FirstWaypoints); + put(first, 4, height - 28, width / 3 - 6, 28); + if (!s.waypoint_page.after) lv_obj_add_state(first, LV_STATE_DISABLED); + auto* next = components::addButton(s.body, "More", Action::NextWaypoints); + put(next, width / 3 + 2, height - 28, width / 3 - 6, 28); + if (!page.has_more) lv_obj_add_state(next, LV_STATE_DISABLED); + auto* save = components::addButton(s.body, "New", Action::NameWaypoint); + put(save, 2 * width / 3, height - 28, width / 3 - 4, 28); + if (!(s.draft.event.flags & ::agenda::HasLocation) || !s.host->waypoint_actions) lv_obj_add_state(save, LV_STATE_DISABLED); +} +} // namespace + +const char* reminderText(const ::agenda::EventRecord& event) +{ + if (!(event.flags & ::agenda::HasReminder)) return kReminderLabels[0]; + switch (event.reminder_offset_sec) + { + case 600: + return kReminderLabels[2]; + case 1800: + return kReminderLabels[3]; + case 3600: + return kReminderLabels[4]; + case 86400: + return kReminderLabels[5]; + default: + return kReminderLabels[1]; + } +} +const char* repeatText(::agenda::Repeat repeat) +{ + const auto index = static_cast(repeat); + return kRepeatLabels[index < sizeof(kRepeatLabels) / sizeof(kRepeatLabels[0]) ? index : 0]; +} +const char* title() +{ + switch (state()->view) + { + case View::DatePicker: + return "Pick Date"; + case View::TimePicker: + return "Pick Time"; + case View::ReminderPicker: + return "Reminder"; + case View::RepeatPicker: + return "Repeat"; + case View::LocationPicker: + return "Location"; + case View::WaypointPicker: + return "Saved waypoint"; + case View::WaypointName: + return "Saved waypoint"; + default: + return state()->draft.editing_existing ? "Edit Event" : "New Event"; + } +} +void showStatus() +{ + auto& s = *state(); + if (!s.editor_widgets.status) return; + const char* key = s.awaiting_command ? "Saving..." : s.error ? s.error + : ""; + ::ui::i18n::set_label_text(s.editor_widgets.status, key); +} +void updateDiscard() +{ + auto& s = *state(); + lv_group_remove_all_objs(s.group); + input::bind(s.top_bar.back_btn); + if (s.confirm_discard) + { + if (!s.editor_widgets.confirm_root) + { + auto* panel = lv_obj_create(s.body); + box(panel); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_size(panel, LV_PCT(100), LV_PCT(100)); + lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + s.editor_widgets.confirm_root = panel; + const int width = lv_obj_get_width(s.body); + put(components::addLabel(panel, "Discard changes?"), 4, 8, width - 8); + auto* keep = components::addButton(panel, "Keep editing", Action::KeepEditing); + put(keep, 4, 48, width - 8, 32); + put(components::addButton(panel, "Discard", Action::Discard), 4, 88, width - 8, 32); + lv_group_focus_obj(keep); + } + } + else + { + if (s.editor_widgets.confirm_root) lv_obj_delete(s.editor_widgets.confirm_root); + s.editor_widgets.confirm_root = nullptr; + const auto bind_tree = [](auto&& self, lv_obj_t* object) -> void + { + if (lv_obj_check_type(object, &lv_button_class) || lv_obj_check_type(object, &lv_textarea_class)) input::bind(object); + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) self(self, lv_obj_get_child(object, i)); + }; + bind_tree(bind_tree, s.body); + lv_group_focus_obj(s.editor_widgets.title); + showStatus(); + } + lv_group_set_editing(s.group, false); +} +void render(lv_coord_t width, lv_coord_t height) +{ + auto& s = *state(); + s.editor_widgets.status = components::addLabel(s.body, "", true, true); + put(s.editor_widgets.status, 4, height - 18, width - 8, 18); + height -= 20; + switch (s.view) + { + case View::Editor: + form(width, height); + break; + case View::DatePicker: + date_picker(width, height); + break; + case View::TimePicker: + time_picker(width, height); + break; + case View::ReminderPicker: + presets(width, height, true); + break; + case View::RepeatPicker: + presets(width, height, false); + break; + case View::LocationPicker: + location_picker(width, height); + break; + case View::WaypointPicker: + waypoint_picker(width, height); + break; + case View::WaypointName: + put(components::addLabel(s.body, "Name"), 4, 4, width - 8); + s.editor_widgets.title = text_field(s.body, s.draft.event.location_name, 32, EditorField::Location); + put(s.editor_widgets.title, 4, 28, width - 8, 32); + s.editor_widgets.focus = s.editor_widgets.title; + put(components::addButton(s.body, "Save", Action::SaveWaypoint), 4, height - 28, width - 8, 28); + break; + default: + break; + } + showStatus(); +} +} // namespace ui::agenda::page::editor diff --git a/modules/ui_shared/src/ui/screens/agenda/agenda_editor_runtime.cpp b/modules/ui_shared/src/ui/screens/agenda/agenda_editor_runtime.cpp new file mode 100644 index 00000000..cd175a9a --- /dev/null +++ b/modules/ui_shared/src/ui/screens/agenda/agenda_editor_runtime.cpp @@ -0,0 +1,320 @@ +#include "ui/screens/agenda/agenda_editor_runtime.h" +#include "ui_presentation/agenda/agenda_waypoint.h" +#include "ui_presentation/waypoint/waypoint_model.h" + +#include + +namespace ui::agenda::page::editor +{ +namespace +{ +bool is_picker(View view) +{ + return view == View::DatePicker || view == View::TimePicker || + view == View::ReminderPicker || view == View::RepeatPicker || view == View::LocationPicker || view == View::WaypointPicker || view == View::WaypointName; +} + +void open_date(bool jump) +{ + auto& s = *state(); + s.picker = {}; + s.picker.jump = jump; + ::agenda::fromCalendarSeconds(jump ? s.request.day_start : s.draft.event.start_time, s.picker.date); + s.view = View::DatePicker; + s.draft.focus = EditorField::Date; +} + +void discard() +{ + auto& s = *state(); + if (s.editor_return == View::Detail) + { + if (s.host->source->detail(s.draft.event.id, s.draft.event) != ::agenda::AgendaResult::Ok) + { + s.editor_return = View::Agenda; + s.error = "Event is unavailable"; + } + } + else s.draft = {}; + s.draft.dirty = false; + s.confirm_discard = false; + s.view = s.editor_return; +} + +bool change_start(int64_t start) +{ + auto& s = *state(); + auto& event = s.draft.event; + const int64_t duration = event.flags & ::agenda::HasEndTime ? event.end_time - event.start_time : 0; + if (start < 0 || start > ::agenda::kLastCalendarSecond - duration) + { + s.error = "Date or time is out of range"; + return false; + } + if (event.start_time != start) s.draft.dirty = true; + event.start_time = start; + if (duration) event.end_time = start + duration; + return true; +} + +bool accept_time() +{ + auto& s = *state(); + auto& event = s.draft.event; + const int64_t day = event.start_time / 86400 * 86400; + const int64_t start = day + lv_spinbox_get_value(s.editor_widgets.clock_fields[0]) * 3600 + + lv_spinbox_get_value(s.editor_widgets.clock_fields[1]) * 60; + const bool has_end = lv_obj_has_state(s.editor_widgets.end_enabled, LV_STATE_CHECKED); + int64_t end = day + lv_spinbox_get_value(s.editor_widgets.clock_fields[2]) * 3600 + + lv_spinbox_get_value(s.editor_widgets.clock_fields[3]) * 60; + if (has_end && end <= start) end += 86400; + if (has_end && end > ::agenda::kLastCalendarSecond) + { + s.error = "Date or time is out of range"; + return false; + } + s.draft.dirty |= start != event.start_time || (has_end != ((event.flags & ::agenda::HasEndTime) != 0)) || + (has_end && end != event.end_time); + event.start_time = start; + event.end_time = has_end ? end : 0; + if (has_end) event.flags |= ::agenda::HasEndTime; + else event.flags &= ~::agenda::HasEndTime; + return true; +} +} // namespace + +bool captureText() +{ + auto& s = *state(); + if (s.view != View::Editor || !s.editor_widgets.title || !s.editor_widgets.note) return true; + const char* title = lv_textarea_get_text(s.editor_widgets.title); + const char* note = lv_textarea_get_text(s.editor_widgets.note); + if (std::strlen(title) >= sizeof(s.draft.event.title) || std::strlen(note) >= sizeof(s.draft.event.note)) + { + s.error = "Text exceeds the UTF-8 byte limit"; + return false; // Preserve the live fields; never silently truncate UTF-8. + } + s.draft.dirty |= std::strcmp(title, s.draft.event.title) != 0 || std::strcmp(note, s.draft.event.note) != 0; + std::strcpy(s.draft.event.title, title); + std::strcpy(s.draft.event.note, note); + if (note[0]) s.draft.event.flags |= ::agenda::HasNote; + else s.draft.event.flags &= ~::agenda::HasNote; + return true; +} + +Transition handle(Action action, uint8_t value) +{ + auto& s = *state(); + if (action == Action::NewEvent) + { + if (!s.host->source->newDraft(s.draft)) s.error = "Set the device time first"; + else + { + s.editor_return = View::Agenda; + s.view = View::Editor; + s.confirm_discard = false; + } + return Transition::Render; + } + if (action == Action::EditEvent) + { + s.draft.editing_existing = true; + s.draft.dirty = false; + s.draft.focus = EditorField::Title; + s.editor_return = View::Detail; + s.view = View::Editor; + return Transition::Render; + } + if (action == Action::JumpToDate) + { + open_date(true); + return Transition::Render; + } + if (action == Action::Back && is_picker(s.view)) + { + s.view = s.view == View::WaypointName ? View::WaypointPicker : s.view == View::WaypointPicker ? View::LocationPicker + : s.view == View::DatePicker && s.picker.jump ? View::Agenda + : View::Editor; + return Transition::Render; + } + if (action == Action::Discard) + { + discard(); + return Transition::Render; + } + if (action == Action::KeepEditing || (action == Action::Back && s.confirm_discard)) + { + s.confirm_discard = false; + return Transition::Render; + } + if (s.view == View::Editor) + { + const bool captured = captureText(); + if (action == Action::Back) + { + if (s.draft.dirty || !captured) s.confirm_discard = true; + else discard(); + return Transition::Render; + } + if (!captured) return Transition::KeepWidgets; + } + switch (action) + { + case Action::NameWaypoint: + if (!(s.draft.event.flags & ::agenda::HasLocation) || !s.host->waypoint_actions) return Transition::KeepWidgets; + s.view = View::WaypointName; + break; + case Action::SaveWaypoint: + { + if (s.view != View::WaypointName || !s.host->waypoint_actions || !s.host->waypoints) return Transition::KeepWidgets; + const char* name = lv_textarea_get_text(s.editor_widgets.title); + ::waypoint::Record record; + if (std::strlen(name) >= sizeof(record.name)) + { + s.error = "Text exceeds the UTF-8 byte limit"; + return Transition::KeepWidgets; + } + std::strcpy(record.name, name); + record.latitude_e7 = s.draft.event.latitude_e7; + record.longitude_e7 = s.draft.event.longitude_e7; + if (!s.host->waypoint_actions->save(record).ok) + { + s.error = "Could not save changes"; + return Transition::KeepWidgets; + } + s.pending_sequence = s.host->waypoints->commandResult().sequence; + s.awaiting_command = true; + return Transition::KeepWidgets; + } + case Action::PickWaypoint: + s.waypoint_page = {}; + s.view = View::WaypointPicker; + break; + case Action::FirstWaypoints: + s.waypoint_page.after = 0; + break; + case Action::NextWaypoints: + if (s.waypoint_page.has_more && s.waypoint_page.count) + s.waypoint_page.after = s.waypoint_page.ids[s.waypoint_page.count - 1]; + break; + case Action::SelectWaypoint: + { + if (s.view != View::WaypointPicker || value >= s.waypoint_page.count) return Transition::KeepWidgets; + ::waypoint::Record selected; + if (!s.host->waypoints || s.host->waypoints->detail(s.waypoint_page.ids[value], selected) != ::waypoint::Result::Ok || + !applyWaypoint(s.draft, selected)) + { + s.error = "Waypoint unavailable"; + return Transition::KeepWidgets; + } + s.view = View::Editor; + break; + } + case Action::Save: + { + const auto result = s.host->actions->save(s.draft.event); + if (!result.ok) + { + s.error = result.failure == ::ui::UiActionFailure::InvalidInput ? "Enter a title and valid date/time" : "Could not save changes"; + return Transition::KeepWidgets; + } + s.pending_sequence = s.host->source->commandResult().sequence; + s.awaiting_command = true; + return Transition::Render; + } + case Action::PickDate: + open_date(false); + break; + case Action::PreviousMonth: + case Action::NextMonth: + { + auto& date = s.picker.date; + int month_index = date.year * 12 + date.month - 1 + (action == Action::NextMonth ? 1 : -1); + if (month_index >= 1970 * 12 && month_index < 10000 * 12) + { + date.year = month_index / 12; + date.month = month_index % 12 + 1; + date.day = 1; + } + break; + } + case Action::SelectDay: + { + auto date = s.picker.date; + date.day = value; + int64_t start = 0; + if (s.picker.jump) date.hour = date.minute = date.second = 0; + if (!::agenda::toCalendarSeconds(date, start)) break; + if (s.picker.jump) + { + s.request.day_start = start; + s.request.after = {}; + s.host->source->snapshot(s.request, s.snapshot); + s.view = View::Agenda; + } + else if (change_start(start)) s.view = View::Editor; + break; + } + case Action::PickTime: + s.draft.focus = EditorField::Time; + s.view = View::TimePicker; + break; + case Action::AcceptTime: + if (!accept_time()) return Transition::KeepWidgets; + s.view = View::Editor; + break; + case Action::PickReminder: + s.draft.focus = EditorField::Reminder; + s.view = View::ReminderPicker; + break; + case Action::SelectReminder: + { + static constexpr uint32_t offsets[] = {0, 0, 600, 1800, 3600, 86400}; + if (value >= 6) break; + s.draft.event.reminder_offset_sec = offsets[value]; + if (value) s.draft.event.flags |= ::agenda::HasReminder; + else s.draft.event.flags &= ~::agenda::HasReminder; + s.draft.dirty = true; + s.view = View::Editor; + break; + } + case Action::PickRepeat: + s.draft.focus = EditorField::Repeat; + s.view = View::RepeatPicker; + break; + case Action::PickLocation: + s.draft.focus = EditorField::Location; + s.view = View::LocationPicker; + break; + case Action::CurrentLocation: + { + AgendaCoordinate coordinate; + if (!s.host->locations || !s.host->locations->currentLocation(coordinate) || !applyCoordinate(s.draft, coordinate)) + { + s.error = "Current position unavailable"; + return Transition::KeepWidgets; + } + s.view = View::Editor; + break; + } + case Action::ChooseOnMap: + if (!s.host->request_map || + !s.host->request_map(s.host->map_context, s.draft, s.editor_return == View::Detail)) + s.error = "Map is unavailable on this target."; + return Transition::KeepWidgets; + case Action::ClearLocation: + clearLocation(s.draft); + s.view = View::Editor; + break; + case Action::SelectRepeat: + if (value > static_cast(::agenda::Repeat::Yearly)) break; + s.draft.event.repeat = static_cast<::agenda::Repeat>(value); + s.draft.dirty = true; + s.view = View::Editor; + break; + default: + return Transition::Unhandled; + } + return Transition::Render; +} +} // namespace ui::agenda::page::editor diff --git a/modules/ui_shared/src/ui/screens/agenda/agenda_page_components.cpp b/modules/ui_shared/src/ui/screens/agenda/agenda_page_components.cpp new file mode 100644 index 00000000..d03ab01b --- /dev/null +++ b/modules/ui_shared/src/ui/screens/agenda/agenda_page_components.cpp @@ -0,0 +1,401 @@ +#include "ui/screens/agenda/agenda_page_components.h" +#include "ui/assets/fonts/font_utils.h" +#include "ui/localization.h" +#include "ui/page/page_profile.h" +#include "ui/screens/agenda/agenda_editor_runtime.h" +#include "ui/screens/agenda/agenda_page_input.h" +#include "ui/screens/agenda/agenda_page_runtime.h" +#include "ui/ui_theme.h" +#include "ui/widgets/top_bar_power_presenter.h" + +#include "ui_presentation/agenda/agenda_countdown.h" +#include +#include +#include + +namespace ui::agenda::page::components +{ +namespace +{ +void plain(lv_obj_t* object) +{ + lv_obj_set_style_pad_all(object, 0, 0); + lv_obj_set_style_border_width(object, 0, 0); + lv_obj_set_style_radius(object, 0, 0); + lv_obj_set_style_bg_color(object, ::ui::theme::page_bg(), 0); + lv_obj_remove_flag(object, LV_OBJ_FLAG_SCROLLABLE); +} + +lv_obj_t* label(lv_obj_t* parent, const char* text, bool translated = true, bool caption = false) +{ + auto* value = lv_label_create(parent); + const auto* font = caption ? ::ui::page_profile::resolve_caption_font() : ::ui::page_profile::resolve_body_font(); + const char* display = translated ? ::ui::i18n::tr(text) : text; + lv_label_set_text(value, display); + if (translated) ::ui::fonts::apply_localized_font(value, display, font); + else ::ui::fonts::apply_content_font(value, display, font); + lv_obj_set_style_text_color(value, ::ui::theme::text(), 0); + lv_label_set_long_mode(value, LV_LABEL_LONG_DOT); + return value; +} + +lv_obj_t* button(lv_obj_t* parent, const char* text, Action action, uint8_t row = 0) +{ + auto* object = lv_button_create(parent); + lv_obj_set_style_bg_color(object, ::ui::theme::surface(), 0); + lv_obj_set_style_bg_color(object, ::ui::theme::accent(), LV_STATE_FOCUSED); + lv_obj_set_style_border_color(object, ::ui::theme::border(), 0); + lv_obj_set_style_border_width(object, 1, 0); + lv_obj_set_style_shadow_width(object, 0, 0); + lv_obj_set_style_pad_all(object, 2, 0); + lv_obj_set_style_radius(object, 3, 0); + const auto encoded = static_cast(action) | (static_cast(row) << 8); + lv_obj_add_event_cb(object, input::activate, LV_EVENT_CLICKED, reinterpret_cast(encoded)); + input::bind(object); + if (text) + { + auto* text_label = label(object, text); + lv_obj_set_width(text_label, LV_PCT(100)); + lv_obj_set_style_text_align(text_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_center(text_label); + } + if (state()->awaiting_command) lv_obj_add_state(object, LV_STATE_DISABLED); + return object; +} + +void date_text(int64_t seconds, char* text, std::size_t size, bool time_only) +{ + ::agenda::CivilTime civil; + if (!::agenda::fromCalendarSeconds(seconds, civil)) + { + text[0] = '\0'; + return; + } + if (time_only) std::snprintf(text, size, "%02u:%02u", civil.hour, civil.minute); + else std::snprintf(text, size, "%04u-%02u-%02u", civil.year, civil.month, civil.day); +} + +void home(lv_coord_t width, lv_coord_t height) +{ + auto& s = *state(); + const auto& profile = ::ui::page_profile::current(); + const bool compact = profile.variant == ::ui::page_profile::LayoutVariant::EncoderCompact; + const int row_height = compact ? 24 : 32; + const int section_height = compact ? 17 : 20; + const int footer_height = profile.control_button_height; + const int content_height = height - footer_height - 4; + int y = 0; + int64_t previous_day = -1; + uint8_t shown = 0; + const char* message = !s.snapshot.storage_ready ? "Agenda storage unavailable" + : !s.snapshot.clock_valid ? "Set the device time first" + : s.snapshot.result != ::agenda::StoreResult::Ok ? "Could not read events" + : s.snapshot.page.count == 0 ? "No upcoming events" + : nullptr; + if (message) + { + auto* text = label(s.body, message); + lv_obj_set_width(text, width - 8); + lv_obj_set_pos(text, 4, 6); + } + else + for (uint8_t i = 0; i < s.snapshot.page.count; ++i) + { + const auto& entry = s.snapshot.page.rows[i]; + const int64_t day = entry.occurrence.start / 86400 * 86400; + const bool section = day != previous_day; + if (y + row_height + (section ? section_height : 0) > content_height) break; + if (section) + { + char date[16]; + date_text(day, date, sizeof(date), false); + const char* title = day == s.snapshot.today_start ? "Today" + : day == s.snapshot.today_start + 86400 ? "Tomorrow" + : date; + auto* heading = label(s.body, title, title != date, true); + lv_obj_set_pos(heading, 4, y); + lv_obj_set_width(heading, width - 8); + y += section_height; + previous_day = day; + } + auto* row = button(s.body, nullptr, Action::OpenRow, i); + lv_obj_set_pos(row, 2, y); + lv_obj_set_size(row, width - 4, row_height); + char time[8]; + date_text(entry.occurrence.start, time, sizeof(time), true); + auto* time_label = label(row, time, false); + lv_obj_set_size(time_label, 48, LV_SIZE_CONTENT); + lv_obj_align(time_label, LV_ALIGN_LEFT_MID, 2, 0); + const int countdown_width = compact ? 100 : 84; + auto* title = label(row, entry.title, false); + lv_obj_set_width(title, width - 64 - countdown_width); + lv_obj_align(title, LV_ALIGN_LEFT_MID, 54, 0); + lv_obj_set_user_data(row, reinterpret_cast(static_cast(i + 1))); + const auto now = s.host->source->currentTime(); + char text[32]; + formatCountdown(entry.occurrence.start, now.calendar_seconds, now.valid, text, sizeof(text)); + auto* countdown = label(row, text, false, true); + lv_obj_set_width(countdown, countdown_width - 4); + lv_obj_set_style_text_align(countdown, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_align(countdown, LV_ALIGN_RIGHT_MID, -2, 0); + y += row_height; + ++shown; + } + if (!message && shown < s.snapshot.page.count) + { + // Pagination resumes after the last row actually rendered, not after + // hidden rows whose section headings no longer fit the framebuffer. + s.snapshot.page.count = shown; + s.snapshot.page.has_more = true; + } + const int segment = width / 4; + auto* create = button(s.body, "New", Action::NewEvent); + lv_obj_set_size(create, segment - 3, footer_height); + lv_obj_set_pos(create, 1, height - footer_height); + if (!s.snapshot.clock_valid || !s.snapshot.storage_ready) lv_obj_add_state(create, LV_STATE_DISABLED); + auto* today = button(s.body, "Today", Action::Today); + lv_obj_set_size(today, segment - 3, footer_height); + lv_obj_set_pos(today, segment + 1, height - footer_height); + auto* date = button(s.body, "Date", Action::JumpToDate); + lv_obj_set_size(date, segment - 3, footer_height); + lv_obj_set_pos(date, segment * 2 + 1, height - footer_height); + auto* next = button(s.body, "More", Action::NextPage); + lv_obj_set_size(next, segment - 3, footer_height); + lv_obj_set_pos(next, segment * 3 + 1, height - footer_height); + if (!s.snapshot.page.has_more) lv_obj_add_state(next, LV_STATE_DISABLED); +} + +void scroll_detail(lv_event_t* event) +{ + auto* object = static_cast(lv_event_get_target(event)); + const auto key = lv_event_get_key(event); + const bool down = key == LV_KEY_DOWN; + const bool up = key == LV_KEY_UP; + if ((!down && !up) || (down && lv_obj_get_scroll_bottom(object) <= 0) || + (up && lv_obj_get_scroll_top(object) <= 0)) return; + lv_obj_scroll_by(object, 0, down ? -24 : 24, LV_ANIM_OFF); + lv_event_stop_processing(event); + lv_event_stop_bubbling(event); +} + +void detail(lv_coord_t width, lv_coord_t height) +{ + auto& s = *state(); + const auto& event = s.draft.event; + auto* title = label(s.body, event.title, false); + lv_obj_set_pos(title, 4, 2); + lv_obj_set_width(title, width - 8); + lv_obj_set_style_text_color(title, ::ui::theme::accent(), 0); + ::ui::fonts::apply_content_font(title, event.title, ::ui::page_profile::resolve_title_font()); + if (s.confirm_delete) + { + auto* prompt = label(s.body, "Delete this event?"); + lv_obj_set_pos(prompt, 4, 36); + lv_obj_set_width(prompt, width - 8); + auto* cancel = button(s.body, "Cancel", Action::CancelDelete); + auto* confirm = button(s.body, "Delete", Action::ConfirmDelete); + const int h = ::ui::page_profile::resolve_control_button_height(); + lv_obj_set_size(cancel, width / 2 - 6, h); + lv_obj_set_size(confirm, width / 2 - 6, h); + lv_obj_set_pos(cancel, 3, height - h); + lv_obj_set_pos(confirm, width / 2 + 3, height - h); + lv_group_focus_obj(cancel); + return; + } + const auto& occurrence = s.detail_occurrence; + char date[16], start[8], end[8], when[48]; + date_text(occurrence.start, date, sizeof(date), false); + date_text(occurrence.start, start, sizeof(start), true); + date_text(occurrence.end, end, sizeof(end), true); + if (occurrence.has_end) std::snprintf(when, sizeof(when), "%s %s - %s", date, start, end); + else std::snprintf(when, sizeof(when), "%s %s", date, start); + auto* date_label = label(s.body, when, false, true); + lv_obj_set_pos(date_label, 4, 28); + lv_obj_set_width(date_label, width - 8); + const int footer = ::ui::page_profile::resolve_control_button_height(); + const bool located = (event.flags & ::agenda::HasLocation) != 0; + const bool wide = width >= 400; + auto* content = lv_obj_create(s.body); + plain(content); + lv_obj_set_pos(content, 4, 50); + lv_obj_set_size(content, width - 8, height - 54 - footer); + lv_obj_add_flag(content, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_dir(content, LV_DIR_VER); + lv_obj_add_event_cb(content, scroll_detail, LV_EVENT_KEY, nullptr); + input::bind(content); + const int text_width = located && wide ? (width - 20) / 2 : width - 16; + int y = 0; + if (located) + { + const int map_width = wide ? (width - 20) / 2 : width - 16; + const int map_height = wide ? height - 58 - footer : 92; + auto* map_host = lv_obj_create(content); + plain(map_host); + lv_obj_set_size(map_host, map_width, map_height); + lv_obj_set_pos(map_host, wide ? text_width + 4 : 0, 0); + s.detail_map = new (std::nothrow)::ui::widgets::map::Runtime; + if (s.detail_map) + { + namespace map = ::ui::widgets::map; + map::create(*s.detail_map, map_host, 180); + map::set_size(*s.detail_map, map_width, map_height); + map::set_gesture_enabled(*s.detail_map, false); + map::Model model; + model.zoom = 16; + model.focus_point = {true, event.latitude_e7 / 1e7, event.longitude_e7 / 1e7}; + map::apply_model(*s.detail_map, model); + } + auto* marker = lv_obj_create(map_host); + plain(marker); + lv_obj_set_size(marker, 12, 12); + lv_obj_set_style_radius(marker, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(marker, ::ui::theme::accent(), 0); + lv_obj_set_style_bg_opa(marker, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(marker, 2, 0); + lv_obj_set_style_border_color(marker, ::ui::theme::text(), 0); + lv_obj_center(marker); + s.map_notice = label(map_host, "Map - No Map Data", true, true); + lv_obj_set_width(s.map_notice, map_width - 4); + lv_obj_align(s.map_notice, LV_ALIGN_BOTTOM_MID, 0, -2); + if (!wide) y = map_height + 8; + } + if (event.flags & ::agenda::HasNote) + { + auto* note = label(content, event.note, false); + lv_obj_set_pos(note, 0, y); + lv_obj_set_width(note, text_width); + lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP); + lv_obj_update_layout(note); + y += lv_obj_get_height(note) + 8; + } + const auto metadata = [&](const char* name, const char* value) + { + auto* caption = label(content, name, true, true); + lv_obj_set_pos(caption, 0, y); + lv_obj_set_width(caption, text_width); + lv_obj_set_style_text_opa(caption, LV_OPA_60, 0); + auto* text = label(content, value, true, true); + lv_obj_set_pos(text, 0, y + 16); + lv_obj_set_width(text, text_width); + y += 38; + }; + if (event.flags & ::agenda::HasReminder) metadata("Reminder", editor::reminderText(event)); + if (event.repeat != ::agenda::Repeat::None) metadata("Repeat", editor::repeatText(event.repeat)); + auto* remove = button(s.body, "Delete", Action::Delete); + auto* edit = button(s.body, "Edit", Action::EditEvent); + const int h = ::ui::page_profile::resolve_control_button_height(); + const bool can_navigate = (event.flags & ::agenda::HasLocation) && s.host->request_target; + const int segment = width / (can_navigate ? 3 : 2); + lv_obj_set_size(edit, segment - 6, h); + lv_obj_set_pos(edit, 3, height - h); + lv_obj_set_size(remove, segment - 6, h); + lv_obj_set_pos(remove, segment + 3, height - h); + if (can_navigate) + { + auto* navigate = button(s.body, "Navigate", Action::Navigate); + lv_obj_set_size(navigate, segment - 6, h); + lv_obj_set_pos(navigate, segment * 2 + 3, height - h); + lv_obj_set_style_bg_color(navigate, ::ui::theme::accent(), 0); + } +} +} // namespace + +lv_obj_t* addLabel(lv_obj_t* parent, const char* text, bool translated, bool caption) +{ + return label(parent, text, translated, caption); +} +lv_obj_t* addButton(lv_obj_t* parent, const char* text, Action action, uint8_t value) +{ + return button(parent, text, action, value); +} + +void create(lv_obj_t* parent) +{ + auto& s = *state(); + s.root = lv_obj_create(parent); + plain(s.root); + lv_obj_set_size(s.root, LV_PCT(100), LV_PCT(100)); + ::ui::widgets::top_bar_init(s.top_bar, s.root); + ::ui::widgets::top_bar_set_back_callback( + s.top_bar, [](void*) + { runtime::request(Action::Back); }, + nullptr); + s.body = lv_obj_create(s.root); + plain(s.body); + const int top = ::ui::page_profile::current().top_bar_height; + lv_obj_set_pos(s.body, 0, top); + lv_obj_set_size(s.body, LV_PCT(100), LV_PCT(100)); + lv_obj_update_layout(s.root); + lv_obj_set_height(s.body, lv_obj_get_height(s.root) - top); +} +void render() +{ + auto& s = *state(); + if (s.view == View::Editor && (s.confirm_discard || s.editor_widgets.confirm_root)) + { + // A small discard confirmation preserves the live textareas, including + // invalid oversized input, so choosing Keep editing loses no text. + editor::updateDiscard(); + return; + } + delete s.detail_map; + s.detail_map = nullptr; + s.map_notice = nullptr; + lv_group_remove_all_objs(s.group); + lv_obj_clean(s.body); + s.editor_widgets = {}; + input::bind(s.top_bar.back_btn); + const bool editing = s.view != View::Agenda && s.view != View::Detail; + ::ui::widgets::top_bar_set_title(s.top_bar, ::ui::i18n::tr(editing ? editor::title() : s.view == View::Agenda ? "Agenda" + : "Event Detail")); + const int width = lv_obj_get_width(s.body); + int height = lv_obj_get_height(s.body); + if (!editing && (s.error || s.awaiting_command)) + { + auto* status = label(s.body, s.awaiting_command ? "Saving..." : s.error, true, true); + lv_obj_set_width(status, width - 8); + lv_obj_set_pos(status, 4, height - 20); + height -= 22; + } + if (editing) editor::render(width, height); + else if (s.view == View::Agenda) home(width, height); + else detail(width, height); + if (!s.confirm_delete) lv_group_focus_obj(s.editor_widgets.focus ? s.editor_widgets.focus : s.top_bar.back_btn); + lv_group_set_editing(s.group, false); +} + +void updateCountdowns() +{ + auto* s = state(); + if (s && s->detail_map && s->map_notice) + { + const bool visible = ::ui::widgets::map::status(*s->detail_map).has_visible_map_data; + if (visible) lv_obj_add_flag(s->map_notice, LV_OBJ_FLAG_HIDDEN); + else lv_obj_remove_flag(s->map_notice, LV_OBJ_FLAG_HIDDEN); + } + if (!s || s->view != View::Agenda || !s->body) return; + const auto now = s->host->source->currentTime(); + for (uint32_t i = 0; i < lv_obj_get_child_count(s->body); ++i) + { + auto* row = lv_obj_get_child(s->body, i); + const auto index = reinterpret_cast(lv_obj_get_user_data(row)); + if (!index || index > s->snapshot.page.count || lv_obj_get_child_count(row) != 3) continue; + auto* value = lv_obj_get_child(row, 2); + char text[32]; + formatCountdown(s->snapshot.page.rows[index - 1].occurrence.start, + now.calendar_seconds, now.valid, text, sizeof(text)); + if (std::strcmp(text, lv_label_get_text(value)) != 0) lv_label_set_text(value, text); + } +} +void destroy() +{ + auto& s = *state(); + delete s.detail_map; + s.detail_map = nullptr; + s.map_notice = nullptr; + ::ui::widgets::top_bar_power::unbind(s.top_bar); + if (s.root) lv_obj_delete(s.root); + s.root = s.body = nullptr; + s.top_bar = {}; +} +} // namespace ui::agenda::page::components diff --git a/modules/ui_shared/src/ui/screens/agenda/agenda_page_flow.cpp b/modules/ui_shared/src/ui/screens/agenda/agenda_page_flow.cpp new file mode 100644 index 00000000..37396cb7 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/agenda/agenda_page_flow.cpp @@ -0,0 +1,391 @@ +#include "ui/screens/agenda/agenda_page_flow.h" + +#include "ui/app_runtime.h" +#include "ui/assets/fonts/font_utils.h" +#include "ui/page/page_profile.h" +#include "ui/screens/agenda/agenda_editor_runtime.h" +#include "ui/screens/agenda/agenda_page_components.h" +#include "ui/screens/agenda/agenda_page_runtime.h" +#include "ui/screens/agenda/agenda_text_snapshot.h" +#include "ui/screens/gps/gps_page_runtime.h" +#include "ui/widgets/ime/ime_widget.h" +#include "ui_lvgl_ux_packs/common/touch_text_editor.h" +#include "ui_presentation/map/map_location_request.h" +#include "ui_presentation/map/map_target_request.h" + +#include +#include + +namespace ui::agenda::page +{ +namespace +{ +using NoteSnapshot = TextSnapshot; +template +bool restoreText(lv_obj_t* field, const TextSnapshot& snapshot) +{ + lv_textarea_set_text(field, ""); + return snapshot.forEachUtf8([field](const char* scalar) + { + lv_textarea_add_text(field, scalar); + return true; }); +} +} // namespace +struct Flow::ReturnContext +{ + AgendaEditorModel draft; + ::agenda::Occurrence detail_occurrence; + ::ui::map::MapLocationRequest location; + ::ui::map::MapTargetRequest target; + ::ui::page::Host navigation; + AgendaRequest agenda_request{}; + ::agenda::CivilTime picker_date{}; + int32_t time_values[4]{}; + // LVGL limits characters, while EventRecord limits UTF-8 bytes. Keep live + // text verbatim across interruptions, including input not yet saveable. + TextSnapshot live_title; + NoteSnapshot live_note; + NoteSnapshot modal_text; + ::ui::widgets::ImeEditState modal_state{}; + uint8_t modal_field = 0; // 0: none, 1: title, 2: note. + const char* error = nullptr; + uint32_t pending_sequence = 0; + uint32_t waypoint_after = 0; + View restore_view = View::Agenda; + bool live_text = false; + bool suspended = false; + bool awaiting_command = false; + bool confirm_delete = false; + bool confirm_discard = false; + bool picker_jump = false; + bool time_has_end = false; + bool prepared = false; + bool return_detail = false; + bool entered = false; + bool returning = false; + bool show_target = false; + bool failed = false; +}; + +Flow::Flow(Host& host) : host_(host) +{ + static_assert(sizeof(ReturnContext) < 1024, "UI recovery context must remain below 1 KiB"); + host_.map_context = this; + host_.request_map = requestMap; + host_.request_target = requestTarget; +} +Flow::~Flow() +{ + close(); + host_.map_context = nullptr; + host_.request_map = nullptr; + host_.request_target = nullptr; +} +void Flow::enter(void* context, lv_obj_t* parent) +{ + auto& self = *static_cast(context); + if (self.parent_ || !parent) return; + if (!self.return_ && !self.reserve_) + { + // Reserve bounded recovery storage before accepting any user input. + // A later call must not lose a draft because allocation is exhausted. + self.reserve_ = new (std::nothrow) ReturnContext{}; + if (!self.reserve_) return; + } + self.parent_ = parent; + if (self.return_) self.return_->suspended = false; + if (!self.return_) runtime::enter(&self.host_, parent); +} +void Flow::exit(void* context, lv_obj_t*) +{ + auto& self = *static_cast(context); + if (self.parent_ && ui_is_interruption_app_active()) self.suspend(); + else self.close(); +} +void Flow::suspend() +{ + if (!return_) return_ = captureReturn(); + if (!return_) + { + close(); // Allocation failure cannot keep a second LVGL tree alive. + return; + } + if (return_->entered) ::gps::ui::shell::exit_route(nullptr, parent_); + else runtime::exit(); + return_->entered = false; + return_->prepared = false; + return_->returning = true; + return_->suspended = true; + parent_ = nullptr; +} +void Flow::close() +{ + if (parent_) + { + if (return_ && return_->entered) ::gps::ui::shell::exit_route(nullptr, parent_); + else runtime::exit(); + } + delete return_; + delete reserve_; + return_ = nullptr; + reserve_ = nullptr; + parent_ = nullptr; +} +bool Flow::requestMap(void* context, const AgendaEditorModel& draft, bool return_detail) +{ + return static_cast(context)->queueMap(draft, return_detail, false); +} +bool Flow::requestTarget(void* context, const AgendaEditorModel& draft) +{ + return static_cast(context)->queueMap(draft, true, true); +} +bool Flow::queueMap(const AgendaEditorModel& draft, bool return_detail, bool show_target) +{ + if (!parent_ || return_ || !::gps::ui::runtime::is_available()) return false; + if (show_target && (!(draft.event.flags & ::agenda::HasLocation) || + !state() || state()->view != View::Detail)) return false; + auto* pending = captureReturn(); + if (!pending) return false; + pending->draft = draft; + if (const auto* current = state()) pending->detail_occurrence = current->detail_occurrence; + pending->return_detail = return_detail; + pending->navigation = {this, requestReturn}; + pending->show_target = show_target; + pending->restore_view = show_target ? View::Detail : View::Editor; + if (draft.event.flags & ::agenda::HasLocation) + { + pending->location.has_initial_viewport = true; + pending->location.initial_viewport.center_lat = draft.event.latitude_e7 / 1e7; + pending->location.initial_viewport.center_lon = draft.event.longitude_e7 / 1e7; + } + if (show_target) + { + pending->target.viewport = pending->location.initial_viewport; + ::ui::copyText(pending->target.label, draft.event.location_name); + if (!pending->target.valid()) + { + reserve_ = pending; + return false; + } + } + return_ = pending; + return true; // Never destroy the caller's widgets inside its event/timer. +} +Flow::ReturnContext* Flow::captureReturn() +{ + auto* pending = reserve_; + reserve_ = nullptr; + if (pending) + { + pending->~ReturnContext(); + new (pending) ReturnContext{}; // Reset in place; no large stack temporary. + } + else pending = new (std::nothrow) ReturnContext{}; + if (!pending) return nullptr; + pending->navigation = {this, requestReturn}; + pending->agenda_request.visible_rows = 0; // Closed Agenda returns to today's default list. + if (parent_ && state()) + { + const auto& s = *state(); + pending->draft = s.draft; + pending->detail_occurrence = s.detail_occurrence; + pending->return_detail = s.editor_return == View::Detail; + pending->restore_view = s.view; + pending->agenda_request = s.request; + pending->picker_date = s.picker.date; + pending->picker_jump = s.picker.jump; + pending->error = s.error; + pending->pending_sequence = s.pending_sequence; + pending->waypoint_after = s.waypoint_page.after; + pending->awaiting_command = s.awaiting_command; + pending->confirm_delete = s.confirm_delete; + pending->confirm_discard = s.confirm_discard; + if ((s.view == View::Editor || s.view == View::WaypointName) && s.editor_widgets.title) + { + const char* title = lv_textarea_get_text(s.editor_widgets.title); + const char* note = s.editor_widgets.note ? lv_textarea_get_text(s.editor_widgets.note) : ""; + // The bounds follow the textareas' configured character limits. + // Reject an unexpected larger value rather than truncate bytes. + if (!pending->live_title.assign(title) || !pending->live_note.assign(note)) + { + reserve_ = pending; + return nullptr; + } + pending->live_text = true; + lv_obj_t* fields[] = {s.editor_widgets.title, s.editor_widgets.note}; + for (unsigned i = 0; i < 2; ++i) + { + if (!fields[i]) continue; + const auto captured = ::ui::widgets::capture_touch_text_editor( + fields[i], [](void* context, const char* text) + { return static_cast(context)->assign(text); }, + &pending->modal_text, pending->modal_state); + if (captured == ::ui::widgets::TouchEditorCapture::InsufficientCapacity) + { + reserve_ = pending; + return nullptr; + } + if (captured == ::ui::widgets::TouchEditorCapture::Captured) + { + pending->modal_field = i + 1; + break; + } + } + } + if (s.view == View::TimePicker) + { + for (unsigned i = 0; i < 4; ++i) + pending->time_values[i] = lv_spinbox_get_value(s.editor_widgets.clock_fields[i]); + pending->time_has_end = lv_obj_has_state(s.editor_widgets.end_enabled, LV_STATE_CHECKED); + } + } + return pending; +} +const char* Flow::prepareDestination(int32_t latitude_e7, int32_t longitude_e7) +{ + if (return_ || ui_is_transition_pending() || ui_is_interruption_app_active()) return "Could not update reminder"; + if (!::gps::ui::runtime::is_available()) return "Map is unavailable on this target."; + if (latitude_e7 < -900000000 || latitude_e7 > 900000000 || + longitude_e7 < -1800000000 || longitude_e7 > 1800000000) return "Invalid location"; + if (parent_) + { + if (!state() || !canPresentReminder()) return "Could not update reminder"; + if (!editor::captureText()) return state()->error; + } + auto* pending = captureReturn(); + if (!pending) return "Map is unavailable on this target."; + pending->show_target = true; + pending->prepared = true; + pending->target.viewport.center_lat = latitude_e7 / 1e7; + pending->target.viewport.center_lon = longitude_e7 / 1e7; + return_ = pending; + return nullptr; +} +void Flow::finishDestination(bool accepted) +{ + if (!return_ || !return_->prepared) return; + if (accepted) return_->prepared = false; + else + { + if (parent_) reserve_ = return_; + else delete return_; + return_ = nullptr; + } +} +bool Flow::needsActivation() const +{ + return return_ && !return_->prepared && !return_->suspended && !parent_; +} +void Flow::discardSuspended() +{ + if (return_ && return_->suspended && !parent_) close(); +} +void Flow::invalidateStorage() +{ + auto* parent = parent_; + close(); + // Rebuild only this flow's active page. Never resurrect a suspended page + // over another application, or preserve IDs/drafts from the old medium. + if (parent) enter(this, parent); +} +void Flow::requestReturn(void* context) +{ + auto& self = *static_cast(context); + if (self.return_) self.return_->returning = true; +} +void Flow::restoreEditor() +{ + // Map must release its entire tree and input group before re-entering Agenda. + if (return_->entered) ::gps::ui::shell::exit_route(nullptr, parent_); + return_->entered = false; + runtime::enter(&host_, parent_); + auto* s = state(); + if (!s) return; // Retain the bounded draft and retry after allocation pressure. + s->draft = return_->draft; + s->detail_occurrence = return_->detail_occurrence; + s->editor_return = return_->return_detail ? View::Detail : View::Agenda; + s->view = return_->restore_view; + s->error = return_->error; + s->pending_sequence = return_->pending_sequence; + s->waypoint_page.after = return_->waypoint_after; + s->awaiting_command = return_->awaiting_command; + s->confirm_delete = return_->confirm_delete; + if (return_->agenda_request.visible_rows) + { + s->request = return_->agenda_request; + host_.source->snapshot(s->request, s->snapshot); + } + s->picker.date = return_->picker_date; + s->picker.jump = return_->picker_jump; + const auto& result = return_->location.result; + if (!return_->show_target && result.state == ::ui::map::MapLocationSelectionState::Picked) + { + AgendaCoordinate coordinate; + if (!coordinateFromDegrees(result.latitude, result.longitude, coordinate) || + !applyCoordinate(s->draft, coordinate)) s->error = "Invalid location"; + } + if (return_->failed) + s->error = "Map is unavailable on this target."; + components::render(); + if ((s->view == View::Editor || s->view == View::WaypointName) && return_->live_text) + { + restoreText(s->editor_widgets.title, return_->live_title); + ::ui::fonts::apply_content_font(s->editor_widgets.title, lv_textarea_get_text(s->editor_widgets.title), ::ui::page_profile::resolve_body_font()); + if (s->editor_widgets.note) + { + restoreText(s->editor_widgets.note, return_->live_note); + ::ui::fonts::apply_content_font(s->editor_widgets.note, lv_textarea_get_text(s->editor_widgets.note), ::ui::page_profile::resolve_body_font()); + } + } + if (return_->confirm_discard) + { + // First reconstruct live fields, then place confirmation above them. + s->confirm_discard = true; + components::render(); + } + if (s->view == View::TimePicker) + { + for (unsigned i = 0; i < 4; ++i) + lv_spinbox_set_value(s->editor_widgets.clock_fields[i], return_->time_values[i]); + if (return_->time_has_end) lv_obj_add_state(s->editor_widgets.end_enabled, LV_STATE_CHECKED); + else lv_obj_remove_state(s->editor_widgets.end_enabled, LV_STATE_CHECKED); + } + if ((s->view == View::Editor || s->view == View::WaypointName) && return_->modal_field) + { + auto* source = return_->modal_field == 1 ? s->editor_widgets.title : s->editor_widgets.note; + if (!::ui::widgets::restore_touch_text_editor( + source, return_->modal_state, [](const void* context, lv_obj_t* field) + { return restoreText(field, *static_cast(context)); }, + &return_->modal_text)) return; + } + // Reuse this allocation for the next interruption while the page is open. + // Never retain both a return context and an additional recovery reserve. + reserve_ = return_; + return_ = nullptr; +} +void Flow::tick() +{ + if (!return_ || return_->prepared || !parent_ || ui_is_interruption_app_active() || ui_is_transition_pending()) return; + if (return_->returning) + { + restoreEditor(); + return; + } + if (return_->entered || ui_is_overlay_active()) return; + runtime::exit(); + // Wait for the old touch/key release before giving the Map its controls. + for (auto* input = lv_indev_get_next(nullptr); input; input = lv_indev_get_next(input)) + lv_indev_wait_release(input); + ::gps::ui::shell::RouteSpec route{&return_->navigation, ::gps::ui::shell::Projection::Map, + return_->show_target ? nullptr : &return_->location, + return_->show_target ? &return_->target : nullptr}; + ::gps::ui::shell::enter_route(&route, parent_); + return_->entered = true; + if (return_->show_target ? !return_->target.entered + : return_->location.result.state != ::ui::map::MapLocationSelectionState::Selecting) + { + return_->failed = true; + return_->returning = true; + } +} +} // namespace ui::agenda::page diff --git a/modules/ui_shared/src/ui/screens/agenda/agenda_page_input.cpp b/modules/ui_shared/src/ui/screens/agenda/agenda_page_input.cpp new file mode 100644 index 00000000..e041562f --- /dev/null +++ b/modules/ui_shared/src/ui/screens/agenda/agenda_page_input.cpp @@ -0,0 +1,68 @@ +#include "ui/screens/agenda/agenda_page_input.h" +#include "ui/app_runtime.h" +#include "ui/screens/agenda/agenda_page_runtime.h" + +namespace ui::agenda::page::input +{ +namespace +{ +void key(lv_event_t* event) +{ + auto* s = state(); + if (!s || !s->group) return; + const auto code = lv_event_get_key(event); + auto* object = static_cast(lv_event_get_target(event)); + const bool text = lv_obj_check_type(object, &lv_textarea_class); + const bool editor = text || lv_obj_check_type(object, &lv_spinbox_class) || lv_obj_check_type(object, &lv_buttonmatrix_class); + // Text backspace belongs to the editor, not to page navigation. Editing + // widgets retain their own arrows; Tab/encoder focus traversal can leave. + if (text && code == LV_KEY_BACKSPACE) return; + if (editor && lv_group_get_editing(s->group) && code != LV_KEY_ESC && + code != LV_KEY_NEXT && code != LV_KEY_PREV) return; + if (code == LV_KEY_ESC || code == LV_KEY_BACKSPACE) runtime::request(Action::Back); + else if (code == LV_KEY_UP || code == LV_KEY_LEFT || code == LV_KEY_PREV || code == 19) + lv_group_focus_prev(s->group); + else if (code == LV_KEY_DOWN || code == LV_KEY_RIGHT || code == LV_KEY_NEXT || code == 20) + lv_group_focus_next(s->group); + else return; + lv_group_set_editing(s->group, false); + lv_event_stop_bubbling(event); + lv_event_stop_processing(event); + // KEY carries a uint32_t*, not an input-device pointer. Synthetic group + // events may have no active device at all. + if (auto* device = lv_indev_active()) lv_indev_stop_processing(device); +} +} // namespace + +void begin() +{ + auto* s = state(); + s->previous_group = lv_group_get_default(); + s->group = lv_group_create(); + lv_group_set_wrap(s->group, true); + set_default_group(s->group); +} +void bind(lv_obj_t* object) +{ + if (!object || !state()) return; + lv_group_add_obj(state()->group, object); + lv_obj_remove_event_cb(object, key); + lv_obj_add_event_cb(object, key, LV_EVENT_KEY, nullptr); +} +void activate(lv_event_t* event) +{ + const auto encoded = reinterpret_cast(lv_event_get_user_data(event)); + runtime::request(static_cast(encoded & 0xff), static_cast(encoded >> 8)); + lv_event_stop_bubbling(event); + lv_event_stop_processing(event); + if (auto* device = lv_event_get_indev(event)) lv_indev_stop_processing(device); +} +void end() +{ + auto* s = state(); + if (!s) return; + set_default_group(s->previous_group); + if (s->group) lv_group_delete(s->group); + s->group = nullptr; +} +} // namespace ui::agenda::page::input diff --git a/modules/ui_shared/src/ui/screens/agenda/agenda_page_runtime.cpp b/modules/ui_shared/src/ui/screens/agenda/agenda_page_runtime.cpp new file mode 100644 index 00000000..cee68b82 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/agenda/agenda_page_runtime.cpp @@ -0,0 +1,173 @@ +#include "ui/screens/agenda/agenda_page_runtime.h" +#include "ui/page/page_profile.h" +#include "ui/screens/agenda/agenda_editor_runtime.h" +#include "ui/screens/agenda/agenda_page_components.h" +#include "ui/screens/agenda/agenda_page_input.h" +#include "ui_presentation/agenda/agenda_waypoint.h" +#include "ui_presentation/waypoint/waypoint_model.h" + +namespace ui::agenda::page::runtime +{ +namespace +{ +void load_today() +{ + auto& s = *state(); + s.host->source->snapshot(s.request, s.snapshot); + s.request.day_start = s.snapshot.today_start; + s.request.after = {}; + s.host->source->snapshot(s.request, s.snapshot); +} + +void tick(lv_timer_t*) +{ + auto* s = state(); + if (!s) return; + components::updateCountdowns(); + if (s->awaiting_command && s->view == View::WaypointName) + { + const auto result = s->host->waypoints->commandResult(); + if (result.sequence != s->pending_sequence || result.state == ::ui::waypoint::CommandState::Pending) return; + s->awaiting_command = false; + ::waypoint::Record saved; + if (result.state == ::ui::waypoint::CommandState::Succeeded && + s->host->waypoints->detail(result.id, saved) == ::waypoint::Result::Ok && applyWaypoint(s->draft, saved)) + { + s->view = View::Editor; + components::render(); + } + else + { + s->error = "Could not save changes"; + editor::showStatus(); // Keep the entered name for retry. + } + return; + } + if (s->awaiting_command) + { + const auto result = s->host->source->commandResult(); + if (result.sequence != s->pending_sequence || result.state == CommandState::Pending) return; + s->awaiting_command = false; + if (result.state == CommandState::Succeeded) + { + s->view = View::Agenda; + s->confirm_delete = false; + load_today(); + } + else s->error = "Could not save changes"; + components::render(); + } + const Action action = s->pending; + s->pending = Action::None; + if (action == Action::None) return; + s->error = nullptr; + const auto transition = editor::handle(action, s->action_value); + if (transition != editor::Transition::Unhandled) + { + if (transition == editor::Transition::Render) components::render(); + else editor::showStatus(); + return; + } + switch (action) + { + case Action::Back: + if (s->confirm_delete) s->confirm_delete = false; + else if (s->view == View::Detail) s->view = View::Agenda; + else + { + ::ui::page::request_exit(&s->host->navigation); + return; // Host may have destroyed this page. + } + break; + case Action::Today: + load_today(); + s->view = View::Agenda; + break; + case Action::NextPage: + if (s->snapshot.page.has_more && s->snapshot.page.count) + { + const auto& last = s->snapshot.page.rows[s->snapshot.page.count - 1].occurrence; + s->request.after = {last.start, last.event_id, true}; + s->host->source->snapshot(s->request, s->snapshot); + } + break; + case Action::OpenRow: + if (s->selected_row < s->snapshot.page.count) + { + const auto id = s->snapshot.page.rows[s->selected_row].occurrence.event_id; + if (s->host->source->detail(id, s->draft.event) == ::agenda::AgendaResult::Ok) + { + s->detail_occurrence = s->snapshot.page.rows[s->selected_row].occurrence; + s->view = View::Detail; + } + else s->error = "Event is unavailable"; + } + break; + case Action::Navigate: + if (s->view != View::Detail || !(s->draft.event.flags & ::agenda::HasLocation)) break; + if (!s->host->request_target || !s->host->request_target(s->host->map_context, s->draft)) + s->error = "Map is unavailable on this target."; + break; + case Action::Delete: + s->confirm_delete = true; + break; + case Action::CancelDelete: + s->confirm_delete = false; + break; + case Action::ConfirmDelete: + if (s->host->actions->remove(s->draft.event.id).ok) + { + s->pending_sequence = s->host->source->commandResult().sequence; + s->awaiting_command = true; + } + else s->error = "Could not save changes"; + break; + default: + break; + } + components::render(); +} +} // namespace + +void enter(const Host* host, lv_obj_t* parent) +{ + if (!host || !host->source || !host->actions || !parent || state() || !createState()) return; + auto& s = *state(); + s.host = host; + input::begin(); + // Both geometries bound the snapshot to the actual visible event budget. + s.request.visible_rows = ::ui::page_profile::current().variant == + ::ui::page_profile::LayoutVariant::EncoderCompact + ? 5 + : 4; + load_today(); + components::create(parent); + components::render(); + s.timer = lv_timer_create(tick, 50, nullptr); +} +void exit() +{ + auto* s = state(); + if (!s) return; + if (s->timer) lv_timer_delete(s->timer); + s->timer = nullptr; + input::end(); + components::destroy(); + destroyState(); +} +void request(Action action, uint8_t row) +{ + auto* s = state(); + if (!s || s->awaiting_command || s->pending != Action::None) return; + s->pending = action; + s->action_value = row; + if (action == Action::OpenRow) s->selected_row = row; +} +void refresh() +{ + auto* s = state(); + if (!s || s->view != View::Agenda || s->awaiting_command) return; + s->host->source->snapshot(s->request, s->snapshot); + components::render(); +} +} // namespace ui::agenda::page::runtime diff --git a/modules/ui_shared/src/ui/screens/agenda/agenda_page_shell.cpp b/modules/ui_shared/src/ui/screens/agenda/agenda_page_shell.cpp new file mode 100644 index 00000000..2cfc7a9b --- /dev/null +++ b/modules/ui_shared/src/ui/screens/agenda/agenda_page_shell.cpp @@ -0,0 +1,8 @@ +#include "ui/screens/agenda/agenda_page_shell.h" +#include "ui/screens/agenda/agenda_page_runtime.h" + +namespace ui::agenda::page +{ +void enter(void* user_data, lv_obj_t* parent) { runtime::enter(static_cast(user_data), parent); } +void exit(void*, lv_obj_t*) { runtime::exit(); } +} // namespace ui::agenda::page diff --git a/modules/ui_shared/src/ui/screens/agenda/agenda_reminder_popup.cpp b/modules/ui_shared/src/ui/screens/agenda/agenda_reminder_popup.cpp new file mode 100644 index 00000000..f8a48a94 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/agenda/agenda_reminder_popup.cpp @@ -0,0 +1,335 @@ +#include "ui/screens/agenda/agenda_reminder_popup.h" + +#include "ui/app_runtime.h" +#include "ui/assets/fonts/font_utils.h" +#include "ui/localization.h" +#include "ui/page/page_profile.h" +#include "ui/ui_theme.h" + +#include +#include +#include + +namespace ui::agenda::reminder_popup +{ +namespace +{ +enum class Action : uint8_t +{ + None, + Snooze, + Done, + Navigate +}; +struct State +{ + Host host; + ::agenda::EventRecord event; + lv_obj_t* root = nullptr; + lv_obj_t* status = nullptr; + lv_obj_t* buttons[3]{}; + lv_group_t* group = nullptr; + lv_group_t* previous_group = nullptr; + uint32_t revision = 0; + uint32_t sequence = 0; + uint32_t opened_at = 0; + Action pending = Action::None; + Action submitted = Action::None; + bool previous_editing = false; + bool navigation_prepared = false; +}; +static_assert(sizeof(State) < 512, "Reminder popup must retain only one bounded event"); +State* current = nullptr; + +bool group_exists(lv_group_t* group) +{ + if (!group) return false; + for (uint32_t i = 0; i < lv_group_get_count(); ++i) + if (lv_group_by_index(i) == group) return true; + return false; +} +void stop(lv_event_t* event) +{ + lv_event_stop_bubbling(event); + lv_event_stop_processing(event); + auto* input = lv_event_get_code(event) == LV_EVENT_KEY ? lv_indev_active() : lv_event_get_indev(event); + if (input) lv_indev_stop_processing(input); +} +void request(Action action) +{ + // Ignore the release/long-press that was in flight when this modal opened. + if (!current || current->submitted != Action::None || current->pending != Action::None || + lv_tick_elaps(current->opened_at) < 400) return; + current->pending = action; +} +void clicked(lv_event_t* event) +{ + request(static_cast(reinterpret_cast(lv_event_get_user_data(event)))); + stop(event); +} +void key(lv_event_t* event) +{ + if (!current) return; + switch (lv_event_get_key(event)) + { + case LV_KEY_ESC: + case LV_KEY_BACKSPACE: + request(Action::Done); + break; + case LV_KEY_UP: + case LV_KEY_LEFT: + case LV_KEY_PREV: + lv_group_focus_prev(current->group); + break; + case LV_KEY_DOWN: + case LV_KEY_RIGHT: + case LV_KEY_NEXT: + lv_group_focus_next(current->group); + break; + default: + return; // LVGL's button handles confirmation and CLICKED. + } + stop(event); +} +void deleted(lv_event_t*) +{ + if (current) current->root = nullptr; +} +lv_obj_t* label(lv_obj_t* parent, const char* text, int y, int width, bool translated, bool small = false) +{ + auto* object = lv_label_create(parent); + const char* value = translated ? ::ui::i18n::tr(text) : text; + lv_label_set_text(object, value); + lv_label_set_long_mode(object, LV_LABEL_LONG_DOT); + lv_obj_set_pos(object, 10, y); + lv_obj_set_width(object, width - 20); + lv_obj_set_style_text_color(object, small ? ::ui::theme::text_muted() : ::ui::theme::text(), 0); + const auto* font = small ? ::ui::page_profile::resolve_caption_font() : ::ui::page_profile::resolve_body_font(); + if (translated) ::ui::fonts::apply_localized_font(object, value, font); + else ::ui::fonts::apply_content_font(object, value, font); + return object; +} +void set_pending(bool pending) +{ + for (auto* button : current->buttons) + { + if (!button) continue; + if (pending) lv_obj_add_state(button, LV_STATE_DISABLED); + else lv_obj_remove_state(button, LV_STATE_DISABLED); + } +} +void status(const char* key) +{ + ::ui::i18n::set_label_text(current->status, key); +} +void button(lv_obj_t* panel, unsigned index, unsigned count, const char* text, Action action, int width, int height) +{ + const int button_width = (width - 20 - (count - 1) * 6) / count; + // Three actions on the compact layout need room for a two-line caption. + // Keep the footer bottom-aligned rather than shrinking the profile font. + const int button_height = count == 3 && width < 400 ? 38 : 28; + auto* object = lv_button_create(panel); + current->buttons[index] = object; + lv_obj_set_pos(object, 10 + index * (button_width + 6), height - button_height - 10); + lv_obj_set_size(object, button_width, button_height); + lv_obj_set_style_radius(object, 4, 0); + lv_obj_set_style_pad_all(object, 2, 0); + lv_obj_set_style_bg_color(object, ::ui::theme::surface(), 0); + lv_obj_set_style_bg_color(object, ::ui::theme::accent(), LV_STATE_FOCUSED); + lv_obj_set_style_border_color(object, ::ui::theme::border(), 0); + lv_obj_set_style_border_width(object, 1, 0); + lv_obj_set_style_shadow_width(object, 0, 0); + lv_obj_set_style_outline_width(object, 0, LV_STATE_FOCUS_KEY); + const char* value = ::ui::i18n::tr(text); + auto* caption = lv_label_create(object); + lv_label_set_text(caption, value); + lv_label_set_long_mode(caption, LV_LABEL_LONG_DOT); + lv_obj_set_width(caption, LV_PCT(100)); + lv_obj_set_style_text_color(caption, ::ui::theme::text(), 0); + lv_obj_set_style_text_align(caption, LV_TEXT_ALIGN_CENTER, 0); + ::ui::fonts::apply_localized_font(caption, value, ::ui::page_profile::resolve_caption_font()); + lv_obj_center(caption); + lv_obj_add_event_cb(object, clicked, LV_EVENT_CLICKED, reinterpret_cast(static_cast(action))); + lv_obj_add_event_cb(object, key, LV_EVENT_KEY, nullptr); + lv_group_add_obj(current->group, object); +} +bool open(const Host& host, const ReminderSnapshot& snapshot) +{ + auto* state = new (std::nothrow) State; + if (!state) return false; + if (host.source->detail(snapshot.reminder.event_id, state->event) != ::agenda::AgendaResult::Ok) + { + delete state; + return false; + } + if (host.wake) host.wake(); + if (!canPresent()) + { + delete state; + return false; + } + current = state; + state->host = host; + state->revision = snapshot.revision; + state->opened_at = lv_tick_get(); + state->previous_group = lv_group_get_default(); + state->previous_editing = state->previous_group && lv_group_get_editing(state->previous_group); + state->group = lv_group_create(); + set_default_group(state->group); + ui_set_overlay_active(true); + for (auto* input = lv_indev_get_next(nullptr); input; input = lv_indev_get_next(input)) lv_indev_wait_release(input); + + state->root = lv_obj_create(lv_layer_top()); + lv_obj_remove_style_all(state->root); + lv_obj_set_size(state->root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(state->root, ::ui::theme::text(), 0); + lv_obj_set_style_bg_opa(state->root, LV_OPA_40, 0); + lv_obj_remove_flag(state->root, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(state->root, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(state->root, deleted, LV_EVENT_DELETE, nullptr); + const int width = std::min(464, static_cast(lv_display_get_horizontal_resolution(nullptr)) - 16); + const int height = std::min(width < 400 ? 224 : 198, static_cast(lv_display_get_vertical_resolution(nullptr)) - 16); + auto* panel = lv_obj_create(state->root); + lv_obj_set_size(panel, width, height); + lv_obj_set_style_bg_color(panel, ::ui::theme::page_bg(), 0); + lv_obj_set_style_border_color(panel, ::ui::theme::accent(), 0); + lv_obj_set_style_border_width(panel, 1, 0); + lv_obj_set_style_pad_all(panel, 0, 0); + lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_center(panel); + label(panel, "Event Reminder", 8, width, true, true); + label(panel, state->event.title, 29, width, false); + ::agenda::CivilTime date; + ::agenda::fromCalendarSeconds(snapshot.reminder.occurrence_start, date); + char time[28]; + std::snprintf(time, sizeof(time), "%04u-%02u-%02u %02u:%02u", date.year, date.month, date.day, date.hour, date.minute); + label(panel, time, 52, width, false); + const bool location = (state->event.flags & ::agenda::HasLocation) != 0; + if (location) label(panel, state->event.location_name, 75, width, false); + if (state->event.flags & ::agenda::HasNote) + { + auto* note = label(panel, state->event.note, location ? 98 : 75, width, false, true); + lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP); + lv_obj_set_height(note, location ? 34 : 55); + } + state->status = label(panel, "", 136, width, true, true); + const bool navigate = location && host.prepare_navigation && host.finish_navigation; + const unsigned count = navigate ? 3 : 2; + unsigned index = 0; + if (navigate) button(panel, index++, count, "Navigate", Action::Navigate, width, height); + button(panel, index++, count, "Snooze 10 min", Action::Snooze, width, height); + button(panel, index, count, "Done", Action::Done, width, height); + lv_group_focus_obj(state->buttons[0]); + return true; +} +} // namespace + +bool visible() { return current && current->root; } +bool canPresent() +{ + return lv_display_get_default() && !ui_is_interruption_app_active() && !ui_is_transition_pending() && + (!ui_is_overlay_active() || visible()); +} +void close() +{ + if (!current) return; + auto* state = current; + if (state->navigation_prepared && state->host.finish_navigation) + state->host.finish_navigation(state->host.navigation_context, false); + const bool owns_input = lv_group_get_default() == state->group; + if (state->root) lv_obj_delete(state->root); + if (owns_input) + { + const bool restore = group_exists(state->previous_group); + set_default_group(restore ? state->previous_group : nullptr); + if (restore) lv_group_set_editing(state->previous_group, state->previous_editing); + // A higher-priority interruption may already own the global marker. + if (!ui_is_interruption_app_active()) ui_set_overlay_active(false); + } + lv_group_delete(state->group); + current = nullptr; + delete state; +} +void tick(const Host& host, bool may_present) +{ + if (!host.source || !host.actions || !host.reminders) + { + close(); + return; + } + if (current && (!canPresent() || lv_group_get_default() != current->group || !current->root)) + { + close(); // Presentation is suspended; do not consume the reminder. + return; + } + const auto snapshot = host.reminders->reminderSnapshot(); + if (current) + { + if (current->submitted != Action::None) + { + const auto result = host.source->commandResult(); + if (result.sequence == current->sequence && result.state != CommandState::Pending) + { + if (result.state == CommandState::Succeeded) + { + const auto action = current->submitted; + const auto navigation = current->host.finish_navigation; + auto* context = current->host.navigation_context; + current->navigation_prepared = false; + close(); + if (action == Action::Navigate && navigation) navigation(context, true); + return; + } + current->submitted = Action::None; + if (current->navigation_prepared) + { + current->host.finish_navigation(current->host.navigation_context, false); + current->navigation_prepared = false; + } + set_pending(false); + status(result.result == ::agenda::AgendaResult::Full ? "Another reminder is already snoozed" : "Could not update reminder"); + } + } + if (!snapshot.reminder.valid || snapshot.revision != current->revision) + { + close(); // Never apply a stale popup action to a replacement. + return; + } + if (current->pending != Action::None) + { + const auto action = current->pending; + current->pending = Action::None; + if (action == Action::Navigate) + { + const auto& navigation = current->host; + const char* error = navigation.prepare_navigation(navigation.navigation_context, + current->event.latitude_e7, current->event.longitude_e7); + if (error) + { + status(error); + return; // Do not consume a reminder when navigation cannot be prepared. + } + current->navigation_prepared = true; + } + const auto result = action == Action::Snooze ? host.actions->snoozeReminder(current->revision) + : host.actions->dismissReminder(current->revision); + if (!result.ok) + { + if (current->navigation_prepared) + { + current->host.finish_navigation(current->host.navigation_context, false); + current->navigation_prepared = false; + } + status("Could not update reminder"); + return; + } + current->sequence = host.source->commandResult().sequence; + current->submitted = action; + set_pending(true); + status("Saving..."); + } + return; + } + if (may_present && canPresent() && snapshot.reminder.valid) open(host, snapshot); +} +} // namespace ui::agenda::reminder_popup diff --git a/modules/ui_shared/src/ui/screens/agenda/agenda_state.cpp b/modules/ui_shared/src/ui/screens/agenda/agenda_state.cpp new file mode 100644 index 00000000..d9ccaf91 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/agenda/agenda_state.cpp @@ -0,0 +1,27 @@ +#include "ui/screens/agenda/agenda_state.h" +#include + +namespace ui::agenda::page +{ +namespace +{ +State* current = nullptr; +} +State* state() { return current; } +bool canPresentReminder() +{ + return !current || (!current->awaiting_command && current->pending == Action::None && + !current->confirm_delete && !current->confirm_discard); +} +bool createState() +{ + if (current) return false; + current = new (std::nothrow) State; + return current != nullptr; +} +void destroyState() +{ + delete current; + current = nullptr; +} +} // namespace ui::agenda::page diff --git a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp index 476be1db..81e0b713 100644 --- a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp @@ -30,9 +30,12 @@ using Projection = gps::ui::shell::Projection; #include "ui/widgets/route_image_strip.h" #include "ui/widgets/top_bar.h" #include "ui_gps_runtime/gps_page_runtime_pump.h" +#include "ui_map_runtime/map_geo_coordinates.h" #include "ui_map_runtime/map_overlay_snapshot_source.h" #include "ui_presentation/gps/gps_status_model.h" +#include "ui_presentation/map/map_location_request.h" #include "ui_presentation/map/map_overlay_snapshot.h" +#include "ui_presentation/map/map_target_request.h" #include "ui_presentation/map/map_workspace_model.h" #include @@ -152,9 +155,15 @@ enum class MapControlAction : uint8_t Help, Route, TeamMember, + PickLocation, + CancelLocation, }; const Host* s_host = nullptr; +::ui::map::MapLocationRequest* s_location_request = nullptr; +::ui::map::MapTargetRequest* s_target_request = nullptr; +lv_obj_t* s_map_pick_btn = nullptr; +lv_obj_t* s_map_cancel_btn = nullptr; lv_obj_t* s_root = nullptr; lv_timer_t* s_timer = nullptr; ::ui::widgets::TopBar s_top_bar; @@ -315,8 +324,17 @@ lv_obj_t* create_map_control_button(lv_obj_t* parent, void sync_map_route_image_strip(); void refresh_route_image_storage_state(bool include_cache_state); +::ui::map::MapWorkspaceModel& map_workspace_model(); + void request_exit() { + if (s_location_request) + { + auto& model = map_workspace_model(); + if (model.locationSelection().state == ::ui::map::MapLocationSelectionState::Selecting && + !model.cancelLocationSelection().ok) return; + s_location_request->result = model.locationSelection(); + } if (s_host) { ::ui::page::request_exit(s_host); @@ -408,7 +426,8 @@ void sync_workspace_viewport_from_renderer() bool sync_workspace_center_from_screen() { - if (app::configFacade().readConfig().map_coord_system != 0) + const auto coordinate_system = app::configFacade().readConfig().map_coord_system; + if (coordinate_system != 0 && !s_location_request && !s_target_request) { return false; } @@ -419,13 +438,15 @@ bool sync_workspace_center_from_screen() return false; } + if ((s_location_request || s_target_request) && !::ui::map_geo::inverse(center.lat, center.lon, coordinate_system, center.lat, center.lon)) + return false; + auto& model = map_workspace_model(); auto viewport = model.viewport(); viewport.center_lat = center.lat; viewport.center_lon = center.lon; viewport.zoom = current_map_zoom(); - (void)model.setViewport(viewport); - return true; + return model.setViewport(viewport).ok; } bool commit_pending_map_pan_from_screen() @@ -452,7 +473,9 @@ bool commit_pending_map_pan_from_screen() const auto& config = app::configFacade().readConfig(); ::ui::widgets::map::Model model{}; - const bool has_viewport_center = has_valid_viewport_center(snapshot.viewport); + const bool has_viewport_center = has_valid_viewport_center(snapshot.viewport) || + ((s_location_request || s_target_request) && snapshot.viewport.zoom != 0 && + ::ui::map_geo::valid(snapshot.viewport.center_lat, snapshot.viewport.center_lon)); model.focus_point.valid = true; model.focus_point.lat = has_viewport_center ? snapshot.viewport.center_lat @@ -524,6 +547,11 @@ bool format_current_gps_map_title(char* out, size_t out_len) void update_map_top_bar_title() { + if (s_location_request) + { + ::ui::widgets::top_bar_set_title(s_top_bar, ::ui::i18n::tr("Choose on map")); + return; + } if (s_projection != Projection::Map) { ::ui::widgets::top_bar_set_title(s_top_bar, ::ui::i18n::tr("GPS")); @@ -579,6 +607,7 @@ void set_button_label(lv_obj_t* btn, const char* text) void clear_map_controls() { + s_map_pick_btn = s_map_cancel_btn = nullptr; s_map_viewport = nullptr; s_map_control_bar = nullptr; s_map_zoom_label = nullptr; @@ -1689,6 +1718,7 @@ void sync_map_notice_overlay() void sync_map_context_buttons(const ::ui::map::MapWorkspaceSnapshot& snapshot) { + if (s_location_request) return; if (!s_map_info_visible) { set_hidden(s_map_route_btn, true); @@ -1768,6 +1798,14 @@ void sync_map_control_labels(const ::ui::map::MapWorkspaceSnapshot& snapshot) return; } + if (s_location_request) + { + char zoom_buf[8]{}; + std::snprintf(zoom_buf, sizeof(zoom_buf), "Z%d", static_cast(current_map_zoom())); + set_compact_label(s_map_zoom_label, zoom_buf); + return; + } + if (!s_map_info_visible) { set_hidden(s_map_control_bar, true); @@ -3174,17 +3212,21 @@ void refresh_view() ui_update_top_bar_battery(s_top_bar); update_map_top_bar_title(); - update_route_deviation_state(); + if (!s_location_request) update_route_deviation_state(); sync_workspace_layers_from_renderer(); auto snapshot = map_workspace_model().snapshot(); (void)map_overlay_source().buildMapOverlaySnapshot(*s_overlay_snapshot); - append_route_image_overlay(*s_overlay_snapshot); - append_track_overlay(*s_overlay_snapshot); + if (!s_location_request) + { + append_route_image_overlay(*s_overlay_snapshot); + append_track_overlay(*s_overlay_snapshot); + } if (!s_map_info_visible) { keep_only_current_position_overlay(*s_overlay_snapshot); } + if (s_target_request) s_target_request->appendOverlay(*s_overlay_snapshot); if (snapshot.header.valid) { @@ -3910,11 +3952,52 @@ void on_map_control_clicked(lv_event_t* e) break; case MapControlAction::TeamMember: break; + case MapControlAction::PickLocation: + consume_key_event(e); + // Commit the rendered crosshair centre (including pending pan) before + // asking the shared workspace model to produce a result. + if (s_location_request && sync_workspace_center_from_screen() && map_workspace_model().pickLocation().ok) + request_exit(); + else show_toast(::ui::i18n::tr("Invalid location"), 2000); + break; + case MapControlAction::CancelLocation: + consume_key_event(e); + request_exit(); + break; } } bool handle_map_key(uint32_t key, lv_event_t* e) { + // Selection exposes only pan/zoom/current-position, not unrelated route, + // track or layer dialogs which could replace the temporary selection. + if (s_location_request) + { + switch (key) + { + case 'a': + case 'A': + case LV_KEY_LEFT: + case 'd': + case 'D': + case LV_KEY_RIGHT: + case 'w': + case 'W': + case LV_KEY_UP: + case 's': + case 'S': + case LV_KEY_DOWN: + case 'q': + case 'Q': + case 'e': + case 'E': + case 'c': + case 'C': + break; + default: + return false; + } + } if (::ui::widgets::route_image_strip::handle_key(s_route_image_strip, key)) { consume_key_event(e); @@ -4075,6 +4158,8 @@ void add_map_controls_to_group(lv_group_t* group) { return; } + if (s_map_pick_btn) lv_group_add_obj(group, s_map_pick_btn); + if (s_map_cancel_btn) lv_group_add_obj(group, s_map_cancel_btn); if (s_map_zoom_out_btn) lv_group_add_obj(group, s_map_zoom_out_btn); if (s_map_zoom_in_btn) lv_group_add_obj(group, s_map_zoom_in_btn); if (s_map_center_btn) lv_group_add_obj(group, s_map_center_btn); @@ -4138,6 +4223,13 @@ void create_map_control_bar(lv_obj_t* viewport) kMapControlButtonMediumWidth, "Pos", MapControlAction::Center); + if (s_location_request) + { + s_map_pick_btn = create_map_control_button(s_map_control_bar, 66, ::ui::i18n::tr("Pick"), MapControlAction::PickLocation); + s_map_cancel_btn = create_map_control_button(s_map_control_bar, 66, ::ui::i18n::tr("Cancel"), MapControlAction::CancelLocation); + lv_obj_move_foreground(s_map_control_bar); + return; + } s_map_layer_btn = create_map_control_button( s_map_control_bar, kMapControlButtonMediumWidth, @@ -4349,12 +4441,36 @@ void create_map_content(lv_obj_t* content) bind_map_key_handler(map_widgets.root); } create_map_control_bar(viewport); + if (s_location_request) + { + // The crosshair is a viewport sibling, not part of the panned tile or + // annotation layer. It never owns or requests map tiles. + auto* crosshair = lv_obj_create(viewport); + lv_obj_remove_style_all(crosshair); + lv_obj_set_size(crosshair, 22, 22); + lv_obj_center(crosshair); + lv_obj_remove_flag(crosshair, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(crosshair, LV_OBJ_FLAG_SCROLLABLE); + for (unsigned axis = 0; axis < 2; ++axis) + { + auto* line = lv_obj_create(crosshair); + lv_obj_remove_style_all(line); + lv_obj_set_size(line, axis ? 2 : 22, axis ? 22 : 2); + lv_obj_set_style_bg_color(line, lv_color_hex(0x6B4A1E), 0); + lv_obj_set_style_bg_opa(line, LV_OPA_COVER, 0); + lv_obj_center(line); + lv_obj_remove_flag(line, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(line, LV_OBJ_FLAG_SCROLLABLE); + } + refresh_view(); + return; + } create_map_altitude_overlay(viewport); create_route_elevation_profile_overlay(viewport); create_map_notice_overlay(viewport); create_map_context_rail(viewport); - if (route_context_available()) + if (!s_target_request && route_context_available()) { (void)load_configured_route_overlay(false); } @@ -4366,6 +4482,11 @@ void create_map_content(lv_obj_t* content) namespace gps::ui::runtime { +void set_marker_binding(const ::ui::map::MapMarkerBinding* binding) +{ + ::ui::widgets::map::set_marker_binding(s_map_runtime, binding); +} + bool is_available() { return platform::ui::device::gps_supported(); @@ -4390,12 +4511,21 @@ bool load_map_track_file(const char* path, bool show_fail_toast) return load_map_track_file_impl(path, show_fail_toast); } -void enter(const shell::Host* host, lv_obj_t* parent, shell::Projection projection) +void enter(const shell::Host* host, lv_obj_t* parent, shell::Projection projection, + ::ui::map::MapLocationRequest* location, ::ui::map::MapTargetRequest* target) { + if (location) location->result = {0.0, 0.0, ::ui::map::MapLocationSelectionState::Cancelled}; + if (target) target->entered = false; + if (target && (location || projection != Projection::Map || !target->valid())) return; if (projection == Projection::Map && !ensure_overlay_snapshot()) { return; } + if (target && !target->focus(map_workspace_model(), gps_ui::kDefaultZoom).ok) + { + release_overlay_snapshot(); + return; + } if (!s_gps_power_lease_active) { @@ -4403,6 +4533,8 @@ void enter(const shell::Host* host, lv_obj_t* parent, shell::Projection projecti s_gps_power_lease_active = true; } s_host = host; + s_location_request = projection == Projection::Map ? location : nullptr; + s_target_request = target; s_projection = projection; clear_gps_status_labels(); clear_map_controls(); @@ -4419,7 +4551,31 @@ void enter(const shell::Host* host, lv_obj_t* parent, shell::Projection projecti } if (s_projection == Projection::Map) { - sync_workspace_viewport_from_renderer(); + if (s_target_request) + { + s_map_zoom = map_workspace_model().viewport().zoom; + s_map_pan_x = s_map_pan_y = 0; + } + else + { + sync_workspace_viewport_from_renderer(); + } + if (s_location_request) + { + auto& model = map_workspace_model(); + if (location->has_initial_viewport) + { + auto initial = location->initial_viewport; + if (::ui::map_geo::valid(initial.center_lat, initial.center_lon)) + { + if (initial.zoom == 0) initial.zoom = gps_ui::kDefaultZoom; + (void)model.setViewport(initial); + s_map_zoom = initial.zoom; + s_map_pan_x = s_map_pan_y = 0; + } + } + if (model.beginLocationSelection().ok) location->result = model.locationSelection(); + } } lv_group_t* prev_group = lv_group_get_default(); @@ -4441,7 +4597,7 @@ void enter(const shell::Host* host, lv_obj_t* parent, shell::Projection projecti ::ui::widgets::top_bar_init(s_top_bar, s_root, top_bar_config); ::ui::widgets::top_bar_set_title( s_top_bar, - ::ui::i18n::tr(s_projection == Projection::GpsStatus ? "GPS" : "Map")); + ::ui::i18n::tr(s_location_request ? "Choose on map" : (s_projection == Projection::GpsStatus ? "GPS" : "Map"))); ::ui::widgets::top_bar_set_back_callback(s_top_bar, on_back, nullptr); if (s_top_bar.back_btn) { @@ -4482,9 +4638,11 @@ void enter(const shell::Host* host, lv_obj_t* parent, shell::Projection projecti if (app_g) { add_map_controls_to_group(app_g); + if (s_map_pick_btn) lv_group_focus_obj(s_map_pick_btn); } } gps_runtime_pump().setActive(true); + if (s_target_request) s_target_request->entered = true; if (!s_timer) { s_timer = lv_timer_create(refresh_timer_cb, 750, nullptr); @@ -4494,6 +4652,17 @@ void enter(const shell::Host* host, lv_obj_t* parent, shell::Projection projecti void exit(lv_obj_t* parent) { (void)parent; + if (s_target_request) s_target_request->entered = false; + s_target_request = nullptr; + + if (s_location_request) + { + auto& model = map_workspace_model(); + if (model.locationSelection().state == ::ui::map::MapLocationSelectionState::Selecting) + (void)model.cancelLocationSelection(); + s_location_request->result = model.locationSelection(); + s_location_request = nullptr; + } if (s_timer) { diff --git a/modules/ui_shared/src/ui/screens/gps/gps_page_shell.cpp b/modules/ui_shared/src/ui/screens/gps/gps_page_shell.cpp index c1f8c258..3fef04ff 100644 --- a/modules/ui_shared/src/ui/screens/gps/gps_page_shell.cpp +++ b/modules/ui_shared/src/ui/screens/gps/gps_page_shell.cpp @@ -37,7 +37,8 @@ void enter_route(const RouteSpec* spec, lv_obj_t* parent) return; } - runtime::enter(route.host, parent, route.projection); + runtime::enter(route.host, parent, route.projection, route.location, route.target); + runtime::set_marker_binding(route.projection == Projection::Map ? route.markers : nullptr); } void exit_route(const RouteSpec* spec, lv_obj_t* parent) diff --git a/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp b/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp index d6e777b9..4aff0557 100644 --- a/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp +++ b/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp @@ -912,6 +912,46 @@ void ImeWidget::setText(const char* text) refresh_labels(); } +bool ImeWidget::captureEditState(char* text, std::size_t capacity, ImeEditState& out) const +{ + if (!textarea_ || (!text && capacity)) return false; + const char* accepted = lv_textarea_get_text(textarea_); + const auto bytes = std::strlen(accepted); + if ((text && bytes >= capacity) || ime_.buffer().size() >= sizeof(out.composition)) return false; + out = {}; + out.cursor = lv_textarea_get_cursor_pos(textarea_); + out.script_index = script_input_index_; + out.candidate_index = ime_.candidateIndex(); + out.candidate_window = candidate_window_start_; + out.mode = static_cast(mode_); + out.shift = touch_shift_; + if (pinyin_mode()) std::strcpy(out.composition, ime_.buffer().c_str()); + if (text) std::memcpy(text, accepted, bytes + 1); + return true; +} + +bool ImeWidget::restoreEditState(const char* text, const ImeEditState& state) +{ + if (!textarea_ || !text || state.mode > static_cast(Mode::NUM) || + !std::memchr(state.composition, '\0', sizeof(state.composition))) return false; + // text may alias textarea_'s buffer; setMode can replace that buffer. + // Take ownership through setText before changing input mode. + setText(text); + script_input_index_ = state.script_index; + setMode(static_cast(state.mode)); // Validates the available script list. + if (pinyin_mode()) + { + for (const char* letter = state.composition; *letter; ++letter) ime_.appendLetter(*letter); + if (state.candidate_index >= 0) ime_.moveCandidate(state.candidate_index); + candidate_window_start_ = std::max(0, std::min(state.candidate_window, + static_cast(ime_.candidates().size()))); + } + touch_shift_ = state.shift; + refresh_labels(); + lv_textarea_set_cursor_pos(textarea_, static_cast(state.cursor)); + return true; +} + #if UI_SHARED_TOUCH_IME_ENABLED void ImeWidget::refresh_touch_keyboard() { diff --git a/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp b/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp index 362767cb..bbbae195 100644 --- a/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp +++ b/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp @@ -10,8 +10,11 @@ #include "app/app_config_changes.h" #include "app/app_facade_access.h" #include "platform/ui/device_runtime.h" +#include "ui/assets/fonts/font_utils.h" #include "ui/localization.h" #include "ui/widgets/map/map_tiles.h" +#include "ui_map_runtime/map_geo_coordinates.h" +#include "ui_presentation/map/map_marker_cache.h" #if defined(TRAIL_MATE_MAP_POI_AVAILABLE) #include "ui/widgets/map/poi_overlay.h" #include "ui_map_runtime/map_poi/annotation_frame.h" @@ -44,6 +47,18 @@ void* allocate_annotation_memory(std::size_t bytes, void*) void release_annotation_memory(void* memory, void*) { heap_caps_free(memory); } #endif +struct MarkerSession +{ + ui::map::MapMarkerCache cache; + GeoPoint reference; + lv_point_t reference_pixel{}; + int zoom = -1; + uint8_t coord_system = 0; + int width = 0; + int height = 0; + uint32_t retry_at = 0; +}; + struct RuntimeImpl { #if defined(ESP_PLATFORM) @@ -70,6 +85,11 @@ struct RuntimeImpl Widgets widgets{}; Model model{}; ui::map::MapOverlaySnapshot overlay{}; + const ui::map::MapMarkerBinding* marker_binding = nullptr; + MarkerSession* markers = nullptr; + uint32_t marker_retry_at = 0; + int marker_drag_dx = 0; + int marker_drag_dy = 0; #if defined(TRAIL_MATE_MAP_POI_AVAILABLE) PoiOverlay poi_overlay; ui::map_poi::AnnotationFrame annotation_frame{allocate_annotation_memory, release_annotation_memory}; @@ -115,64 +135,8 @@ namespace #endif constexpr double kCoordPi = 3.14159265358979323846; -constexpr double kCoordA = 6378245.0; -constexpr double kCoordEe = 0.00669342162296594323; constexpr int kGestureDragStartPx = 6; -bool coord_out_of_china(double lat, double lon) -{ - return (lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271); -} - -double coord_transform_lat(double x, double y) -{ - double ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + - 0.2 * std::sqrt(std::fabs(x)); - ret += (20.0 * std::sin(6.0 * x * kCoordPi) + 20.0 * std::sin(2.0 * x * kCoordPi)) * 2.0 / 3.0; - ret += (20.0 * std::sin(y * kCoordPi) + 40.0 * std::sin(y / 3.0 * kCoordPi)) * 2.0 / 3.0; - ret += (160.0 * std::sin(y / 12.0 * kCoordPi) + 320 * std::sin(y * kCoordPi / 30.0)) * 2.0 / 3.0; - return ret; -} - -double coord_transform_lon(double x, double y) -{ - double ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + - 0.1 * std::sqrt(std::fabs(x)); - ret += (20.0 * std::sin(6.0 * x * kCoordPi) + 20.0 * std::sin(2.0 * x * kCoordPi)) * 2.0 / 3.0; - ret += (20.0 * std::sin(x * kCoordPi) + 40.0 * std::sin(x / 3.0 * kCoordPi)) * 2.0 / 3.0; - ret += (150.0 * std::sin(x / 12.0 * kCoordPi) + 300.0 * std::sin(x / 30.0 * kCoordPi)) * 2.0 / 3.0; - return ret; -} - -void wgs84_to_gcj02(double lat, double lon, double& out_lat, double& out_lon) -{ - if (coord_out_of_china(lat, lon)) - { - out_lat = lat; - out_lon = lon; - return; - } - - double dlat = coord_transform_lat(lon - 105.0, lat - 35.0); - double dlon = coord_transform_lon(lon - 105.0, lat - 35.0); - double radlat = lat / 180.0 * kCoordPi; - double magic = std::sin(radlat); - magic = 1 - kCoordEe * magic * magic; - double sqrt_magic = std::sqrt(magic); - dlat = (dlat * 180.0) / ((kCoordA * (1 - kCoordEe)) / (magic * sqrt_magic) * kCoordPi); - dlon = (dlon * 180.0) / (kCoordA / sqrt_magic * std::cos(radlat) * kCoordPi); - out_lat = lat + dlat; - out_lon = lon + dlon; -} - -void gcj02_to_bd09(double lat, double lon, double& out_lat, double& out_lon) -{ - double z = std::sqrt(lon * lon + lat * lat) + 0.00002 * std::sin(lat * kCoordPi); - double theta = std::atan2(lat, lon) + 0.000003 * std::cos(lon * kCoordPi); - out_lon = z * std::cos(theta) + 0.0065; - out_lat = z * std::sin(theta) + 0.006; -} - void make_plain(lv_obj_t* obj) { if (!obj) @@ -625,6 +589,124 @@ bool screen_center_from_model_pan(const RuntimeImpl& impl, double& lat, double& return true; } +void collect_marker(const ui::map::MapMarker& marker, void* context) +{ + auto& impl = *static_cast(context); + auto& session = *impl.markers; + lv_point_t point{}; + if (!project_geo_point(impl, {true, marker.latitude_e7 / 1e7, marker.longitude_e7 / 1e7}, point)) return; + constexpr int margin = 64; + if (point.x < -margin || point.y < -margin || point.x > session.width + margin || point.y > session.height + margin) return; + const int64_t dx = point.x - session.width / 2; + const int64_t dy = point.y - session.height / 2; + uint64_t distance = static_cast(dx * dx + dy * dy); + // Visible markers always precede candidates kept only for the pan margin. + if (point.x < 0 || point.y < 0 || point.x > session.width || point.y > session.height) distance += uint64_t{1} << 48; + session.cache.offer(marker, distance); +} + +void refresh_markers(RuntimeImpl& impl) +{ + const auto* binding = impl.marker_binding; + if (!binding || !binding->source || !binding->allocate || !binding->release || !is_runtime_alive(impl)) return; + const auto status = binding->source->markerStatus(); + if (!status.ready) + { + if (impl.markers) + { + impl.markers->~MarkerSession(); + binding->release(impl.markers); + impl.markers = nullptr; + lv_obj_invalidate(impl.widgets.overlay_layer); + } + return; + } + const auto now = lv_tick_get(); + if (!impl.markers) + { + if (impl.marker_retry_at && static_cast(now - impl.marker_retry_at) < 0) return; + void* memory = binding->allocate(sizeof(MarkerSession)); + if (!memory) + { + impl.marker_retry_at = now + 3000; + return; + } + impl.markers = new (memory) MarkerSession{}; + impl.marker_retry_at = 0; + } + auto& session = *impl.markers; + if (session.retry_at && static_cast(now - session.retry_at) < 0 && + session.cache.seen.revision == status.revision) return; + const int width = lv_obj_get_width(impl.widgets.root); + const int height = lv_obj_get_height(impl.widgets.root); + if (!impl.anchor.valid || width <= 0 || height <= 0) return; + lv_point_t reference{}; + const int margin = session.cache.truncated ? 0 : 64; + const bool moved = session.zoom != impl.model.zoom || session.coord_system != impl.model.coord_system || + session.width != width || session.height != height || + !project_geo_point(impl, session.reference, reference) || + std::abs(reference.x - session.reference_pixel.x) > margin || + std::abs(reference.y - session.reference_pixel.y) > margin; + if (!moved && !session.cache.stale(status)) return; + session.cache.reset(); + session.width = width; + session.height = height; + session.zoom = impl.model.zoom; + session.coord_system = impl.model.coord_system; + session.reference = impl.model.focus_point; + project_geo_point(impl, session.reference, session.reference_pixel); + if (!binding->source->visitMarkers(collect_marker, &impl, session.cache.scratch, sizeof(session.cache.scratch))) + { + session.cache.reset(); // Never display a partially read card/session. + session.cache.seen = status; + session.retry_at = now + 3000; + } + else + { + session.cache.commit(status); + session.retry_at = 0; + } + lv_obj_invalidate(impl.widgets.overlay_layer); +} + +void draw_markers(lv_event_t* event) +{ + const auto& impl = *static_cast(lv_event_get_user_data(event)); + if (!impl.markers || !impl.markers->cache.valid || !impl.marker_binding) return; + const auto status = impl.marker_binding->source->markerStatus(); + if (!status.ready || status.revision != impl.markers->cache.seen.revision) return; + auto* layer = lv_event_get_layer(event); + lv_area_t origin{}; + lv_obj_get_coords(impl.widgets.overlay_layer, &origin); + const auto* font = lv_obj_get_style_text_font(impl.widgets.overlay_layer, LV_PART_MAIN); + for (std::size_t i = 0; i < impl.markers->cache.count; ++i) + { + const auto& marker = impl.markers->cache.entries[i].marker; + lv_point_t point{}; + if (!project_geo_point(impl, {true, marker.latitude_e7 / 1e7, marker.longitude_e7 / 1e7}, point)) continue; + point.x += impl.marker_drag_dx; + point.y += impl.marker_drag_dy; + if (point.x < -8 || point.y < -8 || point.x >= lv_area_get_width(&origin) || point.y >= lv_area_get_height(&origin)) continue; + const auto color = lv_color_hex(status.clock_valid && status.now <= marker.active_until ? 0xDC2626 : 0x808080); + lv_draw_rect_dsc_t dot; + lv_draw_rect_dsc_init(&dot); + dot.bg_color = color; + dot.bg_opa = LV_OPA_COVER; + dot.radius = LV_RADIUS_CIRCLE; + lv_area_t area{origin.x1 + point.x - 3, origin.y1 + point.y - 3, + origin.x1 + point.x + 3, origin.y1 + point.y + 3}; + lv_draw_rect(layer, &dot, &area); + lv_draw_label_dsc_t label; + lv_draw_label_dsc_init(&label); + label.font = ui::fonts::content_font(marker.title, font); + label.text = marker.title; + label.text_local = 1; + label.color = color; + area = {origin.x1 + point.x + 6, origin.y1 + point.y - 8, origin.x2, origin.y1 + point.y - 8 + label.font->line_height}; + if (area.x1 <= area.x2) lv_draw_label(layer, &label, &area); + } +} + void render_overlay(RuntimeImpl& impl) { if (!is_runtime_alive(impl) || @@ -635,6 +717,9 @@ void render_overlay(RuntimeImpl& impl) } clear_overlay_layer(impl); + impl.marker_drag_dx = 0; + impl.marker_drag_dy = 0; + refresh_markers(impl); if (!impl.overlay.header.valid) { return; @@ -751,6 +836,7 @@ void loader_timer_cb(lv_timer_t* timer) tile_loader_maintenance(impl->tile_ctx); if (impl->gesture_pressed || impl->gesture_dragging || impl->drag_preview_active) return; + refresh_markers(*impl); const uint32_t now_ms = lv_tick_get(); if (impl->last_loader_active_log_ms == 0 || @@ -941,6 +1027,11 @@ void destroy(Runtime& runtime) lv_obj_del(impl->widgets.root); } + if (impl->markers) + { + impl->markers->~MarkerSession(); + impl->marker_binding->release(impl->markers); + } delete impl; runtime.impl_ = nullptr; MAP_VIEWPORT_LOG("destroy end\n"); @@ -1033,12 +1124,36 @@ void apply_model_lightweight(Runtime& runtime, const Model& model) static_cast(impl->model.coord_system)); translate_loaded_tiles(*impl, dx, dy); translate_children(impl->widgets.overlay_layer, dx, dy); + impl->marker_drag_dx += dx; + impl->marker_drag_dy += dy; + if (impl->markers) lv_obj_invalidate(impl->widgets.overlay_layer); #if defined(TRAIL_MATE_MAP_POI_AVAILABLE) impl->poi_overlay.translate(dx, dy); #endif impl->drag_preview_active = true; } +void set_marker_binding(Runtime& runtime, const ui::map::MapMarkerBinding* binding) +{ + auto* impl = runtime.impl_; + if (!impl || !is_runtime_alive(*impl) || impl->marker_binding == binding) return; + if (impl->markers) + { + impl->markers->~MarkerSession(); + impl->marker_binding->release(impl->markers); + impl->markers = nullptr; + } + lv_obj_remove_event_cb(impl->widgets.overlay_layer, draw_markers); + impl->marker_binding = binding; + impl->marker_retry_at = 0; + if (binding) + { + lv_obj_add_event_cb(impl->widgets.overlay_layer, draw_markers, LV_EVENT_DRAW_MAIN, impl); + refresh_markers(*impl); + } + lv_obj_invalidate(impl->widgets.overlay_layer); +} + void apply_overlay(Runtime& runtime, const ui::map::MapOverlaySnapshot& overlay) { if (!runtime.impl_) @@ -1248,24 +1363,8 @@ bool transform_geo_point(const GeoPoint& point, uint8_t coord_system, GeoPoint& return false; } - out_point.valid = true; - if (coord_system == 1) - { - wgs84_to_gcj02(point.lat, point.lon, out_point.lat, out_point.lon); - return true; - } - if (coord_system == 2) - { - double gcj_lat = 0.0; - double gcj_lon = 0.0; - wgs84_to_gcj02(point.lat, point.lon, gcj_lat, gcj_lon); - gcj02_to_bd09(gcj_lat, gcj_lon, out_point.lat, out_point.lon); - return true; - } - - out_point.lat = point.lat; - out_point.lon = point.lon; - return true; + out_point.valid = ::ui::map_geo::transform(point.lat, point.lon, coord_system, out_point.lat, out_point.lon); + return out_point.valid; } LayerState current_layer_state() diff --git a/modules/ui_shared/tests/agenda/CMakeLists.txt b/modules/ui_shared/tests/agenda/CMakeLists.txt new file mode 100644 index 00000000..009b40ce --- /dev/null +++ b/modules/ui_shared/tests/agenda/CMakeLists.txt @@ -0,0 +1,141 @@ +cmake_minimum_required(VERSION 3.20) +project(TrailMateAgendaUiTests LANGUAGES C CXX) +include(CTest) +add_executable(agenda_countdown_test test_countdown.cpp) +target_include_directories(agenda_countdown_test PRIVATE ../../../ui_presentation/include) +target_compile_features(agenda_countdown_test PRIVATE cxx_std_17) +if(MSVC) + target_compile_options(agenda_countdown_test PRIVATE /UNDEBUG) +else() + target_compile_options(agenda_countdown_test PRIVATE -UNDEBUG) +endif() +add_test(NAME agenda_countdown COMMAND agenda_countdown_test) +find_package(Python3 REQUIRED COMPONENTS Interpreter) +add_test(NAME agenda_locale_coverage + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_LIST_DIR}/test_locale_coverage.py") +add_test(NAME agenda_font_build_recipe + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_LIST_DIR}/test_font_build_recipe.py") +get_filename_component(REPO_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../.." ABSOLUTE) +set(LVGL_SOURCE_DIR "" CACHE PATH "LVGL 9.4 source directory") +if(NOT EXISTS "${LVGL_SOURCE_DIR}/lvgl.h") + message(FATAL_ERROR "Set LVGL_SOURCE_DIR to the installed LVGL dependency") +endif() +file(GLOB_RECURSE LVGL_SOURCES CONFIGURE_DEPENDS "${LVGL_SOURCE_DIR}/src/*.c") +add_library(agenda_test_lvgl STATIC ${LVGL_SOURCES}) +target_include_directories(agenda_test_lvgl PUBLIC "${LVGL_SOURCE_DIR}") +target_compile_definitions(agenda_test_lvgl PUBLIC + LV_CONF_SKIP=1 LV_USE_STDLIB_MALLOC=1 LV_USE_STDLIB_STRING=1 + LV_USE_STDLIB_SPRINTF=1 LV_FONT_MONTSERRAT_12=1 LV_FONT_MONTSERRAT_16=1) +if(UNIX) + target_link_libraries(agenda_test_lvgl PUBLIC m) +endif() +add_executable(agenda_font_binary_test test_font_binary.cpp) +target_link_libraries(agenda_font_binary_test PRIVATE agenda_test_lvgl) +target_compile_features(agenda_font_binary_test PRIVATE cxx_std_17) +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + target_sources(agenda_font_binary_test PRIVATE font_memory_probe.cpp) + target_compile_definitions(agenda_font_binary_test PRIVATE AGENDA_FONT_MEMORY_PROBE=1) + target_link_options(agenda_font_binary_test PRIVATE + "-Wl,--wrap=lv_malloc_core" "-Wl,--wrap=lv_realloc_core" "-Wl,--wrap=lv_free_core") +endif() +if(MSVC) + target_compile_options(agenda_font_binary_test PRIVATE /UNDEBUG) +else() + target_compile_options(agenda_font_binary_test PRIVATE -UNDEBUG) +endif() +# Font binaries are generated artifacts, not a dependency of ordinary UI tests. +# Opt in with explicit artifacts; never fetch npm packages during CTest. +set(AGENDA_FONT_BINARY "" CACHE FILEPATH "Generated locale font.bin to verify") +set(AGENDA_FONT_CHARSET "" CACHE FILEPATH "Corresponding UTF-8 charset.txt") +if(AGENDA_FONT_BINARY AND AGENDA_FONT_CHARSET) + add_test(NAME agenda_font_binary COMMAND agenda_font_binary_test "${AGENDA_FONT_BINARY}" "${AGENDA_FONT_CHARSET}") +endif() +add_executable(agenda_ui_test + map_preview_stub.cpp + test_agenda_ui.cpp test_support.cpp popup_test_support.cpp test_map_flow.cpp test_font_scope.cpp test_interruption_flow.cpp test_touch_agenda.cpp + "${REPO_ROOT}/apps/esp32_lvgl/tests/agenda/test_target_root.cpp" + "${REPO_ROOT}/apps/esp32_lvgl/src/esp32_lvgl_arduino_agenda.cpp" + "${REPO_ROOT}/apps/esp32_lvgl/src/esp32_lvgl_runtime_config.cpp" + "${REPO_ROOT}/modules/product_composition/src/target_profile.cpp" + "${REPO_ROOT}/modules/product_composition/src/target_ux_binding.cpp" + "${REPO_ROOT}/modules/ui_presentation/src/page/page_manifest.cpp" + "${REPO_ROOT}/platform/esp/common/src/storage/agenda_file_store.cpp" + "${REPO_ROOT}/platform/esp/common/src/storage/waypoint_file_store.cpp" + "${REPO_ROOT}/modules/core_waypoint/src/waypoint.cpp" + "${REPO_ROOT}/modules/core_waypoint/src/record_codec.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/assets/agenda.c" + "${REPO_ROOT}/modules/core_agenda/src/event_codec.cpp" + "${REPO_ROOT}/modules/core_agenda/src/agenda_service.cpp" + "${REPO_ROOT}/modules/core_agenda/src/agenda_query.cpp" + "${REPO_ROOT}/modules/core_agenda/src/recurrence.cpp" + "${REPO_ROOT}/modules/core_agenda/src/reminder_scheduler.cpp" + "${REPO_ROOT}/modules/ui_presentation/src/agenda/agenda_workspace_model.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/screens/agenda/agenda_state.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/screens/agenda/agenda_page_shell.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/screens/agenda/agenda_page_flow.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/screens/agenda/agenda_page_runtime.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/screens/agenda/agenda_page_input.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/screens/agenda/agenda_page_components.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/screens/agenda/agenda_editor_runtime.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/screens/agenda/agenda_editor_components.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/screens/agenda/agenda_reminder_popup.cpp" + "${REPO_ROOT}/modules/ui_lvgl_ux_packs/src/common/touch_text_editor.cpp" + "${REPO_ROOT}/modules/ui_lvgl_ux_packs/src/common/input_layout.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/widgets/ime/pinyin_ime.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/widgets/ime/ime_input_mode_descriptor.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/widgets/top_bar.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/page/page_profile.cpp") +target_include_directories(agenda_ui_test PRIVATE + stubs + "${CMAKE_CURRENT_SOURCE_DIR}" + "${REPO_ROOT}/apps/esp32_lvgl/tests/agenda/stubs" + "${REPO_ROOT}/apps/esp32_lvgl/src" + "${REPO_ROOT}/platform/esp/common/include" + "${REPO_ROOT}/platform/esp/arduino_common/include" + "${REPO_ROOT}/modules/core_agenda/include" + "${REPO_ROOT}/modules/core_waypoint/include" + "${REPO_ROOT}/modules/core_sys/include" + "${REPO_ROOT}/modules/product_composition/include" + "${REPO_ROOT}/modules/ui_presentation/include" + "${REPO_ROOT}/modules/ui_lvgl_ux_packs/include" + "${REPO_ROOT}/modules/ui_shared/include") +target_compile_features(agenda_ui_test PRIVATE cxx_std_17) +target_compile_definitions(agenda_ui_test PRIVATE UI_SHARED_TOUCH_IME_ENABLED=1) +set_source_files_properties( + "${REPO_ROOT}/apps/esp32_lvgl/src/esp32_lvgl_arduino_agenda.cpp" + "${REPO_ROOT}/apps/esp32_lvgl/src/esp32_lvgl_runtime_config.cpp" + PROPERTIES COMPILE_DEFINITIONS ARDUINO_WIO_TRACKER_L2=1) +target_link_libraries(agenda_ui_test PRIVATE agenda_test_lvgl) +if(MSVC) + target_compile_options(agenda_ui_test PRIVATE /UNDEBUG /utf-8) +else() + target_compile_options(agenda_ui_test PRIVATE -UNDEBUG) +endif() +add_test(NAME agenda_ui COMMAND agenda_ui_test) + +add_executable(agenda_catalog_test test_catalog.cpp test_support.cpp + "${REPO_ROOT}/modules/ui_shared/src/ui/page/page_profile.cpp" + "${REPO_ROOT}/modules/ui_shared/src/ui/app_catalog_builder.cpp") +target_include_directories(agenda_catalog_test PRIVATE catalog_stubs + "${REPO_ROOT}/modules/ui_lvgl_ux_packs/include" + "${REPO_ROOT}/modules/ui_shared/include") +target_compile_definitions(agenda_catalog_test PRIVATE ESP_PLATFORM=1 ARDUINO_T_LORA_PAGER=1) +target_compile_features(agenda_catalog_test PRIVATE cxx_std_17) +target_link_libraries(agenda_catalog_test PRIVATE agenda_test_lvgl) +if(MSVC) + target_compile_options(agenda_catalog_test PRIVATE /UNDEBUG) +else() + target_compile_options(agenda_catalog_test PRIVATE -UNDEBUG) +endif() +add_test(NAME agenda_catalog COMMAND agenda_catalog_test) + +add_executable(agenda_text_snapshot_test test_text_snapshot.cpp) +target_include_directories(agenda_text_snapshot_test PRIVATE "${REPO_ROOT}/modules/ui_shared/include") +target_compile_features(agenda_text_snapshot_test PRIVATE cxx_std_17) +if(MSVC) + target_compile_options(agenda_text_snapshot_test PRIVATE /UNDEBUG) +else() + target_compile_options(agenda_text_snapshot_test PRIVATE -UNDEBUG) +endif() +add_test(NAME agenda_text_snapshot COMMAND agenda_text_snapshot_test) diff --git a/modules/ui_shared/tests/agenda/catalog_stubs/board/BoardBase.h b/modules/ui_shared/tests/agenda/catalog_stubs/board/BoardBase.h new file mode 100644 index 00000000..538b61c7 --- /dev/null +++ b/modules/ui_shared/tests/agenda/catalog_stubs/board/BoardBase.h @@ -0,0 +1,6 @@ +#pragma once +struct CatalogTestBoard +{ + void softwareShutdown() {} +}; +extern CatalogTestBoard board; diff --git a/modules/ui_shared/tests/agenda/catalog_stubs/esp_attr.h b/modules/ui_shared/tests/agenda/catalog_stubs/esp_attr.h new file mode 100644 index 00000000..1baae9f6 --- /dev/null +++ b/modules/ui_shared/tests/agenda/catalog_stubs/esp_attr.h @@ -0,0 +1,3 @@ +#pragma once +#define IRAM_ATTR +#define DRAM_ATTR diff --git a/modules/ui_shared/tests/agenda/catalog_stubs/esp_log.h b/modules/ui_shared/tests/agenda/catalog_stubs/esp_log.h new file mode 100644 index 00000000..57612e6e --- /dev/null +++ b/modules/ui_shared/tests/agenda/catalog_stubs/esp_log.h @@ -0,0 +1,2 @@ +#pragma once +// The catalog does not emit ESP_LOG calls in this test configuration. diff --git a/modules/ui_shared/tests/agenda/catalog_stubs/sdkconfig.h b/modules/ui_shared/tests/agenda/catalog_stubs/sdkconfig.h new file mode 100644 index 00000000..afc3f208 --- /dev/null +++ b/modules/ui_shared/tests/agenda/catalog_stubs/sdkconfig.h @@ -0,0 +1,2 @@ +#pragma once +// No ESP SDK options: this host test uses LV_CONF_SKIP and the native LVGL ABI. diff --git a/modules/ui_shared/tests/agenda/font_memory_probe.cpp b/modules/ui_shared/tests/agenda/font_memory_probe.cpp new file mode 100644 index 00000000..cf5271b4 --- /dev/null +++ b/modules/ui_shared/tests/agenda/font_memory_probe.cpp @@ -0,0 +1,99 @@ +#include "font_memory_probe.h" + +#include + +// GNU-linker test instrumentation only. Firmware allocators are unchanged. +// Requested LVGL payload bytes exclude allocator headers/alignment and libc I/O. +namespace +{ +struct Allocation +{ + void* pointer = nullptr; + std::size_t bytes = 0; +}; +Allocation allocations[512]{}; +bool enabled = false; +agenda_test_fonts::FontMemory memory{}; + +void forget(void* pointer) +{ + if (!pointer) return; + for (auto& slot : allocations) + if (slot.pointer == pointer) + { + memory.bytes -= slot.bytes; + --memory.allocations; + slot = {}; + return; + } +} +void remember(void* pointer, std::size_t bytes) +{ + if (!enabled || !pointer) return; + for (auto& slot : allocations) + if (!slot.pointer) + { + slot = {pointer, bytes}; + memory.bytes += bytes; + ++memory.allocations; + if (memory.bytes > memory.peak_bytes) memory.peak_bytes = memory.bytes; + return; + } + assert(false && "Font probe allocation capacity exceeded"); +} +} // namespace + +extern "C" +{ + void* __real_lv_malloc_core(std::size_t); + void* __real_lv_realloc_core(void*, std::size_t); + void __real_lv_free_core(void*); + + void* __wrap_lv_malloc_core(std::size_t bytes) + { + void* result = __real_lv_malloc_core(bytes); + remember(result, bytes); + return result; + } + void* __wrap_lv_realloc_core(void* pointer, std::size_t bytes) + { + // Keep the old allocation registered on failure. Save its slot before + // realloc, since a successful move invalidates the old pointer value. + Allocation* previous = nullptr; + for (auto& slot : allocations) + if (pointer && slot.pointer == pointer) previous = &slot; + void* result = __real_lv_realloc_core(pointer, bytes); + if (result || bytes == 0) + { + if (previous) + { + memory.bytes -= previous->bytes; + --memory.allocations; + *previous = {}; + } + remember(result, bytes); + } + return result; + } + void __wrap_lv_free_core(void* pointer) + { + forget(pointer); + __real_lv_free_core(pointer); + } +} + +namespace agenda_test_fonts +{ +void beginMemoryProbe() +{ + assert(!enabled && memory.allocations == 0); + memory = {}; + enabled = true; +} +FontMemory memoryProbe() { return memory; } +void endMemoryProbe() +{ + assert(memory.bytes == 0 && memory.allocations == 0); + enabled = false; +} +} // namespace agenda_test_fonts diff --git a/modules/ui_shared/tests/agenda/font_memory_probe.h b/modules/ui_shared/tests/agenda/font_memory_probe.h new file mode 100644 index 00000000..fafa1424 --- /dev/null +++ b/modules/ui_shared/tests/agenda/font_memory_probe.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace agenda_test_fonts +{ +struct FontMemory +{ + std::size_t bytes; + std::size_t allocations; + std::size_t peak_bytes; +}; +void beginMemoryProbe(); +FontMemory memoryProbe(); +void endMemoryProbe(); +} // namespace agenda_test_fonts diff --git a/modules/ui_shared/tests/agenda/map_preview_stub.cpp b/modules/ui_shared/tests/agenda/map_preview_stub.cpp new file mode 100644 index 00000000..8097b1ef --- /dev/null +++ b/modules/ui_shared/tests/agenda/map_preview_stub.cpp @@ -0,0 +1,44 @@ +#include "ui/widgets/map/map_viewport.h" +#include +namespace ui::widgets::map +{ +// Test the real details layout and lifetime without an SD driver. Actual tile +// rendering remains covered by the shared map transport tests and device QA. +struct RuntimeImpl +{ + Widgets widgets; + Model model; +}; +Runtime::~Runtime() { destroy(*this); } +Widgets create(Runtime& runtime, lv_obj_t* parent, uint32_t) +{ + destroy(runtime); + runtime.impl_ = new RuntimeImpl; + runtime.impl_->widgets.root = lv_obj_create(parent); + return runtime.impl_->widgets; +} +void destroy(Runtime& runtime) +{ + if (!runtime.impl_) return; + assert(lv_obj_is_valid(runtime.impl_->widgets.root)); + lv_obj_delete(runtime.impl_->widgets.root); + delete runtime.impl_; + runtime.impl_ = nullptr; +} +void set_size(Runtime& runtime, lv_coord_t w, lv_coord_t h) +{ + lv_obj_set_size(runtime.impl_->widgets.root, w, h); +} +void set_gesture_enabled(Runtime&, bool enabled) { assert(!enabled); } +void apply_model(Runtime& runtime, const Model& model) +{ + assert(model.focus_point.valid); + runtime.impl_->model = model; +} +Status status(const Runtime& runtime) +{ + Status result; + result.alive = runtime.impl_ != nullptr; + return result; +} +} // namespace ui::widgets::map diff --git a/modules/ui_shared/tests/agenda/popup_test_support.cpp b/modules/ui_shared/tests/agenda/popup_test_support.cpp new file mode 100644 index 00000000..f262dbae --- /dev/null +++ b/modules/ui_shared/tests/agenda/popup_test_support.cpp @@ -0,0 +1,16 @@ +#include "popup_test_support.h" +namespace agenda_test_ui +{ +bool overlay = false; +bool interruption = false; +bool transition = false; +unsigned wakes = 0; +} // namespace agenda_test_ui +bool ui_is_overlay_active() { return agenda_test_ui::overlay; } +void ui_set_overlay_active(bool value) { agenda_test_ui::overlay = value; } +bool ui_is_interruption_app_active() { return agenda_test_ui::interruption; } +bool ui_is_transition_pending() { return agenda_test_ui::transition; } +namespace platform::ui::screen +{ +void wake_for_modal() { ++agenda_test_ui::wakes; } +} // namespace platform::ui::screen diff --git a/modules/ui_shared/tests/agenda/popup_test_support.h b/modules/ui_shared/tests/agenda/popup_test_support.h new file mode 100644 index 00000000..80185780 --- /dev/null +++ b/modules/ui_shared/tests/agenda/popup_test_support.h @@ -0,0 +1,8 @@ +#pragma once +namespace agenda_test_ui +{ +extern bool overlay; +extern bool interruption; +extern bool transition; +extern unsigned wakes; +} // namespace agenda_test_ui diff --git a/modules/ui_shared/tests/agenda/stubs/ui/LV_Helper.h b/modules/ui_shared/tests/agenda/stubs/ui/LV_Helper.h new file mode 100644 index 00000000..4439fa29 --- /dev/null +++ b/modules/ui_shared/tests/agenda/stubs/ui/LV_Helper.h @@ -0,0 +1,3 @@ +#pragma once +#include "lvgl.h" +inline lv_indev_t* lv_get_keyboard_indev() { return nullptr; } diff --git a/modules/ui_shared/tests/agenda/stubs/ui/widgets/ime/pinyin_lookup.h b/modules/ui_shared/tests/agenda/stubs/ui/widgets/ime/pinyin_lookup.h new file mode 100644 index 00000000..b6edf1a5 --- /dev/null +++ b/modules/ui_shared/tests/agenda/stubs/ui/widgets/ime/pinyin_lookup.h @@ -0,0 +1,15 @@ +#pragma once +#include +namespace ui::widgets::ime +{ +// Tiny deterministic dictionary fixture; production composition/rendering run. +template +void collectPinyinCandidates(const char* text, Add add) +{ + if (std::strcmp(text, "ni") == 0) + { + add("\xE4\xBD\xA0"); + add("\xE6\xB3\xA5"); + } +} +} // namespace ui::widgets::ime diff --git a/modules/ui_shared/tests/agenda/test_agenda_ui.cpp b/modules/ui_shared/tests/agenda/test_agenda_ui.cpp new file mode 100644 index 00000000..a0cb3041 --- /dev/null +++ b/modules/ui_shared/tests/agenda/test_agenda_ui.cpp @@ -0,0 +1,792 @@ +#include "popup_test_support.h" +#include "product_composition/agenda_composition.h" +#include "ui/app_runtime.h" +#include "ui/page/page_profile.h" +#include "ui/screens/agenda/agenda_page_components.h" +#include "ui/screens/agenda/agenda_page_runtime.h" +#include "ui/screens/agenda/agenda_reminder_popup.h" +#include "ui_presentation/waypoint/waypoint_model.h" + +#include +#include +#include + +namespace +{ +namespace page = ui::agenda::page; +uint16_t framebuffer[480 * 240]; +uint16_t draw_buffer[480 * 40]; +int display_width = 0; + +class Store final : public agenda::IAgendaStore +{ + public: + agenda::EventRecord records[8]{}; + bool fail = false; + agenda::StoreResult readSlot(uint16_t slot, agenda::EventRecord& out) override + { + out = records[slot]; + return agenda::StoreResult::Ok; + } + agenda::StoreResult writeSlot(uint16_t slot, const agenda::EventRecord& record) override + { + if (fail) return agenda::StoreResult::IoError; + records[slot] = record; + return agenda::StoreResult::Ok; + } + agenda::StoreResult eraseSlot(uint16_t) override { return agenda::StoreResult::IoError; } + uint16_t slotCount() const override { return 8; } +}; +class WaypointStore final : public waypoint::IStore +{ + public: + uint16_t slotCount() const override { return 7; } + waypoint::Result read(uint16_t slot, waypoint::Record& out) override + { + out = {}; + out.id = slot + 1; + std::snprintf(out.name, sizeof(out.name), "Campsite %u", unsigned(out.id)); + return waypoint::Result::Ok; + } + waypoint::Result create(const waypoint::Record&, uint32_t&) override { return waypoint::Result::IoError; } + waypoint::Result update(const waypoint::Record&) override { return waypoint::Result::IoError; } + waypoint::Result remove(uint32_t) override { return waypoint::Result::IoError; } +}; +class Clock final : public agenda::IAgendaClock +{ + public: + agenda::ClockSample value{1776330000, 0, 0, true}; + agenda::ClockSample sample() const override { return value; } + void advance(unsigned seconds) + { + value.calendar_seconds += seconds; + value.monotonic_seconds += seconds; + } +}; +class LocationSource final : public ui::agenda::IAgendaLocationSource +{ + public: + bool available = false; + bool currentLocation(ui::agenda::AgendaCoordinate& out) const override + { + if (!available) return false; + out = {248731000, 1180000000}; + return true; + } +}; + +void flush(lv_display_t* display, const lv_area_t* area, uint8_t* pixels) +{ + const auto* source = reinterpret_cast(pixels); + for (int y = area->y1; y <= area->y2; ++y) + for (int x = area->x1; x <= area->x2; ++x) + framebuffer[y * display_width + x] = *source++; + lv_display_flush_ready(display); +} +lv_obj_t* find_label(lv_obj_t* root, const char* text) +{ + if (lv_obj_check_type(root, &lv_label_class) && std::strcmp(lv_label_get_text(root), text) == 0) return root; + for (uint32_t i = 0; i < lv_obj_get_child_count(root); ++i) + if (auto* result = find_label(lv_obj_get_child(root, i), text)) return result; + return nullptr; +} +lv_obj_t* find_button(lv_obj_t* root, const char* text) +{ + if (lv_obj_check_type(root, &lv_button_class) && find_label(root, text)) return root; + for (uint32_t i = 0; i < lv_obj_get_child_count(root); ++i) + if (auto* result = find_button(lv_obj_get_child(root, i), text)) return result; + return nullptr; +} +void click(const char* text) +{ + auto* button = find_button(page::state()->root, text); + if (!button) std::fprintf(stderr, "Missing button '%s', width=%d view=%u pending=%u waiting=%u confirm=%u\n", + text, display_width, unsigned(page::state()->view), unsigned(page::state()->pending), + unsigned(page::state()->awaiting_command), unsigned(page::state()->confirm_delete)); + assert(button); + lv_obj_send_event(button, LV_EVENT_CLICKED, nullptr); +} +void pump(product_composition::AgendaComposition& composition) +{ + composition.tick(false); + lv_tick_inc(60); + lv_timer_handler(); + lv_obj_update_layout(lv_screen_active()); +} +void verify_buttons(lv_obj_t* object, int width, int height) +{ + if (lv_obj_check_type(object, &lv_button_class)) + { + lv_area_t bounds; + lv_obj_get_coords(object, &bounds); + assert(bounds.x1 >= 0 && bounds.y1 >= 0 && bounds.x2 < width && bounds.y2 < height); + } + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) + verify_buttons(lv_obj_get_child(object, i), width, height); +} +void screenshot(lv_display_t* display, int width, int height, const char* view = "home") +{ + lv_refr_now(display); + char path[64]; + std::snprintf(path, sizeof(path), "agenda-%s-%dx%d.bmp", view, width, height); + auto* file = std::fopen(path, "wb"); + assert(file); + const uint32_t stride = (width * 3 + 3) & ~3; + const uint32_t bytes = 54 + stride * height; + uint8_t header[54]{}; + auto put32 = [&](unsigned offset, uint32_t value) + { for (unsigned i = 0; i < 4; ++i) header[offset + i] = static_cast(value >> (8 * i)); }; + header[0] = 'B'; + header[1] = 'M'; + put32(2, bytes); + put32(10, 54); + put32(14, 40); + put32(18, width); + put32(22, height); + header[26] = 1; + header[28] = 24; + assert(std::fwrite(header, 1, sizeof(header), file) == sizeof(header)); + for (int y = height - 1; y >= 0; --y) + { + for (int x = 0; x < width; ++x) + { + const auto color = framebuffer[y * width + x]; + const uint8_t rgb[3] = {static_cast((color & 31) * 255 / 31), + static_cast(((color >> 5) & 63) * 255 / 63), + static_cast((color >> 11) * 255 / 31)}; + assert(std::fwrite(rgb, 1, 3, file) == 3); + } + for (uint32_t pad = width * 3; pad < stride; ++pad) std::fputc(0, file); + } + assert(std::fclose(file) == 0); +} + +void run(int width, int height, const ui::page_profile::PageLayoutProfile& profile) +{ + display_width = width; + auto* display = lv_display_create(width, height); + lv_display_set_color_format(display, LV_COLOR_FORMAT_RGB565); + lv_display_set_buffers(display, draw_buffer, nullptr, sizeof(draw_buffer), LV_DISPLAY_RENDER_MODE_PARTIAL); + lv_display_set_flush_cb(display, flush); + ui::page_profile::set_active_profile(&profile); + auto* screen = lv_screen_active(); + lv_obj_set_style_pad_all(screen, 0, 0); + lv_obj_set_style_border_width(screen, 0, 0); + Store store; + Clock clock; + const int64_t day = clock.sample().calendar_seconds / 86400 * 86400; + for (unsigned i = 0; i < 8; ++i) + { + auto& event = store.records[i]; + event.id = i + 1; + event.state = agenda::RecordState::Active; + event.start_time = day + 34200 + i * 3600; + std::snprintf(event.title, sizeof(event.title), "Radio check %u", i + 1); + if (i == 1) + { + event.flags = agenda::HasLocation; + event.location_type = agenda::LocationType::Coordinate; + std::strcpy(event.location_name, "Pine Creek"); + } + } + product_composition::AgendaComposition composition(store, clock); + ui::workspace::PresentationWorkspace workspace; + composition.bind(workspace); + composition.setStorageReady(true); + bool exited = false; + LocationSource locations; + page::Host host{{&exited, [](void* context) + { + *static_cast(context) = true; + page::exit(nullptr, nullptr); + }}, + workspace.agenda, + workspace.agenda}; + host.locations = &locations; + WaypointStore waypoint_store; + ui::waypoint::Model waypoints(waypoint_store); + waypoints.setReady(true); + host.waypoints = &waypoints; + host.waypoint_actions = &waypoints; + host.request_target = [](void*, const ui::agenda::AgendaEditorModel&) + { return false; }; + page::enter(&host, screen); + assert(page::state() && page::state()->view == page::View::Agenda); + pump(composition); + verify_buttons(page::state()->root, width, height); + assert(find_label(page::state()->root, "Radio check 1")); + assert(page::state()->snapshot.page.has_more); + screenshot(display, width, height); + // Real group traversal includes TopBar Back, even on the encoder profile. + auto* back = page::state()->top_bar.back_btn; + lv_group_focus_obj(back); + struct KeyPayload + { + uint32_t code; + uint8_t guard[512]; + } down{LV_KEY_DOWN, {}}; + const auto original_key = down; + lv_obj_send_event(back, LV_EVENT_KEY, &down.code); + assert(std::memcmp(&down, &original_key, sizeof(down)) == 0); + assert(lv_group_get_focused(page::state()->group) != back); + for (unsigned i = 0; i < 20 && lv_group_get_focused(page::state()->group) != back; ++i) + lv_group_focus_next(page::state()->group); + assert(lv_group_get_focused(page::state()->group) == back); + const auto last = page::state()->snapshot.page.rows[page::state()->snapshot.page.count - 1].occurrence.event_id; + click("More"); + pump(composition); + assert(page::state()->snapshot.page.rows[0].occurrence.event_id == last + 1); + click("Today"); + pump(composition); + click("Radio check 1"); + assert(page::state()->view == page::View::Agenda); // No tree mutation in callback. + pump(composition); + assert(page::state()->view == page::View::Detail); + assert(!find_label(page::state()->root, "Reminder")); + assert(!find_label(page::state()->root, "Repeat")); + assert(!find_label(page::state()->root, "Navigate")); + // All optional fields must coexist without covering the fixed action row. + auto& detail_event = page::state()->draft.event; + detail_event.flags |= agenda::HasReminder | agenda::HasNote | agenda::HasLocation; + detail_event.reminder_offset_sec = 600; + detail_event.repeat = agenda::Repeat::Weekly; + detail_event.location_type = agenda::LocationType::Coordinate; + std::strcpy(detail_event.location_name, "Pine Creek"); + std::strcpy(detail_event.note, "Check the water source, radio batteries and weather before leaving the campsite."); + page::components::render(); + lv_obj_update_layout(page::state()->root); + assert(find_label(page::state()->root, "Reminder")); + assert(find_label(page::state()->root, "10 min before")); + assert(find_label(page::state()->root, "Repeat")); + assert(find_label(page::state()->root, "Weekly")); + assert(find_label(page::state()->root, "Navigate")); + lv_area_t note_bounds, edit_bounds; + lv_obj_get_coords(find_label(page::state()->root, detail_event.note), ¬e_bounds); + lv_obj_get_coords(find_button(page::state()->root, "Edit"), &edit_bounds); + assert(note_bounds.y2 < edit_bounds.y1 && note_bounds.y2 > note_bounds.y1); + verify_buttons(page::state()->root, width, height); + screenshot(display, width, height, "detail-all-fields"); + click("Navigate"); + pump(composition); + assert(page::state()->view == page::View::Detail); + assert(find_label(page::state()->root, "Map is unavailable on this target.")); + page::state()->error = nullptr; + detail_event = store.records[0]; + page::components::render(); + click("Delete"); + pump(composition); + assert(page::state()->confirm_delete); + store.fail = true; + click("Delete"); + pump(composition); + pump(composition); + assert(find_label(page::state()->root, "Could not save changes")); + assert(store.records[0].state == agenda::RecordState::Active); + store.fail = false; + click("Delete"); + pump(composition); + pump(composition); + assert(store.records[0].state == agenda::RecordState::Deleted); + assert(page::state()->view == page::View::Agenda); + + // Invalid multibyte input is neither silently truncated nor a Back trap. + click("New"); + pump(composition); + assert(page::state()->view == page::View::Editor); + click("Save"); + pump(composition); + assert(find_label(page::state()->root, "Enter a title and valid date/time")); + const char* oversized = u8"漢漢漢漢漢漢漢漢漢漢漢漢漢漢"; + auto* original_title = page::state()->editor_widgets.title; + lv_textarea_set_text(original_title, oversized); + click("Save"); + pump(composition); + assert(page::state()->editor_widgets.title == original_title); + assert(find_label(page::state()->root, "Text exceeds the UTF-8 byte limit")); + lv_obj_send_event(page::state()->top_bar.back_btn, LV_EVENT_CLICKED, nullptr); + pump(composition); + assert(page::state()->confirm_discard); + click("Keep editing"); + pump(composition); + assert(page::state()->editor_widgets.title == original_title); + assert(std::strcmp(lv_textarea_get_text(original_title), oversized) == 0); + lv_obj_send_event(page::state()->top_bar.back_btn, LV_EVENT_CLICKED, nullptr); + pump(composition); + click("Discard"); + pump(composition); + assert(page::state()->view == page::View::Agenda); + + click("New"); + pump(composition); + lv_textarea_set_text(page::state()->editor_widgets.title, "Evening radio"); + lv_textarea_set_text(page::state()->editor_widgets.note, "Check battery first"); + screenshot(display, width, height, "editor"); + page::runtime::request(page::Action::PickLocation); + pump(composition); + assert(page::state()->view == page::View::LocationPicker); + verify_buttons(page::state()->root, width, height); + screenshot(display, width, height, "location-picker"); + click("Current position"); + pump(composition); + assert(page::state()->view == page::View::LocationPicker); + assert(find_label(page::state()->root, "Current position unavailable")); + assert(!(page::state()->draft.event.flags & agenda::HasLocation)); + locations.available = true; + click("Current position"); + pump(composition); + assert(page::state()->view == page::View::Editor); + assert(page::state()->draft.event.flags & agenda::HasLocation); + assert(page::state()->draft.event.latitude_e7 == 248731000); + assert(page::state()->draft.event.longitude_e7 == 1180000000); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), "Evening radio") == 0); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.note), "Check battery first") == 0); + assert(store.records[0].state == agenda::RecordState::Deleted); // Still only a draft. + page::runtime::request(page::Action::PickLocation); + pump(composition); + click("Saved waypoint"); + pump(composition); + assert(page::state()->view == page::View::WaypointPicker); + assert(page::state()->waypoint_page.count == 5 && page::state()->waypoint_page.has_more); + verify_buttons(page::state()->root, width, height); + screenshot(display, width, height, "waypoint-picker"); + click("More"); + pump(composition); + assert(page::state()->waypoint_page.count == 2 && !page::state()->waypoint_page.has_more); + assert(find_label(page::state()->root, "Campsite 6")); + click("First"); + pump(composition); + assert(find_label(page::state()->root, "Campsite 1")); + click("New"); + pump(composition); + assert(page::state()->view == page::View::WaypointName); + assert(find_label(page::state()->root, "Name")); + lv_textarea_set_text(page::state()->editor_widgets.title, "Pine Creek campsite"); + verify_buttons(page::state()->root, width, height); + screenshot(display, width, height, "waypoint-name"); + click("Save"); + pump(composition); + assert(page::state()->awaiting_command); + waypoints.pump(); // This fixture rejects writes; the UI must retain the draft. + pump(composition); + assert(page::state()->view == page::View::WaypointName && !page::state()->awaiting_command); + assert(find_label(page::state()->root, "Could not save changes")); + assert(!std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), "Pine Creek campsite")); + assert(store.records[0].state == agenda::RecordState::Deleted); + verify_buttons(page::state()->root, width, height); + screenshot(display, width, height, "waypoint-save-error"); + page::runtime::request(page::Action::Back); + pump(composition); + assert(page::state()->view == page::View::WaypointPicker); + page::runtime::request(page::Action::Back); + pump(composition); + assert(page::state()->view == page::View::LocationPicker); + page::runtime::request(page::Action::Back); + pump(composition); + assert(page::state()->view == page::View::Editor); + page::runtime::request(page::Action::PickLocation); + pump(composition); + locations.available = false; + click("Current position"); + pump(composition); + assert(page::state()->draft.event.latitude_e7 == 248731000); // Failed replacement preserves it. + page::runtime::request(page::Action::Back); + pump(composition); + assert(page::state()->view == page::View::Editor); + page::runtime::request(page::Action::PickLocation); + pump(composition); + click("Remove location"); + pump(composition); + assert(!(page::state()->draft.event.flags & agenda::HasLocation)); + assert(page::state()->draft.event.location_name[0] == '\0'); + page::runtime::request(page::Action::PickLocation); + pump(composition); + locations.available = true; + click("Current position"); + pump(composition); + page::runtime::request(page::Action::PickReminder); + pump(composition); + assert(page::state()->view == page::View::ReminderPicker); + verify_buttons(page::state()->root, width, height); + screenshot(display, width, height, "reminder-picker"); + click("10 min before"); + pump(composition); + assert(page::state()->draft.event.reminder_offset_sec == 600); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), "Evening radio") == 0); + page::runtime::request(page::Action::PickRepeat); + pump(composition); + click("Weekly"); + pump(composition); + assert(page::state()->draft.event.repeat == agenda::Repeat::Weekly); + page::runtime::request(page::Action::PickTime); + pump(composition); + assert(page::state()->view == page::View::TimePicker); + lv_spinbox_set_value(page::state()->editor_widgets.clock_fields[0], 18); + lv_spinbox_set_value(page::state()->editor_widgets.clock_fields[1], 0); + lv_spinbox_set_value(page::state()->editor_widgets.clock_fields[2], 19); + lv_spinbox_set_value(page::state()->editor_widgets.clock_fields[3], 0); + lv_obj_add_state(page::state()->editor_widgets.end_enabled, LV_STATE_CHECKED); + verify_buttons(page::state()->root, width, height); + screenshot(display, width, height, "time-picker"); + click("OK"); + pump(composition); + assert(page::state()->draft.event.end_time - page::state()->draft.event.start_time == 3600); + // Equal start/end explicitly represents the same time on the next day. + page::runtime::request(page::Action::PickTime); + pump(composition); + lv_spinbox_set_value(page::state()->editor_widgets.clock_fields[2], 18); + click("OK"); + pump(composition); + assert(page::state()->draft.event.end_time - page::state()->draft.event.start_time == 86400); + page::runtime::request(page::Action::PickTime); + pump(composition); + lv_spinbox_set_value(page::state()->editor_widgets.clock_fields[2], 19); + click("OK"); + pump(composition); + page::runtime::request(page::Action::PickDate); + pump(composition); + assert(page::state()->view == page::View::DatePicker); + verify_buttons(page::state()->root, width, height); + screenshot(display, width, height, "date-picker"); + const auto original_date = page::state()->picker.date; + const uint8_t selected_day = original_date.day < 28 ? original_date.day + 1 : original_date.day - 1; + auto* days_grid = page::state()->editor_widgets.focus; + assert(lv_obj_check_type(days_grid, &lv_buttonmatrix_class)); + auto* calendar = lv_obj_get_parent(days_grid); + assert(lv_obj_check_type(calendar, &lv_calendar_class)); + assert(lv_group_get_focused(page::state()->group) == days_grid); + assert(lv_obj_get_group(calendar) == nullptr); + const auto initial_button = lv_buttonmatrix_get_selected_button(days_grid); + lv_group_set_editing(page::state()->group, true); + uint32_t right_key = LV_KEY_RIGHT; + lv_obj_send_event(days_grid, LV_EVENT_KEY, &right_key); + assert(lv_buttonmatrix_get_selected_button(days_grid) != initial_button); + assert(lv_group_get_focused(page::state()->group) == days_grid); + // Exercise the buttonmatrix's bubbling event, not a direct runtime command. + char day_text[4]; + std::snprintf(day_text, sizeof(day_text), "%u", selected_day); + bool found_day = false; + for (unsigned i = 7; i < 49; ++i) + { + if (lv_buttonmatrix_has_button_ctrl(days_grid, i, LV_BUTTONMATRIX_CTRL_DISABLED)) continue; + if (std::strcmp(lv_buttonmatrix_get_button_text(days_grid, i), day_text) != 0) continue; + lv_buttonmatrix_set_selected_button(days_grid, i); + lv_obj_send_event(days_grid, LV_EVENT_VALUE_CHANGED, nullptr); + found_day = true; + break; + } + assert(found_day && page::state()->view == page::View::DatePicker); + pump(composition); + assert(page::state()->view == page::View::Editor); + agenda::CivilTime selected; + assert(agenda::fromCalendarSeconds(page::state()->draft.event.start_time, selected)); + assert(selected.day == selected_day && selected.hour == 18 && selected.minute == 0); + assert(page::state()->draft.event.end_time - page::state()->draft.event.start_time == 3600); + store.fail = true; + click("Save"); + pump(composition); + assert(page::state()->awaiting_command); + assert(lv_obj_has_state(page::state()->editor_widgets.title, LV_STATE_DISABLED)); + assert(lv_obj_has_state(page::state()->editor_widgets.note, LV_STATE_DISABLED)); + pump(composition); + assert(page::state()->view == page::View::Editor && !page::state()->awaiting_command); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), "Evening radio") == 0); + assert(find_label(page::state()->root, "Could not save changes")); + store.fail = false; + click("Save"); + pump(composition); + pump(composition); + assert(page::state()->view == page::View::Agenda); + const auto created = workspace.agenda->commandResult().event_id; + assert(created == 9 && store.records[0].id == created); + assert(store.records[0].flags == (agenda::HasReminder | agenda::HasNote | agenda::HasEndTime | agenda::HasLocation)); + assert(store.records[0].latitude_e7 == 248731000 && store.records[0].longitude_e7 == 1180000000); + assert(store.records[0].repeat == agenda::Repeat::Weekly); + assert(std::strcmp(store.records[0].note, "Check battery first") == 0); + // Find the new occurrence through the real bounded list, then edit it. + for (unsigned i = 0; !find_button(page::state()->root, "Evening radio") && i < 8; ++i) + { + assert(page::state()->snapshot.page.has_more); + click("More"); + pump(composition); + } + click("Evening radio"); + pump(composition); + click("Edit"); + pump(composition); + assert(page::state()->draft.editing_existing); + assert(page::state()->draft.event.flags & agenda::HasLocation); + page::runtime::request(page::Action::PickLocation); + pump(composition); + click("Remove location"); + pump(composition); + assert(store.records[0].flags & agenda::HasLocation); // Removal is not persisted until Save. + lv_textarea_set_text(page::state()->editor_widgets.title, "Updated radio"); + click("Save"); + pump(composition); + pump(composition); + assert(store.records[0].id == created && std::strcmp(store.records[0].title, "Updated radio") == 0); + assert(!(store.records[0].flags & agenda::HasLocation)); + assert(store.records[0].location_type == agenda::LocationType::None); + lv_obj_send_event(page::state()->top_bar.back_btn, LV_EVENT_CLICKED, nullptr); + assert(page::state()); + pump(composition); + assert(exited && !page::state()); + assert(lv_obj_get_child_count(screen) == 0); + ui::page_profile::set_active_profile(nullptr); + lv_display_delete(display); +} +void runReminder(int width, int height, const ui::page_profile::PageLayoutProfile& profile) +{ + namespace popup = ui::agenda::reminder_popup; + display_width = width; + auto* display = lv_display_create(width, height); + lv_display_set_color_format(display, LV_COLOR_FORMAT_RGB565); + lv_display_set_buffers(display, draw_buffer, nullptr, sizeof(draw_buffer), LV_DISPLAY_RENDER_MODE_PARTIAL); + lv_display_set_flush_cb(display, flush); + ui::page_profile::set_active_profile(&profile); + auto* previous_group = lv_group_create(); + set_default_group(previous_group); + auto* previous_button = lv_button_create(lv_screen_active()); + lv_group_add_obj(previous_group, previous_button); + lv_group_focus_obj(previous_button); + lv_group_set_editing(previous_group, true); + Store store; + Clock clock; + for (unsigned i = 0; i < 2; ++i) + { + auto& event = store.records[i]; + event.id = i + 1; + event.state = agenda::RecordState::Active; + event.start_time = clock.value.calendar_seconds + 601; + event.flags = agenda::HasReminder | agenda::HasNote; + event.reminder_offset_sec = 600; + std::strcpy(event.title, i ? "Leave campsite" : "Radio check"); + std::strcpy(event.note, "Check battery and verify the meeting point."); + } + auto& located = store.records[1]; + located.flags |= agenda::HasLocation; + located.location_type = agenda::LocationType::Coordinate; + located.latitude_e7 = 248731000; + located.longitude_e7 = 1180000000; + std::strcpy(located.location_name, "Pine Creek"); + product_composition::AgendaComposition composition(store, clock); + ui::workspace::PresentationWorkspace workspace; + composition.bind(workspace); + composition.setStorageReady(true); + struct Navigation + { + bool allow = false; + bool prepared = false; + bool accepted = false; + unsigned cancellations = 0; + } navigation; + popup::Host host{workspace.agenda, workspace.agenda, workspace.agenda_reminders}; + host.navigation_context = &navigation; + host.prepare_navigation = [](void* context, int32_t latitude, int32_t longitude) -> const char* + { + auto& state = *static_cast(context); + assert(popup::visible()); // Prepare must not destroy the current modal. + assert(latitude == 248731000 && longitude == 1180000000); + if (!state.allow) return "Map is unavailable on this target."; + assert(!state.prepared); + state.prepared = true; + return nullptr; + }; + host.finish_navigation = [](void* context, bool accepted) + { + auto& state = *static_cast(context); + assert(state.prepared); + if (accepted) assert(!popup::visible()); + state.prepared = false; + state.accepted = accepted; + if (!accepted) ++state.cancellations; + }; + const auto tick = [&]() + { + const bool allowed = popup::canPresent(); + composition.tick(allowed && !popup::visible()); + popup::tick(host, allowed); + lv_obj_update_layout(lv_layer_top()); + }; + const auto action = [&](const char* text) + { + auto* button = find_button(lv_layer_top(), text); + assert(button); + lv_obj_send_event(button, LV_EVENT_CLICKED, nullptr); + }; + tick(); + clock.advance(1); + agenda_test_ui::overlay = true; + tick(); + assert(!popup::visible()); // Wait for an existing modal, not a second overlay. + agenda_test_ui::overlay = false; + tick(); + assert(popup::visible() && !page::state()); + assert(lv_group_get_default() != previous_group); + assert(!find_button(lv_layer_top(), "Navigate")); + struct KeyPayload + { + uint32_t code; + uint8_t guard[512]; + } next{LV_KEY_NEXT, {}}; + const auto original_key = next; + auto* initial_focus = lv_group_get_focused(lv_group_get_default()); + lv_obj_send_event(initial_focus, LV_EVENT_KEY, &next.code); + assert(std::memcmp(&next, &original_key, sizeof(next)) == 0); + assert(lv_group_get_focused(lv_group_get_default()) != initial_focus); + verify_buttons(lv_layer_top(), width, height); + screenshot(display, width, height, "reminder"); + action("Done"); // In-flight activation is ignored for the first 400 ms. + tick(); + assert(workspace.agenda->commandResult().state == ui::agenda::CommandState::Idle); + lv_tick_inc(401); + action("Snooze 10 min"); + tick(); + assert(workspace.agenda->commandResult().state == ui::agenda::CommandState::Pending && popup::visible()); + tick(); + assert(!popup::visible() && lv_group_get_default() == previous_group); + assert(lv_group_get_focused(previous_group) == previous_button && lv_group_get_editing(previous_group)); + tick(); // The other simultaneous reminder was not consumed by snoozing. + assert(popup::visible() && find_button(lv_layer_top(), "Navigate")); + verify_buttons(lv_layer_top(), width, height); + const char* action_labels[] = {"Navigate", "Snooze 10 min", "Done"}; + for (const char* text : action_labels) + { + auto* control = find_button(lv_layer_top(), text); + auto* caption = find_label(control, text); + assert(caption); + lv_area_t button_bounds; + lv_area_t caption_bounds; + lv_obj_get_coords(control, &button_bounds); + lv_obj_get_coords(caption, &caption_bounds); + assert(caption_bounds.y1 >= button_bounds.y1 + 3); + assert(caption_bounds.y2 <= button_bounds.y2 - 3); + assert(caption_bounds.x1 >= button_bounds.x1 + 3); + assert(caption_bounds.x2 <= button_bounds.x2 - 3); + } + screenshot(display, width, height, "reminder-location"); + lv_tick_inc(401); + action("Snooze 10 min"); + tick(); + tick(); + assert(popup::visible()); + assert(find_label(lv_layer_top(), "Another reminder is already snoozed")); + lv_area_t status_bounds; + lv_area_t action_bounds; + lv_obj_get_coords(find_label(lv_layer_top(), "Another reminder is already snoozed"), &status_bounds); + lv_obj_get_coords(find_button(lv_layer_top(), "Snooze 10 min"), &action_bounds); + assert(status_bounds.y2 < action_bounds.y1); + screenshot(display, width, height, "reminder-snooze-full"); + action("Navigate"); + tick(); + assert(popup::visible() && !navigation.prepared && !navigation.accepted); + assert(find_label(lv_layer_top(), "Map is unavailable on this target.")); + navigation.allow = true; + action("Navigate"); + tick(); + assert(navigation.prepared && !navigation.accepted && popup::visible()); + tick(); + assert(navigation.accepted && !navigation.prepared && !popup::visible()); + clock.advance(599); + tick(); + assert(!popup::visible()); + clock.advance(1); + tick(); + assert(popup::visible() && find_label(lv_layer_top(), "Radio check")); + + // A call takes focus and destroys the interrupted page's group. Closing + // the reminder must neither dereference the old group nor clear call state. + agenda_test_ui::interruption = true; + auto* call_group = lv_group_create(); + set_default_group(call_group); + lv_group_delete(previous_group); + previous_group = nullptr; + tick(); + assert(!popup::visible() && agenda_test_ui::overlay); + assert(lv_group_get_default() == call_group); + agenda_test_ui::interruption = false; + agenda_test_ui::overlay = false; + tick(); + assert(popup::visible()); // Same snoozed reminder is still pending. + lv_tick_inc(401); + action("Done"); + tick(); + tick(); + assert(!popup::visible() && lv_group_get_default() == call_group && !agenda_test_ui::overlay); + tick(); + assert(!popup::visible()); + + // A queued click cannot dismiss a new presentation after a record change. + agenda::EventRecord draft = store.records[0]; + draft.id = 0; + draft.start_time = clock.value.calendar_seconds + 601; + assert(workspace.agenda->save(draft).ok); + tick(); + const auto created = workspace.agenda->commandResult().event_id; + clock.advance(1); + tick(); + assert(popup::visible()); + lv_tick_inc(401); + action("Done"); + assert(workspace.agenda->remove(created).ok); + tick(); + assert(!popup::visible()); + assert(workspace.agenda->commandResult().event_id == created); + // Failed command submission and a higher-priority interruption must both + // release a prepared route. Neither is permission to switch applications. + draft = store.records[1]; + draft.id = 0; + draft.start_time = clock.value.calendar_seconds + 601; + assert(workspace.agenda->save(draft).ok); + tick(); + clock.advance(1); + tick(); + assert(popup::visible() && find_button(lv_layer_top(), "Navigate")); + navigation.accepted = false; + lv_tick_inc(401); + action("Navigate"); + assert(workspace.agenda->save(draft).ok); // Occupy the one command slot. + popup::tick(host, true); + assert(popup::visible() && !navigation.prepared && !navigation.accepted); + assert(navigation.cancellations == 1); + tick(); + if (!popup::visible()) tick(); + lv_tick_inc(401); + action("Navigate"); + popup::tick(host, true); + assert(navigation.prepared); + agenda_test_ui::interruption = true; + popup::tick(host, false); + assert(!popup::visible() && !navigation.prepared && !navigation.accepted); + assert(navigation.cancellations == 2); + agenda_test_ui::interruption = false; + agenda_test_ui::overlay = false; + set_default_group(nullptr); + lv_group_delete(call_group); + ui::page_profile::set_active_profile(nullptr); + lv_display_delete(display); +} +} // namespace + +void testTargetRoot(); +void testMapFlow(); +void testAgendaFontScope(); +void testInterruptionFlow(); +void testTouchAgenda(); + +int main() +{ + lv_init(); + testAgendaFontScope(); + const auto pager = ui::page_profile::make_pager_profile(); + run(480, 222, pager); + const auto deck = ui::page_profile::make_tdeck_profile(); + run(320, 240, deck); + runReminder(480, 222, pager); + runReminder(320, 240, deck); + testTargetRoot(); + testMapFlow(); + testInterruptionFlow(); + testTouchAgenda(); + std::puts("Agenda UI: 480x222 and 320x240 layout, input, CRUD, pickers and save-failure recovery passed"); +} diff --git a/modules/ui_shared/tests/agenda/test_catalog.cpp b/modules/ui_shared/tests/agenda/test_catalog.cpp new file mode 100644 index 00000000..0ab84655 --- /dev/null +++ b/modules/ui_shared/tests/agenda/test_catalog.cpp @@ -0,0 +1,100 @@ +#include "board/BoardBase.h" +#include "ui/app_catalog_builder.h" +#include "ui/callback_app_screen.h" +#include "ui/screens/gps/gps_page_shell.h" + +#include +#include + +CatalogTestBoard board; +lv_group_t* menu_g = nullptr; +void ui_request_exit_to_menu() {} +void ui_set_overlay_active(bool) {} +namespace ui::feedback +{ +bool show_notice(const char*, uint32_t) { return true; } +} // namespace ui::feedback +// Unrelated apps are descriptor-only stubs. The catalog under test is the +// production implementation, compiled with all optional Pager apps enabled. +#define STUB_PAGE(ns) \ + namespace ns \ + { \ + void enter(void*, lv_obj_t*) {} \ + void exit(void*, lv_obj_t*) {} \ + } +STUB_PAGE(chat::ui::shell) +STUB_PAGE(calculator::ui::shell) +STUB_PAGE(gps::ui::shell) +namespace gps::ui::shell +{ +void enter_route(const RouteSpec*, lv_obj_t*) {} +void exit_route(const RouteSpec*, lv_obj_t*) {} +} // namespace gps::ui::shell +STUB_PAGE(gnss::ui::shell) +STUB_PAGE(contacts::ui::shell) +STUB_PAGE(energy_sweep::ui::shell) +STUB_PAGE(team::ui::shell) +STUB_PAGE(tracker::ui::shell) +STUB_PAGE(sstv_page::ui::shell) +STUB_PAGE(usb_storage::ui::shell) +STUB_PAGE(settings::ui::shell) +STUB_PAGE(network::ui::shell) +STUB_PAGE(extensions::ui::shell) +STUB_PAGE(walkie_page::ui::shell) +#undef STUB_PAGE +namespace sstv_page::ui::runtime +{ +bool is_available() { return true; } +} // namespace sstv_page::ui::runtime +extern "C" +{ +#define STUB_ICON(name) extern const lv_image_dsc_t name = {} + STUB_ICON(Chat); + STUB_ICON(calc); + STUB_ICON(gps_icon); + STUB_ICON(Satellite); + STUB_ICON(contact); + STUB_ICON(radar); + STUB_ICON(team_icon); + STUB_ICON(tracker_icon); + STUB_ICON(sstv); + STUB_ICON(Setting); + STUB_ICON(nomad); + STUB_ICON(ext); + STUB_ICON(img_usb); + STUB_ICON(walkie_talkie); + STUB_ICON(shutdown); +#undef STUB_ICON +} + +int main() +{ + ui::CallbackAppScreen calendar("calendar", "Calendar", nullptr, + static_cast(nullptr), nullptr); + ui::app_catalog_builder::FeatureFlags flags; + flags.include_usb = flags.include_network = flags.include_walkie_talkie = flags.include_power_off = true; + auto catalog = ui::app_catalog_builder::build(flags); + assert(ui::catalogCount(catalog) == 15); + flags.calendar_app = &calendar; + catalog = ui::app_catalog_builder::build(flags); + assert(ui::catalogCount(catalog) == 16); + assert(ui::catalogAt(catalog, 0) == &calendar); + bool settings = false, shutdown = false; + for (std::size_t i = 0; i < ui::catalogCount(catalog); ++i) + { + auto* app = ui::catalogAt(catalog, i); + assert(app); + settings |= std::strcmp(app->stable_id(), "settings") == 0; + shutdown |= std::strcmp(app->stable_id(), "shutdown") == 0; + for (std::size_t j = i + 1; j < ui::catalogCount(catalog); ++j) + assert(std::strcmp(app->stable_id(), ui::catalogAt(catalog, j)->stable_id()) != 0); + } + assert(settings && shutdown && !ui::catalogAt(catalog, 16)); + // The IDF catalog and callers that do not inject Agenda retain their shape. + flags.calendar_app = nullptr; + flags.profile = ui::app_catalog_builder::CatalogProfile::IdfDefault; + catalog = ui::app_catalog_builder::build(flags); + assert(ui::catalogCount(catalog) == 15); + for (std::size_t i = 0; i < ui::catalogCount(catalog); ++i) + assert(std::strcmp(ui::catalogAt(catalog, i)->stable_id(), "calendar") != 0); +} diff --git a/modules/ui_shared/tests/agenda/test_countdown.cpp b/modules/ui_shared/tests/agenda/test_countdown.cpp new file mode 100644 index 00000000..76459367 --- /dev/null +++ b/modules/ui_shared/tests/agenda/test_countdown.cpp @@ -0,0 +1,21 @@ +#include "ui_presentation/agenda/agenda_countdown.h" +#include +#include +int main() +{ + char text[32]; + auto check = [&](int64_t start, int64_t now, bool valid, const char* expected) + { + ui::agenda::formatCountdown(start, now, valid, text, sizeof(text)); + assert(std::strcmp(text, expected) == 0); + }; + check(1, 0, true, "T-1m"); + check(60, 0, true, "T-1m"); + check(61, 0, true, "T-2m"); + check(7800, 0, true, "T-2h10m"); + check(90000, 0, true, "T-1d1h"); + check(0, 0, true, "T+0m"); + check(0, 61, true, "T+1m"); + check(0, 90000, true, "T+1d1h"); + check(100, 0, false, "--"); +} diff --git a/modules/ui_shared/tests/agenda/test_font_binary.cpp b/modules/ui_shared/tests/agenda/test_font_binary.cpp new file mode 100644 index 00000000..12609912 --- /dev/null +++ b/modules/ui_shared/tests/agenda/test_font_binary.cpp @@ -0,0 +1,107 @@ +#include "lvgl.h" +#ifdef AGENDA_FONT_MEMORY_PROBE +#include "font_memory_probe.h" +#endif + +#include +#include +#include +#include +#include +#include + +// Optional artifact test: load a generated pack with the real firmware LVGL +// loader and verify actual glyph mappings, not just advertised ranges.txt. +int main(int argc, char** argv) +{ + assert(argc == 3 || (argc == 4 && std::strcmp(argv[3], "--with-base") == 0)); + lv_init(); + lv_fs_drv_t driver; + lv_fs_drv_init(&driver); + driver.letter = 'T'; + driver.user_data = argv[1]; + driver.open_cb = [](lv_fs_drv_t* drv, const char*, lv_fs_mode_t mode) -> void* + { return mode == LV_FS_MODE_RD ? std::fopen(static_cast(drv->user_data), "rb") : nullptr; }; + driver.close_cb = [](lv_fs_drv_t*, void* file) + { return std::fclose(static_cast(file)) == 0 ? LV_FS_RES_OK : LV_FS_RES_FS_ERR; }; + driver.read_cb = [](lv_fs_drv_t*, void* file, void* buffer, uint32_t bytes, uint32_t* read) + { + *read = static_cast(std::fread(buffer, 1, bytes, static_cast(file))); + return std::ferror(static_cast(file)) ? LV_FS_RES_FS_ERR : LV_FS_RES_OK; + }; + driver.seek_cb = [](lv_fs_drv_t*, void* file, uint32_t offset, lv_fs_whence_t origin) + { + const int whence = origin == LV_FS_SEEK_SET ? SEEK_SET : origin == LV_FS_SEEK_CUR ? SEEK_CUR + : SEEK_END; + return std::fseek(static_cast(file), static_cast(offset), whence) == 0 ? LV_FS_RES_OK : LV_FS_RES_FS_ERR; + }; + driver.tell_cb = [](lv_fs_drv_t*, void* file, uint32_t* offset) + { + const long value = std::ftell(static_cast(file)); + if (value < 0) return LV_FS_RES_FS_ERR; + *offset = static_cast(value); + return LV_FS_RES_OK; + }; + lv_fs_drv_register(&driver); +#ifdef AGENDA_FONT_MEMORY_PROBE + agenda_test_fonts::beginMemoryProbe(); +#endif + auto* font = lv_binfont_create("T:font.bin"); + assert(font); + // Match font_utils: the built-in base is tried before the external pack. + lv_font_t composed = lv_font_montserrat_16; + composed.fallback = font; + const lv_font_t* tested = argc == 4 ? &composed : font; + std::ifstream input(argv[2], std::ios::binary); + assert(input); + const std::string charset{std::istreambuf_iterator(input), std::istreambuf_iterator()}; + unsigned checked = 0; + for (std::size_t index = 0; index < charset.size();) + { + const auto first = static_cast(charset[index++]); + uint32_t cp = first; + unsigned following = 0; + if (first >= 0xF0) + { + cp &= 0x07; + following = 3; + } + else if (first >= 0xE0) + { + cp &= 0x0F; + following = 2; + } + else if (first >= 0xC0) + { + cp &= 0x1F; + following = 1; + } + else assert(first < 0x80); + while (following--) + { + assert(index < charset.size()); + const auto byte = static_cast(charset[index++]); + assert((byte & 0xC0) == 0x80); + cp = (cp << 6) | (byte & 0x3F); + } + if (cp == '\r' || cp == '\n' || cp == 0xFEFF) continue; + lv_font_glyph_dsc_t glyph{}; + const bool found = lv_font_get_glyph_dsc(tested, &glyph, cp, 0); + if (!found || glyph.is_placeholder) std::fprintf(stderr, "Missing binary glyph U+%04lX\n", static_cast(cp)); + assert(found && !glyph.is_placeholder); + ++checked; + } + assert(checked > 0); + std::printf("Verified %u glyph mappings (%s): %s\n", checked, argc == 4 ? "base + binary pack" : "binary pack only", argv[1]); +#ifdef AGENDA_FONT_MEMORY_PROBE + const auto memory = agenda_test_fonts::memoryProbe(); + assert(memory.bytes > 0 && memory.allocations > 0); + std::printf("Font LVGL payload: retained=%lu allocations=%lu load_peak=%lu pointer_bits=%u\n", + static_cast(memory.bytes), static_cast(memory.allocations), + static_cast(memory.peak_bytes), unsigned(sizeof(void*) * 8)); +#endif + lv_binfont_destroy(font); +#ifdef AGENDA_FONT_MEMORY_PROBE + agenda_test_fonts::endMemoryProbe(); +#endif +} diff --git a/modules/ui_shared/tests/agenda/test_font_build_recipe.py b/modules/ui_shared/tests/agenda/test_font_build_recipe.py new file mode 100644 index 00000000..dbb31486 --- /dev/null +++ b/modules/ui_shared/tests/agenda/test_font_build_recipe.py @@ -0,0 +1,60 @@ +"""Offline checks for the existing pack-builder/generator boundary.""" + +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[4] +sys.path.insert(0, str(ROOT / "scripts")) +sys.path.insert(0, str(ROOT / "tools")) +import build_pack_repository as builder # noqa: E402 +import generate_binfont_with_lv_font_conv as generator # noqa: E402 + + +class FontBuildRecipe(unittest.TestCase): + def test_outline_fallbacks_are_forwarded_in_order(self): + with tempfile.TemporaryDirectory() as temporary: + stage = Path(temporary) + (stage / "build.ini").write_text( + "font=tools/fonts/NotoNaskhArabic-Regular.otf\n" + "fallback_font=tools/fonts/NotoSans-Regular.ttf,tools/fonts/NotoSansCJKsc-Regular.otf\n" + "size=16\nbpp=2\nno_compress=true\n", encoding="utf-8" + ) + (stage / "charset.txt").write_text("ب…", encoding="utf-8") + with patch.object(builder.subprocess, "run") as execute: + builder.build_font_if_missing(stage, ROOT, "node", None) + command = execute.call_args.args[0] + fallbacks = [command[i + 1] for i, flag in enumerate(command) if flag == "--fallback-font"] + self.assertEqual(fallbacks, [ + str(ROOT / "tools/fonts/NotoSans-Regular.ttf"), + str(ROOT / "tools/fonts/NotoSansCJKsc-Regular.otf"), + ]) + self.assertIn("--no-compress", command) + + def test_existing_single_font_recipe_stays_single(self): + with tempfile.TemporaryDirectory() as temporary: + stage = Path(temporary) + (stage / "build.ini").write_text("font=tools/fonts/NotoSans-Regular.ttf\n", encoding="utf-8") + (stage / "charset.txt").write_text("ą", encoding="utf-8") + with patch.object(builder.subprocess, "run") as execute: + builder.build_font_if_missing(stage, ROOT, "node", None) + self.assertNotIn("--fallback-font", execute.call_args.args[0]) + + def test_native_pixel_font_rejects_outline_fallback(self): + arguments = [ + "generator", "--font", str(ROOT / "tools/fonts/unifont-17.0.05.bdf.gz"), + "--fallback-font", str(ROOT / "tools/fonts/NotoSans-Regular.ttf"), + "--charset-file", str(ROOT / "packs/zh-Hans/fonts/tdeckpro-zh-hans-core/charset.txt"), + "--output", "unused.bin", + ] + with patch.object(sys, "argv", arguments), patch.object(generator, "resolve_executable", return_value="node"): + with patch.object(generator.subprocess, "run") as execute: + with self.assertRaisesRegex(ValueError, "native BDF glyphs must not be resampled"): + generator.main() + execute.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/modules/ui_shared/tests/agenda/test_font_scope.cpp b/modules/ui_shared/tests/agenda/test_font_scope.cpp new file mode 100644 index 00000000..5e1613d7 --- /dev/null +++ b/modules/ui_shared/tests/agenda/test_font_scope.cpp @@ -0,0 +1,44 @@ +#include "ui/assets/fonts/font_utils.h" +#include "ui/page/page_profile.h" +#include "ui/screens/agenda/agenda_page_components.h" + +#include +#include + +namespace agenda_test_fonts +{ +extern unsigned content_preparations; +} + +void testAgendaFontScope() +{ + const auto pager = ui::page_profile::make_pager_profile(); + const auto deck = ui::page_profile::make_tdeck_profile(); + for (const auto* profile : {&pager, &deck}) + { + ui::page_profile::set_active_profile(profile); + auto* display = lv_display_create(480, 240); + for (bool caption : {false, true}) + { + const auto* base = caption ? ui::page_profile::resolve_caption_font() + : ui::page_profile::resolve_body_font(); + const unsigned before = agenda_test_fonts::content_preparations; + auto* chrome = ui::agenda::page::components::addLabel(lv_screen_active(), "Today", true, caption); + assert(agenda_test_fonts::content_preparations == before); + auto* ui_binding = ui::fonts::find_localized_font_binding(base, ui::fonts::FontScope::Ui); + assert(ui_binding && lv_obj_get_style_text_font(chrome, LV_PART_MAIN) == &ui_binding->composed); + + // A user title must not inherit the interface-language font scope. + // Escapes keep this test independent of the Windows source codepage. + const char* title = "\xE9\x9B\x86\xE5\x90\x88"; + auto* content = ui::agenda::page::components::addLabel(lv_screen_active(), title, false, caption); + assert(agenda_test_fonts::content_preparations == before + 1); + auto* content_binding = ui::fonts::find_localized_font_binding(base, ui::fonts::FontScope::Content); + assert(content_binding && lv_obj_get_style_text_font(content, LV_PART_MAIN) == &content_binding->composed); + assert(content_binding != ui_binding); + assert(std::strcmp(lv_label_get_text(content), title) == 0); + } + lv_display_delete(display); + } + ui::page_profile::set_active_profile(nullptr); +} diff --git a/modules/ui_shared/tests/agenda/test_interruption_flow.cpp b/modules/ui_shared/tests/agenda/test_interruption_flow.cpp new file mode 100644 index 00000000..ed9f4c3b --- /dev/null +++ b/modules/ui_shared/tests/agenda/test_interruption_flow.cpp @@ -0,0 +1,188 @@ +#include "popup_test_support.h" +#include "product_composition/agenda_composition.h" +#include "ui/app_runtime.h" +#include "ui/page/page_profile.h" +#include "ui/screens/agenda/agenda_page_flow.h" +#include "ui/screens/agenda/agenda_page_runtime.h" + +#include +#include +#include + +namespace +{ +namespace page = ui::agenda::page; +class Store final : public agenda::IAgendaStore +{ + public: + agenda::EventRecord record{}; + unsigned writes = 0; + bool fail = false; + agenda::StoreResult readSlot(uint16_t, agenda::EventRecord& out) override + { + out = record; + return agenda::StoreResult::Ok; + } + agenda::StoreResult writeSlot(uint16_t, const agenda::EventRecord& value) override + { + ++writes; + if (fail) return agenda::StoreResult::IoError; + record = value; + return agenda::StoreResult::Ok; + } + agenda::StoreResult eraseSlot(uint16_t) override { return agenda::StoreResult::IoError; } + uint16_t slotCount() const override { return 1; } +}; +class Clock final : public agenda::IAgendaClock +{ + agenda::ClockSample sample() const override { return {1776330000, 0, 0, true}; } +}; +void action(page::Action value) +{ + page::runtime::request(value); + lv_tick_inc(60); + lv_timer_handler(); +} +} // namespace + +void testInterruptionFlow() +{ + for (bool wide : {false, true}) + { + const auto profile = wide ? ui::page_profile::make_pager_profile() : ui::page_profile::make_tdeck_profile(); + ui::page_profile::set_active_profile(&profile); + auto* display = lv_display_create(wide ? 480 : 320, wide ? 222 : 240); + auto* parent = lv_screen_active(); + Store store; + Clock clock; + product_composition::AgendaComposition composition(store, clock); + composition.setStorageReady(true); + ui::workspace::PresentationWorkspace workspace; + composition.bind(workspace); + page::Host host; + host.source = workspace.agenda; + host.actions = workspace.agenda; + page::Flow flow(host); + page::Flow::enter(&flow, parent); + + const auto interrupt = [&]() + { + // Exercise the real AppScreen exit/enter callbacks, in the order + // used by app_runtime. Only the external interruption app is fake. + agenda_test_ui::interruption = true; + page::Flow::exit(&flow, parent); + assert(!page::state() && lv_obj_get_child_count(parent) == 0); + auto* call = lv_obj_create(parent); + auto* call_group = lv_group_create(); + set_default_group(call_group); + flow.tick(); + composition.tick(false); // A submitted save may finish during a call. + assert(!page::state() && lv_group_get_default() == call_group); + lv_obj_delete(call); + set_default_group(nullptr); + lv_group_delete(call_group); + agenda_test_ui::interruption = false; + assert(!flow.needsActivation()); // Never steal focus from the shell. + flow.tick(); + assert(!page::state()); + page::Flow::enter(&flow, parent); + flow.tick(); + assert(page::state() && lv_group_get_default() == page::state()->group); + }; + + action(page::Action::NewEvent); + // Merely focus a field, without activating its picker, then interrupt. + lv_group_focus_obj(page::state()->editor_widgets.title); + for (const auto field : {ui::agenda::EditorField::Date, ui::agenda::EditorField::Time, + ui::agenda::EditorField::Reminder, ui::agenda::EditorField::Location, + ui::agenda::EditorField::Repeat}) + { + lv_group_focus_next(page::state()->group); + assert(page::state()->draft.focus == field); + interrupt(); + assert(page::state()->draft.focus == field); + assert(lv_group_get_focused(page::state()->group) == page::state()->editor_widgets.focus); + } + lv_textarea_set_text(page::state()->editor_widgets.title, "Draft interrupted before save"); + lv_textarea_set_text(page::state()->editor_widgets.note, "Not committed"); + interrupt(); + assert(page::state()->view == page::View::Editor); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), "Draft interrupted before save") == 0); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.note), "Not committed") == 0); + assert(store.writes == 0); + + // LVGL limits character count, storage limits UTF-8 bytes. Preserve the + // largest permitted live text even when it is not a valid saved draft. + std::string title, note; + for (unsigned i = 0; i < 39; ++i) title += "\xF0\x9F\x8F\x95"; + for (unsigned i = 0; i < 79; ++i) note += "\xF0\x9F\x8F\x95"; + lv_textarea_set_text(page::state()->editor_widgets.title, title.c_str()); + lv_textarea_set_text(page::state()->editor_widgets.note, note.c_str()); + action(page::Action::Back); + assert(page::state()->confirm_discard); + interrupt(); + assert(page::state()->confirm_discard && page::state()->editor_widgets.confirm_root); + action(page::Action::KeepEditing); + assert(title == lv_textarea_get_text(page::state()->editor_widgets.title)); + assert(note == lv_textarea_get_text(page::state()->editor_widgets.note)); + action(page::Action::Save); + assert(page::state()->error && !page::state()->awaiting_command && store.writes == 0); + + lv_textarea_set_text(page::state()->editor_widgets.title, "Recovered draft"); + lv_textarea_set_text(page::state()->editor_widgets.note, "Recovered note"); + action(page::Action::PickTime); + const int32_t values[] = {7, 23, 9, 41}; + for (unsigned i = 0; i < 4; ++i) lv_spinbox_set_value(page::state()->editor_widgets.clock_fields[i], values[i]); + lv_obj_add_state(page::state()->editor_widgets.end_enabled, LV_STATE_CHECKED); + interrupt(); + assert(page::state()->view == page::View::TimePicker); + for (unsigned i = 0; i < 4; ++i) assert(lv_spinbox_get_value(page::state()->editor_widgets.clock_fields[i]) == values[i]); + assert(lv_obj_has_state(page::state()->editor_widgets.end_enabled, LV_STATE_CHECKED)); + action(page::Action::Back); + action(page::Action::PickDate); + action(page::Action::NextMonth); + const auto date = page::state()->picker.date; + interrupt(); + assert(page::state()->view == page::View::DatePicker); + assert(page::state()->picker.date.year == date.year && page::state()->picker.date.month == date.month); + action(page::Action::Back); + + // A prepared reminder navigation must be cancelled, not resumed as a + // background Map launch after the high-priority app has taken over. + assert(!flow.prepareDestination(0, 0)); + interrupt(); + flow.finishDestination(false); + assert(page::state()->view == page::View::Editor && !flow.mapActive()); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), "Recovered draft") == 0); + + action(page::Action::PickLocation); + action(page::Action::ChooseOnMap); + flow.tick(); + assert(!page::state() && flow.mapActive()); + interrupt(); + assert(page::state()->view == page::View::Editor && !flow.mapActive()); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), "Recovered draft") == 0); + assert(store.writes == 0); + + store.fail = true; + action(page::Action::Save); + assert(page::state()->awaiting_command); + interrupt(); + lv_tick_inc(60); + lv_timer_handler(); + assert(!page::state()->awaiting_command && page::state()->error && store.writes == 1); + assert(page::state()->view == page::View::Editor); + store.fail = false; + action(page::Action::Save); + interrupt(); + lv_tick_inc(60); + lv_timer_handler(); + assert(page::state()->view == page::View::Agenda && store.writes == 2); + assert(std::strcmp(store.record.title, "Recovered draft") == 0); + + page::Flow::exit(&flow, parent); // Normal exit releases all return state. + assert(!flow.mapActive() && !page::state()); + ui::page_profile::set_active_profile(nullptr); + lv_display_delete(display); + } +} diff --git a/modules/ui_shared/tests/agenda/test_locale_coverage.py b/modules/ui_shared/tests/agenda/test_locale_coverage.py new file mode 100644 index 00000000..eb3fb028 --- /dev/null +++ b/modules/ui_shared/tests/agenda/test_locale_coverage.py @@ -0,0 +1,115 @@ +"""Source-key and advertised-glyph coverage, not a native-language review. + +Uses the existing locale validator/parser. This test deliberately reads live +Agenda sources rather than a second hand-maintained copy of the key list. +""" + +from pathlib import Path +import json +import re +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[4] +sys.path.insert(0, str(ROOT / "tools")) +from validate_locale_packs import ( # noqa: E402 + discover_locale_files, + parse_key_value_file, + parse_tsv, + range_spans_include, + validate_strings, +) + + +def agenda_keys() -> set[str]: + keys = {"Calendar", "Pick"} # App catalog and generic Map selection action. + source = ROOT / "modules/ui_shared/src/ui/screens/agenda" + for path in source.glob("*.cpp"): + for line in path.read_text(encoding="utf-8").splitlines(): + if line.lstrip().startswith(("#include", "//")) or "static_assert(" in line: + continue + # Dates/times/numeric coordinates are data, not translated prose. + if "std::snprintf(" in line: + continue + for key in re.findall(r'"([^"\r\n]+)"', line): + if re.search("[A-Za-z]", key): + keys.add(key) + return keys + + +def advertised_ranges(path: Path) -> list[tuple[int, int]]: + # Runtime range files may include comment lines (e.g. the Arabic pack). + text = ",".join(line.split("#", 1)[0] for line in path.read_text(encoding="utf-8").splitlines()) + spans = [] + for item in text.split(","): + item = item.strip() + if not item: + continue + bounds = item.split("-", 1) + spans.append((int(bounds[0], 16), int(bounds[-1], 16))) + return spans + + +class AgendaLocaleCoverage(unittest.TestCase): + def test_keys_and_structure(self): + self.assertEqual(validate_strings(ROOT / "packs"), []) + keys = agenda_keys() + self.assertIn("Choose on map", keys) + self.assertIn("Another reminder is already snoozed", keys) + for path in discover_locale_files(ROOT / "packs"): + with self.subTest(locale=path.parent.name): + rows, _, _ = parse_tsv(path) + self.assertEqual(sorted(keys - rows.keys()), []) + + def test_ui_font_ranges_cover_agenda_translations(self): + fonts = {} + for path in (ROOT / "packs").glob("*/fonts/*/manifest.ini"): + manifest = parse_key_value_file(path) + fonts[manifest.get("id", path.parent.name)] = path.parent + keys = agenda_keys() + for path in discover_locale_files(ROOT / "packs"): + rows, _, _ = parse_tsv(path) + manifest = parse_key_value_file(path.parent / "manifest.ini") + for field in ("ui_font_pack", "tdeck_pro_ui_font_pack"): + font_id = manifest.get(field) + if not font_id: + continue + with self.subTest(locale=path.parent.name, font=font_id): + ranges = advertised_ranges(fonts[font_id] / "ranges.txt") + chars = {ch for key in keys for ch in rows.get(key, "") if ord(ch) >= 128} + missing = sorted(ch for ch in chars if not range_spans_include(ranges, ch)) + self.assertEqual(missing, [], "Missing advertised UI glyphs: " + "".join(missing)) + + +def export_binary_font_cases(directory: Path) -> None: + """Generate artifact-test inputs from live translations, never a second key list.""" + fonts = {} + for path in (ROOT / "packs").glob("*/fonts/*/manifest.ini"): + manifest = parse_key_value_file(path) + fonts[manifest.get("id", path.parent.name)] = path.parent + characters = {} + keys = agenda_keys() + for path in discover_locale_files(ROOT / "packs"): + rows, _, _ = parse_tsv(path) + manifest = parse_key_value_file(path.parent / "manifest.ini") + for field in ("ui_font_pack", "tdeck_pro_ui_font_pack"): + font_id = manifest.get(field) + if font_id: + characters.setdefault(font_id, set()).update( + ch for key in keys for ch in rows.get(key, "") if ord(ch) >= 128 + ) + directory.mkdir(parents=True, exist_ok=True) + cases = [] + for font_id, chars in sorted(characters.items()): + font_dir = fonts[font_id] + charset = directory / (font_dir.name + "-agenda.txt") + charset.write_text("".join(sorted(chars)) + "\n", encoding="utf-8") + cases.append({"directory": str(font_dir), "charset": str(charset.resolve())}) + (directory / "cases.json").write_text(json.dumps(cases, indent=2), encoding="utf-8") + + +if __name__ == "__main__": + if len(sys.argv) == 3 and sys.argv[1] == "--export-font-cases": + export_binary_font_cases(Path(sys.argv[2])) + else: + unittest.main() diff --git a/modules/ui_shared/tests/agenda/test_map_flow.cpp b/modules/ui_shared/tests/agenda/test_map_flow.cpp new file mode 100644 index 00000000..c5a1ecca --- /dev/null +++ b/modules/ui_shared/tests/agenda/test_map_flow.cpp @@ -0,0 +1,368 @@ +#include "popup_test_support.h" +#include "product_composition/agenda_composition.h" +#include "ui/app_runtime.h" +#include "ui/page/page_profile.h" +#include "ui/screens/agenda/agenda_page_flow.h" +#include "ui/screens/agenda/agenda_page_runtime.h" +#include "ui/screens/gps/gps_page_runtime.h" +#include "ui_presentation/map/map_location_request.h" +#include "ui_presentation/map/map_target_request.h" + +#include +#include +#include +#include + +namespace +{ +namespace page = ui::agenda::page; +ui::map::MapLocationRequest* request = nullptr; +ui::map::MapTargetRequest* target = nullptr; +const ui::page::Host* navigation = nullptr; +lv_obj_t* map_tree = nullptr; +lv_group_t* map_group = nullptr; +bool available = true; +bool fail_enter = false; +unsigned enters = 0; +unsigned exits = 0; +class Store final : public agenda::IAgendaStore +{ + public: + unsigned writes = 0; + agenda::EventRecord records[2]{}; + agenda::StoreResult readSlot(uint16_t slot, agenda::EventRecord& out) override + { + out = records[slot]; + return agenda::StoreResult::Ok; + } + agenda::StoreResult writeSlot(uint16_t, const agenda::EventRecord&) override + { + ++writes; + return agenda::StoreResult::Ok; + } + agenda::StoreResult eraseSlot(uint16_t) override { return agenda::StoreResult::IoError; } + uint16_t slotCount() const override { return 2; } +}; +class Clock final : public agenda::IAgendaClock +{ + public: + agenda::ClockSample sample() const override { return {1776330000, 0, 0, true}; } +}; +void action(page::Action value) +{ + page::runtime::request(value); + lv_tick_inc(60); + lv_timer_handler(); +} +bool hasLabel(lv_obj_t* root, const char* text) +{ + if (lv_obj_check_type(root, &lv_label_class) && std::strcmp(lv_label_get_text(root), text) == 0) return true; + for (uint32_t i = 0; i < lv_obj_get_child_count(root); ++i) + if (hasLabel(lv_obj_get_child(root, i), text)) return true; + return false; +} +} // namespace + +// Substitute only the Map shell boundary: the real Agenda coordinator, LVGL +// tree, input groups, draft capture and presentation model run unchanged. +// This proves ownership/return semantics, not tile rendering or device input. +namespace gps::ui::runtime +{ +bool is_available() { return available; } +} // namespace gps::ui::runtime +namespace gps::ui::shell +{ +void enter_route(const RouteSpec* route, lv_obj_t* parent) +{ + assert(!page::state()); // Full editor tree and State have already gone. + assert(lv_obj_get_child_count(parent) == 0); + assert(!map_tree && !request && !target && route->projection == Projection::Map); + ++enters; + request = route->location; + target = route->target; + assert((request != nullptr) != (target != nullptr)); + navigation = route->host; + if (fail_enter) return; + if (request) request->result.state = ::ui::map::MapLocationSelectionState::Selecting; + if (target) target->entered = true; + map_tree = lv_obj_create(parent); + map_group = lv_group_create(); + set_default_group(map_group); + ui_set_overlay_active(true); +} +void exit_route(const RouteSpec*, lv_obj_t*) +{ + ++exits; + if (fail_enter && request) request->result.state = ::ui::map::MapLocationSelectionState::Cancelled; + if (map_tree) lv_obj_delete(map_tree); + if (map_group) + { + set_default_group(nullptr); + lv_group_delete(map_group); + } + map_tree = nullptr; + map_group = nullptr; + request = nullptr; + if (target) target->entered = false; + target = nullptr; + navigation = nullptr; + ui_set_overlay_active(false); +} +} // namespace gps::ui::shell + +void cancelTestMap() +{ + assert(request && navigation && !page::state()); + request->result.state = ui::map::MapLocationSelectionState::Cancelled; + ui::page::request_exit(navigation); +} +void assertTestMapTarget(double latitude, double longitude) +{ + assert(target && target->entered && !request && !page::state() && map_tree); + assert(target->viewport.center_lat == latitude && target->viewport.center_lon == longitude); +} +void returnFromTestMapTarget() +{ + assert(target && navigation && !page::state()); + ui::page::request_exit(navigation); +} + +void testMapFlow() +{ + for (const bool wide : {false, true}) + { + auto profile = wide ? ui::page_profile::make_pager_profile() : ui::page_profile::make_tdeck_profile(); + ui::page_profile::set_active_profile(&profile); + auto* display = lv_display_create(wide ? 480 : 320, wide ? 222 : 240); + auto* parent = lv_screen_active(); + Store store; + Clock clock; + product_composition::AgendaComposition composition(store, clock); + composition.setStorageReady(true); + ui::workspace::PresentationWorkspace workspace; + composition.bind(workspace); + page::Host host; + host.source = workspace.agenda; + host.actions = workspace.agenda; + page::Flow flow(host); + page::Flow::enter(&flow, parent); + action(page::Action::NewEvent); + lv_textarea_set_text(page::state()->editor_widgets.title, "Unsaved campsite"); + lv_textarea_set_text(page::state()->editor_widgets.note, "Keep this draft"); + action(page::Action::PickLocation); + action(page::Action::ChooseOnMap); + assert(flow.mapActive() && page::state()); // Request is deferred. + agenda_test_ui::interruption = true; + flow.tick(); + assert(page::state() && !request); + agenda_test_ui::interruption = false; + flow.tick(); + assert(!page::state() && request && map_tree); + assert(!request->has_initial_viewport); + request->result = {24.8731, 118.0, ui::map::MapLocationSelectionState::Picked}; + ui::page::request_exit(navigation); + assert(!page::state()); // Callback must not delete the active Map. + flow.tick(); + assert(!flow.mapActive() && !map_tree && !request); + assert(page::state()->view == page::View::Editor); + assert(lv_group_get_default() == page::state()->group); + const auto draft = page::state()->draft; + assert(std::strcmp(draft.event.title, "Unsaved campsite") == 0); + assert(std::strcmp(draft.event.note, "Keep this draft") == 0); + assert(draft.event.latitude_e7 == 248731000 && draft.event.longitude_e7 == 1180000000); + assert(draft.dirty && store.writes == 0); + + page::state()->editor_return = page::View::Detail; + action(page::Action::PickLocation); + action(page::Action::ChooseOnMap); + flow.tick(); + assert(request->has_initial_viewport && request->initial_viewport.center_lat == 24.8731); + request->result.state = ui::map::MapLocationSelectionState::Cancelled; + ui::page::request_exit(navigation); + flow.tick(); + assert(page::state()->editor_return == page::View::Detail); + assert(std::memcmp(&page::state()->draft.event, &draft.event, sizeof(draft.event)) == 0); + assert(store.writes == 0); + + available = false; + action(page::Action::PickLocation); + action(page::Action::ChooseOnMap); + assert(!flow.mapActive() && page::state()->error); + available = true; + fail_enter = true; + action(page::Action::ChooseOnMap); + flow.tick(); + flow.tick(); + assert(page::state() && page::state()->error && !flow.mapActive()); + assert(std::strcmp(page::state()->draft.event.title, "Unsaved campsite") == 0); + fail_enter = false; + + action(page::Action::PickLocation); + action(page::Action::ChooseOnMap); + flow.tick(); + assert(map_tree); + page::Flow::exit(&flow, parent); + assert(!page::state() && !map_tree && !flow.mapActive()); + assert(lv_obj_get_child_count(parent) == 0 && store.writes == 0); + // Closing a pending transition must discard only the return context. + page::Flow::enter(&flow, parent); + action(page::Action::NewEvent); + action(page::Action::PickLocation); + action(page::Action::ChooseOnMap); + page::Flow::exit(&flow, parent); + assert(!page::state() && !flow.mapActive()); + assert(enters == exits); + + // A non-first recurring occurrence must keep its date when Map has + // reconstructed the list and unsaved editing is subsequently discarded. + for (unsigned i = 0; i < 2; ++i) + { + auto& record = store.records[i]; + record.state = agenda::RecordState::Active; + record.id = i + 1; + record.start_time = clock.sample().calendar_seconds + (i + 1) * 3600; + std::snprintf(record.title, sizeof(record.title), "Event %u", i + 1); + } + auto& recurring = store.records[1]; + recurring.start_time -= 6 * 86400; + recurring.flags = agenda::HasEndTime; + recurring.end_time = recurring.start_time + 3600; + recurring.repeat = agenda::Repeat::Weekly; + page::Flow::enter(&flow, parent); + assert(page::state()->snapshot.page.count >= 2); + const auto occurrence = page::state()->snapshot.page.rows[1].occurrence; + assert(occurrence.event_id == 2 && occurrence.start != recurring.start_time); + agenda::CivilTime start, end; + assert(agenda::fromCalendarSeconds(occurrence.start, start)); + assert(agenda::fromCalendarSeconds(occurrence.end, end)); + char expected[48]; + std::snprintf(expected, sizeof(expected), "%04u-%02u-%02u %02u:%02u - %02u:%02u", + start.year, start.month, start.day, start.hour, start.minute, end.hour, end.minute); + page::runtime::request(page::Action::OpenRow, 1); + lv_tick_inc(60); + lv_timer_handler(); + assert(hasLabel(page::state()->root, expected)); + action(page::Action::EditEvent); + lv_textarea_set_text(page::state()->editor_widgets.title, "Do not save this title"); + action(page::Action::PickLocation); + action(page::Action::ChooseOnMap); + flow.tick(); + cancelTestMap(); + flow.tick(); + action(page::Action::Back); + assert(page::state()->confirm_discard); + action(page::Action::Discard); + assert(page::state()->view == page::View::Detail); + assert(hasLabel(page::state()->root, "Event 2")); + assert(hasLabel(page::state()->root, expected)); + assert(store.writes == 0); + assert(!hasLabel(page::state()->root, "Navigate")); + recurring.flags |= agenda::HasLocation; + recurring.latitude_e7 = 248731000; + recurring.longitude_e7 = 1180000000; + std::strcpy(recurring.location_name, "Campsite"); + page::runtime::request(page::Action::OpenRow, 1); + lv_tick_inc(60); + lv_timer_handler(); + assert(hasLabel(page::state()->root, "Navigate")); + action(page::Action::Navigate); + assert(page::state() && flow.mapActive()); + flow.tick(); + assert(!page::state() && target && !request && map_tree); + assert(target->viewport.center_lat == 24.8731 && target->viewport.center_lon == 118.0); + assert(std::strcmp(target->label.c_str(), "Campsite") == 0); + ui::page::request_exit(navigation); + assert(map_tree); // Back callback cannot tear down its own widget tree. + flow.tick(); + assert(page::state()->view == page::View::Detail && !target && !map_tree); + assert(hasLabel(page::state()->root, expected)); + assert(page::state()->draft.event.latitude_e7 == recurring.latitude_e7); + assert(store.writes == 0); + fail_enter = true; + action(page::Action::Navigate); + flow.tick(); + flow.tick(); + assert(page::state()->view == page::View::Detail && page::state()->error && !flow.mapActive()); + assert(hasLabel(page::state()->root, expected) && store.writes == 0); + fail_enter = false; + // Reminder navigation can interrupt a picker without accepting its + // provisional values or substituting the reminder for the draft. + action(page::Action::EditEvent); + lv_textarea_set_text(page::state()->editor_widgets.title, "Draft before reminder"); + action(page::Action::PickTime); + const auto original_start = page::state()->draft.event.start_time; + const int32_t times[] = {7, 23, 9, 41}; + for (unsigned i = 0; i < 4; ++i) lv_spinbox_set_value(page::state()->editor_widgets.clock_fields[i], times[i]); + lv_obj_add_state(page::state()->editor_widgets.end_enabled, LV_STATE_CHECKED); + assert(!flow.prepareDestination(0, 0)); + flow.tick(); + assert(page::state() && !target); // Prepared is not yet committed. + flow.finishDestination(false); + assert(!flow.mapActive() && page::state()->view == page::View::TimePicker); + assert(!flow.prepareDestination(0, 0)); + flow.finishDestination(true); + agenda_test_ui::transition = true; + flow.tick(); + assert(page::state() && !target); + agenda_test_ui::transition = false; + flow.tick(); + assertTestMapTarget(0, 0); + returnFromTestMapTarget(); + flow.tick(); + assert(page::state()->view == page::View::TimePicker); + for (unsigned i = 0; i < 4; ++i) assert(lv_spinbox_get_value(page::state()->editor_widgets.clock_fields[i]) == times[i]); + assert(lv_obj_has_state(page::state()->editor_widgets.end_enabled, LV_STATE_CHECKED)); + assert(page::state()->draft.event.start_time == original_start && store.writes == 0); + assert(std::strcmp(page::state()->draft.event.title, "Draft before reminder") == 0); + action(page::Action::Back); + action(page::Action::PickDate); + action(page::Action::NextMonth); + const auto month = page::state()->picker.date; + assert(!flow.prepareDestination(10000000, 20000000)); + flow.finishDestination(true); + flow.tick(); + returnFromTestMapTarget(); + flow.tick(); + assert(page::state()->view == page::View::DatePicker); + assert(page::state()->picker.date.month == month.month && page::state()->picker.date.year == month.year); + assert(page::state()->draft.event.start_time == original_start && store.writes == 0); + // Media loss closes an active Map before releasing its return context. + assert(!flow.prepareDestination(10000000, 20000000)); + flow.finishDestination(true); + flow.tick(); + assert(map_tree && target && !page::state()); + composition.setStorageReady(false); + flow.invalidateStorage(); + assert(!map_tree && !target && !request && !flow.mapActive()); + assert(page::state() && page::state()->view == page::View::Agenda); + assert(!page::state()->snapshot.storage_ready && page::state()->draft.event.id == 0); + assert(lv_group_get_default() == page::state()->group); + composition.setStorageReady(true); + page::runtime::refresh(); + action(page::Action::NewEvent); + lv_textarea_set_text(page::state()->editor_widgets.title, "Old medium draft"); + page::runtime::request(page::Action::Save); // Still only a UI action. + composition.setStorageReady(false); + flow.invalidateStorage(); + composition.setStorageReady(true); + lv_tick_inc(60); + lv_timer_handler(); + composition.tick(false); + assert(store.writes == 0 && page::state()->view == page::View::Agenda); + action(page::Action::NewEvent); + agenda_test_ui::interruption = true; + page::Flow::exit(&flow, parent); + assert(!page::state() && flow.mapActive()); + composition.setStorageReady(false); + flow.invalidateStorage(); + assert(!page::state() && !flow.mapActive() && !flow.needsActivation()); + agenda_test_ui::interruption = false; + composition.setStorageReady(true); + page::Flow::enter(&flow, parent); + flow.tick(); + assert(page::state()->view == page::View::Agenda && page::state()->draft.event.id == 0); + page::Flow::exit(&flow, parent); + ui::page_profile::set_active_profile(nullptr); + lv_display_delete(display); + } +} diff --git a/modules/ui_shared/tests/agenda/test_support.cpp b/modules/ui_shared/tests/agenda/test_support.cpp new file mode 100644 index 00000000..0c9e899e --- /dev/null +++ b/modules/ui_shared/tests/agenda/test_support.cpp @@ -0,0 +1,59 @@ +#include "ui/assets/fonts/font_utils.h" +#include "ui/widgets/top_bar_power_presenter.h" + +namespace agenda_test_fonts +{ +unsigned content_preparations = 0; +ui::fonts::LocalizedFontBinding bindings[12]{}; +} // namespace agenda_test_fonts + +// Host adapters only: production TopBar, layout, fonts routing and Agenda page +// sources are compiled unchanged. External locale packs and board power are +// outside this Latin-font renderer/input test. +namespace ui::i18n +{ +bool test_pinyin_enabled = false; +const char* active_ime_pack_id() { return test_pinyin_enabled ? "pinyin" : ""; } +std::size_t ime_count() { return test_pinyin_enabled ? 1 : 0; } +const ImeInfo* ime_at(std::size_t index) +{ + static const ImeInfo info{"pinyin", "Pinyin", "builtin-pinyin", nullptr, true}; + return index == 0 && test_pinyin_enabled ? &info : nullptr; +} +bool ime_enabled(const char*) { return test_pinyin_enabled; } +void set_content_label_text_raw(lv_obj_t* label, const char* text) { lv_label_set_text(label, text); } +std::string format(const char* fmt, ...) +{ + char buffer[256]{}; + va_list args; + va_start(args, fmt); + std::vsnprintf(buffer, sizeof(buffer), fmt, args); + va_end(args); + return buffer; +} +const char* tr(const char* text) { return text; } +void set_label_text(lv_obj_t* label, const char* text) { lv_label_set_text(label, text); } +const lv_font_t* active_ui_font_fallback() { return nullptr; } +const lv_font_t* active_content_font_fallback() { return nullptr; } +bool ensure_content_font_for_text(const char*) +{ + ++agenda_test_fonts::content_preparations; + return true; +} +} // namespace ui::i18n +namespace ui::fonts +{ +LocalizedFontBinding* localized_font_binding_storage() { return agenda_test_fonts::bindings; } +std::size_t localized_font_binding_storage_size() { return 12; } +} // namespace ui::fonts +namespace ui::widgets::top_bar_power +{ +void bind(TopBar& bar) { lv_label_set_text(bar.right_label, "85%"); } +void unbind(TopBar&) {} +} // namespace ui::widgets::top_bar_power +void set_default_group(lv_group_t* group) +{ + lv_group_set_default(group); + for (auto* device = lv_indev_get_next(nullptr); device; device = lv_indev_get_next(device)) + if (lv_indev_get_type(device) != LV_INDEV_TYPE_POINTER) lv_indev_set_group(device, group); +} diff --git a/modules/ui_shared/tests/agenda/test_text_snapshot.cpp b/modules/ui_shared/tests/agenda/test_text_snapshot.cpp new file mode 100644 index 00000000..d1d6612d --- /dev/null +++ b/modules/ui_shared/tests/agenda/test_text_snapshot.cpp @@ -0,0 +1,62 @@ +#include "ui/screens/agenda/agenda_text_snapshot.h" +#include +#include + +using ui::agenda::page::TextSnapshot; +template +std::string decode(const TextSnapshot& value) +{ + std::string result; + assert(value.forEachUtf8([&](const char* text) + { result += text; return true; })); + return result; +} + +int main() +{ + TextSnapshot<79> snapshot; + assert(decode(snapshot).empty()); + const std::string boundary = "A\x7F\xC2\x80\xDF\xBF\xE0\xA0\x80\xED\x9F\xBF\xEE\x80\x80\xEF\xBF\xBF\xF0\x90\x80\x80\xF4\x8F\xBF\xBF"; + assert(snapshot.assign(boundary.c_str())); + assert(decode(snapshot) == boundary); + std::string maximum; + for (unsigned i = 0; i < 79; ++i) maximum += "\xF0\x9F\x98\x80"; + assert(snapshot.assign(maximum.c_str())); + assert(decode(snapshot) == maximum); + assert(!snapshot.assign((maximum + "x").c_str())); + assert(decode(snapshot) == maximum); + const char* invalid[] = {nullptr, "\x80", "\xC0\x80", "\xC1\xBF", "\xF5\x80\x80\x80", + "\xE0\x80\x80", "\xF0\x80\x80\x80", "\xED\xA0\x80", "\xF4\x90\x80\x80", + "\xC2", "\xE1\x80", "\xF1\x80\x80", "\xC2x"}; + for (const char* text : invalid) + { + assert(!snapshot.assign(text)); + assert(decode(snapshot) == maximum); + } + unsigned calls = 0; + assert(!snapshot.forEachUtf8([&](const char*) + { ++calls; return false; })); + assert(calls == 1); + assert(snapshot.assign("")); + assert(decode(snapshot).empty()); + + // Exercise every non-NUL Unicode scalar, independently encoding the input. + TextSnapshot<1> scalar; + for (uint32_t code = 1; code <= 0x10FFFF; ++code) + { + if (code >= 0xD800 && code <= 0xDFFF) continue; + std::string text; + if (code < 128) text += static_cast(code); + else + { + const unsigned count = code < 0x800 ? 2 : code < 0x10000 ? 3 + : 4; + text += static_cast((count == 2 ? 0xC0 : count == 3 ? 0xE0 + : 0xF0) | + (code >> (6 * (count - 1)))); + for (unsigned i = count - 1; i; --i) text += static_cast(0x80 | ((code >> (6 * (i - 1))) & 63)); + } + assert(scalar.assign(text.c_str())); + assert(decode(scalar) == text); + } +} diff --git a/modules/ui_shared/tests/agenda/test_touch_agenda.cpp b/modules/ui_shared/tests/agenda/test_touch_agenda.cpp new file mode 100644 index 00000000..ed43cfc2 --- /dev/null +++ b/modules/ui_shared/tests/agenda/test_touch_agenda.cpp @@ -0,0 +1,294 @@ +#include "popup_test_support.h" +#include "product_composition/agenda_composition.h" +#include "ui/page/page_profile.h" +#include "ui/screens/agenda/agenda_page_flow.h" +#include "ui/screens/agenda/agenda_page_runtime.h" +#include "ui/widgets/ime/ime_widget.h" +#include "ui_lvgl_ux_packs/common/input_layout.h" +#include "ui_lvgl_ux_packs/common/touch_text_editor.h" +#include "ui_presentation/waypoint/waypoint_model.h" + +#include +#include +#include + +namespace ui::i18n +{ +extern bool test_pinyin_enabled; +} + +namespace +{ +namespace page = ui::agenda::page; +class Store final : public agenda::IAgendaStore +{ + public: + agenda::EventRecord record{}; + unsigned writes = 0; + agenda::StoreResult readSlot(uint16_t, agenda::EventRecord& out) override + { + out = record; + return agenda::StoreResult::Ok; + } + agenda::StoreResult writeSlot(uint16_t, const agenda::EventRecord& value) override + { + record = value; + ++writes; + return agenda::StoreResult::Ok; + } + agenda::StoreResult eraseSlot(uint16_t) override { return agenda::StoreResult::IoError; } + uint16_t slotCount() const override { return 1; } +}; +class Clock final : public agenda::IAgendaClock +{ + agenda::ClockSample sample() const override { return {1776330000, 0, 0, true}; } +}; +class WaypointStore final : public waypoint::IStore +{ + public: + waypoint::Record record{}; + unsigned writes = 0; + uint16_t slotCount() const override { return 1; } + waypoint::Result read(uint16_t, waypoint::Record& out) override + { + out = record; + return waypoint::Result::Ok; + } + waypoint::Result create(const waypoint::Record& value, uint32_t& id) override + { + record = value; + record.id = id = 1; + ++writes; + return waypoint::Result::Ok; + } + waypoint::Result update(const waypoint::Record&) override { return waypoint::Result::IoError; } + waypoint::Result remove(uint32_t) override { return waypoint::Result::IoError; } +}; +lv_obj_t* find(lv_obj_t* object, const lv_obj_class_t* type, const char* text = nullptr) +{ + if (lv_obj_check_type(object, type) && (!text || std::strcmp(lv_label_get_text(object), text) == 0)) return object; + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) + if (auto* found = find(lv_obj_get_child(object, i), type, text)) return found; + return nullptr; +} +void click(lv_obj_t* root, const char* text) +{ + auto* label = find(root, &lv_label_class, text); + assert(label && lv_obj_check_type(lv_obj_get_parent(label), &lv_button_class)); + lv_obj_send_event(lv_obj_get_parent(label), LV_EVENT_CLICKED, nullptr); +} +void key(const char* text) +{ + auto* matrix = find(lv_layer_top(), &lv_buttonmatrix_class); + assert(matrix); + const char* const* map = lv_buttonmatrix_get_map(matrix); + uint32_t button = 0; + for (unsigned i = 0; map[i][0]; ++i) + { + if (std::strcmp(map[i], "\n") == 0) continue; + if (std::strcmp(map[i], text) == 0) + { + lv_obj_send_event(matrix, LV_EVENT_VALUE_CHANGED, &button); + return; + } + ++button; + } + assert(false && "Keyboard key missing"); +} +void pump() +{ + lv_tick_inc(60); + lv_timer_handler(); +} +void open(lv_obj_t* source) +{ + lv_obj_send_event(source, LV_EVENT_CLICKED, nullptr); + lv_obj_update_layout(lv_layer_top()); + auto* matrix = find(lv_layer_top(), &lv_buttonmatrix_class); + assert(matrix && find(lv_layer_top(), &lv_textarea_class)); + lv_area_t area; + lv_obj_get_coords(matrix, &area); + assert(area.x1 >= 0 && area.y1 >= 0 && area.x2 < 320 && area.y2 < 240); +} +} // namespace + +void testTouchAgenda() +{ + ui_lvgl_ux::DeviceUxProfile ux; + ux.screen_class = ui_lvgl_ux::ScreenClass::DeckLandscape; + ux.input_model = ui_lvgl_ux::InputModel::Touch; + ui_lvgl_ux::configureInputLayout(ux); + assert(ui::page_profile::current().compact_touch_keyboard); + auto* display = lv_display_create(320, 240); + Store store; + Clock clock; + product_composition::AgendaComposition composition(store, clock); + composition.setStorageReady(true); + ui::workspace::PresentationWorkspace workspace; + composition.bind(workspace); + page::Host host; + host.source = workspace.agenda; + host.actions = workspace.agenda; + WaypointStore waypoint_store; + ui::waypoint::Model waypoints(waypoint_store); + waypoints.setReady(true); + host.waypoints = &waypoints; + host.waypoint_actions = &waypoints; + page::Flow flow(host); + page::Flow::enter(&flow, lv_screen_active()); + click(page::state()->root, "New"); + pump(); + auto* title = page::state()->editor_widgets.title; + open(title); + key("r"); + key("a"); + key("d"); + key("i"); + key("o"); + assert(std::strcmp(lv_textarea_get_text(title), "") == 0); + assert(std::strcmp(lv_textarea_get_text(find(lv_layer_top(), &lv_textarea_class)), "radio") == 0); + agenda_test_ui::interruption = true; + page::Flow::exit(&flow, lv_screen_active()); + assert(!page::state() && lv_obj_get_child_count(lv_layer_top()) == 0); + agenda_test_ui::interruption = false; + page::Flow::enter(&flow, lv_screen_active()); + flow.tick(); + title = page::state()->editor_widgets.title; + auto* resumed_text = find(lv_layer_top(), &lv_textarea_class); + assert(resumed_text && std::strcmp(lv_textarea_get_text(resumed_text), "radio") == 0); + assert(std::strcmp(lv_textarea_get_text(title), "") == 0); + click(lv_layer_top(), "Cancel"); + assert(lv_obj_get_child_count(lv_layer_top()) == 0 && store.writes == 0); + assert(std::strcmp(lv_textarea_get_text(title), "") == 0); + + open(title); + key("r"); + key("a"); + key("d"); + key("i"); + key("o"); + click(lv_layer_top(), "OK"); + assert(std::strcmp(lv_textarea_get_text(title), "radio") == 0 && store.writes == 0); + open(page::state()->editor_widgets.note); + key("o"); + key("k"); + click(lv_layer_top(), "OK"); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.note), "ok") == 0); + click(page::state()->root, "Save"); + pump(); + assert(page::state()->awaiting_command); + composition.tick(false); + pump(); + assert(page::state()->view == page::View::Agenda && store.writes == 1); + assert(std::strcmp(store.record.title, "radio") == 0 && std::strcmp(store.record.note, "ok") == 0); + assert((store.record.flags & agenda::HasNote) && !(store.record.flags & agenda::HasLocation)); + click(page::state()->root, "New"); + pump(); + const auto interrupt = [&]() + { + agenda_test_ui::interruption = true; + page::Flow::exit(&flow, lv_screen_active()); + assert(!page::state() && lv_obj_get_child_count(lv_layer_top()) == 0); + agenda_test_ui::interruption = false; + page::Flow::enter(&flow, lv_screen_active()); + flow.tick(); + assert(page::state() && find(lv_layer_top(), &lv_textarea_class)); + }; + open(page::state()->editor_widgets.note); + click(lv_layer_top(), "EN"); // No script enabled: cycles to numeric mode. + key("Shift"); + key("["); + auto* modal = find(lv_layer_top(), &lv_textarea_class); + lv_textarea_set_cursor_pos(modal, 0); + char captured[320]{}; + ui::widgets::ImeEditState editing; + char guard[] = {'x', 'y'}; + assert(ui::widgets::capture_touch_text_editor(page::state()->editor_widgets.note, guard, 1, editing) == + ui::widgets::TouchEditorCapture::InsufficientCapacity); + assert(guard[0] == 'x' && guard[1] == 'y'); + interrupt(); + assert(ui::widgets::capture_touch_text_editor(page::state()->editor_widgets.note, captured, sizeof(captured), editing) == + ui::widgets::TouchEditorCapture::Captured); + assert(std::strcmp(captured, "[") == 0 && editing.shift && editing.cursor == 0); + assert(editing.mode == static_cast(ui::widgets::ImeWidget::Mode::NUM)); + key("]"); // Restored symbol keyboard, not its default alphabetic layout. + click(lv_layer_top(), "OK"); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.note), "[]") == 0); + + ui::i18n::test_pinyin_enabled = true; + open(page::state()->editor_widgets.title); + click(lv_layer_top(), "EN"); + key("n"); + key("i"); + assert(find(lv_layer_top(), &lv_label_class, "\xE4\xBD\xA0")); + interrupt(); + assert(ui::widgets::capture_touch_text_editor(page::state()->editor_widgets.title, captured, sizeof(captured), editing) == + ui::widgets::TouchEditorCapture::Captured); + assert(captured[0] == '\0' && std::strcmp(editing.composition, "ni") == 0); + click(lv_layer_top(), "\xE6\xB3\xA5"); + click(lv_layer_top(), "OK"); + assert(std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), "\xE6\xB3\xA5") == 0); + assert(store.writes == 1); // Neither recovery nor OK saves the event. + ui::i18n::test_pinyin_enabled = false; + std::string long_title, original_note, modal_note; + for (unsigned i = 0; i < 39; ++i) long_title += "\xF0\x9F\x8F\x95"; + for (unsigned i = 0; i < 79; ++i) + { + original_note += "\xF0\x9F\x8F\x95"; + modal_note += "\xF0\x9F\x8C\xB2"; + } + lv_textarea_set_text(page::state()->editor_widgets.title, long_title.c_str()); + lv_textarea_set_text(page::state()->editor_widgets.note, original_note.c_str()); + open(page::state()->editor_widgets.note); + lv_textarea_set_text(find(lv_layer_top(), &lv_textarea_class), modal_note.c_str()); + interrupt(); + assert(modal_note == lv_textarea_get_text(find(lv_layer_top(), &lv_textarea_class))); + assert(original_note == lv_textarea_get_text(page::state()->editor_widgets.note)); + assert(long_title == lv_textarea_get_text(page::state()->editor_widgets.title)); + click(lv_layer_top(), "Cancel"); + assert(original_note == lv_textarea_get_text(page::state()->editor_widgets.note)); + // Name a location through the production picker and compact touch editor. + // Modal OK updates the field only; only Save may enqueue persistence. + lv_textarea_set_text(page::state()->editor_widgets.title, "Walk"); + lv_textarea_set_text(page::state()->editor_widgets.note, ""); + page::state()->draft.event.flags |= agenda::HasLocation; + const auto action = [](page::Action value) + { + page::runtime::request(value); + pump(); + }; + action(page::Action::PickLocation); + action(page::Action::PickWaypoint); + action(page::Action::NameWaypoint); + assert(page::state()->view == page::View::WaypointName); + lv_textarea_set_text(page::state()->editor_widgets.title, "Camp"); + open(page::state()->editor_widgets.title); + lv_textarea_set_cursor_pos(find(lv_layer_top(), &lv_textarea_class), LV_TEXTAREA_CURSOR_LAST); + key("x"); + interrupt(); + assert(!std::strcmp(lv_textarea_get_text(find(lv_layer_top(), &lv_textarea_class)), "Campx")); + assert(!std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), "Camp")); + click(lv_layer_top(), "Cancel"); + assert(!std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), "Camp")); + assert(waypoint_store.writes == 0 && store.writes == 1); + open(page::state()->editor_widgets.title); + lv_textarea_set_cursor_pos(find(lv_layer_top(), &lv_textarea_class), LV_TEXTAREA_CURSOR_LAST); + key("x"); + interrupt(); + click(lv_layer_top(), "OK"); + assert(!std::strcmp(lv_textarea_get_text(page::state()->editor_widgets.title), "Campx")); + assert(waypoint_store.writes == 0 && store.writes == 1); + click(page::state()->root, "Save"); + pump(); + assert(page::state()->awaiting_command && waypoint_store.writes == 0); + waypoints.pump(); + pump(); + assert(page::state()->view == page::View::Editor); + assert(waypoint_store.writes == 1 && store.writes == 1); + assert(!std::strcmp(waypoint_store.record.name, "Campx")); + assert(!std::strcmp(page::state()->draft.event.location_name, "Campx")); + page::Flow::exit(&flow, lv_screen_active()); + assert(!page::state() && lv_obj_get_child_count(lv_layer_top()) == 0); + ui::page_profile::set_active_profile(nullptr); + lv_display_delete(display); +} diff --git a/modules/ui_shared/tests/agenda/verify_font_artifacts.py b/modules/ui_shared/tests/agenda/verify_font_artifacts.py new file mode 100644 index 00000000..fc0caf46 --- /dev/null +++ b/modules/ui_shared/tests/agenda/verify_font_artifacts.py @@ -0,0 +1,49 @@ +"""Opt-in glyph and RAM-metadata checks against locally generated font binaries. + +First export cases with test_locale_coverage.py --export-font-cases DIRECTORY, +then build the corresponding binaries in DIRECTORY using their build.ini recipes. +No downloads or font generation are performed by this verifier. +""" + +import argparse +import json +from pathlib import Path +import re +import subprocess + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cases", type=Path, required=True) + parser.add_argument("--tester", type=Path, required=True) + args = parser.parse_args() + cases = json.loads(args.cases.read_text(encoding="utf-8")) + if not cases: + raise RuntimeError("No font artifact cases") + for case in cases: + directory = Path(case["directory"]) + manifest = dict( + line.split("=", 1) + for line in (directory / "manifest.ini").read_text(encoding="utf-8").splitlines() + if "=" in line and not line.lstrip().startswith(("#", ";")) + ) + artifact = args.cases.parent / f"{directory.name}.bin" + result = subprocess.run( + [str(args.tester.resolve()), str(artifact.resolve()), case["charset"]], + check=True, capture_output=True, text=True, + ) + print(result.stdout, end="") + measured = re.search(r"retained=(\d+) allocations=\d+ load_peak=\d+ pointer_bits=(\d+)", result.stdout) + if not measured or measured[2] != "32": + raise RuntimeError("ESP font budget verification requires a 32-bit GNU memory-probe build") + # Round retained payload upward to 1 KiB, then reserve a further 1 KiB + # for allocator/ABI variance. This is an estimate, not a heap watermark. + minimum = ((int(measured[1]) + 1023) // 1024 + 1) * 1024 + declared = int(manifest["estimated_ram_bytes"]) + if declared < minimum: + raise RuntimeError(f"{directory.name}: declared {declared}, estimated minimum {minimum}") + print(f"RAM manifest OK: {declared} B (estimated minimum {minimum} B)") + + +if __name__ == "__main__": + main() diff --git a/modules/ui_shared/tests/test_runtime_map_presentation_adapters.cpp b/modules/ui_shared/tests/test_runtime_map_presentation_adapters.cpp index bb30812e..4851fc90 100644 --- a/modules/ui_shared/tests/test_runtime_map_presentation_adapters.cpp +++ b/modules/ui_shared/tests/test_runtime_map_presentation_adapters.cpp @@ -2,6 +2,7 @@ #include "sys/clock.h" #include "ui/presentation_sources/runtime_map_workspace_source.h" #include "ui_presentation/common/fixed_text.h" +#include "ui_presentation/map/map_workspace_model.h" #include #include @@ -215,5 +216,26 @@ int main() assert(source.buildMapWorkspaceSnapshot(request, snapshot)); assert(!snapshot.team.available); + // Location selection uses the real runtime state/source/sink path. Once + // the renderer commits a centre, a moving GPS fix must not replace it. + ui::map::MapWorkspaceModel model(source, sink); + assert(model.setActiveTool(ui::map::MapToolKind::MeasureDistance).ok); + assert(model.beginLocationSelection().ok); + assert(state.active_tool == ui::map::MapToolKind::SelectLocation); + assert(model.setViewport(viewport(0.0, 0.0, 12)).ok); + gps.snapshot.latitude = 35.0; + gps.snapshot.longitude = 110.0; + assert(model.pickLocation().ok); + const auto selected = model.locationSelection(); + assert(selected.state == ui::map::MapLocationSelectionState::Picked); + assert(selected.latitude == 0.0 && selected.longitude == 0.0); + assert(state.active_tool == ui::map::MapToolKind::MeasureDistance); + assert(state.layers.terrain && !state.layers.contour); + assert(model.beginLocationSelection().ok); + assert(model.setViewport(viewport(-30.0, -120.0, 10)).ok); + assert(model.cancelLocationSelection().ok); + assert(model.locationSelection().state == ui::map::MapLocationSelectionState::Cancelled); + assert(state.active_tool == ui::map::MapToolKind::MeasureDistance); + return 0; } diff --git a/packs/ar/fonts/ar-naskh/LICENSE.txt b/packs/ar/fonts/ar-naskh/LICENSE.txt new file mode 100644 index 00000000..c82d72e4 --- /dev/null +++ b/packs/ar/fonts/ar-naskh/LICENSE.txt @@ -0,0 +1,94 @@ +Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts) + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packs/ar/fonts/ar-naskh/build.ini b/packs/ar/fonts/ar-naskh/build.ini index 152f68b4..e232a42d 100644 --- a/packs/ar/fonts/ar-naskh/build.ini +++ b/packs/ar/fonts/ar-naskh/build.ini @@ -1,7 +1,9 @@ font=tools/fonts/NotoNaskhArabic-Regular.otf +fallback_font=tools/fonts/NotoSans-Regular.ttf size=16 bpp=2 no_compress=true +seed_charset_file=charset.txt ; Arabic needs presentation forms for correct shaping without HarfBuzz. ; charset.txt should include both base Arabic block (0600-06FF) and ; Presentation Forms-A/B (FB50-FDFF, FE70-FEFF) for contextual glyphs. diff --git a/packs/ar/fonts/ar-naskh/charset.txt b/packs/ar/fonts/ar-naskh/charset.txt index a6aba338..59177af3 100644 --- a/packs/ar/fonts/ar-naskh/charset.txt +++ b/packs/ar/fonts/ar-naskh/charset.txt @@ -1,18 +1,18 @@ -؀؁؂؃؄؅؆؇؈؉؊؋،؍؎؏ؘؙؚؐؑؒؓؔؕؖؗ؛؜؝؞؟ؠءآأؤإئابةتثجحخدذرزسشصضطظعغػؼؽؾؿ -ـفقكلمنهوىيًٌٍَُِّْٕٖٜٟٓٔٗ٘ٙٚٛٝٞ٠١٢٣٤٥٦٧٨٩٪٫٬٭ٮٯٰٱٲٳٴٵٶٷٸٹٺٻټٽپٿ -ڀځڂڃڄڅچڇڈډڊڋڌڍڎڏڐڑڒړڔڕږڗژڙښڛڜڝڞڟڠڡڢڣڤڥڦڧڨکڪګڬڭڮگڰڱڲڳڴڵڶڷڸڹںڻڼڽھڿ -ۀہۂۃۄۅۆۇۈۉۊۋیۍێۏېۑےۓ۔ەۖۗۘۙۚۛۜ۝۞ۣ۟۠ۡۢۤۥۦۧۨ۩۪ۭ۫۬ۮۯ۰۱۲۳۴۵۶۷۸۹ۺۻۼ۽۾ۿ -ݐݑݒݓݔݕݖݗݘݙݚݛݜݝݞݟݠݡݢݣݤݥݦݧݨݩݪݫݬݭݮݯݰݱݲݳݴݵݶݷݸݹݺݻݼݽݾݿﭐﭑﭒﭓﭔﭕﭖﭗﭘﭙﭚﭛﭜﭝﭞﭟ -ﭠﭡﭢﭣﭤﭥﭦﭧﭨﭩﭪﭫﭬﭭﭮﭯﭰﭱﭲﭳﭴﭵﭶﭷﭸﭹﭺﭻﭼﭽﭾﭿﮀﮁﮂﮃﮄﮅﮆﮇﮈﮉﮊﮋﮌﮍﮎﮏﮐﮑﮒﮓﮔﮕﮖﮗﮘﮙﮚﮛﮜﮝﮞﮟ -ﮠﮡﮢﮣﮤﮥﮦﮧﮨﮩﮪﮫﮬﮭﮮﮯﮰﮱ﮲﮳﮴﮵﮶﮷﮸﮹﮺﮻﮼﮽﮾﮿﯀﯁﯂﯃﯄﯅﯆﯇﯈﯉﯊﯋﯌﯍﯎﯏﯐﯑﯒ﯓﯔﯕﯖﯗﯘﯙﯚﯛﯜﯝﯞﯟ -ﯠﯡﯢﯣﯤﯥﯦﯧﯨﯩﯪﯫﯬﯭﯮﯯﯰﯱﯲﯳﯴﯵﯶﯷﯸﯹﯺﯻﯼﯽﯾﯿﰀﰁﰂﰃﰄﰅﰆﰇﰈﰉﰊﰋﰌﰍﰎﰏﰐﰑﰒﰓﰔﰕﰖﰗﰘﰙﰚﰛﰜﰝﰞﰟ -ﰠﰡﰢﰣﰤﰥﰦﰧﰨﰩﰪﰫﰬﰭﰮﰯﰰﰱﰲﰳﰴﰵﰶﰷﰸﰹﰺﰻﰼﰽﰾﰿﱀﱁﱂﱃﱄﱅﱆﱇﱈﱉﱊﱋﱌﱍﱎﱏﱐﱑﱒﱓﱔﱕﱖﱗﱘﱙﱚﱛﱜﱝﱞﱟ -ﱠﱡﱢﱣﱤﱥﱦﱧﱨﱩﱪﱫﱬﱭﱮﱯﱰﱱﱲﱳﱴﱵﱶﱷﱸﱹﱺﱻﱼﱽﱾﱿﲀﲁﲂﲃﲄﲅﲆﲇﲈﲉﲊﲋﲌﲍﲎﲏﲐﲑﲒﲓﲔﲕﲖﲗﲘﲙﲚﲛﲜﲝﲞﲟ -ﲠﲡﲢﲣﲤﲥﲦﲧﲨﲩﲪﲫﲬﲭﲮﲯﲰﲱﲲﲳﲴﲵﲶﲷﲸﲹﲺﲻﲼﲽﲾﲿﳀﳁﳂﳃﳄﳅﳆﳇﳈﳉﳊﳋﳌﳍﳎﳏﳐﳑﳒﳓﳔﳕﳖﳗﳘﳙﳚﳛﳜﳝﳞﳟ -ﳠﳡﳢﳣﳤﳥﳦﳧﳨﳩﳪﳫﳬﳭﳮﳯﳰﳱﳲﳳﳴﳵﳶﳷﳸﳹﳺﳻﳼﳽﳾﳿﴀﴁﴂﴃﴄﴅﴆﴇﴈﴉﴊﴋﴌﴍﴎﴏﴐﴑﴒﴓﴔﴕﴖﴗﴘﴙﴚﴛﴜﴝﴞﴟ -ﴠﴡﴢﴣﴤﴥﴦﴧﴨﴩﴪﴫﴬﴭﴮﴯﴰﴱﴲﴳﴴﴵﴶﴷﴸﴹﴺﴻﴼﴽ﴾﴿﵀﵁﵂﵃﵄﵅﵆﵇﵈﵉﵊﵋﵌﵍﵎﵏ﵐﵑﵒﵓﵔﵕﵖﵗﵘﵙﵚﵛﵜﵝﵞﵟ -ﵠﵡﵢﵣﵤﵥﵦﵧﵨﵩﵪﵫﵬﵭﵮﵯﵰﵱﵲﵳﵴﵵﵶﵷﵸﵹﵺﵻﵼﵽﵾﵿﶀﶁﶂﶃﶄﶅﶆﶇﶈﶉﶊﶋﶌﶍﶎﶏ﶐﶑ﶒﶓﶔﶕﶖﶗﶘﶙﶚﶛﶜﶝﶞﶟ -ﶠﶡﶢﶣﶤﶥﶦﶧﶨﶩﶪﶫﶬﶭﶮﶯﶰﶱﶲﶳﶴﶵﶶﶷﶸﶹﶺﶻﶼﶽﶾﶿﷀﷁﷂﷃﷄﷅﷆﷇ﷈﷉﷊﷋﷌﷍﷎﷏﷐﷑﷒﷓﷔﷕﷖﷗﷘﷙﷚﷛﷜﷝﷞﷟ -﷠﷡﷢﷣﷤﷥﷦﷧﷨﷩﷪﷫﷬﷭﷮﷯ﷰﷱﷲﷳﷴﷵﷶﷷﷸﷹﷺﷻ﷼﷽﷾﷿ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏ -ﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏ -ﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ﻽﻾ +°؀؁؂؃؄؅؆؇؈؉؊؋،؍؎؏ؘؙؚؐؑؒؓؔؕؖؗ؛؜؝؞؟ؠءآأؤإئابةتثجحخدذرزسشصضطظعغػؼؽؾ +ؿـفقكلمنهوىيًٌٍَُِّْٕٖٜٟٓٔٗ٘ٙٚٛٝٞ٠١٢٣٤٥٦٧٨٩٪٫٬٭ٮٯٰٱٲٳٴٵٶٷٸٹٺٻټٽپ +ٿڀځڂڃڄڅچڇڈډڊڋڌڍڎڏڐڑڒړڔڕږڗژڙښڛڜڝڞڟڠڡڢڣڤڥڦڧڨکڪګڬڭڮگڰڱڲڳڴڵڶڷڸڹںڻڼڽھ +ڿۀہۂۃۄۅۆۇۈۉۊۋیۍێۏېۑےۓ۔ەۖۗۘۙۚۛۜ۝۞ۣ۟۠ۡۢۤۥۦۧۨ۩۪ۭ۫۬ۮۯ۰۱۲۳۴۵۶۷۸۹ۺۻۼ۽۾ +ۿݐݑݒݓݔݕݖݗݘݙݚݛݜݝݞݟݠݡݢݣݤݥݦݧݨݩݪݫݬݭݮݯݰݱݲݳݴݵݶݷݸݹݺݻݼݽݾݿ…ﭐﭑﭒﭓﭔﭕﭖﭗﭘﭙﭚﭛﭜﭝ +ﭞﭟﭠﭡﭢﭣﭤﭥﭦﭧﭨﭩﭪﭫﭬﭭﭮﭯﭰﭱﭲﭳﭴﭵﭶﭷﭸﭹﭺﭻﭼﭽﭾﭿﮀﮁﮂﮃﮄﮅﮆﮇﮈﮉﮊﮋﮌﮍﮎﮏﮐﮑﮒﮓﮔﮕﮖﮗﮘﮙﮚﮛﮜﮝ +ﮞﮟﮠﮡﮢﮣﮤﮥﮦﮧﮨﮩﮪﮫﮬﮭﮮﮯﮰﮱ﮲﮳﮴﮵﮶﮷﮸﮹﮺﮻﮼﮽﮾﮿﯀﯁﯂﯃﯄﯅﯆﯇﯈﯉﯊﯋﯌﯍﯎﯏﯐﯑﯒ﯓﯔﯕﯖﯗﯘﯙﯚﯛﯜﯝ +ﯞﯟﯠﯡﯢﯣﯤﯥﯦﯧﯨﯩﯪﯫﯬﯭﯮﯯﯰﯱﯲﯳﯴﯵﯶﯷﯸﯹﯺﯻﯼﯽﯾﯿﰀﰁﰂﰃﰄﰅﰆﰇﰈﰉﰊﰋﰌﰍﰎﰏﰐﰑﰒﰓﰔﰕﰖﰗﰘﰙﰚﰛﰜﰝ +ﰞﰟﰠﰡﰢﰣﰤﰥﰦﰧﰨﰩﰪﰫﰬﰭﰮﰯﰰﰱﰲﰳﰴﰵﰶﰷﰸﰹﰺﰻﰼﰽﰾﰿﱀﱁﱂﱃﱄﱅﱆﱇﱈﱉﱊﱋﱌﱍﱎﱏﱐﱑﱒﱓﱔﱕﱖﱗﱘﱙﱚﱛﱜﱝ +ﱞﱟﱠﱡﱢﱣﱤﱥﱦﱧﱨﱩﱪﱫﱬﱭﱮﱯﱰﱱﱲﱳﱴﱵﱶﱷﱸﱹﱺﱻﱼﱽﱾﱿﲀﲁﲂﲃﲄﲅﲆﲇﲈﲉﲊﲋﲌﲍﲎﲏﲐﲑﲒﲓﲔﲕﲖﲗﲘﲙﲚﲛﲜﲝ +ﲞﲟﲠﲡﲢﲣﲤﲥﲦﲧﲨﲩﲪﲫﲬﲭﲮﲯﲰﲱﲲﲳﲴﲵﲶﲷﲸﲹﲺﲻﲼﲽﲾﲿﳀﳁﳂﳃﳄﳅﳆﳇﳈﳉﳊﳋﳌﳍﳎﳏﳐﳑﳒﳓﳔﳕﳖﳗﳘﳙﳚﳛﳜﳝ +ﳞﳟﳠﳡﳢﳣﳤﳥﳦﳧﳨﳩﳪﳫﳬﳭﳮﳯﳰﳱﳲﳳﳴﳵﳶﳷﳸﳹﳺﳻﳼﳽﳾﳿﴀﴁﴂﴃﴄﴅﴆﴇﴈﴉﴊﴋﴌﴍﴎﴏﴐﴑﴒﴓﴔﴕﴖﴗﴘﴙﴚﴛﴜﴝ +ﴞﴟﴠﴡﴢﴣﴤﴥﴦﴧﴨﴩﴪﴫﴬﴭﴮﴯﴰﴱﴲﴳﴴﴵﴶﴷﴸﴹﴺﴻﴼﴽ﴾﴿﵀﵁﵂﵃﵄﵅﵆﵇﵈﵉﵊﵋﵌﵍﵎﵏ﵐﵑﵒﵓﵔﵕﵖﵗﵘﵙﵚﵛﵜﵝ +ﵞﵟﵠﵡﵢﵣﵤﵥﵦﵧﵨﵩﵪﵫﵬﵭﵮﵯﵰﵱﵲﵳﵴﵵﵶﵷﵸﵹﵺﵻﵼﵽﵾﵿﶀﶁﶂﶃﶄﶅﶆﶇﶈﶉﶊﶋﶌﶍﶎﶏ﶐﶑ﶒﶓﶔﶕﶖﶗﶘﶙﶚﶛﶜﶝ +ﶞﶟﶠﶡﶢﶣﶤﶥﶦﶧﶨﶩﶪﶫﶬﶭﶮﶯﶰﶱﶲﶳﶴﶵﶶﶷﶸﶹﶺﶻﶼﶽﶾﶿﷀﷁﷂﷃﷄﷅﷆﷇ﷈﷉﷊﷋﷌﷍﷎﷏﷐﷑﷒﷓﷔﷕﷖﷗﷘﷙﷚﷛﷜﷝ +﷞﷟﷠﷡﷢﷣﷤﷥﷦﷧﷨﷩﷪﷫﷬﷭﷮﷯ﷰﷱﷲﷳﷴﷵﷶﷷﷸﷹﷺﷻ﷼﷽﷾﷿ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍ +ﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍ +ﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ﻽﻾ diff --git a/packs/ar/fonts/ar-naskh/manifest.ini b/packs/ar/fonts/ar-naskh/manifest.ini index 8563f3b6..52b175ae 100644 --- a/packs/ar/fonts/ar-naskh/manifest.ini +++ b/packs/ar/fonts/ar-naskh/manifest.ini @@ -2,7 +2,7 @@ kind=font id=ar-naskh display_name=Arabic Naskh usage=both -estimated_ram_bytes=18432 +estimated_ram_bytes=52224 source=binfont file=font.bin ranges=ranges.txt diff --git a/packs/ar/fonts/ar-naskh/ranges.txt b/packs/ar/fonts/ar-naskh/ranges.txt index e8b1bf06..129eea07 100644 --- a/packs/ar/fonts/ar-naskh/ranges.txt +++ b/packs/ar/fonts/ar-naskh/ranges.txt @@ -1,10 +1 @@ -# Arabic (core letters, diacritics, digits, punctuation) -0600-06FF -# Arabic Supplement -0750-077F -# Arabic Presentation Forms-A (ligatures, contextual) -FB50-FDFF -# Arabic Presentation Forms-B (isolated/final/medial/initial) -FE70-FEFF -# Basic Latin (for mixed-script UI: numbers, punctuation, Latin text) -0020-007F +0xB0,0x600-0x6FF,0x750-0x77F,0x2026,0xFB50-0xFDFF,0xFE70-0xFEFF diff --git a/packs/ar/locales/ar/strings.tsv b/packs/ar/locales/ar/strings.tsv index 3defe1b5..f0ca28c3 100644 --- a/packs/ar/locales/ar/strings.tsv +++ b/packs/ar/locales/ar/strings.tsv @@ -758,3 +758,72 @@ Start recording بدء التسجيل Stop recording إيقاف التسجيل Not recording لا يجري التسجيل Add KML files to the routes folder أضف ملفات KML إلى مجلد المسارات + +# Agenda / Calendar +1 day before قبل يوم واحد +1 hour before قبل ساعة واحدة +10 min before قبل 10 دقائق +30 min before قبل 30 دقيقة +Agenda جدول المواعيد +Agenda storage unavailable تخزين المواعيد غير متاح +Another reminder is already snoozed يوجد تذكير آخر مؤجل بالفعل +At time في الموعد +Changes apply to the entire series تسري التغييرات على السلسلة بأكملها +Choose on map اختيار على الخريطة +Could not read events تعذرت قراءة المواعيد +Could not save changes تعذر حفظ التغييرات +Could not update reminder تعذر تحديث التذكير +Current position الموقع الحالي +Current position unavailable الموقع الحالي غير متاح +Daily يوميًا +Date or time is out of range التاريخ أو الوقت خارج النطاق +Delete this event? هل تريد حذف هذا الموعد؟ +Discard تجاهل +Discard changes? هل تريد تجاهل التغييرات؟ +Done تم +Edit Event تعديل الموعد +End <= start: next day النهاية قبل البداية أو تساويها: اليوم التالي +End time (optional) وقت النهاية (اختياري) +Enter a title and valid date/time أدخل عنوانًا وتاريخًا ووقتًا صالحين +Event Detail تفاصيل الموعد +Event is unavailable الموعد غير متاح +Event Reminder تذكير بالموعد +Fri الجمعة +Invalid location موقع غير صالح +Keep editing متابعة التعديل +Location is optional الموقع اختياري +Map is unavailable on this target. الخريطة غير متاحة على هذا الجهاز. +Mon الاثنين +Monthly شهريًا +More المزيد +Navigate الملاحة +New Event موعد جديد +No upcoming events لا توجد مواعيد قادمة +Note ملاحظة +Pick Date اختيار التاريخ +Pick Time اختيار الوقت +Reminder تذكير +Remove location إزالة الموقع +Repeat التكرار +Sat السبت +Saving... جارٍ الحفظ… +Set the device time first اضبط وقت الجهاز أولًا +Snooze 10 min تأجيل 10 دقائق +Start time وقت البداية +Sun الأحد +Text exceeds the UTF-8 byte limit النص يتجاوز حد بايتات UTF-8 +Thu الخميس +Title العنوان +Today اليوم +Tomorrow غدًا +Tue الثلاثاء +Wed الأربعاء +Weekly أسبوعيًا +Yearly سنويًا +Calendar التقويم +Pick اختيار +Could not read waypoints تعذر قراءة نقاط الطريق +First الأولى +No saved waypoints لا توجد نقاط طريق محفوظة +Saved waypoint نقطة طريق محفوظة +Waypoint unavailable نقطة الطريق غير متاحة diff --git a/packs/ar/package.ini b/packs/ar/package.ini index 5a4440a0..c4b04394 100644 --- a/packs/ar/package.ini +++ b/packs/ar/package.ini @@ -1,7 +1,7 @@ kind=package id=ar package_type=locale-bundle -version=1.2.0 +version=1.3.0 display_name=Arabic summary=Arabic review locale bundle with Naskh font coverage for RTL UI text and mixed-script content rendering; display-only until Arabic input is implemented. description=DESCRIPTION.txt diff --git a/packs/europe-cyrillic-ext/fonts/cyrillic-eu/build.ini b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/build.ini index d0889975..dd5356ec 100644 --- a/packs/europe-cyrillic-ext/fonts/cyrillic-eu/build.ini +++ b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/build.ini @@ -2,3 +2,4 @@ font=tools/fonts/NotoSansCJKsc-Regular.otf size=16 bpp=2 no_compress=true +seed_charset_file=charset.txt diff --git a/packs/europe-cyrillic-ext/fonts/cyrillic-eu/charset.txt b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/charset.txt index 351311f7..09e8537c 100644 --- a/packs/europe-cyrillic-ext/fonts/cyrillic-eu/charset.txt +++ b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/charset.txt @@ -1 +1,2 @@ -ЁАБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюяё +«°»ЁАБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъы +ьэюяё–—…≤ diff --git a/packs/europe-cyrillic-ext/fonts/cyrillic-eu/manifest.ini b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/manifest.ini index 1acd8b13..71e6189c 100644 --- a/packs/europe-cyrillic-ext/fonts/cyrillic-eu/manifest.ini +++ b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/manifest.ini @@ -2,7 +2,7 @@ kind=font id=cyrillic-eu display_name=Cyrillic EU usage=both -estimated_ram_bytes=12000 +estimated_ram_bytes=5120 source=binfont file=font.bin ranges=ranges.txt diff --git a/packs/europe-cyrillic-ext/fonts/cyrillic-eu/ranges.txt b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/ranges.txt index d39d06ed..9199ee3b 100644 --- a/packs/europe-cyrillic-ext/fonts/cyrillic-eu/ranges.txt +++ b/packs/europe-cyrillic-ext/fonts/cyrillic-eu/ranges.txt @@ -1 +1 @@ -0x401,0x410-0x44F,0x451 +0xAB,0xB0,0xBB,0x401,0x410-0x44F,0x451,0x2013-0x2014,0x2026,0x2264 diff --git a/packs/europe-cyrillic-ext/locales/ru/strings.tsv b/packs/europe-cyrillic-ext/locales/ru/strings.tsv index feb16c2a..4c0dae4b 100644 --- a/packs/europe-cyrillic-ext/locales/ru/strings.tsv +++ b/packs/europe-cyrillic-ext/locales/ru/strings.tsv @@ -758,3 +758,72 @@ Start recording Начать запись Stop recording Остановить запись Not recording Запись не ведётся Add KML files to the routes folder Добавьте файлы KML в папку маршрутов + +# Agenda / Calendar +1 day before За 1 день +1 hour before За 1 час +10 min before За 10 мин +30 min before За 30 мин +Agenda Расписание +Agenda storage unavailable Хранилище событий недоступно +Another reminder is already snoozed Другое напоминание уже отложено +At time В момент начала +Changes apply to the entire series Изменения применяются ко всей серии +Choose on map Выбрать на карте +Could not read events Не удалось прочитать события +Could not save changes Не удалось сохранить изменения +Could not update reminder Не удалось обновить напоминание +Current position Текущая позиция +Current position unavailable Текущая позиция недоступна +Daily Ежедневно +Date or time is out of range Дата или время вне допустимого диапазона +Delete this event? Удалить это событие? +Discard Отбросить +Discard changes? Отбросить изменения? +Done Готово +Edit Event Изменить событие +End <= start: next day Конец ≤ начала: следующий день +End time (optional) Время окончания (необязательно) +Enter a title and valid date/time Введите название и корректные дату и время +Event Detail Сведения о событии +Event is unavailable Событие недоступно +Event Reminder Напоминание о событии +Fri Пт +Invalid location Недопустимое место +Keep editing Продолжить редактирование +Location is optional Место указывать необязательно +Map is unavailable on this target. Карта недоступна на этом устройстве. +Mon Пн +Monthly Ежемесячно +More Ещё +Navigate Навигация +New Event Новое событие +No upcoming events Предстоящих событий нет +Note Заметка +Pick Date Выбрать дату +Pick Time Выбрать время +Reminder Напоминание +Remove location Убрать место +Repeat Повтор +Sat Сб +Saving... Сохранение… +Set the device time first Сначала установите время устройства +Snooze 10 min Отложить на 10 мин +Start time Время начала +Sun Вс +Text exceeds the UTF-8 byte limit Текст превышает лимит байтов UTF-8 +Thu Чт +Title Название +Today Сегодня +Tomorrow Завтра +Tue Вт +Wed Ср +Weekly Еженедельно +Yearly Ежегодно +Calendar Календарь +Pick Выбрать +Could not read waypoints Не удалось прочитать точки +First Первая +No saved waypoints Нет сохранённых точек +Saved waypoint Сохранённая точка +Waypoint unavailable Точка недоступна diff --git a/packs/europe-cyrillic-ext/package.ini b/packs/europe-cyrillic-ext/package.ini index ae61952d..b2273d28 100644 --- a/packs/europe-cyrillic-ext/package.ini +++ b/packs/europe-cyrillic-ext/package.ini @@ -1,7 +1,7 @@ kind=package id=europe-cyrillic-ext package_type=locale-bundle -version=1.3.0 +version=1.4.0 display_name=European Cyrillic Extended summary=Shared Cyrillic font coverage, a Russian review locale pack, and a Russian Cyrillic keyboard layout. description=DESCRIPTION.txt diff --git a/packs/europe-latin-ext/fonts/latin-ext-eu/LICENSE.txt b/packs/europe-latin-ext/fonts/latin-ext-eu/LICENSE.txt new file mode 100644 index 00000000..c82d72e4 --- /dev/null +++ b/packs/europe-latin-ext/fonts/latin-ext-eu/LICENSE.txt @@ -0,0 +1,94 @@ +Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts) + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packs/europe-latin-ext/fonts/latin-ext-eu/build.ini b/packs/europe-latin-ext/fonts/latin-ext-eu/build.ini index d0889975..e40eae2e 100644 --- a/packs/europe-latin-ext/fonts/latin-ext-eu/build.ini +++ b/packs/europe-latin-ext/fonts/latin-ext-eu/build.ini @@ -1,4 +1,6 @@ -font=tools/fonts/NotoSansCJKsc-Regular.otf +font=tools/fonts/NotoSans-Regular.ttf +fallback_font=tools/fonts/NotoSansCJKsc-Regular.otf size=16 bpp=2 no_compress=true +seed_charset_file=charset.txt diff --git a/packs/europe-latin-ext/fonts/latin-ext-eu/charset.txt b/packs/europe-latin-ext/fonts/latin-ext-eu/charset.txt index dfac4ca7..672fee04 100644 --- a/packs/europe-latin-ext/fonts/latin-ext-eu/charset.txt +++ b/packs/europe-latin-ext/fonts/latin-ext-eu/charset.txt @@ -1 +1 @@ -¿ÀÉßàáãäçèéêñóõöúüąćꣳńŚśźż +°¿ÀÁÃÄÇÉÊÍÓÕÖÚÜßàáâãäçèéêëíîïñòóôõöùúüĄąćĘꣳńŒœŚśŹźŻż–’…≤ diff --git a/packs/europe-latin-ext/fonts/latin-ext-eu/manifest.ini b/packs/europe-latin-ext/fonts/latin-ext-eu/manifest.ini index 7b2ee99d..935bc634 100644 --- a/packs/europe-latin-ext/fonts/latin-ext-eu/manifest.ini +++ b/packs/europe-latin-ext/fonts/latin-ext-eu/manifest.ini @@ -2,7 +2,7 @@ kind=font id=latin-ext-eu display_name=Latin Extended EU usage=both -estimated_ram_bytes=941 +estimated_ram_bytes=4096 source=binfont file=font.bin ranges=ranges.txt diff --git a/packs/europe-latin-ext/fonts/latin-ext-eu/ranges.txt b/packs/europe-latin-ext/fonts/latin-ext-eu/ranges.txt index 3c3597ea..add9dd1a 100644 --- a/packs/europe-latin-ext/fonts/latin-ext-eu/ranges.txt +++ b/packs/europe-latin-ext/fonts/latin-ext-eu/ranges.txt @@ -1 +1 @@ -0xBF-0xC0,0xC9,0xDF-0xE1,0xE3-0xE4,0xE7-0xEA,0xF1,0xF3,0xF5-0xF6,0xFA,0xFC,0x105,0x107,0x119,0x141-0x142,0x144,0x15A-0x15B,0x17A,0x17C +0xB0,0xBF-0xC1,0xC3-0xC4,0xC7,0xC9-0xCA,0xCD,0xD3,0xD5-0xD6,0xDA,0xDC,0xDF-0xE4,0xE7-0xEB,0xED-0xEF,0xF1-0xF6,0xF9-0xFA,0xFC,0x104-0x105,0x107,0x118-0x119,0x141-0x142,0x144,0x152-0x153,0x15A-0x15B,0x179-0x17C,0x2013,0x2019,0x2026,0x2264 diff --git a/packs/europe-latin-ext/locales/de/strings.tsv b/packs/europe-latin-ext/locales/de/strings.tsv index 0732fed0..6327f606 100644 --- a/packs/europe-latin-ext/locales/de/strings.tsv +++ b/packs/europe-latin-ext/locales/de/strings.tsv @@ -758,3 +758,72 @@ Start recording Aufzeichnung starten Stop recording Aufzeichnung stoppen Not recording Keine Aufzeichnung Add KML files to the routes folder KML-Dateien im Routenordner ablegen + +# Agenda / Calendar +1 day before 1 Tag vorher +1 hour before 1 Stunde vorher +10 min before 10 Min. vorher +30 min before 30 Min. vorher +Agenda Terminübersicht +Agenda storage unavailable Terminspeicher nicht verfügbar +Another reminder is already snoozed Eine andere Erinnerung ist bereits verschoben +At time Zum Beginn +Changes apply to the entire series Änderungen gelten für die gesamte Serie +Choose on map Auf Karte wählen +Could not read events Termine konnten nicht gelesen werden +Could not save changes Änderungen konnten nicht gespeichert werden +Could not update reminder Erinnerung konnte nicht aktualisiert werden +Current position Aktuelle Position +Current position unavailable Aktuelle Position nicht verfügbar +Daily Täglich +Date or time is out of range Datum oder Uhrzeit außerhalb des Bereichs +Delete this event? Diesen Termin löschen? +Discard Verwerfen +Discard changes? Änderungen verwerfen? +Done Fertig +Edit Event Termin bearbeiten +End <= start: next day Ende ≤ Beginn: nächster Tag +End time (optional) Endzeit (optional) +Enter a title and valid date/time Titel und gültiges Datum/Uhrzeit eingeben +Event Detail Termindetails +Event is unavailable Termin nicht verfügbar +Event Reminder Terminerinnerung +Fri Fr +Invalid location Ungültiger Ort +Keep editing Weiter bearbeiten +Location is optional Ort ist optional +Map is unavailable on this target. Karte auf diesem Gerät nicht verfügbar. +Mon Mo +Monthly Monatlich +More Mehr +Navigate Navigieren +New Event Neuer Termin +No upcoming events Keine anstehenden Termine +Note Notiz +Pick Date Datum wählen +Pick Time Uhrzeit wählen +Reminder Erinnerung +Remove location Ort entfernen +Repeat Wiederholung +Sat Sa +Saving... Wird gespeichert… +Set the device time first Zuerst Gerätezeit einstellen +Snooze 10 min In 10 Min. erinnern +Start time Startzeit +Sun So +Text exceeds the UTF-8 byte limit Text überschreitet das UTF-8-Byte-Limit +Thu Do +Title Titel +Today Heute +Tomorrow Morgen +Tue Di +Wed Mi +Weekly Wöchentlich +Yearly Jährlich +Calendar Kalender +Pick Auswählen +Could not read waypoints Wegpunkte konnten nicht gelesen werden +First Erste +No saved waypoints Keine gespeicherten Wegpunkte +Saved waypoint Gespeicherter Wegpunkt +Waypoint unavailable Wegpunkt nicht verfügbar diff --git a/packs/europe-latin-ext/locales/es/strings.tsv b/packs/europe-latin-ext/locales/es/strings.tsv index 68ba7ce3..2dc35f82 100644 --- a/packs/europe-latin-ext/locales/es/strings.tsv +++ b/packs/europe-latin-ext/locales/es/strings.tsv @@ -758,3 +758,72 @@ Start recording Iniciar registro Stop recording Detener registro Not recording Sin registrar Add KML files to the routes folder Añade archivos KML a la carpeta de rutas + +# Agenda / Calendar +1 day before 1 día antes +1 hour before 1 hora antes +10 min before 10 min antes +30 min before 30 min antes +Agenda Agenda +Agenda storage unavailable Almacenamiento de eventos no disponible +Another reminder is already snoozed Ya hay otro recordatorio pospuesto +At time A la hora +Changes apply to the entire series Los cambios se aplican a toda la serie +Choose on map Elegir en el mapa +Could not read events No se pudieron leer los eventos +Could not save changes No se pudieron guardar los cambios +Could not update reminder No se pudo actualizar el recordatorio +Current position Posición actual +Current position unavailable Posición actual no disponible +Daily Diariamente +Date or time is out of range Fecha u hora fuera de rango +Delete this event? ¿Eliminar este evento? +Discard Descartar +Discard changes? ¿Descartar los cambios? +Done Hecho +Edit Event Editar evento +End <= start: next day Fin ≤ inicio: día siguiente +End time (optional) Hora de fin (opcional) +Enter a title and valid date/time Introduce un título y una fecha y hora válidas +Event Detail Detalles del evento +Event is unavailable Evento no disponible +Event Reminder Recordatorio de evento +Fri Vie +Invalid location Ubicación no válida +Keep editing Seguir editando +Location is optional La ubicación es opcional +Map is unavailable on this target. El mapa no está disponible en este dispositivo. +Mon Lun +Monthly Mensualmente +More Más +Navigate Navegar +New Event Nuevo evento +No upcoming events No hay próximos eventos +Note Nota +Pick Date Elegir fecha +Pick Time Elegir hora +Reminder Recordatorio +Remove location Quitar ubicación +Repeat Repetir +Sat Sáb +Saving... Guardando… +Set the device time first Configura primero la hora del dispositivo +Snooze 10 min Posponer 10 min +Start time Hora de inicio +Sun Dom +Text exceeds the UTF-8 byte limit El texto supera el límite de bytes UTF-8 +Thu Jue +Title Título +Today Hoy +Tomorrow Mañana +Tue Mar +Wed Mié +Weekly Semanalmente +Yearly Anualmente +Calendar Calendario +Pick Elegir +Could not read waypoints No se pudieron leer los puntos +First Primera +No saved waypoints No hay puntos guardados +Saved waypoint Punto guardado +Waypoint unavailable Punto no disponible diff --git a/packs/europe-latin-ext/locales/fr/strings.tsv b/packs/europe-latin-ext/locales/fr/strings.tsv index 20245325..026ecf48 100644 --- a/packs/europe-latin-ext/locales/fr/strings.tsv +++ b/packs/europe-latin-ext/locales/fr/strings.tsv @@ -758,3 +758,72 @@ Start recording Démarrer le suivi Stop recording Arrêter le suivi Not recording Suivi inactif Add KML files to the routes folder Ajoutez les fichiers KML au dossier des itinéraires + +# Agenda / Calendar +1 day before 1 jour avant +1 hour before 1 heure avant +10 min before 10 min avant +30 min before 30 min avant +Agenda Agenda +Agenda storage unavailable Stockage des événements indisponible +Another reminder is already snoozed Un autre rappel est déjà reporté +At time À l'heure +Changes apply to the entire series Les modifications concernent toute la série +Choose on map Choisir sur la carte +Could not read events Impossible de lire les événements +Could not save changes Impossible d'enregistrer les modifications +Could not update reminder Impossible de mettre à jour le rappel +Current position Position actuelle +Current position unavailable Position actuelle indisponible +Daily Chaque jour +Date or time is out of range Date ou heure hors limites +Delete this event? Supprimer cet événement ? +Discard Abandonner +Discard changes? Abandonner les modifications ? +Done Terminé +Edit Event Modifier l'événement +End <= start: next day Fin ≤ début : jour suivant +End time (optional) Heure de fin (facultative) +Enter a title and valid date/time Saisissez un titre et une date/heure valides +Event Detail Détails de l'événement +Event is unavailable Événement indisponible +Event Reminder Rappel d'événement +Fri Ven +Invalid location Lieu non valide +Keep editing Continuer la modification +Location is optional Le lieu est facultatif +Map is unavailable on this target. Carte indisponible sur cet appareil. +Mon Lun +Monthly Chaque mois +More Plus +Navigate Naviguer +New Event Nouvel événement +No upcoming events Aucun événement à venir +Note Note +Pick Date Choisir la date +Pick Time Choisir l'heure +Reminder Rappel +Remove location Retirer le lieu +Repeat Répétition +Sat Sam +Saving... Enregistrement… +Set the device time first Réglez d'abord l'heure de l'appareil +Snooze 10 min Reporter de 10 min +Start time Heure de début +Sun Dim +Text exceeds the UTF-8 byte limit Le texte dépasse la limite d'octets UTF-8 +Thu Jeu +Title Titre +Today Aujourd'hui +Tomorrow Demain +Tue Mar +Wed Mer +Weekly Chaque semaine +Yearly Chaque année +Calendar Calendrier +Pick Choisir +Could not read waypoints Lecture des points impossible +First Première +No saved waypoints Aucun point enregistré +Saved waypoint Point enregistré +Waypoint unavailable Point indisponible diff --git a/packs/europe-latin-ext/locales/it/strings.tsv b/packs/europe-latin-ext/locales/it/strings.tsv index 2a0bd581..5f2f5fb8 100644 --- a/packs/europe-latin-ext/locales/it/strings.tsv +++ b/packs/europe-latin-ext/locales/it/strings.tsv @@ -758,3 +758,72 @@ Start recording Avvia registrazione Stop recording Ferma registrazione Not recording Registrazione inattiva Add KML files to the routes folder Aggiungi i file KML alla cartella dei percorsi + +# Agenda / Calendar +1 day before 1 giorno prima +1 hour before 1 ora prima +10 min before 10 min prima +30 min before 30 min prima +Agenda Agenda +Agenda storage unavailable Archivio eventi non disponibile +Another reminder is already snoozed Un altro promemoria è già rinviato +At time All'ora prevista +Changes apply to the entire series Le modifiche si applicano all'intera serie +Choose on map Scegli sulla mappa +Could not read events Impossibile leggere gli eventi +Could not save changes Impossibile salvare le modifiche +Could not update reminder Impossibile aggiornare il promemoria +Current position Posizione attuale +Current position unavailable Posizione attuale non disponibile +Daily Ogni giorno +Date or time is out of range Data o ora fuori intervallo +Delete this event? Eliminare questo evento? +Discard Scarta +Discard changes? Scartare le modifiche? +Done Fatto +Edit Event Modifica evento +End <= start: next day Fine ≤ inizio: giorno successivo +End time (optional) Ora di fine (facoltativa) +Enter a title and valid date/time Inserisci un titolo e una data/ora valide +Event Detail Dettagli evento +Event is unavailable Evento non disponibile +Event Reminder Promemoria evento +Fri Ven +Invalid location Posizione non valida +Keep editing Continua a modificare +Location is optional La posizione è facoltativa +Map is unavailable on this target. Mappa non disponibile su questo dispositivo. +Mon Lun +Monthly Ogni mese +More Altro +Navigate Naviga +New Event Nuovo evento +No upcoming events Nessun evento in programma +Note Nota +Pick Date Scegli data +Pick Time Scegli ora +Reminder Promemoria +Remove location Rimuovi posizione +Repeat Ripeti +Sat Sab +Saving... Salvataggio… +Set the device time first Imposta prima l'ora del dispositivo +Snooze 10 min Rinvia di 10 min +Start time Ora di inizio +Sun Dom +Text exceeds the UTF-8 byte limit Il testo supera il limite di byte UTF-8 +Thu Gio +Title Titolo +Today Oggi +Tomorrow Domani +Tue Mar +Wed Mer +Weekly Ogni settimana +Yearly Ogni anno +Calendar Calendario +Pick Scegli +Could not read waypoints Impossibile leggere i punti +First Prima +No saved waypoints Nessun punto salvato +Saved waypoint Punto salvato +Waypoint unavailable Punto non disponibile diff --git a/packs/europe-latin-ext/locales/nl/strings.tsv b/packs/europe-latin-ext/locales/nl/strings.tsv index 8c2d7a18..d0f0ad50 100644 --- a/packs/europe-latin-ext/locales/nl/strings.tsv +++ b/packs/europe-latin-ext/locales/nl/strings.tsv @@ -758,3 +758,72 @@ Start recording Opname starten Stop recording Opname stoppen Not recording Geen opname actief Add KML files to the routes folder Plaats KML-bestanden in de routemap + +# Agenda / Calendar +1 day before 1 dag vooraf +1 hour before 1 uur vooraf +10 min before 10 min vooraf +30 min before 30 min vooraf +Agenda Agenda +Agenda storage unavailable Opslag voor afspraken niet beschikbaar +Another reminder is already snoozed Een andere herinnering is al uitgesteld +At time Op de begintijd +Changes apply to the entire series Wijzigingen gelden voor de hele reeks +Choose on map Op kaart kiezen +Could not read events Kan afspraken niet lezen +Could not save changes Kan wijzigingen niet opslaan +Could not update reminder Kan herinnering niet bijwerken +Current position Huidige positie +Current position unavailable Huidige positie niet beschikbaar +Daily Dagelijks +Date or time is out of range Datum of tijd buiten bereik +Delete this event? Deze afspraak verwijderen? +Discard Verwerpen +Discard changes? Wijzigingen verwerpen? +Done Gereed +Edit Event Afspraak bewerken +End <= start: next day Einde ≤ begin: volgende dag +End time (optional) Eindtijd (optioneel) +Enter a title and valid date/time Voer een titel en geldige datum/tijd in +Event Detail Afspraakdetails +Event is unavailable Afspraak niet beschikbaar +Event Reminder Afspraakherinnering +Fri Vr +Invalid location Ongeldige locatie +Keep editing Verder bewerken +Location is optional Locatie is optioneel +Map is unavailable on this target. Kaart niet beschikbaar op dit apparaat. +Mon Ma +Monthly Maandelijks +More Meer +Navigate Navigeren +New Event Nieuwe afspraak +No upcoming events Geen komende afspraken +Note Notitie +Pick Date Datum kiezen +Pick Time Tijd kiezen +Reminder Herinnering +Remove location Locatie verwijderen +Repeat Herhalen +Sat Za +Saving... Opslaan… +Set the device time first Stel eerst de apparaattijd in +Snooze 10 min 10 min uitstellen +Start time Begintijd +Sun Zo +Text exceeds the UTF-8 byte limit Tekst overschrijdt de UTF-8-bytelimiet +Thu Do +Title Titel +Today Vandaag +Tomorrow Morgen +Tue Di +Wed Wo +Weekly Wekelijks +Yearly Jaarlijks +Calendar Kalender +Pick Kiezen +Could not read waypoints Kan routepunten niet lezen +First Eerste +No saved waypoints Geen opgeslagen routepunten +Saved waypoint Opgeslagen routepunt +Waypoint unavailable Routepunt niet beschikbaar diff --git a/packs/europe-latin-ext/locales/pl/strings.tsv b/packs/europe-latin-ext/locales/pl/strings.tsv index aa3ea1b0..3f5ed30f 100644 --- a/packs/europe-latin-ext/locales/pl/strings.tsv +++ b/packs/europe-latin-ext/locales/pl/strings.tsv @@ -758,3 +758,72 @@ Start recording Rozpocznij zapis Stop recording Zatrzymaj zapis Not recording Zapis nieaktywny Add KML files to the routes folder Dodaj pliki KML do folderu tras + +# Agenda / Calendar +1 day before 1 dzień wcześniej +1 hour before 1 godzinę wcześniej +10 min before 10 min wcześniej +30 min before 30 min wcześniej +Agenda Harmonogram +Agenda storage unavailable Pamięć wydarzeń niedostępna +Another reminder is already snoozed Inne przypomnienie jest już odłożone +At time O czasie +Changes apply to the entire series Zmiany dotyczą całej serii +Choose on map Wybierz na mapie +Could not read events Nie można odczytać wydarzeń +Could not save changes Nie można zapisać zmian +Could not update reminder Nie można zaktualizować przypomnienia +Current position Bieżąca pozycja +Current position unavailable Bieżąca pozycja niedostępna +Daily Codziennie +Date or time is out of range Data lub godzina poza zakresem +Delete this event? Usunąć to wydarzenie? +Discard Odrzuć +Discard changes? Odrzucić zmiany? +Done Gotowe +Edit Event Edytuj wydarzenie +End <= start: next day Koniec ≤ początek: następny dzień +End time (optional) Godzina zakończenia (opcjonalna) +Enter a title and valid date/time Wpisz tytuł oraz prawidłową datę i godzinę +Event Detail Szczegóły wydarzenia +Event is unavailable Wydarzenie niedostępne +Event Reminder Przypomnienie o wydarzeniu +Fri Pt +Invalid location Nieprawidłowe miejsce +Keep editing Kontynuuj edycję +Location is optional Miejsce jest opcjonalne +Map is unavailable on this target. Mapa niedostępna na tym urządzeniu. +Mon Pon +Monthly Co miesiąc +More Więcej +Navigate Nawiguj +New Event Nowe wydarzenie +No upcoming events Brak nadchodzących wydarzeń +Note Notatka +Pick Date Wybierz datę +Pick Time Wybierz godzinę +Reminder Przypomnienie +Remove location Usuń miejsce +Repeat Powtarzanie +Sat Sob +Saving... Zapisywanie… +Set the device time first Najpierw ustaw czas urządzenia +Snooze 10 min Odłóż na 10 min +Start time Godzina rozpoczęcia +Sun Niedz +Text exceeds the UTF-8 byte limit Tekst przekracza limit bajtów UTF-8 +Thu Czw +Title Tytuł +Today Dzisiaj +Tomorrow Jutro +Tue Wt +Wed Śr +Weekly Co tydzień +Yearly Co rok +Calendar Kalendarz +Pick Wybierz +Could not read waypoints Nie można odczytać punktów +First Pierwsza +No saved waypoints Brak zapisanych punktów +Saved waypoint Zapisany punkt +Waypoint unavailable Punkt niedostępny diff --git a/packs/europe-latin-ext/locales/pt-PT/strings.tsv b/packs/europe-latin-ext/locales/pt-PT/strings.tsv index 09f99845..43d6e899 100644 --- a/packs/europe-latin-ext/locales/pt-PT/strings.tsv +++ b/packs/europe-latin-ext/locales/pt-PT/strings.tsv @@ -758,3 +758,72 @@ Start recording Iniciar gravação Stop recording Parar gravação Not recording Sem gravação ativa Add KML files to the routes folder Adicione ficheiros KML à pasta de rotas + +# Agenda / Calendar +1 day before 1 dia antes +1 hour before 1 hora antes +10 min before 10 min antes +30 min before 30 min antes +Agenda Agenda +Agenda storage unavailable Armazenamento de eventos indisponível +Another reminder is already snoozed Já existe outro lembrete adiado +At time À hora marcada +Changes apply to the entire series As alterações aplicam-se a toda a série +Choose on map Escolher no mapa +Could not read events Não foi possível ler os eventos +Could not save changes Não foi possível guardar as alterações +Could not update reminder Não foi possível atualizar o lembrete +Current position Posição atual +Current position unavailable Posição atual indisponível +Daily Diariamente +Date or time is out of range Data ou hora fora do intervalo +Delete this event? Eliminar este evento? +Discard Descartar +Discard changes? Descartar as alterações? +Done Concluído +Edit Event Editar evento +End <= start: next day Fim ≤ início: dia seguinte +End time (optional) Hora de fim (opcional) +Enter a title and valid date/time Introduza um título e uma data/hora válidas +Event Detail Detalhes do evento +Event is unavailable Evento indisponível +Event Reminder Lembrete de evento +Fri Sex +Invalid location Localização inválida +Keep editing Continuar a editar +Location is optional A localização é opcional +Map is unavailable on this target. O mapa não está disponível neste dispositivo. +Mon Seg +Monthly Mensalmente +More Mais +Navigate Navegar +New Event Novo evento +No upcoming events Sem eventos futuros +Note Nota +Pick Date Escolher data +Pick Time Escolher hora +Reminder Lembrete +Remove location Remover localização +Repeat Repetir +Sat Sáb +Saving... A guardar… +Set the device time first Defina primeiro a hora do dispositivo +Snooze 10 min Adiar 10 min +Start time Hora de início +Sun Dom +Text exceeds the UTF-8 byte limit O texto excede o limite de bytes UTF-8 +Thu Qui +Title Título +Today Hoje +Tomorrow Amanhã +Tue Ter +Wed Qua +Weekly Semanalmente +Yearly Anualmente +Calendar Calendário +Pick Escolher +Could not read waypoints Não foi possível ler os pontos +First Primeira +No saved waypoints Sem pontos guardados +Saved waypoint Ponto guardado +Waypoint unavailable Ponto indisponível diff --git a/packs/europe-latin-ext/package.ini b/packs/europe-latin-ext/package.ini index 3b1408eb..18f2d58c 100644 --- a/packs/europe-latin-ext/package.ini +++ b/packs/europe-latin-ext/package.ini @@ -1,7 +1,7 @@ kind=package id=europe-latin-ext package_type=locale-bundle -version=1.2.0 +version=1.3.0 display_name=European Latin Extended summary=Shared Latin-Extended font coverage plus German, Spanish, French, Italian, Dutch, Polish, and Portuguese review locale packs. description=DESCRIPTION.txt diff --git a/packs/ja/fonts/ja-cjk/charset.txt b/packs/ja/fonts/ja-cjk/charset.txt index 2c7557e4..063e0401 100644 --- a/packs/ja/fonts/ja-cjk/charset.txt +++ b/packs/ja/fonts/ja-cjk/charset.txt @@ -1,7 +1,8 @@ -°·—‘’“”…、。〈〉《》「」『』【】〔〕あいうえかがきぎくけこさしじすずせそただちっつてでとなにのはばぱへべまみめもやよらり -るれろをんァアィイウェエォオカキギクグケゲコサザシジスズセソゾタダチッテデトドニネノハバパビピフブプヘベペホボポマミムメモャヤュ -ユョラリルレロワンー一上下不中丸了二互交京付代以会位低体作使係保信修値停健側備像元先入全共内再出分切初利制刻削前割力功加効動化北 -去参取受古可号合名向周噪回因図固国在地域報場声大失始子字存完定実容対属層工帯常幅平度座康延式弱張当形役待後復必応急性情態成戦戻所 -扩択押拡振挿接換携操放敗数文断新方既日明星時暗更書替最有期未本査校桁検概標機次止正残比波消済源準無状率現理用画留略番異発的目直眺 -知短確磁示秒移空章符等答管系級索細終絡続緊線編縮繁置者聞能自致良英荷著行衛表装複要見規視解言計設許証試話該詳認語読調識護走起跡軌 -転軸較輪辞込近返追退送通連進遅選郭配重量針録長閉開間限除隔隠集離電非面韓音須頻頼频高!(),:;?~¥ +°·—‘’“”…≦、。〈〉《》「」『』【】〔〕あいうえおかがきぎくぐけこさしじすずせそただちっつてでとなにのはばぱへべまみめもや +よらりるれろをんァアィイウェエォオカキギクグケゲコサザシジスズセソゾタダチッテデトドナニネノハバパビピフブプヘベペホボポマミムメ +モャヤュユョラリルレロワンー一上下不中丸了予二互交京今付代以件任会位低体作使係保信修値停健側備像元先入全共内再出分切列初別利制刻 +削前割力功加効動化北去参取受古可号合名向周噪回因囲図固国土在地域報場声変外大失始子字存完定実容対属層工帯常幅平年度座康延式弱張当 +形役待後得復必応急性情意態成戦戻所扩抜択押拡振挿探接換携操放敗数文断新方既日明星時暗更書替最月有期木未末本査校桁棄検概標機次止正 +残毎比水決波消済源準火無状率現理生用画留略番異発的目直眺知短破確磁示秒移空章端符等答管範系級索細終絡給続緊線編縮繁繰置翌者聞能自 +致良英荷著行衛表装複要見規視覧解言計記設許証試話該詳認語読調識護走起超跡軌転軸較輪辞込近返追退送通連週進遅適選郭配重量金針録長閉 +開間限除隔隠集離電非面韓音須領頻頼频高!(),:;?~¥ diff --git a/packs/ja/fonts/ja-cjk/manifest.ini b/packs/ja/fonts/ja-cjk/manifest.ini index 13bd1346..59b785a4 100644 --- a/packs/ja/fonts/ja-cjk/manifest.ini +++ b/packs/ja/fonts/ja-cjk/manifest.ini @@ -2,7 +2,7 @@ kind=font id=ja-cjk display_name=Japanese CJK usage=both -estimated_ram_bytes=10707 +estimated_ram_bytes=34816 source=binfont file=font.bin ranges=ranges.txt diff --git a/packs/ja/fonts/ja-cjk/ranges.txt b/packs/ja/fonts/ja-cjk/ranges.txt index 0fe077fb..bee691af 100644 --- a/packs/ja/fonts/ja-cjk/ranges.txt +++ b/packs/ja/fonts/ja-cjk/ranges.txt @@ -1 +1 @@ -0xB0,0xB7,0x2014,0x2018-0x2019,0x201C-0x201D,0x2026,0x3001-0x3002,0x3008-0x3011,0x3014-0x3015,0x3042,0x3044,0x3046,0x3048,0x304B-0x304F,0x3051,0x3053,0x3055,0x3057-0x305B,0x305D,0x305F-0x3061,0x3063-0x3064,0x3066-0x3068,0x306A-0x306B,0x306E-0x3071,0x3078-0x3079,0x307E-0x307F,0x3081-0x3082,0x3084,0x3088-0x308D,0x3092-0x3093,0x30A1-0x30A4,0x30A6-0x30AB,0x30AD-0x30B3,0x30B5-0x30BB,0x30BD-0x30C1,0x30C3,0x30C6-0x30C9,0x30CB,0x30CD-0x30D1,0x30D3-0x30E7,0x30E9-0x30ED,0x30EF,0x30F3,0x30FC,0x4E00,0x4E0A-0x4E0B,0x4E0D,0x4E2D,0x4E38,0x4E86,0x4E8C,0x4E92,0x4EA4,0x4EAC,0x4ED8,0x4EE3,0x4EE5,0x4F1A,0x4F4D-0x4F4E,0x4F53,0x4F5C,0x4F7F,0x4FC2,0x4FDD,0x4FE1,0x4FEE,0x5024,0x505C,0x5065,0x5074,0x5099,0x50CF,0x5143,0x5148,0x5165,0x5168,0x5171,0x5185,0x518D,0x51FA,0x5206-0x5207,0x521D,0x5229,0x5236,0x523B,0x524A,0x524D,0x5272,0x529B,0x529F-0x52A0,0x52B9,0x52D5,0x5316-0x5317,0x53BB,0x53C2,0x53D6-0x53D7,0x53E4,0x53EF,0x53F7,0x5408,0x540D,0x5411,0x5468,0x566A,0x56DE,0x56E0,0x56F3,0x56FA,0x56FD,0x5728,0x5730,0x57DF,0x5831,0x5834,0x58F0,0x5927,0x5931,0x59CB,0x5B50,0x5B57-0x5B58,0x5B8C,0x5B9A,0x5B9F,0x5BB9,0x5BFE,0x5C5E,0x5C64,0x5DE5,0x5E2F,0x5E38,0x5E45,0x5E73,0x5EA6-0x5EA7,0x5EB7,0x5EF6,0x5F0F,0x5F31,0x5F35,0x5F53,0x5F62,0x5F79,0x5F85,0x5F8C,0x5FA9,0x5FC5,0x5FDC,0x6025,0x6027,0x60C5,0x614B,0x6210,0x6226,0x623B,0x6240,0x6269,0x629E,0x62BC,0x62E1,0x632F,0x633F,0x63A5,0x63DB,0x643A,0x64CD,0x653E,0x6557,0x6570,0x6587,0x65AD,0x65B0,0x65B9,0x65E2,0x65E5,0x660E,0x661F,0x6642,0x6697,0x66F4,0x66F8,0x66FF-0x6700,0x6709,0x671F,0x672A,0x672C,0x67FB,0x6821,0x6841,0x691C,0x6982,0x6A19,0x6A5F,0x6B21,0x6B62-0x6B63,0x6B8B,0x6BD4,0x6CE2,0x6D88,0x6E08,0x6E90,0x6E96,0x7121,0x72B6,0x7387,0x73FE,0x7406,0x7528,0x753B,0x7559,0x7565,0x756A,0x7570,0x767A,0x7684,0x76EE,0x76F4,0x773A,0x77E5,0x77ED,0x78BA,0x78C1,0x793A,0x79D2,0x79FB,0x7A7A,0x7AE0,0x7B26,0x7B49,0x7B54,0x7BA1,0x7CFB,0x7D1A,0x7D22,0x7D30,0x7D42,0x7D61,0x7D9A,0x7DCA,0x7DDA,0x7DE8,0x7E2E,0x7E41,0x7F6E,0x8005,0x805E,0x80FD,0x81EA,0x81F4,0x826F,0x82F1,0x8377,0x8457,0x884C,0x885B,0x8868,0x88C5,0x8907,0x8981,0x898B,0x898F,0x8996,0x89E3,0x8A00,0x8A08,0x8A2D,0x8A31,0x8A3C,0x8A66,0x8A71-0x8A73,0x8A8D,0x8A9E,0x8AAD,0x8ABF,0x8B58,0x8B77,0x8D70,0x8D77,0x8DE1,0x8ECC,0x8EE2,0x8EF8,0x8F03,0x8F2A,0x8F9E,0x8FBC,0x8FD1,0x8FD4,0x8FFD,0x9000-0x9001,0x901A,0x9023,0x9032,0x9045,0x9078,0x90ED,0x914D,0x91CD,0x91CF,0x91DD,0x9332,0x9577,0x9589,0x958B,0x9593,0x9650,0x9664,0x9694,0x96A0,0x96C6,0x96E2,0x96FB,0x975E,0x9762,0x97D3,0x97F3,0x9808,0x983B-0x983C,0x9891,0x9AD8,0xFF01,0xFF08-0xFF09,0xFF0C,0xFF1A-0xFF1B,0xFF1F,0xFF5E,0xFFE5 +0xB0,0xB7,0x2014,0x2018-0x2019,0x201C-0x201D,0x2026,0x2266,0x3001-0x3002,0x3008-0x3011,0x3014-0x3015,0x3042,0x3044,0x3046,0x3048,0x304A-0x3051,0x3053,0x3055,0x3057-0x305B,0x305D,0x305F-0x3061,0x3063-0x3064,0x3066-0x3068,0x306A-0x306B,0x306E-0x3071,0x3078-0x3079,0x307E-0x307F,0x3081-0x3082,0x3084,0x3088-0x308D,0x3092-0x3093,0x30A1-0x30A4,0x30A6-0x30AB,0x30AD-0x30B3,0x30B5-0x30BB,0x30BD-0x30C1,0x30C3,0x30C6-0x30CB,0x30CD-0x30D1,0x30D3-0x30E7,0x30E9-0x30ED,0x30EF,0x30F3,0x30FC,0x4E00,0x4E0A-0x4E0B,0x4E0D,0x4E2D,0x4E38,0x4E86,0x4E88,0x4E8C,0x4E92,0x4EA4,0x4EAC,0x4ECA,0x4ED8,0x4EE3,0x4EE5,0x4EF6,0x4EFB,0x4F1A,0x4F4D-0x4F4E,0x4F53,0x4F5C,0x4F7F,0x4FC2,0x4FDD,0x4FE1,0x4FEE,0x5024,0x505C,0x5065,0x5074,0x5099,0x50CF,0x5143,0x5148,0x5165,0x5168,0x5171,0x5185,0x518D,0x51FA,0x5206-0x5207,0x5217,0x521D,0x5225,0x5229,0x5236,0x523B,0x524A,0x524D,0x5272,0x529B,0x529F-0x52A0,0x52B9,0x52D5,0x5316-0x5317,0x53BB,0x53C2,0x53D6-0x53D7,0x53E4,0x53EF,0x53F7,0x5408,0x540D,0x5411,0x5468,0x566A,0x56DE,0x56E0,0x56F2-0x56F3,0x56FA,0x56FD,0x571F,0x5728,0x5730,0x57DF,0x5831,0x5834,0x58F0,0x5909,0x5916,0x5927,0x5931,0x59CB,0x5B50,0x5B57-0x5B58,0x5B8C,0x5B9A,0x5B9F,0x5BB9,0x5BFE,0x5C5E,0x5C64,0x5DE5,0x5E2F,0x5E38,0x5E45,0x5E73-0x5E74,0x5EA6-0x5EA7,0x5EB7,0x5EF6,0x5F0F,0x5F31,0x5F35,0x5F53,0x5F62,0x5F79,0x5F85,0x5F8C,0x5F97,0x5FA9,0x5FC5,0x5FDC,0x6025,0x6027,0x60C5,0x610F,0x614B,0x6210,0x6226,0x623B,0x6240,0x6269,0x629C,0x629E,0x62BC,0x62E1,0x632F,0x633F,0x63A2,0x63A5,0x63DB,0x643A,0x64CD,0x653E,0x6557,0x6570,0x6587,0x65AD,0x65B0,0x65B9,0x65E2,0x65E5,0x660E,0x661F,0x6642,0x6697,0x66F4,0x66F8,0x66FF-0x6700,0x6708-0x6709,0x671F,0x6728,0x672A-0x672C,0x67FB,0x6821,0x6841,0x68C4,0x691C,0x6982,0x6A19,0x6A5F,0x6B21,0x6B62-0x6B63,0x6B8B,0x6BCE,0x6BD4,0x6C34,0x6C7A,0x6CE2,0x6D88,0x6E08,0x6E90,0x6E96,0x706B,0x7121,0x72B6,0x7387,0x73FE,0x7406,0x751F,0x7528,0x753B,0x7559,0x7565,0x756A,0x7570,0x767A,0x7684,0x76EE,0x76F4,0x773A,0x77E5,0x77ED,0x7834,0x78BA,0x78C1,0x793A,0x79D2,0x79FB,0x7A7A,0x7AE0,0x7AEF,0x7B26,0x7B49,0x7B54,0x7BA1,0x7BC4,0x7CFB,0x7D1A,0x7D22,0x7D30,0x7D42,0x7D61,0x7D66,0x7D9A,0x7DCA,0x7DDA,0x7DE8,0x7E2E,0x7E41,0x7E70,0x7F6E,0x7FCC,0x8005,0x805E,0x80FD,0x81EA,0x81F4,0x826F,0x82F1,0x8377,0x8457,0x884C,0x885B,0x8868,0x88C5,0x8907,0x8981,0x898B,0x898F,0x8996,0x89A7,0x89E3,0x8A00,0x8A08,0x8A18,0x8A2D,0x8A31,0x8A3C,0x8A66,0x8A71-0x8A73,0x8A8D,0x8A9E,0x8AAD,0x8ABF,0x8B58,0x8B77,0x8D70,0x8D77,0x8D85,0x8DE1,0x8ECC,0x8EE2,0x8EF8,0x8F03,0x8F2A,0x8F9E,0x8FBC,0x8FD1,0x8FD4,0x8FFD,0x9000-0x9001,0x901A,0x9023,0x9031-0x9032,0x9045,0x9069,0x9078,0x90ED,0x914D,0x91CD,0x91CF,0x91D1,0x91DD,0x9332,0x9577,0x9589,0x958B,0x9593,0x9650,0x9664,0x9694,0x96A0,0x96C6,0x96E2,0x96FB,0x975E,0x9762,0x97D3,0x97F3,0x9808,0x9818,0x983B-0x983C,0x9891,0x9AD8,0xFF01,0xFF08-0xFF09,0xFF0C,0xFF1A-0xFF1B,0xFF1F,0xFF5E,0xFFE5 diff --git a/packs/ja/locales/ja/strings.tsv b/packs/ja/locales/ja/strings.tsv index 5afd816d..6ecd2cfd 100644 --- a/packs/ja/locales/ja/strings.tsv +++ b/packs/ja/locales/ja/strings.tsv @@ -758,3 +758,72 @@ Start recording 記録を開始 Stop recording 記録を停止 Not recording 記録していません Add KML files to the routes folder KML ファイルをルートフォルダーに入れてください + +# Agenda / Calendar +1 day before 1日前 +1 hour before 1時間前 +10 min before 10分前 +30 min before 30分前 +Agenda 予定一覧 +Agenda storage unavailable 予定の保存領域を利用できません +Another reminder is already snoozed 別の通知がすでに再通知待ちです +At time 開始時刻 +Changes apply to the entire series 変更は繰り返し予定全体に適用されます +Choose on map 地図で選択 +Could not read events 予定を読み込めませんでした +Could not save changes 変更を保存できませんでした +Could not update reminder 通知を更新できませんでした +Current position 現在地 +Current position unavailable 現在地を取得できません +Daily 毎日 +Date or time is out of range 日付または時刻が範囲外です +Delete this event? この予定を削除しますか? +Discard 破棄 +Discard changes? 変更を破棄しますか? +Done 完了 +Edit Event 予定を編集 +End <= start: next day 終了≦開始の場合は翌日 +End time (optional) 終了時刻(任意) +Enter a title and valid date/time 件名と有効な日時を入力してください +Event Detail 予定の詳細 +Event is unavailable 予定を利用できません +Event Reminder 予定の通知 +Fri 金 +Invalid location 無効な場所 +Keep editing 編集を続ける +Location is optional 場所は任意です +Map is unavailable on this target. この端末では地図を利用できません。 +Mon 月 +Monthly 毎月 +More もっと見る +Navigate ナビ +New Event 新しい予定 +No upcoming events 今後の予定はありません +Note メモ +Pick Date 日付を選択 +Pick Time 時刻を選択 +Reminder 通知 +Remove location 場所を削除 +Repeat 繰り返し +Sat 土 +Saving... 保存中… +Set the device time first 先に端末の時刻を設定してください +Snooze 10 min 10分後に再通知 +Start time 開始時刻 +Sun 日 +Text exceeds the UTF-8 byte limit 文字列がUTF-8のバイト数制限を超えています +Thu 木 +Title 件名 +Today 今日 +Tomorrow 明日 +Tue 火 +Wed 水 +Weekly 毎週 +Yearly 毎年 +Calendar カレンダー +Pick 決定 +Could not read waypoints ウェイポイントを読み取れません +First 最初 +No saved waypoints 保存済みのウェイポイントはありません +Saved waypoint 保存済みウェイポイント +Waypoint unavailable ウェイポイントを利用できません diff --git a/packs/ja/package.ini b/packs/ja/package.ini index 59e83717..37944a99 100644 --- a/packs/ja/package.ini +++ b/packs/ja/package.ini @@ -1,7 +1,7 @@ kind=package id=ja package_type=locale-bundle -version=1.2.0 +version=1.3.0 display_name=Japanese summary=Japanese review locale bundle with external CJK font coverage for UI text and mixed-language content; display-only until Japanese input is implemented. description=DESCRIPTION.txt diff --git a/packs/ko/fonts/ko-cjk/charset.txt b/packs/ko/fonts/ko-cjk/charset.txt index e3c98446..9a8e21f7 100644 --- a/packs/ko/fonts/ko-cjk/charset.txt +++ b/packs/ko/fonts/ko-cjk/charset.txt @@ -1,7 +1,7 @@ -°·—‘’“”…、。〈〉《》「」『』【】〔〕信噪因子扩比频가간감값강개거건검것게겟겠겨격견결경계고공과곽관교구국군권궤귀규그근글 -금급기긴김깁까깐깨꺼꼴꽉끊끔나날남납내냄너널넣네넷노녹누는늘능니닉다단닫답당대더덮데뎀도돌동되된됨됩뒤듀듈드득든듣들디딩또라락래 -랙략량러렇레려력령로록롯뢰료루류륨르를름리릴림릿링마막만망맥맵먼메멤면명모목못무문미밀바반받발밝방배백버번베벨변병보복본볼부북분 -불브블비빈사삭산삽상새색서선설성세셋션소속솔송수숨숫쉐스슬습시식신실십쓰아안않알암았액앱야약어언업없었에여역연열영예오온와완요용 -우운웁워원월웨위윕유윤율으은을음응의이인일읽임입있자작잘잠장재저적전점접정제져졌조족종좋좌주준줍중증지직진짐집징짜짧째쪽차참찼찾 -채책처첫청체초최추축출취층치침카캐캔캠커컬케켜켬코콜크클키킥킹타탈태택탭터텍템토퇴투트티팀팅파패팩페편평포폭폰폴표품프플피픽필하 -한할함합항해핸행향허현형호홈홉화확환활황회효후휴힙!(),:;?~¥ +°·—‘’“”…、。〈〉《》「」『』【】〔〕信噪因子扩比频가간감값강개갱거건검것게겟겠겨격견결경계고공과곽관교구국군권궤귀규그근 +글금급기긴길김깁까깐깨꺼꼴꽉끄끊끌끔나날남납났내냄너널넣네넷년노녹놓누는늘능니닉님다단닫달답당대더덮데뎀도돌동되된됨됩뒤듀듈드득 +든듣들디딩또라락래랙략량러렇레려력령로록롯뢰료루룬류륨르른를름리릴림릿링마막만망매맥맵먼메멤면명모목못무문미밀및바반받발밝방배백 +버번범벗베벨변병보복본볼부북분불브블비빈사삭산삽상새색생서선설성세셋션소속솔송수숨숫쉐스슬습시식신실십쓰아안않알암았액앱야약어언 +업없었에여역연열영예오온올와완요용우운웁워원월웨위윕유윤율으은을음응의이인일읽임입있자작잘잠장재저적전점접정제져졌조족종좋좌주준 +줍중증지직진짐집징짜짧째쪽차참찼찾채책처첫청체초최추축출취층치침칩카캐캔캠커컬케켜켬코콜크클키킥킹타탈탐태택탭터텍템토퇴투트티팀 +팅파패팩페편평포폭폰폴표품프플피픽필하한할함합항해핸했행향허현형호홈홉화확환활황회효후휴힙!(),:;?~¥ diff --git a/packs/ko/fonts/ko-cjk/manifest.ini b/packs/ko/fonts/ko-cjk/manifest.ini index c8d38822..8cfb3818 100644 --- a/packs/ko/fonts/ko-cjk/manifest.ini +++ b/packs/ko/fonts/ko-cjk/manifest.ini @@ -2,7 +2,7 @@ kind=font id=ko-cjk display_name=Korean CJK usage=both -estimated_ram_bytes=8143 +estimated_ram_bytes=26624 source=binfont file=font.bin ranges=ranges.txt diff --git a/packs/ko/fonts/ko-cjk/ranges.txt b/packs/ko/fonts/ko-cjk/ranges.txt index 348e8e6e..9bf5ad2d 100644 --- a/packs/ko/fonts/ko-cjk/ranges.txt +++ b/packs/ko/fonts/ko-cjk/ranges.txt @@ -1 +1 @@ -0xB0,0xB7,0x2014,0x2018-0x2019,0x201C-0x201D,0x2026,0x3001-0x3002,0x3008-0x3011,0x3014-0x3015,0x4FE1,0x566A,0x56E0,0x5B50,0x6269,0x6BD4,0x9891,0xAC00,0xAC04,0xAC10,0xAC12,0xAC15,0xAC1C,0xAC70,0xAC74,0xAC80,0xAC83,0xAC8C,0xAC9F-0xACA0,0xACA8-0xACA9,0xACAC,0xACB0,0xACBD,0xACC4,0xACE0,0xACF5,0xACFC-0xACFD,0xAD00,0xAD50,0xAD6C-0xAD6D,0xAD70,0xAD8C,0xADA4,0xADC0,0xADDC,0xADF8,0xADFC,0xAE00,0xAE08-0xAE09,0xAE30,0xAE34,0xAE40-0xAE41,0xAE4C,0xAE50,0xAE68,0xAEBC,0xAF34,0xAF49,0xB04A,0xB054,0xB098,0xB0A0,0xB0A8-0xB0A9,0xB0B4,0xB0C4,0xB108,0xB110,0xB123-0xB124,0xB137,0xB178-0xB179,0xB204,0xB294,0xB298,0xB2A5,0xB2C8-0xB2C9,0xB2E4,0xB2E8,0xB2EB,0xB2F5,0xB2F9,0xB300,0xB354,0xB36E,0xB370,0xB380,0xB3C4,0xB3CC,0xB3D9,0xB418,0xB41C,0xB428-0xB429,0xB4A4,0xB4C0,0xB4C8,0xB4DC-0xB4DD,0xB4E0,0xB4E3-0xB4E4,0xB514,0xB529,0xB610,0xB77C-0xB77D,0xB798-0xB799,0xB7B5,0xB7C9,0xB7EC,0xB807-0xB808,0xB824-0xB825,0xB839,0xB85C-0xB85D,0xB86F,0xB8B0,0xB8CC,0xB8E8,0xB958,0xB968,0xB974,0xB97C,0xB984,0xB9AC,0xB9B4,0xB9BC,0xB9BF,0xB9C1,0xB9C8-0xB9C9,0xB9CC,0xB9DD,0xB9E5,0xB9F5,0xBA3C,0xBA54,0xBA64,0xBA74,0xBA85,0xBAA8-0xBAA9,0xBABB,0xBB34,0xBB38,0xBBF8,0xBC00,0xBC14,0xBC18,0xBC1B-0xBC1D,0xBC29,0xBC30-0xBC31,0xBC84,0xBC88,0xBCA0,0xBCA8,0xBCC0,0xBCD1,0xBCF4-0xBCF5,0xBCF8,0xBCFC,0xBD80-0xBD81,0xBD84,0xBD88,0xBE0C,0xBE14,0xBE44,0xBE48,0xC0AC-0xC0AD,0xC0B0,0xC0BD,0xC0C1,0xC0C8-0xC0C9,0xC11C,0xC120,0xC124,0xC131,0xC138,0xC14B,0xC158,0xC18C-0xC18D,0xC194,0xC1A1,0xC218,0xC228,0xC22B,0xC250,0xC2A4,0xC2AC,0xC2B5,0xC2DC-0xC2DD,0xC2E0,0xC2E4,0xC2ED,0xC4F0,0xC544,0xC548,0xC54A,0xC54C,0xC554,0xC558,0xC561,0xC571,0xC57C-0xC57D,0xC5B4,0xC5B8,0xC5C5-0xC5C6,0xC5C8,0xC5D0,0xC5EC-0xC5ED,0xC5F0,0xC5F4,0xC601,0xC608,0xC624,0xC628,0xC640,0xC644,0xC694,0xC6A9,0xC6B0,0xC6B4,0xC6C1,0xC6CC,0xC6D0,0xC6D4,0xC6E8,0xC704,0xC715,0xC720,0xC724,0xC728,0xC73C,0xC740,0xC744,0xC74C,0xC751,0xC758,0xC774,0xC778,0xC77C-0xC77D,0xC784-0xC785,0xC788,0xC790-0xC791,0xC798,0xC7A0,0xC7A5,0xC7AC,0xC800-0xC801,0xC804,0xC810-0xC811,0xC815,0xC81C,0xC838,0xC84C,0xC870-0xC871,0xC885,0xC88B-0xC88C,0xC8FC,0xC900,0xC90D,0xC911,0xC99D,0xC9C0-0xC9C1,0xC9C4,0xC9D0-0xC9D1,0xC9D5,0xC9DC,0xC9E7,0xC9F8,0xCABD,0xCC28,0xCC38,0xCC3C,0xCC3E,0xCC44-0xCC45,0xCC98,0xCCAB,0xCCAD,0xCCB4,0xCD08,0xCD5C,0xCD94-0xCD95,0xCD9C,0xCDE8,0xCE35,0xCE58,0xCE68,0xCE74,0xCE90,0xCE94,0xCEA0,0xCEE4,0xCEEC,0xCF00,0xCF1C,0xCF2C,0xCF54,0xCF5C,0xD06C,0xD074,0xD0A4-0xD0A5,0xD0B9,0xD0C0,0xD0C8,0xD0DC-0xD0DD,0xD0ED,0xD130,0xD14D,0xD15C,0xD1A0,0xD1F4,0xD22C,0xD2B8,0xD2F0,0xD300,0xD305,0xD30C,0xD328-0xD329,0xD398,0xD3B8,0xD3C9,0xD3EC-0xD3ED,0xD3F0,0xD3F4,0xD45C,0xD488,0xD504,0xD50C,0xD53C-0xD53D,0xD544,0xD558,0xD55C,0xD560,0xD568-0xD569,0xD56D,0xD574,0xD578,0xD589,0xD5A5,0xD5C8,0xD604,0xD615,0xD638,0xD648-0xD649,0xD654-0xD655,0xD658,0xD65C,0xD669,0xD68C,0xD6A8,0xD6C4,0xD734,0xD799,0xFF01,0xFF08-0xFF09,0xFF0C,0xFF1A-0xFF1B,0xFF1F,0xFF5E,0xFFE5 +0xB0,0xB7,0x2014,0x2018-0x2019,0x201C-0x201D,0x2026,0x3001-0x3002,0x3008-0x3011,0x3014-0x3015,0x4FE1,0x566A,0x56E0,0x5B50,0x6269,0x6BD4,0x9891,0xAC00,0xAC04,0xAC10,0xAC12,0xAC15,0xAC1C,0xAC31,0xAC70,0xAC74,0xAC80,0xAC83,0xAC8C,0xAC9F-0xACA0,0xACA8-0xACA9,0xACAC,0xACB0,0xACBD,0xACC4,0xACE0,0xACF5,0xACFC-0xACFD,0xAD00,0xAD50,0xAD6C-0xAD6D,0xAD70,0xAD8C,0xADA4,0xADC0,0xADDC,0xADF8,0xADFC,0xAE00,0xAE08-0xAE09,0xAE30,0xAE34,0xAE38,0xAE40-0xAE41,0xAE4C,0xAE50,0xAE68,0xAEBC,0xAF34,0xAF49,0xB044,0xB04A,0xB04C,0xB054,0xB098,0xB0A0,0xB0A8-0xB0A9,0xB0AC,0xB0B4,0xB0C4,0xB108,0xB110,0xB123-0xB124,0xB137,0xB144,0xB178-0xB179,0xB193,0xB204,0xB294,0xB298,0xB2A5,0xB2C8-0xB2C9,0xB2D8,0xB2E4,0xB2E8,0xB2EB-0xB2EC,0xB2F5,0xB2F9,0xB300,0xB354,0xB36E,0xB370,0xB380,0xB3C4,0xB3CC,0xB3D9,0xB418,0xB41C,0xB428-0xB429,0xB4A4,0xB4C0,0xB4C8,0xB4DC-0xB4DD,0xB4E0,0xB4E3-0xB4E4,0xB514,0xB529,0xB610,0xB77C-0xB77D,0xB798-0xB799,0xB7B5,0xB7C9,0xB7EC,0xB807-0xB808,0xB824-0xB825,0xB839,0xB85C-0xB85D,0xB86F,0xB8B0,0xB8CC,0xB8E8,0xB8EC,0xB958,0xB968,0xB974,0xB978,0xB97C,0xB984,0xB9AC,0xB9B4,0xB9BC,0xB9BF,0xB9C1,0xB9C8-0xB9C9,0xB9CC,0xB9DD,0xB9E4-0xB9E5,0xB9F5,0xBA3C,0xBA54,0xBA64,0xBA74,0xBA85,0xBAA8-0xBAA9,0xBABB,0xBB34,0xBB38,0xBBF8,0xBC00,0xBC0F,0xBC14,0xBC18,0xBC1B-0xBC1D,0xBC29,0xBC30-0xBC31,0xBC84,0xBC88,0xBC94,0xBC97,0xBCA0,0xBCA8,0xBCC0,0xBCD1,0xBCF4-0xBCF5,0xBCF8,0xBCFC,0xBD80-0xBD81,0xBD84,0xBD88,0xBE0C,0xBE14,0xBE44,0xBE48,0xC0AC-0xC0AD,0xC0B0,0xC0BD,0xC0C1,0xC0C8-0xC0C9,0xC0DD,0xC11C,0xC120,0xC124,0xC131,0xC138,0xC14B,0xC158,0xC18C-0xC18D,0xC194,0xC1A1,0xC218,0xC228,0xC22B,0xC250,0xC2A4,0xC2AC,0xC2B5,0xC2DC-0xC2DD,0xC2E0,0xC2E4,0xC2ED,0xC4F0,0xC544,0xC548,0xC54A,0xC54C,0xC554,0xC558,0xC561,0xC571,0xC57C-0xC57D,0xC5B4,0xC5B8,0xC5C5-0xC5C6,0xC5C8,0xC5D0,0xC5EC-0xC5ED,0xC5F0,0xC5F4,0xC601,0xC608,0xC624,0xC628,0xC62C,0xC640,0xC644,0xC694,0xC6A9,0xC6B0,0xC6B4,0xC6C1,0xC6CC,0xC6D0,0xC6D4,0xC6E8,0xC704,0xC715,0xC720,0xC724,0xC728,0xC73C,0xC740,0xC744,0xC74C,0xC751,0xC758,0xC774,0xC778,0xC77C-0xC77D,0xC784-0xC785,0xC788,0xC790-0xC791,0xC798,0xC7A0,0xC7A5,0xC7AC,0xC800-0xC801,0xC804,0xC810-0xC811,0xC815,0xC81C,0xC838,0xC84C,0xC870-0xC871,0xC885,0xC88B-0xC88C,0xC8FC,0xC900,0xC90D,0xC911,0xC99D,0xC9C0-0xC9C1,0xC9C4,0xC9D0-0xC9D1,0xC9D5,0xC9DC,0xC9E7,0xC9F8,0xCABD,0xCC28,0xCC38,0xCC3C,0xCC3E,0xCC44-0xCC45,0xCC98,0xCCAB,0xCCAD,0xCCB4,0xCD08,0xCD5C,0xCD94-0xCD95,0xCD9C,0xCDE8,0xCE35,0xCE58,0xCE68-0xCE69,0xCE74,0xCE90,0xCE94,0xCEA0,0xCEE4,0xCEEC,0xCF00,0xCF1C,0xCF2C,0xCF54,0xCF5C,0xD06C,0xD074,0xD0A4-0xD0A5,0xD0B9,0xD0C0,0xD0C8,0xD0D0,0xD0DC-0xD0DD,0xD0ED,0xD130,0xD14D,0xD15C,0xD1A0,0xD1F4,0xD22C,0xD2B8,0xD2F0,0xD300,0xD305,0xD30C,0xD328-0xD329,0xD398,0xD3B8,0xD3C9,0xD3EC-0xD3ED,0xD3F0,0xD3F4,0xD45C,0xD488,0xD504,0xD50C,0xD53C-0xD53D,0xD544,0xD558,0xD55C,0xD560,0xD568-0xD569,0xD56D,0xD574,0xD578,0xD588-0xD589,0xD5A5,0xD5C8,0xD604,0xD615,0xD638,0xD648-0xD649,0xD654-0xD655,0xD658,0xD65C,0xD669,0xD68C,0xD6A8,0xD6C4,0xD734,0xD799,0xFF01,0xFF08-0xFF09,0xFF0C,0xFF1A-0xFF1B,0xFF1F,0xFF5E,0xFFE5 diff --git a/packs/ko/locales/ko/strings.tsv b/packs/ko/locales/ko/strings.tsv index 4d0cde77..b77581df 100644 --- a/packs/ko/locales/ko/strings.tsv +++ b/packs/ko/locales/ko/strings.tsv @@ -758,3 +758,72 @@ Start recording 기록 시작 Stop recording 기록 중지 Not recording 기록 중 아님 Add KML files to the routes folder 경로 폴더에 KML 파일을 넣어 주세요 + +# Agenda / Calendar +1 day before 1일 전 +1 hour before 1시간 전 +10 min before 10분 전 +30 min before 30분 전 +Agenda 일정 +Agenda storage unavailable 일정 저장소를 사용할 수 없습니다 +Another reminder is already snoozed 이미 미룬 다른 알림이 있습니다 +At time 정시 +Changes apply to the entire series 변경 사항은 반복 일정 전체에 적용됩니다 +Choose on map 지도에서 선택 +Could not read events 일정을 읽을 수 없습니다 +Could not save changes 변경 사항을 저장할 수 없습니다 +Could not update reminder 알림을 갱신할 수 없습니다 +Current position 현재 위치 +Current position unavailable 현재 위치를 확인할 수 없습니다 +Daily 매일 +Date or time is out of range 날짜 또는 시간이 범위를 벗어났습니다 +Delete this event? 이 일정을 삭제할까요? +Discard 버리기 +Discard changes? 변경 사항을 버릴까요? +Done 완료 +Edit Event 일정 편집 +End <= start: next day 종료가 시작 이하이면 다음 날 +End time (optional) 종료 시간(선택) +Enter a title and valid date/time 제목과 올바른 날짜 및 시간을 입력하세요 +Event Detail 일정 상세 +Event is unavailable 일정을 사용할 수 없습니다 +Event Reminder 일정 알림 +Fri 금 +Invalid location 잘못된 위치 +Keep editing 계속 편집 +Location is optional 위치는 선택 사항입니다 +Map is unavailable on this target. 이 기기에서는 지도를 사용할 수 없습니다. +Mon 월 +Monthly 매월 +More 더 보기 +Navigate 길 안내 +New Event 새 일정 +No upcoming events 예정된 일정이 없습니다 +Note 메모 +Pick Date 날짜 선택 +Pick Time 시간 선택 +Reminder 알림 +Remove location 위치 삭제 +Repeat 반복 +Sat 토 +Saving... 저장 중… +Set the device time first 먼저 기기 시간을 설정하세요 +Snooze 10 min 10분 후 다시 알림 +Start time 시작 시간 +Sun 일 +Text exceeds the UTF-8 byte limit 텍스트가 UTF-8 바이트 제한을 초과했습니다 +Thu 목 +Title 제목 +Today 오늘 +Tomorrow 내일 +Tue 화 +Wed 수 +Weekly 매주 +Yearly 매년 +Calendar 달력 +Pick 선택 +Could not read waypoints 지점을 읽을 수 없습니다 +First 처음 +No saved waypoints 저장된 지점이 없습니다 +Saved waypoint 저장된 지점 +Waypoint unavailable 지점을 사용할 수 없습니다 diff --git a/packs/ko/package.ini b/packs/ko/package.ini index 933c8d53..2bc40cc1 100644 --- a/packs/ko/package.ini +++ b/packs/ko/package.ini @@ -1,7 +1,7 @@ kind=package id=ko package_type=locale-bundle -version=1.2.0 +version=1.3.0 display_name=Korean summary=Korean review locale bundle with external CJK font coverage for UI text and mixed-language content; display-only until Korean input is implemented. description=DESCRIPTION.txt diff --git a/packs/zh-Hans/fonts/tdeckpro-zh-hans-core/charset.txt b/packs/zh-Hans/fonts/tdeckpro-zh-hans-core/charset.txt index 29f04bfe..65459572 100644 --- a/packs/zh-Hans/fonts/tdeckpro-zh-hans-core/charset.txt +++ b/packs/zh-Hans/fonts/tdeckpro-zh-hans-core/charset.txt @@ -4,47 +4,47 @@ 俸俺俾倍倒倘候倚倜倦倩倪倭债值假偈偌偏偕做停偬偶偷偻傀傍储催傲傻像僚僧僭僮儋儿允元兄充先光克免兑兔党入全八公六兰共关兴兵其具典兹 养兼内冈冉册再冒写军农冠冥冬冯冰冲决况冷冻冼净准凑凝几凡凤凭凯凳凵凶凹出击凿刀分切刊划列刘则刚创初删判刨利别刮到刳制刷券刹刺刻削 前剖剡剥剩副剽剿劐力劝办功加务劣动助努劲劳劾势勇勉勒勖勘募勤勺勾勿匀包匆匕化北匙匝匡匪匮匹区匿十千卅升半华协卑卒卓单卖南卜卞卟占 -卡卤卦卧卫卯印危即却卵卷卸厂厅压厕厘厚厝原厦厶去县叁参又及友双反发取受变叟叠口古句另叨叩只叫召叭可台叱右叶号司叹叻叽吁吃各合同名 -后向吓吖吗君吞吟吡否吧吨吩含听吭吮启吱吲吴吵吹吾呀呃呆呈告呐呒呔呕呗员呢周呱呲味呵呶呷呼命咀咋和咎咔咖咣咦咩咪咬咭咯咱咳咴咸咻咽 -哀品哆哇哈哌响哏哐哕哝哟哦哧哨哩哪哭哮哲哺哼哽唆唇唏唐唑唔唛唤唧唪唬售唰唷唼啁啃啄商啉啊啐啜啡啥啦啻喀喁喃善喇喈喉喊喋喏喔喘喙喜 -喝喟喳喷喽嗄嗌嗍嗑嗒嗓嗔嗖嗟嗡嗨嗯嗲嗽嘀嘁嘎嘏嘘嘞嘬嘲嘴嘿噌噍噎噢器噪噫噱噶噻嚓嚣嚯嚷嚼囊囔囗囚四囝回因囡团囤园困囱固国图圈圜土 -圣在圩圪地圳场圻圾均坎坏坐坑块坠坡坤坦坨坫坯坳坶坷坻垂垄垌垒垠垦垧垩垮垸埃埋城埏埔埝域埤埯培基埽堂堆堇堋堕堡堤堪堵塄塌塔塘塞填境 -墅墙增墨壅壑壤壬壮声壳壹夂处备复夏夕外多夜够大天太夫夭央夯失头夸夹夺奇奈奉奋奏契奔奖套奘奠奥女奴奶奸她好妁如妃妆妈妖妗妙妞妮妯妹 -妾姆始姐姓姚姣姥姬娃娆娉娘娜娩娶婆婕婚婺媪媲媵嫂嫔嫖嫩嬗嬲嬷孀子孔字存孙孛孟学孩孱孽宁它宅守安宋完宓宕宗官定宛宝实客宣宪宰害家容 -宽宾宿密寇寒察寡寨寰寸对寺寻导封射将尉尊小少尔尚尝尢尥尬就尹尺尼尽尾尿层居屈屎屏展属屮屯山屹岁岂岈岑岗岛岸峒峙峤峦崆崇崎崔崛崤崩 -崴嵇嵊嵌嵯嶷巛川州巡巢工左巧巨差己已巳巴巷巽币市布帅希帐帑帔帕帖帘帝带帧帮帱常幅幕幢干平年并幸幺幼广庄庇床库应底店庙庚府庞废度座 -庭庳庵庶康廊廑廓廖延廷建开弁弄式引弘弟张弥弩弱弹强彀归当录彖彗彡形彦彩彪彬彭影彳彷彻往待很徊律後徒得徙御循徵德徼心必忍忏忐忑忒忖 -志忙忠忪快忮忱念忽忾怀态怍怎怒怕怙怛怜思怠急性怨怪怫怵总怼恁恂恃恍恐恒恝恢恨恩恫息恰恳恶恼恿悄悛悝患您悬悯悲情惆惊惝惠惨惭惰想惴 -惹愀愁愉愍意感愠愣愤愧愿慊慌慎慝慢慨慷憋憎憧憾懂懊懒懔戆戌戎戏成我戕或战戚戛戡戳戴户房所扁扃扇手才扎扑扒打扔托扛扣执扩扪扫扭扮扰 -扳扶批找承技抄把抓投抖抗折抛抟抠抡抢护报抨抬抱抵抹抻押抽拂担拆拈拉拊拌拍拎拐拓拔拖拗拘拚招拜拟拢拥拦拧择括拮拱拴拶拷拼拽拾拿持挂 -指挈按挎挑挖挛挝挟挠挡挥挪挫振挲挺捆捉捋捌捍捎捏捐损换捧捩捭据捶捷捻掀掇授掉掊掌掎排掘掠探接控推措掴掸掺掾揄揉揍描提插揖揞握揣揩 -揭揲援搂搋搓搛搜搞搡搬搭摁摇摔摧摩摺撅撇撒撕撖撙撞撤撩播撮撰撺擂操擐擒擗擘擞擦攉攒攘攥攮支收改攻放政故效敌敏救敕敖教敞敢散敦敫数 -整文斌斗料斜斡斥斩斫断斯新方於施旁旄旋族无既日旦旧早旬旮旰时昂昆昌明昏昔昕昙星映春昧昨是昵显晁晃晒晚晟晨普景暂暇暖暗暧暨暴曝曩曰 -曲更曷曹曼曾最月有朊朋服朐朔朗望朝期朦木未末本札术朱朴朵机杀杂权杉杌李材村杓杜杠条来杨杭杷杼松板构枇枉枋析枕林枘果枝枞枪枯架枸柁 -柏某柑染柔柙柚柜柞柠柢查柳柴柽栅标栎栏树栓栖栗栝校栩样核根格栾桁桂桃桅案桊桌桐桑桔桡档桥桧梁梅梏梗梢梦梧梭检棉棋棍棒棣森棰棱棵棹 -椎椹椿楂楚楞楠楮楱楷楼概榜槁槌槐槛槟槭槽槿樘模横樽橐橙檀檐檗檠檫次欢欣欧欹款歃歇歙止正此步武歪歹死殁残殍殖殡段殷殿毁毋母每毒比毕 -毛毫毳毹氏氐民氓气氕氤水氵永氽汀汁求汇汉汕汗汝江池汤汩汪汶汽汾沂沃沆沈沉沌沏沓沙沟没沦沧沮沱河沸治沾泄泅泉泊泌泐泔法泖泛泞波泣泥 -注泪泫泯泰泵泷泺泽洁洇洋洒洗洚洛洞洪洫洮洲洵活洼洽派流浅测浍浏浑浒浓浚浜浩浪海浸涂涅消涉涌涎涑涓涔涞涡涣润涩涪涮液淋淌淑淖淙淠淡 -淦淫淬淮深淳混添清渑渠渥温渫港渴游湃湍湓湔湛湟湫湮湾溃溉源溘溟溥溪溯溱溲溴溶溺滂滇滋滑滕滚满滨漂漆漏漕漫漯潘潜潢潦潭潮潼澄澉澍澡 -澳澶澹激濂濞濡濯瀑瀹灌火灬灭灯灰灵灾灿炅炊炎炔炕炖炮炯炸点烁烂烈烙烛烟烤烧热焉焖焘焦焯焱然煦照煨熄熊熙熟熨熬燃燔燠燥燹爆爝爨爪爬 -爱爷爸爹爻爽爿片版牌牙牛牟牢物特犄犍犒犯犴状狂狃狄狗狠狡独狻狼猎猓猗猛猫猴獠獬獾玄率王玟玢玩环现珍珞珩班珲球琅理琢琨琳琴琼瑗瑙瑞 -瑟瓒瓜瓠瓤瓦瓮瓶瓿甄甘甚甜生用甩甫甬甭田由甲电男甸町甾界留畚畜略番畸畹疋疑疒疔疗疙疟疮疴疵疸疹疼病痕痛痦痪瘕瘟瘥瘳瘵癌癞癸登白百 -的皇皋皓皖皤皮皴皿盅盆盍盎监盒盖盘盛盟目盯盲直相盹盼盾省眈看眙真眠眩眭眷眸眼着睃睇睡睢督睫睽睾睿瞀瞑瞠瞢瞥瞧瞪瞬瞵瞿矛矜矣知短石 -矸矿码砉砌砍研砖砝砟砥砩砬砰破砸硅硌硎硐硕硝硪硫硬确碌碎碟碡碣碰碱磁磅磉磷磺礤示社祁祈祓祖祝神祠祢票祭禁禅禳离秀私秋种科秒秘租秤 -秦积称秸移程稍税稞稠稷稹稽稿穑穰穴究穷穹空穿窀突窃窒窖窗窘窜窝窥窨窬立站竟章童端竺笋笏笑笤符笨第笮笼等筋筒答策筛筠筷筹简箐算管箢 -箬箴箸篇篡篦篾簇簪簿籁籍米籴类粉粗粘粢粥粮粹精糁糅糖糜糟糸系素索紧累絮綮繁繇纂纛红纤纥约级纯纱纲纳纵纶纷纽线练组细终绍经结绕给络 -绝统绢绣继绪续绰绲绵综缀缉缎缏缓编缗缠缩缪缯缲缴缵缶缺网罗罚罢罪置罱美羞羡群羧羸羹羽翁翟翠翮翻老考者耆而耍耐耕耗耙耨耳耶耷耸聊聒 -联聘聪聱肃肄肆肉肋肜股肢肥肩肫肭肮肯育肷肺胂胆背胍胖胗胜胝胞胡胩胯胱胲胳胶胸胺能脉脏脑脓脔脘脚脞脯脱脸脾腊腌腔腧腮腱腾腿膀膊膘膦 -膪膻臂臣臧自臭至致臾舂舄舌舍舒舔舜舡航般舱舳船艘艟艮良色艴艽艾艿节芋芍芎芒芘芟芤芥芦芩芪芫芭芮芯花芳芴芾苍苏苑苒苓苕苗苛苞苣苤若 -苦苫苯英苴苹苻茁范茄茇茈茑茛茜茧茫茬茭茶荃草荑荒荛荠荡荣荤荥荨药荼荽莆莉莎莘莜莞莠莨莩莫莱获莽菀菅菌菏菜菥菩菲菸菹菽萁萆萌萑营萨 -落著葚葜葡董葬葭葱葶蒋蒙蒜蒡蒯蒲蒽蒿蓁蓉蓊蓑蓝蓼蓿蔑蔓蔚蔟蔡蔫蔽蕃蕈蕉蕊蕞蕹蕺薄薛薜藉藏藐藤藿蘖蘧虐虑虫虹虽虾蚌蚕蚝蚣蚤蚰蚴蚵蚺 -蛆蛇蛑蛙蛞蛤蛩蛮蛸蛾蜍蜒蜓蜕蜚蜡蜩蜮蜱蜴蜷蜻蜾蝎蝙蝤蝥蝶螃螅融螫蟀蟆蟊蟒蟠蠃蠖蠛蠡蠢蠼血行衙衡补表衮衰衿袂袅袍袒袖袜袢袤被袷袼裁 -裂装裒裙裢裨裳裴裼褊褚褥褪褶西要覃覆见观规视览觉角觖觜解觳言訇詹誊计订认讨让训议讯记讲讷许论讼设访证评识诈诉诊词诏试诘话该详语误 -诰说诵诶请诹诺读课谁调谆谈谋谍谑谜谠谢谬谮谱谷豁豆豉豚象豪豫豳豸豺貉貊貌贝财责败贫购贰贲贴贵贸费贼贾赃资赉赏赖赚赛赞赠赦赫走赶起 -趁趄超越趑趟趣足趵趼趿跃跋跌跑跛距跞跟跣跤跨跫跬路跳踅踉踔踢踣踩踪踱踹蹂蹄蹊蹦蹭蹲蹴蹶蹿躁躅躇躔身躲躺车轧轨转轮软轰轴载较辇辍辏 -辑输辗辛辞辟辣辨辩辰边辽达迁迅过迎运近返还这进远连迟迤迥迦迩迫迭迮述迷迹追退送适逃逄逆选逊透逐途逗通逛逞速造逡逢逮逯逾遁遂遇遍道 -遗遣遭遵遽邋邑邓邛邢那邦邪邹邺邻郄郅郇郎郗郝郡郢部郭郴都鄱鄹配酐酒酡酢酪酬酮酷酸酾酿醇醉醋醒醛醮采里重野量金銮鏖鑫针钏钔钗钞钟钢 -钥钦钭钮钯钱钻钿铁铅铊铛铣铤铥铪铫铭银铺铽链铿销锁锊锐锕错锤锬键锻镇镐镖镡镦长门闩闪闭问闯闰闲间闵闷闹闻闽阏阔阙阚队阢阮防阳阵阻 -阽阿陀陂附陆陇陈陋降限陕陟院除险陪陬陴陵陶隆隋随隐隔隗隘隧隰隶隹隼隽难雀雄雅集雇雉雩雪零雷需震霍霜霰露霸霾青靓静非靠靡面革鞋鞔鞘 -鞠鞣鞫鞴韩音页顶项顺须顿颂预领颇颈颉颌频颗题额颠颡颤风飒飘飞食飧餐餮饧饭饶饿馁馆馇馈馓馕首馗馘馥马驮骀骂骇验骗骚骠骰骱骸髁髂髋高 -髟髯髻鬓鬲鬻鬼魁魂魄鲁鲅鲑鲧鲭鳃鸟鸣鸾鸿鹄鹏鹘麇麟麦麻麽黄黑默黝黧黹黾鼎齐龈龙龟!(),:;?~¥ +卡卤卦卧卫卯印危即却卵卷卸厂厅历压厕厘厚厝原厦厶去县叁参又及友双反发取受变叟叠口古句另叨叩只叫召叭可台叱右叶号司叹叻叽吁吃各合同 +名后向吓吖吗君吞吟吡否吧吨吩含听吭吮启吱吲吴吵吹吾呀呃呆呈告呐呒呔呕呗员呢周呱呲味呵呶呷呼命咀咋和咎咔咖咣咦咩咪咬咭咯咱咳咴咸咻 +咽哀品哆哇哈哌响哏哐哕哝哟哦哧哨哩哪哭哮哲哺哼哽唆唇唏唐唑唔唛唤唧唪唬售唰唷唼啁啃啄商啉啊啐啜啡啥啦啻喀喁喃善喇喈喉喊喋喏喔喘喙 +喜喝喟喳喷喽嗄嗌嗍嗑嗒嗓嗔嗖嗟嗡嗨嗯嗲嗽嘀嘁嘎嘏嘘嘞嘬嘲嘴嘿噌噍噎噢器噪噫噱噶噻嚓嚣嚯嚷嚼囊囔囗囚四囝回因囡团囤园困囱围固国图圈 +圜土圣在圩圪地圳场圻圾均坎坏坐坑块坠坡坤坦坨坫坯坳坶坷坻垂垄垌垒垠垦垧垩垮垸埃埋城埏埔埝域埤埯培基埽堂堆堇堋堕堡堤堪堵塄塌塔塘塞 +填境墅墙增墨壅壑壤壬壮声壳壹夂处备复夏夕外多夜够大天太夫夭央夯失头夸夹夺奇奈奉奋奏契奔奖套奘奠奥女奴奶奸她好妁如妃妆妈妖妗妙妞妮 +妯妹妾姆始姐姓姚姣姥姬娃娆娉娘娜娩娶婆婕婚婺媪媲媵嫂嫔嫖嫩嬗嬲嬷孀子孔字存孙孛孟学孩孱孽宁它宅守安宋完宓宕宗官定宛宝实客宣宪宰害 +家容宽宾宿密寇寒察寡寨寰寸对寺寻导封射将尉尊小少尔尚尝尢尥尬就尹尺尼尽尾尿层居屈屎屏展属屮屯山屹岁岂岈岑岗岛岸峒峙峤峦崆崇崎崔崛 +崤崩崴嵇嵊嵌嵯嶷巛川州巡巢工左巧巨差己已巳巴巷巽币市布帅希帐帑帔帕帖帘帝带帧帮帱常幅幕幢干平年并幸幺幼广庄庇床库应底店庙庚府庞废 +度座庭庳庵庶康廊廑廓廖延廷建开弁弃弄式引弘弟张弥弩弱弹强彀归当录彖彗彡形彦彩彪彬彭影彳彷彻往待很徊律後徒得徙御循徵德徼心必忍忏忐 +忑忒忖志忙忠忪快忮忱念忽忾怀态怍怎怒怕怙怛怜思怠急性怨怪怫怵总怼恁恂恃恍恐恒恝恢恨恩恫息恰恳恶恼恿悄悛悝患您悬悯悲情惆惊惝惠惨惭 +惰想惴惹愀愁愉愍意感愠愣愤愧愿慊慌慎慝慢慨慷憋憎憧憾懂懊懒懔戆戌戎戏成我戕或战戚戛戡戳戴户房所扁扃扇手才扎扑扒打扔托扛扣执扩扪扫 +扭扮扰扳扶批找承技抄把抓投抖抗折抛抟抠抡抢护报抨抬抱抵抹抻押抽拂担拆拈拉拊拌拍拎拐拓拔拖拗拘拚招拜拟拢拥拦拧择括拮拱拴拶拷拼拽拾 +拿持挂指挈按挎挑挖挛挝挟挠挡挥挪挫振挲挺捆捉捋捌捍捎捏捐损换捧捩捭据捶捷捻掀掇授掉掊掌掎排掘掠探接控推措掴掸掺掾揄揉揍描提插揖揞 +握揣揩揭揲援搂搋搓搛搜搞搡搬搭摁摇摔摧摩摺撅撇撒撕撖撙撞撤撩播撮撰撺擂操擐擒擗擘擞擦攉攒攘攥攮支收改攻放政故效敌敏救敕敖教敞敢散 +敦敫数整文斌斗料斜斡斥斩斫断斯新方於施旁旄旋族无既日旦旧早旬旮旰时昂昆昌明昏昔昕昙星映春昧昨是昵显晁晃晒晚晟晨普景暂暇暖暗暧暨暴 +曝曩曰曲更曷曹曼曾最月有朊朋服朐朔朗望朝期朦木未末本札术朱朴朵机杀杂权杉杌李材村杓杜束杠条来杨杭杷杼松板构枇枉枋析枕林枘果枝枞枪 +枯架枸柁柏某柑染柔柙柚柜柞柠柢查柳柴柽栅标栎栏树栓栖栗栝校栩样核根格栾桁桂桃桅案桊桌桐桑桔桡档桥桧梁梅梏梗梢梦梧梭检棉棋棍棒棣森 +棰棱棵棹椎椹椿楂楚楞楠楮楱楷楼概榜槁槌槐槛槟槭槽槿樘模横樽橐橙檀檐檗檠檫次欢欣欧欹款歃歇歙止正此步武歪歹死殁残殍殖殡段殷殿毁毋母 +每毒比毕毛毫毳毹氏氐民氓气氕氤水氵永氽汀汁求汇汉汕汗汝江池汤汩汪汶汽汾沂沃沆沈沉沌沏沓沙沟没沦沧沮沱河沸治沾泄泅泉泊泌泐泔法泖泛 +泞波泣泥注泪泫泯泰泵泷泺泽洁洇洋洒洗洚洛洞洪洫洮洲洵活洼洽派流浅测浍浏浑浒浓浚浜浩浪海浸涂涅消涉涌涎涑涓涔涞涡涣润涩涪涮液淋淌淑 +淖淙淠淡淦淫淬淮深淳混添清渑渠渥温渫港渴游湃湍湓湔湛湟湫湮湾溃溉源溘溟溥溪溯溱溲溴溶溺滂滇滋滑滕滚满滨漂漆漏漕漫漯潘潜潢潦潭潮潼 +澄澉澍澡澳澶澹激濂濞濡濯瀑瀹灌火灬灭灯灰灵灾灿炅炊炎炔炕炖炮炯炸点烁烂烈烙烛烟烤烧热焉焖焘焦焯焱然煦照煨熄熊熙熟熨熬燃燔燠燥燹爆 +爝爨爪爬爱爷爸爹爻爽爿片版牌牙牛牟牢物特犄犍犒犯犴状狂狃狄狗狠狡独狻狼猎猓猗猛猫猴獠獬獾玄率王玟玢玩环现珍珞珩班珲球琅理琢琨琳琴 +琼瑗瑙瑞瑟瓒瓜瓠瓤瓦瓮瓶瓿甄甘甚甜生用甩甫甬甭田由甲电男甸町甾界留畚畜略番畸畹疋疑疒疔疗疙疟疮疴疵疸疹疼病痕痛痦痪瘕瘟瘥瘳瘵癌癞 +癸登白百的皇皋皓皖皤皮皴皿盅盆盍盎监盒盖盘盛盟目盯盲直相盹盼盾省眈看眙真眠眩眭眷眸眼着睃睇睡睢督睫睽睾睿瞀瞑瞠瞢瞥瞧瞪瞬瞵瞿矛矜 +矣知短石矸矿码砉砌砍研砖砝砟砥砩砬砰破砸硅硌硎硐硕硝硪硫硬确碌碎碟碡碣碰碱磁磅磉磷磺礤示社祁祈祓祖祝神祠祢票祭禁禅禳离秀私秋种科 +秒秘租秤秦积称秸移程稍税稞稠稷稹稽稿穑穰穴究穷穹空穿窀突窃窒窖窗窘窜窝窥窨窬立站竟章童端竺笋笏笑笤符笨第笮笼等筋筒答策筛筠筷筹简 +箐算管箢箬箴箸篇篡篦篾簇簪簿籁籍米籴类粉粗粘粢粥粮粹精糁糅糖糜糟糸系素索紧累絮綮繁繇纂纛红纤纥约级纯纱纲纳纵纶纷纽线练组细终绍经 +结绕给络绝统绢绣继绪续绰绲绵综缀缉缎缏缓编缗缠缩缪缯缲缴缵缶缺网罗罚罢罪置罱美羞羡群羧羸羹羽翁翟翠翮翻老考者耆而耍耐耕耗耙耨耳耶 +耷耸聊聒联聘聪聱肃肄肆肉肋肜股肢肥肩肫肭肮肯育肷肺胂胆背胍胖胗胜胝胞胡胩胯胱胲胳胶胸胺能脉脏脑脓脔脘脚脞脯脱脸脾腊腌腔腧腮腱腾腿 +膀膊膘膦膪膻臂臣臧自臭至致臾舂舄舌舍舒舔舜舡航般舱舳船艘艟艮良色艴艽艾艿节芋芍芎芒芘芟芤芥芦芩芪芫芭芮芯花芳芴芾苍苏苑苒苓苕苗苛 +苞苣苤若苦苫苯英苴苹苻茁范茄茇茈茑茛茜茧茫茬茭茶荃草荑荒荛荠荡荣荤荥荨药荼荽莆莉莎莘莜莞莠莨莩莫莱获莽菀菅菌菏菜菥菩菲菸菹菽萁萆 +萌萑营萨落著葚葜葡董葬葭葱葶蒋蒙蒜蒡蒯蒲蒽蒿蓁蓉蓊蓑蓝蓼蓿蔑蔓蔚蔟蔡蔫蔽蕃蕈蕉蕊蕞蕹蕺薄薛薜藉藏藐藤藿蘖蘧虐虑虫虹虽虾蚌蚕蚝蚣蚤 +蚰蚴蚵蚺蛆蛇蛑蛙蛞蛤蛩蛮蛸蛾蜍蜒蜓蜕蜚蜡蜩蜮蜱蜴蜷蜻蜾蝎蝙蝤蝥蝶螃螅融螫蟀蟆蟊蟒蟠蠃蠖蠛蠡蠢蠼血行衙衡补表衮衰衿袂袅袍袒袖袜袢袤 +被袷袼裁裂装裒裙裢裨裳裴裼褊褚褥褪褶西要覃覆见观规视览觉角觖觜解觳言訇詹誊计订认讨让训议讯记讲讷许论讼设访证评识诈诉诊词诏试诘话 +该详语误诰说诵诶请诹诺读课谁调谆谈谋谍谑谜谠谢谬谮谱谷豁豆豉豚象豪豫豳豸豺貉貊貌贝财责败贫购贰贲贴贵贸费贼贾赃资赉赏赖赚赛赞赠赦 +赫走赶起趁趄超越趑趟趣足趵趼趿跃跋跌跑跛距跞跟跣跤跨跫跬路跳踅踉踔踢踣踩踪踱踹蹂蹄蹊蹦蹭蹲蹴蹶蹿躁躅躇躔身躲躺车轧轨转轮软轰轴载 +较辇辍辏辑输辗辛辞辟辣辨辩辰边辽达迁迅过迎运近返还这进远连迟迤迥迦迩迫迭迮述迷迹追退送适逃逄逆选逊透逐途逗通逛逞速造逡逢逮逯逾遁 +遂遇遍道遗遣遭遵遽邋邑邓邛邢那邦邪邹邺邻郄郅郇郎郗郝郡郢部郭郴都鄱鄹配酐酒酡酢酪酬酮酷酸酾酿醇醉醋醒醛醮采里重野量金銮鏖鑫针钏钔 +钗钞钟钢钥钦钭钮钯钱钻钿铁铅铊铛铣铤铥铪铫铭银铺铽链铿销锁锊锐锕错锤锬键锻镇镐镖镡镦长门闩闪闭问闯闰闲间闵闷闹闻闽阏阔阙阚队阢阮 +防阳阵阻阽阿陀陂附陆陇陈陋降限陕陟院除险陪陬陴陵陶隆隋随隐隔隗隘隧隰隶隹隼隽难雀雄雅集雇雉雩雪零雷需震霍霜霰露霸霾青靓静非靠靡面 +革鞋鞔鞘鞠鞣鞫鞴韩音页顶项顺须顿颂预领颇颈颉颌频颗题额颠颡颤风飒飘飞食飧餐餮饧饭饶饿馁馆馇馈馓馕首馗馘馥马驮骀骂骇验骗骚骠骰骱骸 +髁髂髋高髟髯髻鬓鬲鬻鬼魁魂魄鲁鲅鲑鲧鲭鳃鸟鸣鸾鸿鹄鹏鹘麇麟麦麻麽黄黑默黝黧黹黾鼎齐龈龙龟!(),:;?~¥ diff --git a/packs/zh-Hans/fonts/tdeckpro-zh-hans-core/manifest.ini b/packs/zh-Hans/fonts/tdeckpro-zh-hans-core/manifest.ini index 0c37e4ec..6f57513b 100644 --- a/packs/zh-Hans/fonts/tdeckpro-zh-hans-core/manifest.ini +++ b/packs/zh-Hans/fonts/tdeckpro-zh-hans-core/manifest.ini @@ -2,7 +2,7 @@ kind=font id=tdeckpro-zh-hans-core display_name=Simplified Chinese Core (T-Deck Pro) usage=both -estimated_ram_bytes=130560 +estimated_ram_bytes=139264 source=binfont file=font.bin ranges=ranges.txt diff --git a/packs/zh-Hans/fonts/tdeckpro-zh-hans-core/ranges.txt b/packs/zh-Hans/fonts/tdeckpro-zh-hans-core/ranges.txt index f9ed7e56..45ae86e5 100644 --- a/packs/zh-Hans/fonts/tdeckpro-zh-hans-core/ranges.txt +++ b/packs/zh-Hans/fonts/tdeckpro-zh-hans-core/ranges.txt @@ -1 +1 @@ -0xB0,0xB7,0x2014,0x2018-0x2019,0x201C-0x201D,0x2026,0x3001-0x3002,0x3008-0x3011,0x3014-0x3015,0x4E00-0x4E01,0x4E03,0x4E07-0x4E0E,0x4E11,0x4E13-0x4E14,0x4E18-0x4E1C,0x4E1E,0x4E22,0x4E24-0x4E25,0x4E27,0x4E2A,0x4E2D,0x4E30,0x4E32,0x4E34,0x4E39-0x4E3B,0x4E3E-0x4E3F,0x4E43,0x4E45,0x4E47-0x4E49,0x4E4B,0x4E4D-0x4E50,0x4E56,0x4E58-0x4E59,0x4E5C-0x4E5D,0x4E5F,0x4E66,0x4E70-0x4E71,0x4E7E,0x4E86,0x4E88-0x4E89,0x4E8B-0x4E8C,0x4E8E-0x4E8F,0x4E91,0x4E94,0x4E98,0x4E9A-0x4E9B,0x4E9F,0x4EA1-0x4EA2,0x4EA4-0x4EA5,0x4EA7-0x4EA8,0x4EAB-0x4EAE,0x4EB2,0x4EBA,0x4EC0-0x4EC2,0x4EC5-0x4EC7,0x4ECA-0x4ECB,0x4ECD-0x4ECE,0x4ED1,0x4ED3-0x4ED4,0x4ED6,0x4EE1,0x4EE3-0x4EE5,0x4EEC,0x4EF0,0x4EF3,0x4EF6-0x4EF7,0x4EFB,0x4EFD,0x4EFF,0x4F09,0x4F0D-0x4F0E,0x4F10-0x4F11,0x4F17,0x4F1A,0x4F1E,0x4F20,0x4F24,0x4F26-0x4F27,0x4F2F,0x4F34,0x4F3A,0x4F3C-0x4F3D,0x4F43,0x4F46,0x4F4D-0x4F4F,0x4F53,0x4F55,0x4F57,0x4F59-0x4F5D,0x4F60,0x4F67,0x4F69,0x4F6C,0x4F70,0x4F73-0x4F74,0x4F7B-0x4F7C,0x4F7F,0x4F89,0x4F8B,0x4F8F,0x4F94,0x4F97,0x4F9B,0x4FA0,0x4FA5-0x4FA7,0x4FAC,0x4FAF,0x4FB5,0x4FBF,0x4FC3-0x4FC4,0x4FCA,0x4FCF,0x4FD7,0x4FDD-0x4FDF,0x4FE1,0x4FE9,0x4FEE,0x4FF8,0x4FFA,0x4FFE,0x500D,0x5012,0x5018-0x501A,0x501C,0x5026,0x5029-0x502A,0x502D,0x503A,0x503C,0x5047-0x5048,0x504C,0x504F,0x5055,0x505A,0x505C,0x506C,0x5076-0x5077,0x507B,0x5080,0x508D,0x50A8,0x50AC,0x50B2,0x50BB,0x50CF,0x50DA,0x50E7,0x50ED-0x50EE,0x510B,0x513F,0x5141,0x5143-0x5145,0x5148-0x5149,0x514B,0x514D,0x5151,0x5154,0x515A,0x5165,0x5168,0x516B-0x516D,0x5170-0x5171,0x5173-0x5179,0x517B-0x517C,0x5185,0x5188-0x5189,0x518C-0x518D,0x5192,0x5199,0x519B-0x519C,0x51A0,0x51A5,0x51AC,0x51AF-0x51B0,0x51B2-0x51B3,0x51B5,0x51B7,0x51BB-0x51BC,0x51C0,0x51C6,0x51D1,0x51DD,0x51E0-0x51E1,0x51E4,0x51ED,0x51EF,0x51F3,0x51F5-0x51F6,0x51F9-0x51FB,0x51FF-0x5200,0x5206-0x5207,0x520A,0x5212,0x5217-0x521B,0x521D,0x5220,0x5224,0x5228-0x5229,0x522B,0x522E,0x5230,0x5233,0x5236-0x523B,0x524A,0x524D,0x5256,0x5261,0x5265,0x5269,0x526F,0x527D,0x527F,0x5290,0x529B,0x529D-0x52A1,0x52A3,0x52A8-0x52AA,0x52B2-0x52B3,0x52BE-0x52BF,0x52C7,0x52C9,0x52D2,0x52D6,0x52D8,0x52DF,0x52E4,0x52FA,0x52FE-0x5300,0x5305-0x5306,0x5315-0x5317,0x5319,0x531D,0x5321,0x532A,0x532E,0x5339-0x533A,0x533F,0x5341,0x5343,0x5345,0x5347,0x534A,0x534E-0x534F,0x5351-0x5353,0x5355-0x5357,0x535C,0x535E-0x5361,0x5364,0x5366-0x5367,0x536B,0x536F-0x5371,0x5373-0x5375,0x5377-0x5378,0x5382,0x5385,0x538B,0x5395,0x5398,0x539A,0x539D,0x539F,0x53A6,0x53B6,0x53BB,0x53BF,0x53C1-0x53C2,0x53C8,0x53CA-0x53CD,0x53D1,0x53D6-0x53D8,0x53DF-0x53E0,0x53E3-0x53E6,0x53E8-0x53ED,0x53EF-0x53F1,0x53F3,0x53F6-0x53F9,0x53FB,0x53FD,0x5401,0x5403-0x5404,0x5408,0x540C-0x540E,0x5411,0x5413,0x5416-0x5417,0x541B,0x541E-0x541F,0x5421,0x5426-0x5429,0x542B-0x542F,0x5431-0x5432,0x5434-0x5435,0x5439,0x543E,0x5440,0x5443,0x5446,0x5448,0x544A,0x5450,0x5452,0x5454-0x5455,0x5457-0x5458,0x5462,0x5468,0x5471-0x5473,0x5475-0x5477,0x547C-0x547D,0x5480,0x548B-0x548C,0x548E,0x5494,0x5496,0x54A3,0x54A6,0x54A9-0x54AA,0x54AC-0x54AD,0x54AF,0x54B1,0x54B3-0x54B4,0x54B8,0x54BB,0x54BD,0x54C0-0x54C1,0x54C6-0x54C8,0x54CC-0x54CD,0x54CF-0x54D0,0x54D5,0x54DD,0x54DF,0x54E6-0x54EA,0x54ED-0x54EE,0x54F2,0x54FA,0x54FC-0x54FD,0x5506-0x5507,0x550F-0x5511,0x5514,0x551B,0x5524,0x5527,0x552A,0x552C,0x552E,0x5530,0x5537,0x553C,0x5541,0x5543-0x5544,0x5546,0x5549-0x554A,0x5550,0x555C,0x5561,0x5565-0x5566,0x557B,0x5580-0x5581,0x5583-0x5584,0x5587-0x558B,0x558F,0x5594,0x5598-0x5599,0x559C-0x559D,0x559F,0x55B3,0x55B7,0x55BD,0x55C4,0x55CC-0x55CD,0x55D1-0x55D4,0x55D6,0x55DF,0x55E1,0x55E8,0x55EF,0x55F2,0x55FD,0x5600-0x5601,0x560E-0x560F,0x5618,0x561E,0x562C,0x5632,0x5634,0x563F,0x564C-0x564E,0x5662,0x5668,0x566A-0x566B,0x5671,0x5676,0x567B,0x5693,0x56A3,0x56AF,0x56B7,0x56BC,0x56CA,0x56D4,0x56D7,0x56DA-0x56DB,0x56DD-0x56DE,0x56E0-0x56E2,0x56E4,0x56ED,0x56F0-0x56F1,0x56FA,0x56FD-0x56FE,0x5708,0x571C,0x571F,0x5723,0x5728-0x572A,0x5730,0x5733,0x573A-0x573B,0x573E,0x5747,0x574E-0x5751,0x5757,0x5760-0x5761,0x5764,0x5766,0x5768,0x576B,0x576F,0x5773,0x5776-0x5777,0x577B,0x5782,0x5784,0x578C,0x5792,0x57A0,0x57A6-0x57A7,0x57A9,0x57AE,0x57B8,0x57C3,0x57CB,0x57CE-0x57CF,0x57D4,0x57DD,0x57DF,0x57E4,0x57EF,0x57F9-0x57FA,0x57FD,0x5802,0x5806-0x5807,0x580B,0x5815,0x5821,0x5824,0x582A,0x5835,0x5844,0x584C,0x5854,0x5858,0x585E,0x586B,0x5883,0x5885,0x5899,0x589E,0x58A8,0x58C5,0x58D1,0x58E4,0x58EC,0x58EE,0x58F0,0x58F3,0x58F9,0x5902,0x5904,0x5907,0x590D,0x590F,0x5915-0x5916,0x591A,0x591C,0x591F,0x5927,0x5929-0x592B,0x592D-0x592F,0x5931,0x5934,0x5938-0x593A,0x5947-0x5949,0x594B,0x594F,0x5951,0x5954,0x5956-0x5958,0x5960,0x5965,0x5973-0x5974,0x5976,0x5978-0x5979,0x597D,0x5981-0x5983,0x5986,0x5988,0x5996-0x5997,0x5999,0x599E,0x59AE-0x59AF,0x59B9,0x59BE,0x59C6,0x59CB,0x59D0,0x59D3,0x59DA,0x59E3,0x59E5,0x59EC,0x5A03,0x5A06,0x5A09,0x5A18,0x5A1C,0x5A29,0x5A36,0x5A46,0x5A55,0x5A5A,0x5A7A,0x5AAA,0x5AB2,0x5AB5,0x5AC2,0x5AD4,0x5AD6,0x5AE9,0x5B17,0x5B32,0x5B37,0x5B40,0x5B50,0x5B54,0x5B57-0x5B59,0x5B5B,0x5B5F,0x5B66,0x5B69,0x5B71,0x5B7D,0x5B81,0x5B83,0x5B85,0x5B88-0x5B89,0x5B8B-0x5B8C,0x5B93,0x5B95,0x5B97-0x5B98,0x5B9A-0x5B9B,0x5B9D-0x5B9E,0x5BA2-0x5BA3,0x5BAA,0x5BB0,0x5BB3,0x5BB6,0x5BB9,0x5BBD-0x5BBF,0x5BC6-0x5BC7,0x5BD2,0x5BDF,0x5BE1,0x5BE8,0x5BF0,0x5BF8-0x5BFC,0x5C01,0x5C04,0x5C06,0x5C09-0x5C0A,0x5C0F,0x5C11,0x5C14,0x5C1A,0x5C1D,0x5C22,0x5C25,0x5C2C,0x5C31,0x5C39-0x5C3A,0x5C3C-0x5C3F,0x5C42,0x5C45,0x5C48,0x5C4E-0x5C4F,0x5C55,0x5C5E,0x5C6E-0x5C6F,0x5C71,0x5C79,0x5C81-0x5C82,0x5C88,0x5C91,0x5C97,0x5C9B,0x5CB8,0x5CD2,0x5CD9,0x5CE4,0x5CE6,0x5D06-0x5D07,0x5D0E,0x5D14,0x5D1B,0x5D24,0x5D29,0x5D34,0x5D47,0x5D4A,0x5D4C,0x5D6F,0x5DB7,0x5DDB,0x5DDD-0x5DDE,0x5DE1-0x5DE2,0x5DE5-0x5DE8,0x5DEE,0x5DF1-0x5DF4,0x5DF7,0x5DFD,0x5E01-0x5E03,0x5E05,0x5E0C,0x5E10-0x5E11,0x5E14-0x5E16,0x5E18,0x5E1D,0x5E26-0x5E27,0x5E2E,0x5E31,0x5E38,0x5E45,0x5E55,0x5E62,0x5E72-0x5E74,0x5E76,0x5E78,0x5E7A,0x5E7C,0x5E7F,0x5E84,0x5E87,0x5E8A,0x5E93-0x5E95,0x5E97,0x5E99-0x5E9A,0x5E9C,0x5E9E-0x5E9F,0x5EA6-0x5EA7,0x5EAD,0x5EB3,0x5EB5-0x5EB7,0x5ECA,0x5ED1,0x5ED3,0x5ED6,0x5EF6-0x5EF7,0x5EFA,0x5F00-0x5F01,0x5F04,0x5F0F,0x5F15,0x5F18,0x5F1F-0x5F20,0x5F25,0x5F29,0x5F31,0x5F39-0x5F3A,0x5F40,0x5F52-0x5F53,0x5F55-0x5F57,0x5F61-0x5F62,0x5F66,0x5F69-0x5F6A,0x5F6C-0x5F6D,0x5F71,0x5F73,0x5F77,0x5F7B,0x5F80,0x5F85,0x5F88,0x5F8A-0x5F8C,0x5F92,0x5F97,0x5F99,0x5FA1,0x5FAA,0x5FB5,0x5FB7,0x5FBC,0x5FC3,0x5FC5,0x5FCD,0x5FCF-0x5FD2,0x5FD6-0x5FD7,0x5FD9,0x5FE0,0x5FEA-0x5FEB,0x5FEE,0x5FF1,0x5FF5,0x5FFD-0x5FFE,0x6000-0x6001,0x600D-0x600E,0x6012,0x6015,0x6019,0x601B-0x601D,0x6020,0x6025,0x6027-0x6028,0x602A-0x602B,0x6035,0x603B-0x603C,0x6041-0x6043,0x604D,0x6050,0x6052,0x605D,0x6062,0x6068-0x6069,0x606B,0x606F-0x6070,0x6073,0x6076,0x607C,0x607F,0x6084,0x609B,0x609D,0x60A3,0x60A8,0x60AC,0x60AF,0x60B2,0x60C5-0x60C6,0x60CA,0x60DD,0x60E0,0x60E8,0x60ED,0x60F0,0x60F3-0x60F4,0x60F9,0x6100-0x6101,0x6109,0x610D,0x610F,0x611F-0x6120,0x6123-0x6124,0x6127,0x613F,0x614A,0x614C,0x614E,0x615D,0x6162,0x6168,0x6177,0x618B,0x618E,0x61A7,0x61BE,0x61C2,0x61CA,0x61D2,0x61D4,0x6206,0x620C,0x620E-0x6211,0x6215-0x6216,0x6218,0x621A-0x621B,0x6221,0x6233-0x6234,0x6237,0x623F-0x6241,0x6243,0x6247,0x624B,0x624D-0x624E,0x6251-0x6254,0x6258,0x625B,0x6263,0x6267,0x6269-0x626B,0x626D-0x626E,0x6270,0x6273,0x6276,0x6279,0x627E-0x6280,0x6284,0x628A,0x6293,0x6295-0x6298,0x629B,0x629F-0x62A2,0x62A4-0x62A5,0x62A8,0x62AC,0x62B1,0x62B5,0x62B9,0x62BB-0x62BD,0x62C2,0x62C5-0x62C6,0x62C8-0x62CA,0x62CC-0x62CE,0x62D0,0x62D3-0x62D4,0x62D6-0x62D8,0x62DA-0x62DC,0x62DF,0x62E2,0x62E5-0x62E7,0x62E9,0x62EC,0x62EE,0x62F1,0x62F4,0x62F6-0x62F7,0x62FC-0x62FF,0x6301-0x6302,0x6307-0x6309,0x630E,0x6311,0x6316,0x631B,0x631D,0x631F-0x6321,0x6325,0x632A-0x632B,0x632F,0x6332,0x633A,0x6346,0x6349,0x634B-0x6350,0x635F,0x6362,0x6367,0x6369,0x636D-0x636E,0x6376-0x6377,0x637B,0x6380,0x6387-0x638A,0x638C,0x638E,0x6392,0x6398,0x63A0,0x63A2,0x63A5,0x63A7-0x63A8,0x63AA,0x63B4,0x63B8,0x63BA,0x63BE,0x63C4,0x63C9,0x63CD,0x63CF-0x63D0,0x63D2,0x63D6,0x63DE,0x63E1,0x63E3,0x63E9,0x63ED,0x63F2,0x63F4,0x6402,0x640B,0x6413,0x641B-0x641C,0x641E,0x6421,0x642C-0x642D,0x6441,0x6447,0x6454,0x6467,0x6469,0x647A,0x6485,0x6487,0x6492,0x6495-0x6496,0x6499,0x649E,0x64A4,0x64A9,0x64AD-0x64AE,0x64B0,0x64BA,0x64C2,0x64CD,0x64D0,0x64D2,0x64D7-0x64D8,0x64DE,0x64E6,0x6509,0x6512,0x6518,0x6525,0x652E-0x652F,0x6536,0x6539,0x653B,0x653E-0x653F,0x6545,0x6548,0x654C,0x654F,0x6551,0x6555-0x6556,0x6559,0x655E,0x6562-0x6563,0x6566,0x656B,0x6570,0x6574,0x6587,0x658C,0x6597,0x6599,0x659C,0x65A1,0x65A5,0x65A9,0x65AB,0x65AD,0x65AF-0x65B0,0x65B9,0x65BC-0x65BD,0x65C1,0x65C4,0x65CB,0x65CF,0x65E0,0x65E2,0x65E5-0x65E7,0x65E9,0x65EC,0x65EE,0x65F0,0x65F6,0x6602,0x6606,0x660C,0x660E-0x660F,0x6614-0x6615,0x6619,0x661F-0x6620,0x6625,0x6627-0x6628,0x662F,0x6635,0x663E,0x6641,0x6643,0x6652,0x665A,0x665F,0x6668,0x666E-0x666F,0x6682,0x6687,0x6696-0x6697,0x66A7-0x66A8,0x66B4,0x66DD,0x66E9,0x66F0,0x66F2,0x66F4,0x66F7,0x66F9,0x66FC,0x66FE,0x6700,0x6708-0x670B,0x670D,0x6710,0x6714,0x6717,0x671B,0x671D,0x671F,0x6726,0x6728,0x672A-0x672D,0x672F,0x6731,0x6734-0x6735,0x673A,0x6740,0x6742-0x6743,0x6749,0x674C,0x674E,0x6750-0x6751,0x6753,0x675C,0x6760-0x6761,0x6765,0x6768,0x676D,0x6777,0x677C,0x677E-0x677F,0x6784,0x6787,0x6789,0x678B,0x6790,0x6795,0x6797-0x6798,0x679C-0x679E,0x67AA,0x67AF,0x67B6,0x67B8,0x67C1,0x67CF-0x67D1,0x67D3-0x67D4,0x67D9-0x67DA,0x67DC,0x67DE,0x67E0,0x67E2,0x67E5,0x67F3-0x67F4,0x67FD,0x6805,0x6807,0x680E-0x680F,0x6811,0x6813,0x6816-0x6817,0x681D,0x6821,0x6829,0x6837-0x6839,0x683C,0x683E,0x6841-0x6843,0x6845,0x6848,0x684A,0x684C,0x6850-0x6851,0x6854,0x6861,0x6863,0x6865,0x6867,0x6881,0x6885,0x688F,0x6897,0x68A2,0x68A6-0x68A7,0x68AD,0x68C0,0x68C9,0x68CB,0x68CD,0x68D2,0x68E3,0x68EE,0x68F0-0x68F1,0x68F5,0x68F9,0x690E,0x6939,0x693F,0x6942,0x695A,0x695E,0x6960,0x696E,0x6971,0x6977,0x697C,0x6982,0x699C,0x69C1,0x69CC,0x69D0,0x69DB,0x69DF,0x69ED,0x69FD,0x69FF,0x6A18,0x6A21,0x6A2A,0x6A3D,0x6A50,0x6A59,0x6A80,0x6A90,0x6A97,0x6AA0,0x6AAB,0x6B21-0x6B23,0x6B27,0x6B39,0x6B3E,0x6B43,0x6B47,0x6B59,0x6B62-0x6B66,0x6B6A,0x6B79,0x6B7B,0x6B81,0x6B8B,0x6B8D,0x6B96,0x6BA1,0x6BB5,0x6BB7,0x6BBF,0x6BC1,0x6BCB,0x6BCD,0x6BCF,0x6BD2,0x6BD4-0x6BD5,0x6BDB,0x6BEB,0x6BF3,0x6BF9,0x6C0F-0x6C11,0x6C13-0x6C15,0x6C24,0x6C34-0x6C35,0x6C38,0x6C3D,0x6C40-0x6C42,0x6C47,0x6C49,0x6C55,0x6C57,0x6C5D,0x6C5F-0x6C60,0x6C64,0x6C69-0x6C6A,0x6C76,0x6C7D-0x6C7E,0x6C82-0x6C83,0x6C86,0x6C88-0x6C89,0x6C8C,0x6C8F,0x6C93,0x6C99,0x6C9F,0x6CA1,0x6CA6-0x6CA7,0x6CAE,0x6CB1,0x6CB3,0x6CB8,0x6CBB,0x6CBE,0x6CC4-0x6CC5,0x6CC9-0x6CCA,0x6CCC,0x6CD0,0x6CD4-0x6CD6,0x6CDB,0x6CDE,0x6CE2-0x6CE3,0x6CE5,0x6CE8,0x6CEA-0x6CEB,0x6CEF-0x6CF0,0x6CF5,0x6CF7,0x6CFA,0x6CFD,0x6D01,0x6D07,0x6D0B,0x6D12,0x6D17,0x6D1A-0x6D1B,0x6D1E,0x6D2A-0x6D2B,0x6D2E,0x6D32,0x6D35,0x6D3B-0x6D3E,0x6D41,0x6D45,0x6D4B,0x6D4D,0x6D4F,0x6D51-0x6D53,0x6D5A,0x6D5C,0x6D69-0x6D6A,0x6D77-0x6D78,0x6D82,0x6D85,0x6D88-0x6D89,0x6D8C,0x6D8E,0x6D91,0x6D93-0x6D94,0x6D9E,0x6DA1,0x6DA3,0x6DA6,0x6DA9-0x6DAA,0x6DAE,0x6DB2,0x6DCB-0x6DCC,0x6DD1,0x6DD6,0x6DD9,0x6DE0-0x6DE1,0x6DE6,0x6DEB-0x6DEC,0x6DEE,0x6DF1,0x6DF3,0x6DF7,0x6DFB,0x6E05,0x6E11,0x6E20,0x6E25,0x6E29,0x6E2B,0x6E2F,0x6E34,0x6E38,0x6E43,0x6E4D,0x6E53-0x6E54,0x6E5B,0x6E5F,0x6E6B,0x6E6E,0x6E7E,0x6E83,0x6E89,0x6E90,0x6E98,0x6E9F,0x6EA5,0x6EAA,0x6EAF,0x6EB1-0x6EB2,0x6EB4,0x6EB6,0x6EBA,0x6EC2,0x6EC7,0x6ECB,0x6ED1,0x6ED5,0x6EDA,0x6EE1,0x6EE8,0x6F02,0x6F06,0x6F0F,0x6F15,0x6F2B,0x6F2F,0x6F58,0x6F5C,0x6F62,0x6F66,0x6F6D-0x6F6E,0x6F7C,0x6F84,0x6F89,0x6F8D,0x6FA1,0x6FB3,0x6FB6,0x6FB9,0x6FC0,0x6FC2,0x6FDE,0x6FE1,0x6FEF,0x7011,0x7039,0x704C,0x706B-0x706D,0x706F-0x7070,0x7075,0x707E-0x707F,0x7085,0x708A,0x708E,0x7094-0x7096,0x70AE-0x70AF,0x70B8-0x70B9,0x70C1-0x70C2,0x70C8,0x70D9,0x70DB,0x70DF,0x70E4,0x70E7,0x70ED,0x7109,0x7116,0x7118,0x7126,0x712F,0x7131,0x7136,0x7166-0x7168,0x7184,0x718A,0x7199,0x719F,0x71A8,0x71AC,0x71C3,0x71D4,0x71E0,0x71E5,0x71F9,0x7206,0x721D,0x7228,0x722A,0x722C,0x7231,0x7237-0x7239,0x723B,0x723D,0x723F,0x7247-0x7248,0x724C,0x7259,0x725B,0x725F,0x7262,0x7269,0x7279,0x7284,0x728D,0x7292,0x72AF,0x72B4,0x72B6,0x72C2-0x72C4,0x72D7,0x72E0-0x72E1,0x72EC,0x72FB-0x72FC,0x730E,0x7313,0x7317,0x731B,0x732B,0x7334,0x7360,0x736C,0x737E,0x7384,0x7387,0x738B,0x739F,0x73A2,0x73A9,0x73AF-0x73B0,0x73CD,0x73DE,0x73E9,0x73ED,0x73F2,0x7403,0x7405-0x7406,0x7422,0x7428,0x7433-0x7434,0x743C,0x7457,0x7459,0x745E-0x745F,0x74D2,0x74DC,0x74E0,0x74E4,0x74E6,0x74EE,0x74F6,0x74FF,0x7504,0x7518,0x751A,0x751C,0x751F,0x7528-0x7529,0x752B-0x752D,0x7530-0x7532,0x7535,0x7537-0x7538,0x753A,0x753E,0x754C,0x7559-0x755A,0x755C,0x7565,0x756A,0x7578-0x7579,0x758B,0x7591-0x7592,0x7594,0x7597,0x7599,0x759F,0x75AE,0x75B4-0x75B5,0x75B8-0x75B9,0x75BC,0x75C5,0x75D5,0x75DB,0x75E6,0x75EA,0x7615,0x761F,0x7625,0x7633,0x7635,0x764C,0x765E,0x7678,0x767B,0x767D-0x767E,0x7684,0x7687,0x768B,0x7693,0x7696,0x76A4,0x76AE,0x76B4,0x76BF,0x76C5-0x76C6,0x76CD-0x76CE,0x76D1-0x76D2,0x76D6,0x76D8,0x76DB,0x76DF,0x76EE-0x76EF,0x76F2,0x76F4,0x76F8-0x76F9,0x76FC,0x76FE,0x7701,0x7708,0x770B,0x7719,0x771F-0x7720,0x7729,0x772D,0x7737-0x7738,0x773C,0x7740,0x7743,0x7747,0x7761-0x7763,0x776B,0x777D-0x7780,0x7791,0x77A0,0x77A2,0x77A5,0x77A7,0x77AA,0x77AC,0x77B5,0x77BF,0x77DB-0x77DC,0x77E3,0x77E5,0x77ED,0x77F3,0x77F8,0x77FF,0x7801,0x7809,0x780C-0x780D,0x7814,0x7816,0x781D,0x781F,0x7825,0x7829,0x782C,0x7830,0x7834,0x7838,0x7845,0x784C,0x784E,0x7850,0x7855,0x785D,0x786A-0x786C,0x786E,0x788C,0x788E,0x789F,0x78A1,0x78A3,0x78B0-0x78B1,0x78C1,0x78C5,0x78C9,0x78F7,0x78FA,0x7924,0x793A,0x793E,0x7941,0x7948,0x7953,0x7956,0x795D-0x795E,0x7960,0x7962,0x7968,0x796D,0x7981,0x7985,0x79B3,0x79BB,0x79C0-0x79C1,0x79CB,0x79CD,0x79D1-0x79D2,0x79D8,0x79DF,0x79E4,0x79E6,0x79EF-0x79F0,0x79F8,0x79FB,0x7A0B,0x7A0D-0x7A0E,0x7A1E,0x7A20,0x7A37,0x7A39,0x7A3D,0x7A3F,0x7A51,0x7A70,0x7A74,0x7A76-0x7A77,0x7A79-0x7A7A,0x7A7F-0x7A81,0x7A83,0x7A92,0x7A96-0x7A98,0x7A9C-0x7A9D,0x7AA5,0x7AA8,0x7AAC,0x7ACB,0x7AD9,0x7ADF-0x7AE0,0x7AE5,0x7AEF,0x7AFA,0x7B0B,0x7B0F,0x7B11,0x7B24,0x7B26,0x7B28,0x7B2C,0x7B2E,0x7B3C,0x7B49,0x7B4B,0x7B52,0x7B54,0x7B56,0x7B5B,0x7B60,0x7B77,0x7B79,0x7B80,0x7B90,0x7B97,0x7BA1-0x7BA2,0x7BAC,0x7BB4,0x7BB8,0x7BC7,0x7BE1,0x7BE6,0x7BFE,0x7C07,0x7C2A,0x7C3F,0x7C41,0x7C4D,0x7C73-0x7C74,0x7C7B,0x7C89,0x7C97-0x7C98,0x7CA2,0x7CA5,0x7CAE,0x7CB9,0x7CBE,0x7CC1,0x7CC5,0x7CD6,0x7CDC,0x7CDF,0x7CF8,0x7CFB,0x7D20,0x7D22,0x7D27,0x7D2F,0x7D6E,0x7DAE,0x7E41,0x7E47,0x7E82,0x7E9B,0x7EA2,0x7EA4-0x7EA7,0x7EAF,0x7EB1-0x7EB3,0x7EB5-0x7EB7,0x7EBD,0x7EBF,0x7EC3-0x7EC4,0x7EC6,0x7EC8,0x7ECD,0x7ECF,0x7ED3,0x7ED5,0x7ED9,0x7EDC-0x7EDD,0x7EDF,0x7EE2-0x7EE3,0x7EE7,0x7EEA,0x7EED,0x7EF0,0x7EF2,0x7EF5,0x7EFC,0x7F00,0x7F09,0x7F0E-0x7F0F,0x7F13,0x7F16-0x7F17,0x7F20,0x7F29-0x7F2A,0x7F2F,0x7F32,0x7F34-0x7F36,0x7F3A,0x7F51,0x7F57,0x7F5A,0x7F62,0x7F6A,0x7F6E,0x7F71,0x7F8E,0x7F9E,0x7FA1,0x7FA4,0x7FA7,0x7FB8-0x7FB9,0x7FBD,0x7FC1,0x7FDF-0x7FE0,0x7FEE,0x7FFB,0x8001,0x8003,0x8005-0x8006,0x800C-0x800D,0x8010,0x8015,0x8017,0x8019,0x8028,0x8033,0x8036-0x8038,0x804A,0x8052,0x8054,0x8058,0x806A,0x8071,0x8083-0x8084,0x8086,0x8089,0x808B,0x809C,0x80A1-0x80A2,0x80A5,0x80A9,0x80AB,0x80AD-0x80AF,0x80B2,0x80B7,0x80BA,0x80C2,0x80C6,0x80CC-0x80CD,0x80D6-0x80D7,0x80DC-0x80DE,0x80E1,0x80E9,0x80EF,0x80F1-0x80F3,0x80F6,0x80F8,0x80FA,0x80FD,0x8109,0x810F,0x8111,0x8113-0x8114,0x8118,0x811A,0x811E,0x812F,0x8131,0x8138,0x813E,0x814A,0x814C,0x8154,0x8167,0x816E,0x8171,0x817E-0x8180,0x818A,0x8198,0x81A6,0x81AA,0x81BB,0x81C2,0x81E3,0x81E7,0x81EA,0x81ED,0x81F3-0x81F4,0x81FE,0x8202,0x8204,0x820C-0x820D,0x8212,0x8214,0x821C,0x8221,0x822A,0x822C,0x8231,0x8233,0x8239,0x8258,0x825F,0x826E-0x826F,0x8272,0x8274,0x827D-0x827F,0x8282,0x828B,0x828D-0x828E,0x8292,0x8298,0x829F,0x82A4-0x82A6,0x82A9-0x82AB,0x82AD-0x82AF,0x82B1,0x82B3-0x82B4,0x82BE,0x82CD,0x82CF,0x82D1-0x82D3,0x82D5,0x82D7,0x82DB,0x82DE,0x82E3-0x82E6,0x82EB,0x82EF,0x82F1,0x82F4,0x82F9,0x82FB,0x8301,0x8303-0x8304,0x8307-0x8308,0x8311,0x831B-0x831C,0x8327,0x832B-0x832D,0x8336,0x8343,0x8349,0x8351-0x8352,0x835B,0x8360-0x8361,0x8363-0x8365,0x8368,0x836F,0x837C-0x837D,0x8386,0x8389,0x838E,0x8398,0x839C,0x839E,0x83A0,0x83A8-0x83A9,0x83AB,0x83B1,0x83B7,0x83BD,0x83C0,0x83C5,0x83CC,0x83CF,0x83DC,0x83E5,0x83E9,0x83F2,0x83F8-0x83F9,0x83FD,0x8401,0x8406,0x840C,0x8411,0x8425,0x8428,0x843D,0x8457,0x845A,0x845C,0x8461,0x8463,0x846C-0x846D,0x8471,0x8476,0x848B,0x8499,0x849C,0x84A1,0x84AF,0x84B2,0x84BD,0x84BF,0x84C1,0x84C9-0x84CA,0x84D1,0x84DD,0x84FC,0x84FF,0x8511,0x8513,0x851A,0x851F,0x8521,0x852B,0x853D,0x8543,0x8548-0x854A,0x855E,0x8579-0x857A,0x8584,0x859B-0x859C,0x85C9,0x85CF-0x85D0,0x85E4,0x85FF,0x8616,0x8627,0x8650-0x8651,0x866B,0x8679,0x867D-0x867E,0x868C,0x8695,0x869D,0x86A3-0x86A4,0x86B0,0x86B4-0x86B5,0x86BA,0x86C6-0x86C7,0x86D1,0x86D9,0x86DE,0x86E4,0x86E9,0x86EE,0x86F8,0x86FE,0x870D,0x8712-0x8713,0x8715,0x871A,0x8721,0x8729,0x872E,0x8731,0x8734,0x8737,0x873B,0x873E,0x874E,0x8759,0x8764-0x8765,0x8776,0x8783,0x8785,0x878D,0x87AB,0x87C0,0x87C6,0x87CA,0x87D2,0x87E0,0x8803,0x8816,0x881B,0x8821-0x8822,0x883C,0x8840,0x884C,0x8859,0x8861,0x8865,0x8868,0x886E,0x8870,0x887F,0x8882,0x8885,0x888D,0x8892,0x8896,0x889C,0x88A2,0x88A4,0x88AB,0x88B7,0x88BC,0x88C1-0x88C2,0x88C5,0x88D2,0x88D9,0x88E2,0x88E8,0x88F3-0x88F4,0x88FC,0x890A,0x891A,0x8925,0x892A,0x8936,0x897F,0x8981,0x8983,0x8986,0x89C1-0x89C2,0x89C4,0x89C6,0x89C8-0x89C9,0x89D2,0x89D6,0x89DC,0x89E3,0x89F3,0x8A00,0x8A07,0x8A79,0x8A8A,0x8BA1-0x8BA2,0x8BA4,0x8BA8-0x8BA9,0x8BAD-0x8BB0,0x8BB2,0x8BB7-0x8BB8,0x8BBA,0x8BBC,0x8BBE-0x8BBF,0x8BC1,0x8BC4,0x8BC6,0x8BC8-0x8BCA,0x8BCD,0x8BCF,0x8BD5,0x8BD8,0x8BDD,0x8BE5-0x8BE6,0x8BED,0x8BEF-0x8BF0,0x8BF4-0x8BF7,0x8BF9-0x8BFB,0x8BFE,0x8C01,0x8C03,0x8C06,0x8C08,0x8C0B,0x8C0D,0x8C11,0x8C1C,0x8C20,0x8C22,0x8C2C,0x8C2E,0x8C31,0x8C37,0x8C41,0x8C46,0x8C49,0x8C5A,0x8C61,0x8C6A-0x8C6B,0x8C73,0x8C78,0x8C7A,0x8C89-0x8C8A,0x8C8C,0x8D1D,0x8D22-0x8D23,0x8D25,0x8D2B,0x8D2D,0x8D30,0x8D32,0x8D34-0x8D35,0x8D38-0x8D39,0x8D3C,0x8D3E,0x8D43-0x8D44,0x8D49,0x8D4F,0x8D56,0x8D5A-0x8D5B,0x8D5E,0x8D60,0x8D66,0x8D6B,0x8D70,0x8D76-0x8D77,0x8D81,0x8D84-0x8D85,0x8D8A,0x8D91,0x8D9F,0x8DA3,0x8DB3,0x8DB5,0x8DBC,0x8DBF,0x8DC3,0x8DCB-0x8DCC,0x8DD1,0x8DDB,0x8DDD-0x8DDF,0x8DE3-0x8DE4,0x8DE8,0x8DEB-0x8DEC,0x8DEF,0x8DF3,0x8E05,0x8E09,0x8E14,0x8E22-0x8E23,0x8E29-0x8E2A,0x8E31,0x8E39,0x8E42,0x8E44,0x8E4A,0x8E66,0x8E6D,0x8E72,0x8E74,0x8E76,0x8E7F,0x8E81,0x8E85,0x8E87,0x8E94,0x8EAB,0x8EB2,0x8EBA,0x8F66-0x8F68,0x8F6C,0x8F6E-0x8F70,0x8F74,0x8F7D,0x8F83,0x8F87,0x8F8D,0x8F8F,0x8F91,0x8F93,0x8F97,0x8F9B,0x8F9E-0x8F9F,0x8FA3,0x8FA8-0x8FA9,0x8FB0,0x8FB9,0x8FBD-0x8FBE,0x8FC1,0x8FC5,0x8FC7,0x8FCE,0x8FD0-0x8FD1,0x8FD4,0x8FD8-0x8FD9,0x8FDB-0x8FDC,0x8FDE-0x8FDF,0x8FE4-0x8FE6,0x8FE9,0x8FEB,0x8FED-0x8FEE,0x8FF0,0x8FF7,0x8FF9,0x8FFD,0x9000-0x9004,0x9006,0x9009-0x900A,0x900F-0x9010,0x9014,0x9017,0x901A-0x901B,0x901E-0x9022,0x902E-0x902F,0x903E,0x9041-0x9042,0x9047,0x904D,0x9053,0x9057,0x9063,0x906D,0x9075,0x907D,0x908B,0x9091,0x9093,0x909B,0x90A2-0x90A3,0x90A6,0x90AA,0x90B9-0x90BB,0x90C4-0x90C5,0x90C7,0x90CE,0x90D7,0x90DD,0x90E1-0x90E2,0x90E8,0x90ED,0x90F4,0x90FD,0x9131,0x9139,0x914D,0x9150,0x9152,0x9161-0x9162,0x916A,0x916C,0x916E,0x9177-0x9178,0x917E-0x917F,0x9187,0x9189,0x918B,0x9192,0x919B,0x91AE,0x91C7,0x91CC-0x91CF,0x91D1,0x92AE,0x93D6,0x946B,0x9488,0x948F,0x9494,0x9497,0x949E-0x949F,0x94A2,0x94A5-0x94A6,0x94AD-0x94AF,0x94B1,0x94BB,0x94BF,0x94C1,0x94C5,0x94CA,0x94DB,0x94E3-0x94E5,0x94EA-0x94EB,0x94ED,0x94F6,0x94FA,0x94FD-0x9501,0x950A,0x9510,0x9515,0x9519,0x9524,0x952C,0x952E,0x953B,0x9547,0x9550,0x9556,0x9561,0x9566,0x957F,0x95E8-0x95EA,0x95ED-0x95F0,0x95F2,0x95F4-0x95F5,0x95F7,0x95F9,0x95FB,0x95FD,0x960F,0x9614,0x9619-0x961A,0x961F,0x9622,0x962E,0x9632-0x9633,0x9635,0x963B,0x963D,0x963F-0x9640,0x9642,0x9644,0x9646-0x9648,0x964B,0x964D,0x9650,0x9655,0x965F,0x9662,0x9664,0x9669-0x966A,0x966C,0x9674-0x9676,0x9686,0x968B,0x968F-0x9690,0x9694,0x9697-0x9698,0x96A7,0x96B0,0x96B6,0x96B9,0x96BC-0x96BE,0x96C0,0x96C4-0x96C7,0x96C9,0x96E9-0x96EA,0x96F6-0x96F7,0x9700,0x9707,0x970D,0x971C,0x9730,0x9732,0x9738,0x973E,0x9752-0x9753,0x9759,0x975E,0x9760-0x9762,0x9769,0x978B,0x9794,0x9798,0x97A0,0x97A3,0x97AB,0x97B4,0x97E9,0x97F3,0x9875-0x9876,0x9879-0x987B,0x987F,0x9882,0x9884,0x9886-0x9889,0x988C,0x9891,0x9897-0x9898,0x989D,0x98A0-0x98A1,0x98A4,0x98CE,0x98D2,0x98D8,0x98DE-0x98DF,0x98E7,0x9910,0x992E,0x9967,0x996D,0x9976,0x997F,0x9981,0x9986-0x9988,0x9993,0x9995-0x9998,0x99A5,0x9A6C,0x9A6E,0x9A80,0x9A82,0x9A87,0x9A8C,0x9A97,0x9A9A,0x9AA0,0x9AB0-0x9AB1,0x9AB8,0x9AC1-0x9AC2,0x9ACB,0x9AD8,0x9ADF,0x9AEF,0x9AFB,0x9B13,0x9B32,0x9B3B-0x9B3C,0x9B41-0x9B42,0x9B44,0x9C81,0x9C85,0x9C91,0x9CA7,0x9CAD,0x9CC3,0x9E1F,0x9E23,0x9E3E-0x9E3F,0x9E44,0x9E4F,0x9E58,0x9E87,0x9E9F,0x9EA6,0x9EBB,0x9EBD,0x9EC4,0x9ED1,0x9ED8,0x9EDD,0x9EE7,0x9EF9,0x9EFE,0x9F0E,0x9F50,0x9F88,0x9F99,0x9F9F,0xFF01,0xFF08-0xFF09,0xFF0C,0xFF1A-0xFF1B,0xFF1F,0xFF5E,0xFFE5 +0xB0,0xB7,0x2014,0x2018-0x2019,0x201C-0x201D,0x2026,0x3001-0x3002,0x3008-0x3011,0x3014-0x3015,0x4E00-0x4E01,0x4E03,0x4E07-0x4E0E,0x4E11,0x4E13-0x4E14,0x4E18-0x4E1C,0x4E1E,0x4E22,0x4E24-0x4E25,0x4E27,0x4E2A,0x4E2D,0x4E30,0x4E32,0x4E34,0x4E39-0x4E3B,0x4E3E-0x4E3F,0x4E43,0x4E45,0x4E47-0x4E49,0x4E4B,0x4E4D-0x4E50,0x4E56,0x4E58-0x4E59,0x4E5C-0x4E5D,0x4E5F,0x4E66,0x4E70-0x4E71,0x4E7E,0x4E86,0x4E88-0x4E89,0x4E8B-0x4E8C,0x4E8E-0x4E8F,0x4E91,0x4E94,0x4E98,0x4E9A-0x4E9B,0x4E9F,0x4EA1-0x4EA2,0x4EA4-0x4EA5,0x4EA7-0x4EA8,0x4EAB-0x4EAE,0x4EB2,0x4EBA,0x4EC0-0x4EC2,0x4EC5-0x4EC7,0x4ECA-0x4ECB,0x4ECD-0x4ECE,0x4ED1,0x4ED3-0x4ED4,0x4ED6,0x4EE1,0x4EE3-0x4EE5,0x4EEC,0x4EF0,0x4EF3,0x4EF6-0x4EF7,0x4EFB,0x4EFD,0x4EFF,0x4F09,0x4F0D-0x4F0E,0x4F10-0x4F11,0x4F17,0x4F1A,0x4F1E,0x4F20,0x4F24,0x4F26-0x4F27,0x4F2F,0x4F34,0x4F3A,0x4F3C-0x4F3D,0x4F43,0x4F46,0x4F4D-0x4F4F,0x4F53,0x4F55,0x4F57,0x4F59-0x4F5D,0x4F60,0x4F67,0x4F69,0x4F6C,0x4F70,0x4F73-0x4F74,0x4F7B-0x4F7C,0x4F7F,0x4F89,0x4F8B,0x4F8F,0x4F94,0x4F97,0x4F9B,0x4FA0,0x4FA5-0x4FA7,0x4FAC,0x4FAF,0x4FB5,0x4FBF,0x4FC3-0x4FC4,0x4FCA,0x4FCF,0x4FD7,0x4FDD-0x4FDF,0x4FE1,0x4FE9,0x4FEE,0x4FF8,0x4FFA,0x4FFE,0x500D,0x5012,0x5018-0x501A,0x501C,0x5026,0x5029-0x502A,0x502D,0x503A,0x503C,0x5047-0x5048,0x504C,0x504F,0x5055,0x505A,0x505C,0x506C,0x5076-0x5077,0x507B,0x5080,0x508D,0x50A8,0x50AC,0x50B2,0x50BB,0x50CF,0x50DA,0x50E7,0x50ED-0x50EE,0x510B,0x513F,0x5141,0x5143-0x5145,0x5148-0x5149,0x514B,0x514D,0x5151,0x5154,0x515A,0x5165,0x5168,0x516B-0x516D,0x5170-0x5171,0x5173-0x5179,0x517B-0x517C,0x5185,0x5188-0x5189,0x518C-0x518D,0x5192,0x5199,0x519B-0x519C,0x51A0,0x51A5,0x51AC,0x51AF-0x51B0,0x51B2-0x51B3,0x51B5,0x51B7,0x51BB-0x51BC,0x51C0,0x51C6,0x51D1,0x51DD,0x51E0-0x51E1,0x51E4,0x51ED,0x51EF,0x51F3,0x51F5-0x51F6,0x51F9-0x51FB,0x51FF-0x5200,0x5206-0x5207,0x520A,0x5212,0x5217-0x521B,0x521D,0x5220,0x5224,0x5228-0x5229,0x522B,0x522E,0x5230,0x5233,0x5236-0x523B,0x524A,0x524D,0x5256,0x5261,0x5265,0x5269,0x526F,0x527D,0x527F,0x5290,0x529B,0x529D-0x52A1,0x52A3,0x52A8-0x52AA,0x52B2-0x52B3,0x52BE-0x52BF,0x52C7,0x52C9,0x52D2,0x52D6,0x52D8,0x52DF,0x52E4,0x52FA,0x52FE-0x5300,0x5305-0x5306,0x5315-0x5317,0x5319,0x531D,0x5321,0x532A,0x532E,0x5339-0x533A,0x533F,0x5341,0x5343,0x5345,0x5347,0x534A,0x534E-0x534F,0x5351-0x5353,0x5355-0x5357,0x535C,0x535E-0x5361,0x5364,0x5366-0x5367,0x536B,0x536F-0x5371,0x5373-0x5375,0x5377-0x5378,0x5382,0x5385-0x5386,0x538B,0x5395,0x5398,0x539A,0x539D,0x539F,0x53A6,0x53B6,0x53BB,0x53BF,0x53C1-0x53C2,0x53C8,0x53CA-0x53CD,0x53D1,0x53D6-0x53D8,0x53DF-0x53E0,0x53E3-0x53E6,0x53E8-0x53ED,0x53EF-0x53F1,0x53F3,0x53F6-0x53F9,0x53FB,0x53FD,0x5401,0x5403-0x5404,0x5408,0x540C-0x540E,0x5411,0x5413,0x5416-0x5417,0x541B,0x541E-0x541F,0x5421,0x5426-0x5429,0x542B-0x542F,0x5431-0x5432,0x5434-0x5435,0x5439,0x543E,0x5440,0x5443,0x5446,0x5448,0x544A,0x5450,0x5452,0x5454-0x5455,0x5457-0x5458,0x5462,0x5468,0x5471-0x5473,0x5475-0x5477,0x547C-0x547D,0x5480,0x548B-0x548C,0x548E,0x5494,0x5496,0x54A3,0x54A6,0x54A9-0x54AA,0x54AC-0x54AD,0x54AF,0x54B1,0x54B3-0x54B4,0x54B8,0x54BB,0x54BD,0x54C0-0x54C1,0x54C6-0x54C8,0x54CC-0x54CD,0x54CF-0x54D0,0x54D5,0x54DD,0x54DF,0x54E6-0x54EA,0x54ED-0x54EE,0x54F2,0x54FA,0x54FC-0x54FD,0x5506-0x5507,0x550F-0x5511,0x5514,0x551B,0x5524,0x5527,0x552A,0x552C,0x552E,0x5530,0x5537,0x553C,0x5541,0x5543-0x5544,0x5546,0x5549-0x554A,0x5550,0x555C,0x5561,0x5565-0x5566,0x557B,0x5580-0x5581,0x5583-0x5584,0x5587-0x558B,0x558F,0x5594,0x5598-0x5599,0x559C-0x559D,0x559F,0x55B3,0x55B7,0x55BD,0x55C4,0x55CC-0x55CD,0x55D1-0x55D4,0x55D6,0x55DF,0x55E1,0x55E8,0x55EF,0x55F2,0x55FD,0x5600-0x5601,0x560E-0x560F,0x5618,0x561E,0x562C,0x5632,0x5634,0x563F,0x564C-0x564E,0x5662,0x5668,0x566A-0x566B,0x5671,0x5676,0x567B,0x5693,0x56A3,0x56AF,0x56B7,0x56BC,0x56CA,0x56D4,0x56D7,0x56DA-0x56DB,0x56DD-0x56DE,0x56E0-0x56E2,0x56E4,0x56ED,0x56F0-0x56F1,0x56F4,0x56FA,0x56FD-0x56FE,0x5708,0x571C,0x571F,0x5723,0x5728-0x572A,0x5730,0x5733,0x573A-0x573B,0x573E,0x5747,0x574E-0x5751,0x5757,0x5760-0x5761,0x5764,0x5766,0x5768,0x576B,0x576F,0x5773,0x5776-0x5777,0x577B,0x5782,0x5784,0x578C,0x5792,0x57A0,0x57A6-0x57A7,0x57A9,0x57AE,0x57B8,0x57C3,0x57CB,0x57CE-0x57CF,0x57D4,0x57DD,0x57DF,0x57E4,0x57EF,0x57F9-0x57FA,0x57FD,0x5802,0x5806-0x5807,0x580B,0x5815,0x5821,0x5824,0x582A,0x5835,0x5844,0x584C,0x5854,0x5858,0x585E,0x586B,0x5883,0x5885,0x5899,0x589E,0x58A8,0x58C5,0x58D1,0x58E4,0x58EC,0x58EE,0x58F0,0x58F3,0x58F9,0x5902,0x5904,0x5907,0x590D,0x590F,0x5915-0x5916,0x591A,0x591C,0x591F,0x5927,0x5929-0x592B,0x592D-0x592F,0x5931,0x5934,0x5938-0x593A,0x5947-0x5949,0x594B,0x594F,0x5951,0x5954,0x5956-0x5958,0x5960,0x5965,0x5973-0x5974,0x5976,0x5978-0x5979,0x597D,0x5981-0x5983,0x5986,0x5988,0x5996-0x5997,0x5999,0x599E,0x59AE-0x59AF,0x59B9,0x59BE,0x59C6,0x59CB,0x59D0,0x59D3,0x59DA,0x59E3,0x59E5,0x59EC,0x5A03,0x5A06,0x5A09,0x5A18,0x5A1C,0x5A29,0x5A36,0x5A46,0x5A55,0x5A5A,0x5A7A,0x5AAA,0x5AB2,0x5AB5,0x5AC2,0x5AD4,0x5AD6,0x5AE9,0x5B17,0x5B32,0x5B37,0x5B40,0x5B50,0x5B54,0x5B57-0x5B59,0x5B5B,0x5B5F,0x5B66,0x5B69,0x5B71,0x5B7D,0x5B81,0x5B83,0x5B85,0x5B88-0x5B89,0x5B8B-0x5B8C,0x5B93,0x5B95,0x5B97-0x5B98,0x5B9A-0x5B9B,0x5B9D-0x5B9E,0x5BA2-0x5BA3,0x5BAA,0x5BB0,0x5BB3,0x5BB6,0x5BB9,0x5BBD-0x5BBF,0x5BC6-0x5BC7,0x5BD2,0x5BDF,0x5BE1,0x5BE8,0x5BF0,0x5BF8-0x5BFC,0x5C01,0x5C04,0x5C06,0x5C09-0x5C0A,0x5C0F,0x5C11,0x5C14,0x5C1A,0x5C1D,0x5C22,0x5C25,0x5C2C,0x5C31,0x5C39-0x5C3A,0x5C3C-0x5C3F,0x5C42,0x5C45,0x5C48,0x5C4E-0x5C4F,0x5C55,0x5C5E,0x5C6E-0x5C6F,0x5C71,0x5C79,0x5C81-0x5C82,0x5C88,0x5C91,0x5C97,0x5C9B,0x5CB8,0x5CD2,0x5CD9,0x5CE4,0x5CE6,0x5D06-0x5D07,0x5D0E,0x5D14,0x5D1B,0x5D24,0x5D29,0x5D34,0x5D47,0x5D4A,0x5D4C,0x5D6F,0x5DB7,0x5DDB,0x5DDD-0x5DDE,0x5DE1-0x5DE2,0x5DE5-0x5DE8,0x5DEE,0x5DF1-0x5DF4,0x5DF7,0x5DFD,0x5E01-0x5E03,0x5E05,0x5E0C,0x5E10-0x5E11,0x5E14-0x5E16,0x5E18,0x5E1D,0x5E26-0x5E27,0x5E2E,0x5E31,0x5E38,0x5E45,0x5E55,0x5E62,0x5E72-0x5E74,0x5E76,0x5E78,0x5E7A,0x5E7C,0x5E7F,0x5E84,0x5E87,0x5E8A,0x5E93-0x5E95,0x5E97,0x5E99-0x5E9A,0x5E9C,0x5E9E-0x5E9F,0x5EA6-0x5EA7,0x5EAD,0x5EB3,0x5EB5-0x5EB7,0x5ECA,0x5ED1,0x5ED3,0x5ED6,0x5EF6-0x5EF7,0x5EFA,0x5F00-0x5F01,0x5F03-0x5F04,0x5F0F,0x5F15,0x5F18,0x5F1F-0x5F20,0x5F25,0x5F29,0x5F31,0x5F39-0x5F3A,0x5F40,0x5F52-0x5F53,0x5F55-0x5F57,0x5F61-0x5F62,0x5F66,0x5F69-0x5F6A,0x5F6C-0x5F6D,0x5F71,0x5F73,0x5F77,0x5F7B,0x5F80,0x5F85,0x5F88,0x5F8A-0x5F8C,0x5F92,0x5F97,0x5F99,0x5FA1,0x5FAA,0x5FB5,0x5FB7,0x5FBC,0x5FC3,0x5FC5,0x5FCD,0x5FCF-0x5FD2,0x5FD6-0x5FD7,0x5FD9,0x5FE0,0x5FEA-0x5FEB,0x5FEE,0x5FF1,0x5FF5,0x5FFD-0x5FFE,0x6000-0x6001,0x600D-0x600E,0x6012,0x6015,0x6019,0x601B-0x601D,0x6020,0x6025,0x6027-0x6028,0x602A-0x602B,0x6035,0x603B-0x603C,0x6041-0x6043,0x604D,0x6050,0x6052,0x605D,0x6062,0x6068-0x6069,0x606B,0x606F-0x6070,0x6073,0x6076,0x607C,0x607F,0x6084,0x609B,0x609D,0x60A3,0x60A8,0x60AC,0x60AF,0x60B2,0x60C5-0x60C6,0x60CA,0x60DD,0x60E0,0x60E8,0x60ED,0x60F0,0x60F3-0x60F4,0x60F9,0x6100-0x6101,0x6109,0x610D,0x610F,0x611F-0x6120,0x6123-0x6124,0x6127,0x613F,0x614A,0x614C,0x614E,0x615D,0x6162,0x6168,0x6177,0x618B,0x618E,0x61A7,0x61BE,0x61C2,0x61CA,0x61D2,0x61D4,0x6206,0x620C,0x620E-0x6211,0x6215-0x6216,0x6218,0x621A-0x621B,0x6221,0x6233-0x6234,0x6237,0x623F-0x6241,0x6243,0x6247,0x624B,0x624D-0x624E,0x6251-0x6254,0x6258,0x625B,0x6263,0x6267,0x6269-0x626B,0x626D-0x626E,0x6270,0x6273,0x6276,0x6279,0x627E-0x6280,0x6284,0x628A,0x6293,0x6295-0x6298,0x629B,0x629F-0x62A2,0x62A4-0x62A5,0x62A8,0x62AC,0x62B1,0x62B5,0x62B9,0x62BB-0x62BD,0x62C2,0x62C5-0x62C6,0x62C8-0x62CA,0x62CC-0x62CE,0x62D0,0x62D3-0x62D4,0x62D6-0x62D8,0x62DA-0x62DC,0x62DF,0x62E2,0x62E5-0x62E7,0x62E9,0x62EC,0x62EE,0x62F1,0x62F4,0x62F6-0x62F7,0x62FC-0x62FF,0x6301-0x6302,0x6307-0x6309,0x630E,0x6311,0x6316,0x631B,0x631D,0x631F-0x6321,0x6325,0x632A-0x632B,0x632F,0x6332,0x633A,0x6346,0x6349,0x634B-0x6350,0x635F,0x6362,0x6367,0x6369,0x636D-0x636E,0x6376-0x6377,0x637B,0x6380,0x6387-0x638A,0x638C,0x638E,0x6392,0x6398,0x63A0,0x63A2,0x63A5,0x63A7-0x63A8,0x63AA,0x63B4,0x63B8,0x63BA,0x63BE,0x63C4,0x63C9,0x63CD,0x63CF-0x63D0,0x63D2,0x63D6,0x63DE,0x63E1,0x63E3,0x63E9,0x63ED,0x63F2,0x63F4,0x6402,0x640B,0x6413,0x641B-0x641C,0x641E,0x6421,0x642C-0x642D,0x6441,0x6447,0x6454,0x6467,0x6469,0x647A,0x6485,0x6487,0x6492,0x6495-0x6496,0x6499,0x649E,0x64A4,0x64A9,0x64AD-0x64AE,0x64B0,0x64BA,0x64C2,0x64CD,0x64D0,0x64D2,0x64D7-0x64D8,0x64DE,0x64E6,0x6509,0x6512,0x6518,0x6525,0x652E-0x652F,0x6536,0x6539,0x653B,0x653E-0x653F,0x6545,0x6548,0x654C,0x654F,0x6551,0x6555-0x6556,0x6559,0x655E,0x6562-0x6563,0x6566,0x656B,0x6570,0x6574,0x6587,0x658C,0x6597,0x6599,0x659C,0x65A1,0x65A5,0x65A9,0x65AB,0x65AD,0x65AF-0x65B0,0x65B9,0x65BC-0x65BD,0x65C1,0x65C4,0x65CB,0x65CF,0x65E0,0x65E2,0x65E5-0x65E7,0x65E9,0x65EC,0x65EE,0x65F0,0x65F6,0x6602,0x6606,0x660C,0x660E-0x660F,0x6614-0x6615,0x6619,0x661F-0x6620,0x6625,0x6627-0x6628,0x662F,0x6635,0x663E,0x6641,0x6643,0x6652,0x665A,0x665F,0x6668,0x666E-0x666F,0x6682,0x6687,0x6696-0x6697,0x66A7-0x66A8,0x66B4,0x66DD,0x66E9,0x66F0,0x66F2,0x66F4,0x66F7,0x66F9,0x66FC,0x66FE,0x6700,0x6708-0x670B,0x670D,0x6710,0x6714,0x6717,0x671B,0x671D,0x671F,0x6726,0x6728,0x672A-0x672D,0x672F,0x6731,0x6734-0x6735,0x673A,0x6740,0x6742-0x6743,0x6749,0x674C,0x674E,0x6750-0x6751,0x6753,0x675C,0x675F-0x6761,0x6765,0x6768,0x676D,0x6777,0x677C,0x677E-0x677F,0x6784,0x6787,0x6789,0x678B,0x6790,0x6795,0x6797-0x6798,0x679C-0x679E,0x67AA,0x67AF,0x67B6,0x67B8,0x67C1,0x67CF-0x67D1,0x67D3-0x67D4,0x67D9-0x67DA,0x67DC,0x67DE,0x67E0,0x67E2,0x67E5,0x67F3-0x67F4,0x67FD,0x6805,0x6807,0x680E-0x680F,0x6811,0x6813,0x6816-0x6817,0x681D,0x6821,0x6829,0x6837-0x6839,0x683C,0x683E,0x6841-0x6843,0x6845,0x6848,0x684A,0x684C,0x6850-0x6851,0x6854,0x6861,0x6863,0x6865,0x6867,0x6881,0x6885,0x688F,0x6897,0x68A2,0x68A6-0x68A7,0x68AD,0x68C0,0x68C9,0x68CB,0x68CD,0x68D2,0x68E3,0x68EE,0x68F0-0x68F1,0x68F5,0x68F9,0x690E,0x6939,0x693F,0x6942,0x695A,0x695E,0x6960,0x696E,0x6971,0x6977,0x697C,0x6982,0x699C,0x69C1,0x69CC,0x69D0,0x69DB,0x69DF,0x69ED,0x69FD,0x69FF,0x6A18,0x6A21,0x6A2A,0x6A3D,0x6A50,0x6A59,0x6A80,0x6A90,0x6A97,0x6AA0,0x6AAB,0x6B21-0x6B23,0x6B27,0x6B39,0x6B3E,0x6B43,0x6B47,0x6B59,0x6B62-0x6B66,0x6B6A,0x6B79,0x6B7B,0x6B81,0x6B8B,0x6B8D,0x6B96,0x6BA1,0x6BB5,0x6BB7,0x6BBF,0x6BC1,0x6BCB,0x6BCD,0x6BCF,0x6BD2,0x6BD4-0x6BD5,0x6BDB,0x6BEB,0x6BF3,0x6BF9,0x6C0F-0x6C11,0x6C13-0x6C15,0x6C24,0x6C34-0x6C35,0x6C38,0x6C3D,0x6C40-0x6C42,0x6C47,0x6C49,0x6C55,0x6C57,0x6C5D,0x6C5F-0x6C60,0x6C64,0x6C69-0x6C6A,0x6C76,0x6C7D-0x6C7E,0x6C82-0x6C83,0x6C86,0x6C88-0x6C89,0x6C8C,0x6C8F,0x6C93,0x6C99,0x6C9F,0x6CA1,0x6CA6-0x6CA7,0x6CAE,0x6CB1,0x6CB3,0x6CB8,0x6CBB,0x6CBE,0x6CC4-0x6CC5,0x6CC9-0x6CCA,0x6CCC,0x6CD0,0x6CD4-0x6CD6,0x6CDB,0x6CDE,0x6CE2-0x6CE3,0x6CE5,0x6CE8,0x6CEA-0x6CEB,0x6CEF-0x6CF0,0x6CF5,0x6CF7,0x6CFA,0x6CFD,0x6D01,0x6D07,0x6D0B,0x6D12,0x6D17,0x6D1A-0x6D1B,0x6D1E,0x6D2A-0x6D2B,0x6D2E,0x6D32,0x6D35,0x6D3B-0x6D3E,0x6D41,0x6D45,0x6D4B,0x6D4D,0x6D4F,0x6D51-0x6D53,0x6D5A,0x6D5C,0x6D69-0x6D6A,0x6D77-0x6D78,0x6D82,0x6D85,0x6D88-0x6D89,0x6D8C,0x6D8E,0x6D91,0x6D93-0x6D94,0x6D9E,0x6DA1,0x6DA3,0x6DA6,0x6DA9-0x6DAA,0x6DAE,0x6DB2,0x6DCB-0x6DCC,0x6DD1,0x6DD6,0x6DD9,0x6DE0-0x6DE1,0x6DE6,0x6DEB-0x6DEC,0x6DEE,0x6DF1,0x6DF3,0x6DF7,0x6DFB,0x6E05,0x6E11,0x6E20,0x6E25,0x6E29,0x6E2B,0x6E2F,0x6E34,0x6E38,0x6E43,0x6E4D,0x6E53-0x6E54,0x6E5B,0x6E5F,0x6E6B,0x6E6E,0x6E7E,0x6E83,0x6E89,0x6E90,0x6E98,0x6E9F,0x6EA5,0x6EAA,0x6EAF,0x6EB1-0x6EB2,0x6EB4,0x6EB6,0x6EBA,0x6EC2,0x6EC7,0x6ECB,0x6ED1,0x6ED5,0x6EDA,0x6EE1,0x6EE8,0x6F02,0x6F06,0x6F0F,0x6F15,0x6F2B,0x6F2F,0x6F58,0x6F5C,0x6F62,0x6F66,0x6F6D-0x6F6E,0x6F7C,0x6F84,0x6F89,0x6F8D,0x6FA1,0x6FB3,0x6FB6,0x6FB9,0x6FC0,0x6FC2,0x6FDE,0x6FE1,0x6FEF,0x7011,0x7039,0x704C,0x706B-0x706D,0x706F-0x7070,0x7075,0x707E-0x707F,0x7085,0x708A,0x708E,0x7094-0x7096,0x70AE-0x70AF,0x70B8-0x70B9,0x70C1-0x70C2,0x70C8,0x70D9,0x70DB,0x70DF,0x70E4,0x70E7,0x70ED,0x7109,0x7116,0x7118,0x7126,0x712F,0x7131,0x7136,0x7166-0x7168,0x7184,0x718A,0x7199,0x719F,0x71A8,0x71AC,0x71C3,0x71D4,0x71E0,0x71E5,0x71F9,0x7206,0x721D,0x7228,0x722A,0x722C,0x7231,0x7237-0x7239,0x723B,0x723D,0x723F,0x7247-0x7248,0x724C,0x7259,0x725B,0x725F,0x7262,0x7269,0x7279,0x7284,0x728D,0x7292,0x72AF,0x72B4,0x72B6,0x72C2-0x72C4,0x72D7,0x72E0-0x72E1,0x72EC,0x72FB-0x72FC,0x730E,0x7313,0x7317,0x731B,0x732B,0x7334,0x7360,0x736C,0x737E,0x7384,0x7387,0x738B,0x739F,0x73A2,0x73A9,0x73AF-0x73B0,0x73CD,0x73DE,0x73E9,0x73ED,0x73F2,0x7403,0x7405-0x7406,0x7422,0x7428,0x7433-0x7434,0x743C,0x7457,0x7459,0x745E-0x745F,0x74D2,0x74DC,0x74E0,0x74E4,0x74E6,0x74EE,0x74F6,0x74FF,0x7504,0x7518,0x751A,0x751C,0x751F,0x7528-0x7529,0x752B-0x752D,0x7530-0x7532,0x7535,0x7537-0x7538,0x753A,0x753E,0x754C,0x7559-0x755A,0x755C,0x7565,0x756A,0x7578-0x7579,0x758B,0x7591-0x7592,0x7594,0x7597,0x7599,0x759F,0x75AE,0x75B4-0x75B5,0x75B8-0x75B9,0x75BC,0x75C5,0x75D5,0x75DB,0x75E6,0x75EA,0x7615,0x761F,0x7625,0x7633,0x7635,0x764C,0x765E,0x7678,0x767B,0x767D-0x767E,0x7684,0x7687,0x768B,0x7693,0x7696,0x76A4,0x76AE,0x76B4,0x76BF,0x76C5-0x76C6,0x76CD-0x76CE,0x76D1-0x76D2,0x76D6,0x76D8,0x76DB,0x76DF,0x76EE-0x76EF,0x76F2,0x76F4,0x76F8-0x76F9,0x76FC,0x76FE,0x7701,0x7708,0x770B,0x7719,0x771F-0x7720,0x7729,0x772D,0x7737-0x7738,0x773C,0x7740,0x7743,0x7747,0x7761-0x7763,0x776B,0x777D-0x7780,0x7791,0x77A0,0x77A2,0x77A5,0x77A7,0x77AA,0x77AC,0x77B5,0x77BF,0x77DB-0x77DC,0x77E3,0x77E5,0x77ED,0x77F3,0x77F8,0x77FF,0x7801,0x7809,0x780C-0x780D,0x7814,0x7816,0x781D,0x781F,0x7825,0x7829,0x782C,0x7830,0x7834,0x7838,0x7845,0x784C,0x784E,0x7850,0x7855,0x785D,0x786A-0x786C,0x786E,0x788C,0x788E,0x789F,0x78A1,0x78A3,0x78B0-0x78B1,0x78C1,0x78C5,0x78C9,0x78F7,0x78FA,0x7924,0x793A,0x793E,0x7941,0x7948,0x7953,0x7956,0x795D-0x795E,0x7960,0x7962,0x7968,0x796D,0x7981,0x7985,0x79B3,0x79BB,0x79C0-0x79C1,0x79CB,0x79CD,0x79D1-0x79D2,0x79D8,0x79DF,0x79E4,0x79E6,0x79EF-0x79F0,0x79F8,0x79FB,0x7A0B,0x7A0D-0x7A0E,0x7A1E,0x7A20,0x7A37,0x7A39,0x7A3D,0x7A3F,0x7A51,0x7A70,0x7A74,0x7A76-0x7A77,0x7A79-0x7A7A,0x7A7F-0x7A81,0x7A83,0x7A92,0x7A96-0x7A98,0x7A9C-0x7A9D,0x7AA5,0x7AA8,0x7AAC,0x7ACB,0x7AD9,0x7ADF-0x7AE0,0x7AE5,0x7AEF,0x7AFA,0x7B0B,0x7B0F,0x7B11,0x7B24,0x7B26,0x7B28,0x7B2C,0x7B2E,0x7B3C,0x7B49,0x7B4B,0x7B52,0x7B54,0x7B56,0x7B5B,0x7B60,0x7B77,0x7B79,0x7B80,0x7B90,0x7B97,0x7BA1-0x7BA2,0x7BAC,0x7BB4,0x7BB8,0x7BC7,0x7BE1,0x7BE6,0x7BFE,0x7C07,0x7C2A,0x7C3F,0x7C41,0x7C4D,0x7C73-0x7C74,0x7C7B,0x7C89,0x7C97-0x7C98,0x7CA2,0x7CA5,0x7CAE,0x7CB9,0x7CBE,0x7CC1,0x7CC5,0x7CD6,0x7CDC,0x7CDF,0x7CF8,0x7CFB,0x7D20,0x7D22,0x7D27,0x7D2F,0x7D6E,0x7DAE,0x7E41,0x7E47,0x7E82,0x7E9B,0x7EA2,0x7EA4-0x7EA7,0x7EAF,0x7EB1-0x7EB3,0x7EB5-0x7EB7,0x7EBD,0x7EBF,0x7EC3-0x7EC4,0x7EC6,0x7EC8,0x7ECD,0x7ECF,0x7ED3,0x7ED5,0x7ED9,0x7EDC-0x7EDD,0x7EDF,0x7EE2-0x7EE3,0x7EE7,0x7EEA,0x7EED,0x7EF0,0x7EF2,0x7EF5,0x7EFC,0x7F00,0x7F09,0x7F0E-0x7F0F,0x7F13,0x7F16-0x7F17,0x7F20,0x7F29-0x7F2A,0x7F2F,0x7F32,0x7F34-0x7F36,0x7F3A,0x7F51,0x7F57,0x7F5A,0x7F62,0x7F6A,0x7F6E,0x7F71,0x7F8E,0x7F9E,0x7FA1,0x7FA4,0x7FA7,0x7FB8-0x7FB9,0x7FBD,0x7FC1,0x7FDF-0x7FE0,0x7FEE,0x7FFB,0x8001,0x8003,0x8005-0x8006,0x800C-0x800D,0x8010,0x8015,0x8017,0x8019,0x8028,0x8033,0x8036-0x8038,0x804A,0x8052,0x8054,0x8058,0x806A,0x8071,0x8083-0x8084,0x8086,0x8089,0x808B,0x809C,0x80A1-0x80A2,0x80A5,0x80A9,0x80AB,0x80AD-0x80AF,0x80B2,0x80B7,0x80BA,0x80C2,0x80C6,0x80CC-0x80CD,0x80D6-0x80D7,0x80DC-0x80DE,0x80E1,0x80E9,0x80EF,0x80F1-0x80F3,0x80F6,0x80F8,0x80FA,0x80FD,0x8109,0x810F,0x8111,0x8113-0x8114,0x8118,0x811A,0x811E,0x812F,0x8131,0x8138,0x813E,0x814A,0x814C,0x8154,0x8167,0x816E,0x8171,0x817E-0x8180,0x818A,0x8198,0x81A6,0x81AA,0x81BB,0x81C2,0x81E3,0x81E7,0x81EA,0x81ED,0x81F3-0x81F4,0x81FE,0x8202,0x8204,0x820C-0x820D,0x8212,0x8214,0x821C,0x8221,0x822A,0x822C,0x8231,0x8233,0x8239,0x8258,0x825F,0x826E-0x826F,0x8272,0x8274,0x827D-0x827F,0x8282,0x828B,0x828D-0x828E,0x8292,0x8298,0x829F,0x82A4-0x82A6,0x82A9-0x82AB,0x82AD-0x82AF,0x82B1,0x82B3-0x82B4,0x82BE,0x82CD,0x82CF,0x82D1-0x82D3,0x82D5,0x82D7,0x82DB,0x82DE,0x82E3-0x82E6,0x82EB,0x82EF,0x82F1,0x82F4,0x82F9,0x82FB,0x8301,0x8303-0x8304,0x8307-0x8308,0x8311,0x831B-0x831C,0x8327,0x832B-0x832D,0x8336,0x8343,0x8349,0x8351-0x8352,0x835B,0x8360-0x8361,0x8363-0x8365,0x8368,0x836F,0x837C-0x837D,0x8386,0x8389,0x838E,0x8398,0x839C,0x839E,0x83A0,0x83A8-0x83A9,0x83AB,0x83B1,0x83B7,0x83BD,0x83C0,0x83C5,0x83CC,0x83CF,0x83DC,0x83E5,0x83E9,0x83F2,0x83F8-0x83F9,0x83FD,0x8401,0x8406,0x840C,0x8411,0x8425,0x8428,0x843D,0x8457,0x845A,0x845C,0x8461,0x8463,0x846C-0x846D,0x8471,0x8476,0x848B,0x8499,0x849C,0x84A1,0x84AF,0x84B2,0x84BD,0x84BF,0x84C1,0x84C9-0x84CA,0x84D1,0x84DD,0x84FC,0x84FF,0x8511,0x8513,0x851A,0x851F,0x8521,0x852B,0x853D,0x8543,0x8548-0x854A,0x855E,0x8579-0x857A,0x8584,0x859B-0x859C,0x85C9,0x85CF-0x85D0,0x85E4,0x85FF,0x8616,0x8627,0x8650-0x8651,0x866B,0x8679,0x867D-0x867E,0x868C,0x8695,0x869D,0x86A3-0x86A4,0x86B0,0x86B4-0x86B5,0x86BA,0x86C6-0x86C7,0x86D1,0x86D9,0x86DE,0x86E4,0x86E9,0x86EE,0x86F8,0x86FE,0x870D,0x8712-0x8713,0x8715,0x871A,0x8721,0x8729,0x872E,0x8731,0x8734,0x8737,0x873B,0x873E,0x874E,0x8759,0x8764-0x8765,0x8776,0x8783,0x8785,0x878D,0x87AB,0x87C0,0x87C6,0x87CA,0x87D2,0x87E0,0x8803,0x8816,0x881B,0x8821-0x8822,0x883C,0x8840,0x884C,0x8859,0x8861,0x8865,0x8868,0x886E,0x8870,0x887F,0x8882,0x8885,0x888D,0x8892,0x8896,0x889C,0x88A2,0x88A4,0x88AB,0x88B7,0x88BC,0x88C1-0x88C2,0x88C5,0x88D2,0x88D9,0x88E2,0x88E8,0x88F3-0x88F4,0x88FC,0x890A,0x891A,0x8925,0x892A,0x8936,0x897F,0x8981,0x8983,0x8986,0x89C1-0x89C2,0x89C4,0x89C6,0x89C8-0x89C9,0x89D2,0x89D6,0x89DC,0x89E3,0x89F3,0x8A00,0x8A07,0x8A79,0x8A8A,0x8BA1-0x8BA2,0x8BA4,0x8BA8-0x8BA9,0x8BAD-0x8BB0,0x8BB2,0x8BB7-0x8BB8,0x8BBA,0x8BBC,0x8BBE-0x8BBF,0x8BC1,0x8BC4,0x8BC6,0x8BC8-0x8BCA,0x8BCD,0x8BCF,0x8BD5,0x8BD8,0x8BDD,0x8BE5-0x8BE6,0x8BED,0x8BEF-0x8BF0,0x8BF4-0x8BF7,0x8BF9-0x8BFB,0x8BFE,0x8C01,0x8C03,0x8C06,0x8C08,0x8C0B,0x8C0D,0x8C11,0x8C1C,0x8C20,0x8C22,0x8C2C,0x8C2E,0x8C31,0x8C37,0x8C41,0x8C46,0x8C49,0x8C5A,0x8C61,0x8C6A-0x8C6B,0x8C73,0x8C78,0x8C7A,0x8C89-0x8C8A,0x8C8C,0x8D1D,0x8D22-0x8D23,0x8D25,0x8D2B,0x8D2D,0x8D30,0x8D32,0x8D34-0x8D35,0x8D38-0x8D39,0x8D3C,0x8D3E,0x8D43-0x8D44,0x8D49,0x8D4F,0x8D56,0x8D5A-0x8D5B,0x8D5E,0x8D60,0x8D66,0x8D6B,0x8D70,0x8D76-0x8D77,0x8D81,0x8D84-0x8D85,0x8D8A,0x8D91,0x8D9F,0x8DA3,0x8DB3,0x8DB5,0x8DBC,0x8DBF,0x8DC3,0x8DCB-0x8DCC,0x8DD1,0x8DDB,0x8DDD-0x8DDF,0x8DE3-0x8DE4,0x8DE8,0x8DEB-0x8DEC,0x8DEF,0x8DF3,0x8E05,0x8E09,0x8E14,0x8E22-0x8E23,0x8E29-0x8E2A,0x8E31,0x8E39,0x8E42,0x8E44,0x8E4A,0x8E66,0x8E6D,0x8E72,0x8E74,0x8E76,0x8E7F,0x8E81,0x8E85,0x8E87,0x8E94,0x8EAB,0x8EB2,0x8EBA,0x8F66-0x8F68,0x8F6C,0x8F6E-0x8F70,0x8F74,0x8F7D,0x8F83,0x8F87,0x8F8D,0x8F8F,0x8F91,0x8F93,0x8F97,0x8F9B,0x8F9E-0x8F9F,0x8FA3,0x8FA8-0x8FA9,0x8FB0,0x8FB9,0x8FBD-0x8FBE,0x8FC1,0x8FC5,0x8FC7,0x8FCE,0x8FD0-0x8FD1,0x8FD4,0x8FD8-0x8FD9,0x8FDB-0x8FDC,0x8FDE-0x8FDF,0x8FE4-0x8FE6,0x8FE9,0x8FEB,0x8FED-0x8FEE,0x8FF0,0x8FF7,0x8FF9,0x8FFD,0x9000-0x9004,0x9006,0x9009-0x900A,0x900F-0x9010,0x9014,0x9017,0x901A-0x901B,0x901E-0x9022,0x902E-0x902F,0x903E,0x9041-0x9042,0x9047,0x904D,0x9053,0x9057,0x9063,0x906D,0x9075,0x907D,0x908B,0x9091,0x9093,0x909B,0x90A2-0x90A3,0x90A6,0x90AA,0x90B9-0x90BB,0x90C4-0x90C5,0x90C7,0x90CE,0x90D7,0x90DD,0x90E1-0x90E2,0x90E8,0x90ED,0x90F4,0x90FD,0x9131,0x9139,0x914D,0x9150,0x9152,0x9161-0x9162,0x916A,0x916C,0x916E,0x9177-0x9178,0x917E-0x917F,0x9187,0x9189,0x918B,0x9192,0x919B,0x91AE,0x91C7,0x91CC-0x91CF,0x91D1,0x92AE,0x93D6,0x946B,0x9488,0x948F,0x9494,0x9497,0x949E-0x949F,0x94A2,0x94A5-0x94A6,0x94AD-0x94AF,0x94B1,0x94BB,0x94BF,0x94C1,0x94C5,0x94CA,0x94DB,0x94E3-0x94E5,0x94EA-0x94EB,0x94ED,0x94F6,0x94FA,0x94FD-0x9501,0x950A,0x9510,0x9515,0x9519,0x9524,0x952C,0x952E,0x953B,0x9547,0x9550,0x9556,0x9561,0x9566,0x957F,0x95E8-0x95EA,0x95ED-0x95F0,0x95F2,0x95F4-0x95F5,0x95F7,0x95F9,0x95FB,0x95FD,0x960F,0x9614,0x9619-0x961A,0x961F,0x9622,0x962E,0x9632-0x9633,0x9635,0x963B,0x963D,0x963F-0x9640,0x9642,0x9644,0x9646-0x9648,0x964B,0x964D,0x9650,0x9655,0x965F,0x9662,0x9664,0x9669-0x966A,0x966C,0x9674-0x9676,0x9686,0x968B,0x968F-0x9690,0x9694,0x9697-0x9698,0x96A7,0x96B0,0x96B6,0x96B9,0x96BC-0x96BE,0x96C0,0x96C4-0x96C7,0x96C9,0x96E9-0x96EA,0x96F6-0x96F7,0x9700,0x9707,0x970D,0x971C,0x9730,0x9732,0x9738,0x973E,0x9752-0x9753,0x9759,0x975E,0x9760-0x9762,0x9769,0x978B,0x9794,0x9798,0x97A0,0x97A3,0x97AB,0x97B4,0x97E9,0x97F3,0x9875-0x9876,0x9879-0x987B,0x987F,0x9882,0x9884,0x9886-0x9889,0x988C,0x9891,0x9897-0x9898,0x989D,0x98A0-0x98A1,0x98A4,0x98CE,0x98D2,0x98D8,0x98DE-0x98DF,0x98E7,0x9910,0x992E,0x9967,0x996D,0x9976,0x997F,0x9981,0x9986-0x9988,0x9993,0x9995-0x9998,0x99A5,0x9A6C,0x9A6E,0x9A80,0x9A82,0x9A87,0x9A8C,0x9A97,0x9A9A,0x9AA0,0x9AB0-0x9AB1,0x9AB8,0x9AC1-0x9AC2,0x9ACB,0x9AD8,0x9ADF,0x9AEF,0x9AFB,0x9B13,0x9B32,0x9B3B-0x9B3C,0x9B41-0x9B42,0x9B44,0x9C81,0x9C85,0x9C91,0x9CA7,0x9CAD,0x9CC3,0x9E1F,0x9E23,0x9E3E-0x9E3F,0x9E44,0x9E4F,0x9E58,0x9E87,0x9E9F,0x9EA6,0x9EBB,0x9EBD,0x9EC4,0x9ED1,0x9ED8,0x9EDD,0x9EE7,0x9EF9,0x9EFE,0x9F0E,0x9F50,0x9F88,0x9F99,0x9F9F,0xFF01,0xFF08-0xFF09,0xFF0C,0xFF1A-0xFF1B,0xFF1F,0xFF5E,0xFFE5 diff --git a/packs/zh-Hans/fonts/zh-hans-core/charset.txt b/packs/zh-Hans/fonts/zh-hans-core/charset.txt index f0186576..65459572 100644 --- a/packs/zh-Hans/fonts/zh-hans-core/charset.txt +++ b/packs/zh-Hans/fonts/zh-hans-core/charset.txt @@ -4,47 +4,47 @@ 俸俺俾倍倒倘候倚倜倦倩倪倭债值假偈偌偏偕做停偬偶偷偻傀傍储催傲傻像僚僧僭僮儋儿允元兄充先光克免兑兔党入全八公六兰共关兴兵其具典兹 养兼内冈冉册再冒写军农冠冥冬冯冰冲决况冷冻冼净准凑凝几凡凤凭凯凳凵凶凹出击凿刀分切刊划列刘则刚创初删判刨利别刮到刳制刷券刹刺刻削 前剖剡剥剩副剽剿劐力劝办功加务劣动助努劲劳劾势勇勉勒勖勘募勤勺勾勿匀包匆匕化北匙匝匡匪匮匹区匿十千卅升半华协卑卒卓单卖南卜卞卟占 -卡卤卦卧卫卯印危即却卵卷卸厂厅压厕厘厚厝原厦厶去县叁参又及友双反发取受变叟叠口古句另叨叩只叫召叭可台叱右叶号司叹叻叽吁吃各合同名 -后向吓吖吗君吞吟吡否吧吨吩含听吭吮启吱吲吴吵吹吾呀呃呆呈告呐呒呔呕呗员呢周呱呲味呵呶呷呼命咀咋和咎咔咖咣咦咩咪咬咭咯咱咳咴咸咻咽 -哀品哆哇哈哌响哏哐哕哝哟哦哧哨哩哪哭哮哲哺哼哽唆唇唏唐唑唔唛唤唧唪唬售唰唷唼啁啃啄商啉啊啐啜啡啥啦啻喀喁喃善喇喈喉喊喋喏喔喘喙喜 -喝喟喳喷喽嗄嗌嗍嗑嗒嗓嗔嗖嗟嗡嗨嗯嗲嗽嘀嘁嘎嘏嘘嘞嘬嘲嘴嘿噌噍噎噢器噪噫噱噶噻嚓嚣嚯嚷嚼囊囔囗囚四囝回因囡团囤园困囱固国图圈圜土 -圣在圩圪地圳场圻圾均坎坏坐坑块坠坡坤坦坨坫坯坳坶坷坻垂垄垌垒垠垦垧垩垮垸埃埋城埏埔埝域埤埯培基埽堂堆堇堋堕堡堤堪堵塄塌塔塘塞填境 -墅墙增墨壅壑壤壬壮声壳壹夂处备复夏夕外多夜够大天太夫夭央夯失头夸夹夺奇奈奉奋奏契奔奖套奘奠奥女奴奶奸她好妁如妃妆妈妖妗妙妞妮妯妹 -妾姆始姐姓姚姣姥姬娃娆娉娘娜娩娶婆婕婚婺媪媲媵嫂嫔嫖嫩嬗嬲嬷孀子孔字存孙孛孟学孩孱孽宁它宅守安宋完宓宕宗官定宛宝实客宣宪宰害家容 -宽宾宿密寇寒察寡寨寰寸对寺寻导封射将尉尊小少尔尚尝尢尥尬就尹尺尼尽尾尿层居屈屎屏展属屮屯山屹岁岂岈岑岗岛岸峒峙峤峦崆崇崎崔崛崤崩 -崴嵇嵊嵌嵯嶷巛川州巡巢工左巧巨差己已巳巴巷巽币市布帅希帐帑帔帕帖帘帝带帧帮帱常幅幕幢干平年并幸幺幼广庄庇床库应底店庙庚府庞废度座 -庭庳庵庶康廊廑廓廖延廷建开弁弄式引弘弟张弥弩弱弹强彀归当录彖彗彡形彦彩彪彬彭影彳彷彻往待很徊律後徒得徙御循徵德徼心必忍忏忐忑忒忖 -志忙忠忪快忮忱念忽忾怀态怍怎怒怕怙怛怜思怠急性怨怪怫怵总怼恁恂恃恍恐恒恝恢恨恩恫息恰恳恶恼恿悄悛悝患您悬悯悲情惆惊惝惠惨惭惰想惴 -惹愀愁愉愍意感愠愣愤愧愿慊慌慎慝慢慨慷憋憎憧憾懂懊懒懔戆戌戎戏成我戕或战戚戛戡戳戴户房所扁扃扇手才扎扑扒打扔托扛扣执扩扪扫扭扮扰 -扳扶批找承技抄把抓投抖抗折抛抟抠抡抢护报抨抬抱抵抹抻押抽拂担拆拈拉拊拌拍拎拐拓拔拖拗拘拚招拜拟拢拥拦拧择括拮拱拴拶拷拼拽拾拿持挂 -指挈按挎挑挖挛挝挟挠挡挥挪挫振挲挺捆捉捋捌捍捎捏捐损换捧捩捭据捶捷捻掀掇授掉掊掌掎排掘掠探接控推措掴掸掺掾揄揉揍描提插揖揞握揣揩 -揭揲援搂搋搓搛搜搞搡搬搭摁摇摔摧摩摺撅撇撒撕撖撙撞撤撩播撮撰撺擂操擐擒擗擘擞擦攉攒攘攥攮支收改攻放政故效敌敏救敕敖教敞敢散敦敫数 -整文斌斗料斜斡斥斩斫断斯新方於施旁旄旋族无既日旦旧早旬旮旰时昂昆昌明昏昔昕昙星映春昧昨是昵显晁晃晒晚晟晨普景暂暇暖暗暧暨暴曝曩曰 -曲更曷曹曼曾最月有朊朋服朐朔朗望朝期朦木未末本札术朱朴朵机杀杂权杉杌李材村杓杜杠条来杨杭杷杼松板构枇枉枋析枕林枘果枝枞枪枯架枸柁 -柏某柑染柔柙柚柜柞柠柢查柳柴柽栅标栎栏树栓栖栗栝校栩样核根格栾桁桂桃桅案桊桌桐桑桔桡档桥桧梁梅梏梗梢梦梧梭检棉棋棍棒棣森棰棱棵棹 -椎椹椿楂楚楞楠楮楱楷楼概榜槁槌槐槛槟槭槽槿樘模横樽橐橙檀檐檗檠檫次欢欣欧欹款歃歇歙止正此步武歪歹死殁残殍殖殡段殷殿毁毋母每毒比毕 -毛毫毳毹氏氐民氓气氕氤水氵永氽汀汁求汇汉汕汗汝江池汤汩汪汶汽汾沂沃沆沈沉沌沏沓沙沟没沦沧沮沱河沸治沾泄泅泉泊泌泐泔法泖泛泞波泣泥 -注泪泫泯泰泵泷泺泽洁洇洋洒洗洚洛洞洪洫洮洲洵活洼洽派流浅测浍浑浒浓浚浜浩浪海浸涂涅消涉涌涎涑涓涔涞涡涣润涩涪涮液淋淌淑淖淙淠淡淦 -淫淬淮深淳混添清渑渠渥温渫港渴游湃湍湓湔湛湟湫湮湾溃溉源溘溟溥溪溯溱溲溴溶溺滂滇滋滑滕滚满滨漂漆漏漕漫漯潘潜潢潦潭潮潼澄澉澍澡澳 -澶澹激濂濞濡濯瀑瀹灌火灬灭灯灰灵灾灿炅炊炎炔炕炖炮炯炸点烁烂烈烙烛烟烤烧热焉焖焘焦焯焱然煦照煨熄熊熙熟熨熬燃燔燠燥燹爆爝爨爪爬爱 -爷爸爹爻爽爿片版牌牙牛牟牢物特犄犍犒犯犴状狂狃狄狗狠狡独狻狼猎猓猗猛猫猴獠獬獾玄率王玟玢玩环现珍珞珩班珲球琅理琢琨琳琴琼瑗瑙瑞瑟 -瓒瓜瓠瓤瓦瓮瓶瓿甄甘甚甜生用甩甫甬甭田由甲电男甸町甾界留畚畜略番畸畹疋疑疒疔疗疙疟疮疴疵疸疹疼病痕痛痦痪瘕瘟瘥瘳瘵癌癞癸登白百的 -皇皋皓皖皤皮皴皿盅盆盍盎监盒盖盘盛盟目盯盲直相盹盼盾省眈看眙真眠眩眭眷眸眼着睃睇睡睢督睫睽睾睿瞀瞑瞠瞢瞥瞧瞪瞬瞵瞿矛矜矣知短石矸 -矿码砉砌砍研砖砝砟砥砩砬砰破砸硅硌硎硐硕硝硪硫硬确碌碎碟碡碣碰碱磁磅磉磷磺礤示社祁祈祓祖祝神祠祢票祭禁禅禳离秀私秋种科秒秘租秤秦 -积称秸移程稍税稞稠稷稹稽稿穑穰穴究穷穹空穿窀突窃窒窖窗窘窜窝窥窨窬立站竟章童端竺笋笏笑笤符笨第笮笼等筋筒答策筛筠筷筹简箐算管箢箬 -箴箸篇篡篦篾簇簪簿籁籍米籴类粉粗粘粢粥粮粹精糁糅糖糜糟糸系素索紧累絮綮繁繇纂纛红纤纥约级纯纱纲纳纵纶纷纽线练组细终绍经结绕给络绝 -统绢绣继绪续绰绲绵综缀缉缎缏缓编缗缠缩缪缯缲缴缵缶缺网罗罚罢罪置罱美羞羡群羧羸羹羽翁翟翠翮翻老考者耆而耍耐耕耗耙耨耳耶耷耸聊聒联 -聘聪聱肃肄肆肉肋肜股肢肥肩肫肭肮肯育肷肺胂胆背胍胖胗胜胝胞胡胩胯胱胲胳胶胸胺能脉脏脑脓脔脘脚脞脯脱脸脾腊腌腔腧腮腱腾腿膀膊膘膦膪 -膻臂臣臧自臭至致臾舂舄舌舍舒舔舜舡航般舱舳船艘艟艮良色艴艽艾艿节芋芍芎芒芘芟芤芥芦芩芪芫芭芮花芳芴芾苍苏苑苒苓苕苗苛苞苣苤若苦苫 -苯英苴苹苻茁范茄茇茈茑茛茜茧茫茬茭茶荃草荑荒荛荠荡荣荤荥荨药荼荽莆莉莎莘莜莞莠莨莩莫莱获莽菀菅菌菏菜菥菩菲菸菹菽萁萆萌萑营萨落著 -葚葜葡董葬葭葱葶蒋蒙蒜蒡蒯蒲蒽蒿蓁蓉蓊蓑蓝蓼蓿蔑蔓蔚蔟蔡蔫蔽蕃蕈蕉蕊蕞蕹蕺薄薛薜藉藏藐藤藿蘖蘧虐虑虫虹虽虾蚌蚕蚝蚣蚤蚰蚴蚵蚺蛆蛇 -蛑蛙蛞蛤蛩蛮蛸蛾蜍蜒蜓蜕蜚蜡蜩蜮蜱蜴蜷蜻蜾蝎蝙蝤蝥蝶螃螅融螫蟀蟆蟊蟒蟠蠃蠖蠛蠡蠢蠼血行衙衡补表衮衰衿袂袅袍袒袖袜袢袤被袷袼裁裂装 -裒裙裢裨裳裴裼褊褚褥褪褶西要覃覆见观规视览觉角觖觜解觳言訇詹誊计订认讨让训议讯记讲讷许论讼设访证评识诈诉词诏试诘话该详语误诰说诵 -诶请诹诺读课谁调谆谈谋谍谑谜谠谢谬谮谱谷豁豆豉豚象豪豫豳豸豺貉貊貌贝财责败贫购贰贲贴贵贸费贼贾赃资赉赏赖赚赛赞赠赦赫走赶起趁趄超 -越趑趟趣足趵趼趿跃跋跌跑跛距跞跟跣跤跨跫跬路跳踅踉踔踢踣踩踪踱踹蹂蹄蹊蹦蹭蹲蹴蹶蹿躁躅躇躔身躲躺车轧轨转轮软轰轴载较辇辍辏辑输辗 -辛辞辟辣辨辩辰边辽达迁迅过迎运近返还这进远连迟迤迥迦迩迫迭迮述迷迹追退送适逃逄逆选逊透逐途逗通逛逞速造逡逢逮逯逾遁遂遇遍道遗遣遭 -遵遽邋邑邓邛邢那邦邪邹邺邻郄郅郇郎郗郝郡郢部郭郴都鄱鄹配酐酒酡酢酪酬酮酷酸酾酿醇醉醋醒醛醮采里重野量金銮鏖鑫针钏钔钗钞钟钢钥钦钭 -钮钯钱钻钿铁铅铊铛铣铤铥铪铫铭银铺铽链铿销锁锊锐锕错锤锬键锻镇镐镖镡镦长门闩闪闭问闯闰闲间闵闷闹闻闽阏阔阙阚队阢阮防阳阵阻阽阿陀 -陂附陆陇陈陋降限陕陟院除险陪陬陴陵陶隆隋随隐隔隗隘隧隰隶隹隼隽难雀雄雅集雇雉雩雪零雷需震霍霜霰露霸霾青靓静非靠靡面革鞋鞔鞘鞠鞣鞫 -鞴韩音页顶顺须顿颂预领颇颈颉颌频颗题额颠颡颤风飒飘飞食飧餐餮饧饭饶饿馁馆馇馈馓馕首馗馘馥马驮骀骂骇验骗骚骠骰骱骸髁髂髋高髟髯髻鬓 -鬲鬻鬼魁魂魄鲁鲅鲑鲧鲭鳃鸟鸣鸾鸿鹄鹏鹘麇麟麦麻麽黄黑黝黧黹黾鼎齐龈龙龟!(),:;?~¥ +卡卤卦卧卫卯印危即却卵卷卸厂厅历压厕厘厚厝原厦厶去县叁参又及友双反发取受变叟叠口古句另叨叩只叫召叭可台叱右叶号司叹叻叽吁吃各合同 +名后向吓吖吗君吞吟吡否吧吨吩含听吭吮启吱吲吴吵吹吾呀呃呆呈告呐呒呔呕呗员呢周呱呲味呵呶呷呼命咀咋和咎咔咖咣咦咩咪咬咭咯咱咳咴咸咻 +咽哀品哆哇哈哌响哏哐哕哝哟哦哧哨哩哪哭哮哲哺哼哽唆唇唏唐唑唔唛唤唧唪唬售唰唷唼啁啃啄商啉啊啐啜啡啥啦啻喀喁喃善喇喈喉喊喋喏喔喘喙 +喜喝喟喳喷喽嗄嗌嗍嗑嗒嗓嗔嗖嗟嗡嗨嗯嗲嗽嘀嘁嘎嘏嘘嘞嘬嘲嘴嘿噌噍噎噢器噪噫噱噶噻嚓嚣嚯嚷嚼囊囔囗囚四囝回因囡团囤园困囱围固国图圈 +圜土圣在圩圪地圳场圻圾均坎坏坐坑块坠坡坤坦坨坫坯坳坶坷坻垂垄垌垒垠垦垧垩垮垸埃埋城埏埔埝域埤埯培基埽堂堆堇堋堕堡堤堪堵塄塌塔塘塞 +填境墅墙增墨壅壑壤壬壮声壳壹夂处备复夏夕外多夜够大天太夫夭央夯失头夸夹夺奇奈奉奋奏契奔奖套奘奠奥女奴奶奸她好妁如妃妆妈妖妗妙妞妮 +妯妹妾姆始姐姓姚姣姥姬娃娆娉娘娜娩娶婆婕婚婺媪媲媵嫂嫔嫖嫩嬗嬲嬷孀子孔字存孙孛孟学孩孱孽宁它宅守安宋完宓宕宗官定宛宝实客宣宪宰害 +家容宽宾宿密寇寒察寡寨寰寸对寺寻导封射将尉尊小少尔尚尝尢尥尬就尹尺尼尽尾尿层居屈屎屏展属屮屯山屹岁岂岈岑岗岛岸峒峙峤峦崆崇崎崔崛 +崤崩崴嵇嵊嵌嵯嶷巛川州巡巢工左巧巨差己已巳巴巷巽币市布帅希帐帑帔帕帖帘帝带帧帮帱常幅幕幢干平年并幸幺幼广庄庇床库应底店庙庚府庞废 +度座庭庳庵庶康廊廑廓廖延廷建开弁弃弄式引弘弟张弥弩弱弹强彀归当录彖彗彡形彦彩彪彬彭影彳彷彻往待很徊律後徒得徙御循徵德徼心必忍忏忐 +忑忒忖志忙忠忪快忮忱念忽忾怀态怍怎怒怕怙怛怜思怠急性怨怪怫怵总怼恁恂恃恍恐恒恝恢恨恩恫息恰恳恶恼恿悄悛悝患您悬悯悲情惆惊惝惠惨惭 +惰想惴惹愀愁愉愍意感愠愣愤愧愿慊慌慎慝慢慨慷憋憎憧憾懂懊懒懔戆戌戎戏成我戕或战戚戛戡戳戴户房所扁扃扇手才扎扑扒打扔托扛扣执扩扪扫 +扭扮扰扳扶批找承技抄把抓投抖抗折抛抟抠抡抢护报抨抬抱抵抹抻押抽拂担拆拈拉拊拌拍拎拐拓拔拖拗拘拚招拜拟拢拥拦拧择括拮拱拴拶拷拼拽拾 +拿持挂指挈按挎挑挖挛挝挟挠挡挥挪挫振挲挺捆捉捋捌捍捎捏捐损换捧捩捭据捶捷捻掀掇授掉掊掌掎排掘掠探接控推措掴掸掺掾揄揉揍描提插揖揞 +握揣揩揭揲援搂搋搓搛搜搞搡搬搭摁摇摔摧摩摺撅撇撒撕撖撙撞撤撩播撮撰撺擂操擐擒擗擘擞擦攉攒攘攥攮支收改攻放政故效敌敏救敕敖教敞敢散 +敦敫数整文斌斗料斜斡斥斩斫断斯新方於施旁旄旋族无既日旦旧早旬旮旰时昂昆昌明昏昔昕昙星映春昧昨是昵显晁晃晒晚晟晨普景暂暇暖暗暧暨暴 +曝曩曰曲更曷曹曼曾最月有朊朋服朐朔朗望朝期朦木未末本札术朱朴朵机杀杂权杉杌李材村杓杜束杠条来杨杭杷杼松板构枇枉枋析枕林枘果枝枞枪 +枯架枸柁柏某柑染柔柙柚柜柞柠柢查柳柴柽栅标栎栏树栓栖栗栝校栩样核根格栾桁桂桃桅案桊桌桐桑桔桡档桥桧梁梅梏梗梢梦梧梭检棉棋棍棒棣森 +棰棱棵棹椎椹椿楂楚楞楠楮楱楷楼概榜槁槌槐槛槟槭槽槿樘模横樽橐橙檀檐檗檠檫次欢欣欧欹款歃歇歙止正此步武歪歹死殁残殍殖殡段殷殿毁毋母 +每毒比毕毛毫毳毹氏氐民氓气氕氤水氵永氽汀汁求汇汉汕汗汝江池汤汩汪汶汽汾沂沃沆沈沉沌沏沓沙沟没沦沧沮沱河沸治沾泄泅泉泊泌泐泔法泖泛 +泞波泣泥注泪泫泯泰泵泷泺泽洁洇洋洒洗洚洛洞洪洫洮洲洵活洼洽派流浅测浍浏浑浒浓浚浜浩浪海浸涂涅消涉涌涎涑涓涔涞涡涣润涩涪涮液淋淌淑 +淖淙淠淡淦淫淬淮深淳混添清渑渠渥温渫港渴游湃湍湓湔湛湟湫湮湾溃溉源溘溟溥溪溯溱溲溴溶溺滂滇滋滑滕滚满滨漂漆漏漕漫漯潘潜潢潦潭潮潼 +澄澉澍澡澳澶澹激濂濞濡濯瀑瀹灌火灬灭灯灰灵灾灿炅炊炎炔炕炖炮炯炸点烁烂烈烙烛烟烤烧热焉焖焘焦焯焱然煦照煨熄熊熙熟熨熬燃燔燠燥燹爆 +爝爨爪爬爱爷爸爹爻爽爿片版牌牙牛牟牢物特犄犍犒犯犴状狂狃狄狗狠狡独狻狼猎猓猗猛猫猴獠獬獾玄率王玟玢玩环现珍珞珩班珲球琅理琢琨琳琴 +琼瑗瑙瑞瑟瓒瓜瓠瓤瓦瓮瓶瓿甄甘甚甜生用甩甫甬甭田由甲电男甸町甾界留畚畜略番畸畹疋疑疒疔疗疙疟疮疴疵疸疹疼病痕痛痦痪瘕瘟瘥瘳瘵癌癞 +癸登白百的皇皋皓皖皤皮皴皿盅盆盍盎监盒盖盘盛盟目盯盲直相盹盼盾省眈看眙真眠眩眭眷眸眼着睃睇睡睢督睫睽睾睿瞀瞑瞠瞢瞥瞧瞪瞬瞵瞿矛矜 +矣知短石矸矿码砉砌砍研砖砝砟砥砩砬砰破砸硅硌硎硐硕硝硪硫硬确碌碎碟碡碣碰碱磁磅磉磷磺礤示社祁祈祓祖祝神祠祢票祭禁禅禳离秀私秋种科 +秒秘租秤秦积称秸移程稍税稞稠稷稹稽稿穑穰穴究穷穹空穿窀突窃窒窖窗窘窜窝窥窨窬立站竟章童端竺笋笏笑笤符笨第笮笼等筋筒答策筛筠筷筹简 +箐算管箢箬箴箸篇篡篦篾簇簪簿籁籍米籴类粉粗粘粢粥粮粹精糁糅糖糜糟糸系素索紧累絮綮繁繇纂纛红纤纥约级纯纱纲纳纵纶纷纽线练组细终绍经 +结绕给络绝统绢绣继绪续绰绲绵综缀缉缎缏缓编缗缠缩缪缯缲缴缵缶缺网罗罚罢罪置罱美羞羡群羧羸羹羽翁翟翠翮翻老考者耆而耍耐耕耗耙耨耳耶 +耷耸聊聒联聘聪聱肃肄肆肉肋肜股肢肥肩肫肭肮肯育肷肺胂胆背胍胖胗胜胝胞胡胩胯胱胲胳胶胸胺能脉脏脑脓脔脘脚脞脯脱脸脾腊腌腔腧腮腱腾腿 +膀膊膘膦膪膻臂臣臧自臭至致臾舂舄舌舍舒舔舜舡航般舱舳船艘艟艮良色艴艽艾艿节芋芍芎芒芘芟芤芥芦芩芪芫芭芮芯花芳芴芾苍苏苑苒苓苕苗苛 +苞苣苤若苦苫苯英苴苹苻茁范茄茇茈茑茛茜茧茫茬茭茶荃草荑荒荛荠荡荣荤荥荨药荼荽莆莉莎莘莜莞莠莨莩莫莱获莽菀菅菌菏菜菥菩菲菸菹菽萁萆 +萌萑营萨落著葚葜葡董葬葭葱葶蒋蒙蒜蒡蒯蒲蒽蒿蓁蓉蓊蓑蓝蓼蓿蔑蔓蔚蔟蔡蔫蔽蕃蕈蕉蕊蕞蕹蕺薄薛薜藉藏藐藤藿蘖蘧虐虑虫虹虽虾蚌蚕蚝蚣蚤 +蚰蚴蚵蚺蛆蛇蛑蛙蛞蛤蛩蛮蛸蛾蜍蜒蜓蜕蜚蜡蜩蜮蜱蜴蜷蜻蜾蝎蝙蝤蝥蝶螃螅融螫蟀蟆蟊蟒蟠蠃蠖蠛蠡蠢蠼血行衙衡补表衮衰衿袂袅袍袒袖袜袢袤 +被袷袼裁裂装裒裙裢裨裳裴裼褊褚褥褪褶西要覃覆见观规视览觉角觖觜解觳言訇詹誊计订认讨让训议讯记讲讷许论讼设访证评识诈诉诊词诏试诘话 +该详语误诰说诵诶请诹诺读课谁调谆谈谋谍谑谜谠谢谬谮谱谷豁豆豉豚象豪豫豳豸豺貉貊貌贝财责败贫购贰贲贴贵贸费贼贾赃资赉赏赖赚赛赞赠赦 +赫走赶起趁趄超越趑趟趣足趵趼趿跃跋跌跑跛距跞跟跣跤跨跫跬路跳踅踉踔踢踣踩踪踱踹蹂蹄蹊蹦蹭蹲蹴蹶蹿躁躅躇躔身躲躺车轧轨转轮软轰轴载 +较辇辍辏辑输辗辛辞辟辣辨辩辰边辽达迁迅过迎运近返还这进远连迟迤迥迦迩迫迭迮述迷迹追退送适逃逄逆选逊透逐途逗通逛逞速造逡逢逮逯逾遁 +遂遇遍道遗遣遭遵遽邋邑邓邛邢那邦邪邹邺邻郄郅郇郎郗郝郡郢部郭郴都鄱鄹配酐酒酡酢酪酬酮酷酸酾酿醇醉醋醒醛醮采里重野量金銮鏖鑫针钏钔 +钗钞钟钢钥钦钭钮钯钱钻钿铁铅铊铛铣铤铥铪铫铭银铺铽链铿销锁锊锐锕错锤锬键锻镇镐镖镡镦长门闩闪闭问闯闰闲间闵闷闹闻闽阏阔阙阚队阢阮 +防阳阵阻阽阿陀陂附陆陇陈陋降限陕陟院除险陪陬陴陵陶隆隋随隐隔隗隘隧隰隶隹隼隽难雀雄雅集雇雉雩雪零雷需震霍霜霰露霸霾青靓静非靠靡面 +革鞋鞔鞘鞠鞣鞫鞴韩音页顶项顺须顿颂预领颇颈颉颌频颗题额颠颡颤风飒飘飞食飧餐餮饧饭饶饿馁馆馇馈馓馕首馗馘馥马驮骀骂骇验骗骚骠骰骱骸 +髁髂髋高髟髯髻鬓鬲鬻鬼魁魂魄鲁鲅鲑鲧鲭鳃鸟鸣鸾鸿鹄鹏鹘麇麟麦麻麽黄黑默黝黧黹黾鼎齐龈龙龟!(),:;?~¥ diff --git a/packs/zh-Hans/fonts/zh-hans-core/manifest.ini b/packs/zh-Hans/fonts/zh-hans-core/manifest.ini index 3fac3787..909b3c62 100644 --- a/packs/zh-Hans/fonts/zh-hans-core/manifest.ini +++ b/packs/zh-Hans/fonts/zh-hans-core/manifest.ini @@ -2,7 +2,7 @@ kind=font id=zh-hans-core display_name=Simplified Chinese Core usage=both -estimated_ram_bytes=217456 +estimated_ram_bytes=224256 source=binfont file=font.bin ranges=ranges.txt diff --git a/packs/zh-Hans/fonts/zh-hans-core/ranges.txt b/packs/zh-Hans/fonts/zh-hans-core/ranges.txt index e2747342..45ae86e5 100644 --- a/packs/zh-Hans/fonts/zh-hans-core/ranges.txt +++ b/packs/zh-Hans/fonts/zh-hans-core/ranges.txt @@ -1 +1 @@ -0xB0,0xB7,0x2014,0x2018-0x2019,0x201C-0x201D,0x2026,0x3001-0x3002,0x3008-0x3011,0x3014-0x3015,0x4E00-0x4E01,0x4E03,0x4E07-0x4E0E,0x4E11,0x4E13-0x4E14,0x4E18-0x4E1C,0x4E1E,0x4E22,0x4E24-0x4E25,0x4E27,0x4E2A,0x4E2D,0x4E30,0x4E32,0x4E34,0x4E39-0x4E3B,0x4E3E-0x4E3F,0x4E43,0x4E45,0x4E47-0x4E49,0x4E4B,0x4E4D-0x4E50,0x4E56,0x4E58-0x4E59,0x4E5C-0x4E5D,0x4E5F,0x4E66,0x4E70-0x4E71,0x4E7E,0x4E86,0x4E88-0x4E89,0x4E8B-0x4E8C,0x4E8E-0x4E8F,0x4E91,0x4E94,0x4E98,0x4E9A-0x4E9B,0x4E9F,0x4EA1-0x4EA2,0x4EA4-0x4EA5,0x4EA7-0x4EA8,0x4EAB-0x4EAE,0x4EB2,0x4EBA,0x4EC0-0x4EC2,0x4EC5-0x4EC7,0x4ECA-0x4ECB,0x4ECD-0x4ECE,0x4ED1,0x4ED3-0x4ED4,0x4ED6,0x4EE1,0x4EE3-0x4EE5,0x4EEC,0x4EF0,0x4EF3,0x4EF6-0x4EF7,0x4EFB,0x4EFD,0x4EFF,0x4F09,0x4F0D-0x4F0E,0x4F10-0x4F11,0x4F17,0x4F1A,0x4F1E,0x4F20,0x4F24,0x4F26-0x4F27,0x4F2F,0x4F34,0x4F3A,0x4F3C-0x4F3D,0x4F43,0x4F46,0x4F4D-0x4F4F,0x4F53,0x4F55,0x4F57,0x4F59-0x4F5D,0x4F60,0x4F67,0x4F69,0x4F6C,0x4F70,0x4F73-0x4F74,0x4F7B-0x4F7C,0x4F7F,0x4F89,0x4F8B,0x4F8F,0x4F94,0x4F97,0x4F9B,0x4FA0,0x4FA5-0x4FA7,0x4FAC,0x4FAF,0x4FB5,0x4FBF,0x4FC3-0x4FC4,0x4FCA,0x4FCF,0x4FD7,0x4FDD-0x4FDF,0x4FE1,0x4FE9,0x4FEE,0x4FF8,0x4FFA,0x4FFE,0x500D,0x5012,0x5018-0x501A,0x501C,0x5026,0x5029-0x502A,0x502D,0x503A,0x503C,0x5047-0x5048,0x504C,0x504F,0x5055,0x505A,0x505C,0x506C,0x5076-0x5077,0x507B,0x5080,0x508D,0x50A8,0x50AC,0x50B2,0x50BB,0x50CF,0x50DA,0x50E7,0x50ED-0x50EE,0x510B,0x513F,0x5141,0x5143-0x5145,0x5148-0x5149,0x514B,0x514D,0x5151,0x5154,0x515A,0x5165,0x5168,0x516B-0x516D,0x5170-0x5171,0x5173-0x5179,0x517B-0x517C,0x5185,0x5188-0x5189,0x518C-0x518D,0x5192,0x5199,0x519B-0x519C,0x51A0,0x51A5,0x51AC,0x51AF-0x51B0,0x51B2-0x51B3,0x51B5,0x51B7,0x51BB-0x51BC,0x51C0,0x51C6,0x51D1,0x51DD,0x51E0-0x51E1,0x51E4,0x51ED,0x51EF,0x51F3,0x51F5-0x51F6,0x51F9-0x51FB,0x51FF-0x5200,0x5206-0x5207,0x520A,0x5212,0x5217-0x521B,0x521D,0x5220,0x5224,0x5228-0x5229,0x522B,0x522E,0x5230,0x5233,0x5236-0x523B,0x524A,0x524D,0x5256,0x5261,0x5265,0x5269,0x526F,0x527D,0x527F,0x5290,0x529B,0x529D-0x52A1,0x52A3,0x52A8-0x52AA,0x52B2-0x52B3,0x52BE-0x52BF,0x52C7,0x52C9,0x52D2,0x52D6,0x52D8,0x52DF,0x52E4,0x52FA,0x52FE-0x5300,0x5305-0x5306,0x5315-0x5317,0x5319,0x531D,0x5321,0x532A,0x532E,0x5339-0x533A,0x533F,0x5341,0x5343,0x5345,0x5347,0x534A,0x534E-0x534F,0x5351-0x5353,0x5355-0x5357,0x535C,0x535E-0x5361,0x5364,0x5366-0x5367,0x536B,0x536F-0x5371,0x5373-0x5375,0x5377-0x5378,0x5382,0x5385,0x538B,0x5395,0x5398,0x539A,0x539D,0x539F,0x53A6,0x53B6,0x53BB,0x53BF,0x53C1-0x53C2,0x53C8,0x53CA-0x53CD,0x53D1,0x53D6-0x53D8,0x53DF-0x53E0,0x53E3-0x53E6,0x53E8-0x53ED,0x53EF-0x53F1,0x53F3,0x53F6-0x53F9,0x53FB,0x53FD,0x5401,0x5403-0x5404,0x5408,0x540C-0x540E,0x5411,0x5413,0x5416-0x5417,0x541B,0x541E-0x541F,0x5421,0x5426-0x5429,0x542B-0x542F,0x5431-0x5432,0x5434-0x5435,0x5439,0x543E,0x5440,0x5443,0x5446,0x5448,0x544A,0x5450,0x5452,0x5454-0x5455,0x5457-0x5458,0x5462,0x5468,0x5471-0x5473,0x5475-0x5477,0x547C-0x547D,0x5480,0x548B-0x548C,0x548E,0x5494,0x5496,0x54A3,0x54A6,0x54A9-0x54AA,0x54AC-0x54AD,0x54AF,0x54B1,0x54B3-0x54B4,0x54B8,0x54BB,0x54BD,0x54C0-0x54C1,0x54C6-0x54C8,0x54CC-0x54CD,0x54CF-0x54D0,0x54D5,0x54DD,0x54DF,0x54E6-0x54EA,0x54ED-0x54EE,0x54F2,0x54FA,0x54FC-0x54FD,0x5506-0x5507,0x550F-0x5511,0x5514,0x551B,0x5524,0x5527,0x552A,0x552C,0x552E,0x5530,0x5537,0x553C,0x5541,0x5543-0x5544,0x5546,0x5549-0x554A,0x5550,0x555C,0x5561,0x5565-0x5566,0x557B,0x5580-0x5581,0x5583-0x5584,0x5587-0x558B,0x558F,0x5594,0x5598-0x5599,0x559C-0x559D,0x559F,0x55B3,0x55B7,0x55BD,0x55C4,0x55CC-0x55CD,0x55D1-0x55D4,0x55D6,0x55DF,0x55E1,0x55E8,0x55EF,0x55F2,0x55FD,0x5600-0x5601,0x560E-0x560F,0x5618,0x561E,0x562C,0x5632,0x5634,0x563F,0x564C-0x564E,0x5662,0x5668,0x566A-0x566B,0x5671,0x5676,0x567B,0x5693,0x56A3,0x56AF,0x56B7,0x56BC,0x56CA,0x56D4,0x56D7,0x56DA-0x56DB,0x56DD-0x56DE,0x56E0-0x56E2,0x56E4,0x56ED,0x56F0-0x56F1,0x56FA,0x56FD-0x56FE,0x5708,0x571C,0x571F,0x5723,0x5728-0x572A,0x5730,0x5733,0x573A-0x573B,0x573E,0x5747,0x574E-0x5751,0x5757,0x5760-0x5761,0x5764,0x5766,0x5768,0x576B,0x576F,0x5773,0x5776-0x5777,0x577B,0x5782,0x5784,0x578C,0x5792,0x57A0,0x57A6-0x57A7,0x57A9,0x57AE,0x57B8,0x57C3,0x57CB,0x57CE-0x57CF,0x57D4,0x57DD,0x57DF,0x57E4,0x57EF,0x57F9-0x57FA,0x57FD,0x5802,0x5806-0x5807,0x580B,0x5815,0x5821,0x5824,0x582A,0x5835,0x5844,0x584C,0x5854,0x5858,0x585E,0x586B,0x5883,0x5885,0x5899,0x589E,0x58A8,0x58C5,0x58D1,0x58E4,0x58EC,0x58EE,0x58F0,0x58F3,0x58F9,0x5902,0x5904,0x5907,0x590D,0x590F,0x5915-0x5916,0x591A,0x591C,0x591F,0x5927,0x5929-0x592B,0x592D-0x592F,0x5931,0x5934,0x5938-0x593A,0x5947-0x5949,0x594B,0x594F,0x5951,0x5954,0x5956-0x5958,0x5960,0x5965,0x5973-0x5974,0x5976,0x5978-0x5979,0x597D,0x5981-0x5983,0x5986,0x5988,0x5996-0x5997,0x5999,0x599E,0x59AE-0x59AF,0x59B9,0x59BE,0x59C6,0x59CB,0x59D0,0x59D3,0x59DA,0x59E3,0x59E5,0x59EC,0x5A03,0x5A06,0x5A09,0x5A18,0x5A1C,0x5A29,0x5A36,0x5A46,0x5A55,0x5A5A,0x5A7A,0x5AAA,0x5AB2,0x5AB5,0x5AC2,0x5AD4,0x5AD6,0x5AE9,0x5B17,0x5B32,0x5B37,0x5B40,0x5B50,0x5B54,0x5B57-0x5B59,0x5B5B,0x5B5F,0x5B66,0x5B69,0x5B71,0x5B7D,0x5B81,0x5B83,0x5B85,0x5B88-0x5B89,0x5B8B-0x5B8C,0x5B93,0x5B95,0x5B97-0x5B98,0x5B9A-0x5B9B,0x5B9D-0x5B9E,0x5BA2-0x5BA3,0x5BAA,0x5BB0,0x5BB3,0x5BB6,0x5BB9,0x5BBD-0x5BBF,0x5BC6-0x5BC7,0x5BD2,0x5BDF,0x5BE1,0x5BE8,0x5BF0,0x5BF8-0x5BFC,0x5C01,0x5C04,0x5C06,0x5C09-0x5C0A,0x5C0F,0x5C11,0x5C14,0x5C1A,0x5C1D,0x5C22,0x5C25,0x5C2C,0x5C31,0x5C39-0x5C3A,0x5C3C-0x5C3F,0x5C42,0x5C45,0x5C48,0x5C4E-0x5C4F,0x5C55,0x5C5E,0x5C6E-0x5C6F,0x5C71,0x5C79,0x5C81-0x5C82,0x5C88,0x5C91,0x5C97,0x5C9B,0x5CB8,0x5CD2,0x5CD9,0x5CE4,0x5CE6,0x5D06-0x5D07,0x5D0E,0x5D14,0x5D1B,0x5D24,0x5D29,0x5D34,0x5D47,0x5D4A,0x5D4C,0x5D6F,0x5DB7,0x5DDB,0x5DDD-0x5DDE,0x5DE1-0x5DE2,0x5DE5-0x5DE8,0x5DEE,0x5DF1-0x5DF4,0x5DF7,0x5DFD,0x5E01-0x5E03,0x5E05,0x5E0C,0x5E10-0x5E11,0x5E14-0x5E16,0x5E18,0x5E1D,0x5E26-0x5E27,0x5E2E,0x5E31,0x5E38,0x5E45,0x5E55,0x5E62,0x5E72-0x5E74,0x5E76,0x5E78,0x5E7A,0x5E7C,0x5E7F,0x5E84,0x5E87,0x5E8A,0x5E93-0x5E95,0x5E97,0x5E99-0x5E9A,0x5E9C,0x5E9E-0x5E9F,0x5EA6-0x5EA7,0x5EAD,0x5EB3,0x5EB5-0x5EB7,0x5ECA,0x5ED1,0x5ED3,0x5ED6,0x5EF6-0x5EF7,0x5EFA,0x5F00-0x5F01,0x5F04,0x5F0F,0x5F15,0x5F18,0x5F1F-0x5F20,0x5F25,0x5F29,0x5F31,0x5F39-0x5F3A,0x5F40,0x5F52-0x5F53,0x5F55-0x5F57,0x5F61-0x5F62,0x5F66,0x5F69-0x5F6A,0x5F6C-0x5F6D,0x5F71,0x5F73,0x5F77,0x5F7B,0x5F80,0x5F85,0x5F88,0x5F8A-0x5F8C,0x5F92,0x5F97,0x5F99,0x5FA1,0x5FAA,0x5FB5,0x5FB7,0x5FBC,0x5FC3,0x5FC5,0x5FCD,0x5FCF-0x5FD2,0x5FD6-0x5FD7,0x5FD9,0x5FE0,0x5FEA-0x5FEB,0x5FEE,0x5FF1,0x5FF5,0x5FFD-0x5FFE,0x6000-0x6001,0x600D-0x600E,0x6012,0x6015,0x6019,0x601B-0x601D,0x6020,0x6025,0x6027-0x6028,0x602A-0x602B,0x6035,0x603B-0x603C,0x6041-0x6043,0x604D,0x6050,0x6052,0x605D,0x6062,0x6068-0x6069,0x606B,0x606F-0x6070,0x6073,0x6076,0x607C,0x607F,0x6084,0x609B,0x609D,0x60A3,0x60A8,0x60AC,0x60AF,0x60B2,0x60C5-0x60C6,0x60CA,0x60DD,0x60E0,0x60E8,0x60ED,0x60F0,0x60F3-0x60F4,0x60F9,0x6100-0x6101,0x6109,0x610D,0x610F,0x611F-0x6120,0x6123-0x6124,0x6127,0x613F,0x614A,0x614C,0x614E,0x615D,0x6162,0x6168,0x6177,0x618B,0x618E,0x61A7,0x61BE,0x61C2,0x61CA,0x61D2,0x61D4,0x6206,0x620C,0x620E-0x6211,0x6215-0x6216,0x6218,0x621A-0x621B,0x6221,0x6233-0x6234,0x6237,0x623F-0x6241,0x6243,0x6247,0x624B,0x624D-0x624E,0x6251-0x6254,0x6258,0x625B,0x6263,0x6267,0x6269-0x626B,0x626D-0x626E,0x6270,0x6273,0x6276,0x6279,0x627E-0x6280,0x6284,0x628A,0x6293,0x6295-0x6298,0x629B,0x629F-0x62A2,0x62A4-0x62A5,0x62A8,0x62AC,0x62B1,0x62B5,0x62B9,0x62BB-0x62BD,0x62C2,0x62C5-0x62C6,0x62C8-0x62CA,0x62CC-0x62CE,0x62D0,0x62D3-0x62D4,0x62D6-0x62D8,0x62DA-0x62DC,0x62DF,0x62E2,0x62E5-0x62E7,0x62E9,0x62EC,0x62EE,0x62F1,0x62F4,0x62F6-0x62F7,0x62FC-0x62FF,0x6301-0x6302,0x6307-0x6309,0x630E,0x6311,0x6316,0x631B,0x631D,0x631F-0x6321,0x6325,0x632A-0x632B,0x632F,0x6332,0x633A,0x6346,0x6349,0x634B-0x6350,0x635F,0x6362,0x6367,0x6369,0x636D-0x636E,0x6376-0x6377,0x637B,0x6380,0x6387-0x638A,0x638C,0x638E,0x6392,0x6398,0x63A0,0x63A2,0x63A5,0x63A7-0x63A8,0x63AA,0x63B4,0x63B8,0x63BA,0x63BE,0x63C4,0x63C9,0x63CD,0x63CF-0x63D0,0x63D2,0x63D6,0x63DE,0x63E1,0x63E3,0x63E9,0x63ED,0x63F2,0x63F4,0x6402,0x640B,0x6413,0x641B-0x641C,0x641E,0x6421,0x642C-0x642D,0x6441,0x6447,0x6454,0x6467,0x6469,0x647A,0x6485,0x6487,0x6492,0x6495-0x6496,0x6499,0x649E,0x64A4,0x64A9,0x64AD-0x64AE,0x64B0,0x64BA,0x64C2,0x64CD,0x64D0,0x64D2,0x64D7-0x64D8,0x64DE,0x64E6,0x6509,0x6512,0x6518,0x6525,0x652E-0x652F,0x6536,0x6539,0x653B,0x653E-0x653F,0x6545,0x6548,0x654C,0x654F,0x6551,0x6555-0x6556,0x6559,0x655E,0x6562-0x6563,0x6566,0x656B,0x6570,0x6574,0x6587,0x658C,0x6597,0x6599,0x659C,0x65A1,0x65A5,0x65A9,0x65AB,0x65AD,0x65AF-0x65B0,0x65B9,0x65BC-0x65BD,0x65C1,0x65C4,0x65CB,0x65CF,0x65E0,0x65E2,0x65E5-0x65E7,0x65E9,0x65EC,0x65EE,0x65F0,0x65F6,0x6602,0x6606,0x660C,0x660E-0x660F,0x6614-0x6615,0x6619,0x661F-0x6620,0x6625,0x6627-0x6628,0x662F,0x6635,0x663E,0x6641,0x6643,0x6652,0x665A,0x665F,0x6668,0x666E-0x666F,0x6682,0x6687,0x6696-0x6697,0x66A7-0x66A8,0x66B4,0x66DD,0x66E9,0x66F0,0x66F2,0x66F4,0x66F7,0x66F9,0x66FC,0x66FE,0x6700,0x6708-0x670B,0x670D,0x6710,0x6714,0x6717,0x671B,0x671D,0x671F,0x6726,0x6728,0x672A-0x672D,0x672F,0x6731,0x6734-0x6735,0x673A,0x6740,0x6742-0x6743,0x6749,0x674C,0x674E,0x6750-0x6751,0x6753,0x675C,0x6760-0x6761,0x6765,0x6768,0x676D,0x6777,0x677C,0x677E-0x677F,0x6784,0x6787,0x6789,0x678B,0x6790,0x6795,0x6797-0x6798,0x679C-0x679E,0x67AA,0x67AF,0x67B6,0x67B8,0x67C1,0x67CF-0x67D1,0x67D3-0x67D4,0x67D9-0x67DA,0x67DC,0x67DE,0x67E0,0x67E2,0x67E5,0x67F3-0x67F4,0x67FD,0x6805,0x6807,0x680E-0x680F,0x6811,0x6813,0x6816-0x6817,0x681D,0x6821,0x6829,0x6837-0x6839,0x683C,0x683E,0x6841-0x6843,0x6845,0x6848,0x684A,0x684C,0x6850-0x6851,0x6854,0x6861,0x6863,0x6865,0x6867,0x6881,0x6885,0x688F,0x6897,0x68A2,0x68A6-0x68A7,0x68AD,0x68C0,0x68C9,0x68CB,0x68CD,0x68D2,0x68E3,0x68EE,0x68F0-0x68F1,0x68F5,0x68F9,0x690E,0x6939,0x693F,0x6942,0x695A,0x695E,0x6960,0x696E,0x6971,0x6977,0x697C,0x6982,0x699C,0x69C1,0x69CC,0x69D0,0x69DB,0x69DF,0x69ED,0x69FD,0x69FF,0x6A18,0x6A21,0x6A2A,0x6A3D,0x6A50,0x6A59,0x6A80,0x6A90,0x6A97,0x6AA0,0x6AAB,0x6B21-0x6B23,0x6B27,0x6B39,0x6B3E,0x6B43,0x6B47,0x6B59,0x6B62-0x6B66,0x6B6A,0x6B79,0x6B7B,0x6B81,0x6B8B,0x6B8D,0x6B96,0x6BA1,0x6BB5,0x6BB7,0x6BBF,0x6BC1,0x6BCB,0x6BCD,0x6BCF,0x6BD2,0x6BD4-0x6BD5,0x6BDB,0x6BEB,0x6BF3,0x6BF9,0x6C0F-0x6C11,0x6C13-0x6C15,0x6C24,0x6C34-0x6C35,0x6C38,0x6C3D,0x6C40-0x6C42,0x6C47,0x6C49,0x6C55,0x6C57,0x6C5D,0x6C5F-0x6C60,0x6C64,0x6C69-0x6C6A,0x6C76,0x6C7D-0x6C7E,0x6C82-0x6C83,0x6C86,0x6C88-0x6C89,0x6C8C,0x6C8F,0x6C93,0x6C99,0x6C9F,0x6CA1,0x6CA6-0x6CA7,0x6CAE,0x6CB1,0x6CB3,0x6CB8,0x6CBB,0x6CBE,0x6CC4-0x6CC5,0x6CC9-0x6CCA,0x6CCC,0x6CD0,0x6CD4-0x6CD6,0x6CDB,0x6CDE,0x6CE2-0x6CE3,0x6CE5,0x6CE8,0x6CEA-0x6CEB,0x6CEF-0x6CF0,0x6CF5,0x6CF7,0x6CFA,0x6CFD,0x6D01,0x6D07,0x6D0B,0x6D12,0x6D17,0x6D1A-0x6D1B,0x6D1E,0x6D2A-0x6D2B,0x6D2E,0x6D32,0x6D35,0x6D3B-0x6D3E,0x6D41,0x6D45,0x6D4B,0x6D4D,0x6D51-0x6D53,0x6D5A,0x6D5C,0x6D69-0x6D6A,0x6D77-0x6D78,0x6D82,0x6D85,0x6D88-0x6D89,0x6D8C,0x6D8E,0x6D91,0x6D93-0x6D94,0x6D9E,0x6DA1,0x6DA3,0x6DA6,0x6DA9-0x6DAA,0x6DAE,0x6DB2,0x6DCB-0x6DCC,0x6DD1,0x6DD6,0x6DD9,0x6DE0-0x6DE1,0x6DE6,0x6DEB-0x6DEC,0x6DEE,0x6DF1,0x6DF3,0x6DF7,0x6DFB,0x6E05,0x6E11,0x6E20,0x6E25,0x6E29,0x6E2B,0x6E2F,0x6E34,0x6E38,0x6E43,0x6E4D,0x6E53-0x6E54,0x6E5B,0x6E5F,0x6E6B,0x6E6E,0x6E7E,0x6E83,0x6E89,0x6E90,0x6E98,0x6E9F,0x6EA5,0x6EAA,0x6EAF,0x6EB1-0x6EB2,0x6EB4,0x6EB6,0x6EBA,0x6EC2,0x6EC7,0x6ECB,0x6ED1,0x6ED5,0x6EDA,0x6EE1,0x6EE8,0x6F02,0x6F06,0x6F0F,0x6F15,0x6F2B,0x6F2F,0x6F58,0x6F5C,0x6F62,0x6F66,0x6F6D-0x6F6E,0x6F7C,0x6F84,0x6F89,0x6F8D,0x6FA1,0x6FB3,0x6FB6,0x6FB9,0x6FC0,0x6FC2,0x6FDE,0x6FE1,0x6FEF,0x7011,0x7039,0x704C,0x706B-0x706D,0x706F-0x7070,0x7075,0x707E-0x707F,0x7085,0x708A,0x708E,0x7094-0x7096,0x70AE-0x70AF,0x70B8-0x70B9,0x70C1-0x70C2,0x70C8,0x70D9,0x70DB,0x70DF,0x70E4,0x70E7,0x70ED,0x7109,0x7116,0x7118,0x7126,0x712F,0x7131,0x7136,0x7166-0x7168,0x7184,0x718A,0x7199,0x719F,0x71A8,0x71AC,0x71C3,0x71D4,0x71E0,0x71E5,0x71F9,0x7206,0x721D,0x7228,0x722A,0x722C,0x7231,0x7237-0x7239,0x723B,0x723D,0x723F,0x7247-0x7248,0x724C,0x7259,0x725B,0x725F,0x7262,0x7269,0x7279,0x7284,0x728D,0x7292,0x72AF,0x72B4,0x72B6,0x72C2-0x72C4,0x72D7,0x72E0-0x72E1,0x72EC,0x72FB-0x72FC,0x730E,0x7313,0x7317,0x731B,0x732B,0x7334,0x7360,0x736C,0x737E,0x7384,0x7387,0x738B,0x739F,0x73A2,0x73A9,0x73AF-0x73B0,0x73CD,0x73DE,0x73E9,0x73ED,0x73F2,0x7403,0x7405-0x7406,0x7422,0x7428,0x7433-0x7434,0x743C,0x7457,0x7459,0x745E-0x745F,0x74D2,0x74DC,0x74E0,0x74E4,0x74E6,0x74EE,0x74F6,0x74FF,0x7504,0x7518,0x751A,0x751C,0x751F,0x7528-0x7529,0x752B-0x752D,0x7530-0x7532,0x7535,0x7537-0x7538,0x753A,0x753E,0x754C,0x7559-0x755A,0x755C,0x7565,0x756A,0x7578-0x7579,0x758B,0x7591-0x7592,0x7594,0x7597,0x7599,0x759F,0x75AE,0x75B4-0x75B5,0x75B8-0x75B9,0x75BC,0x75C5,0x75D5,0x75DB,0x75E6,0x75EA,0x7615,0x761F,0x7625,0x7633,0x7635,0x764C,0x765E,0x7678,0x767B,0x767D-0x767E,0x7684,0x7687,0x768B,0x7693,0x7696,0x76A4,0x76AE,0x76B4,0x76BF,0x76C5-0x76C6,0x76CD-0x76CE,0x76D1-0x76D2,0x76D6,0x76D8,0x76DB,0x76DF,0x76EE-0x76EF,0x76F2,0x76F4,0x76F8-0x76F9,0x76FC,0x76FE,0x7701,0x7708,0x770B,0x7719,0x771F-0x7720,0x7729,0x772D,0x7737-0x7738,0x773C,0x7740,0x7743,0x7747,0x7761-0x7763,0x776B,0x777D-0x7780,0x7791,0x77A0,0x77A2,0x77A5,0x77A7,0x77AA,0x77AC,0x77B5,0x77BF,0x77DB-0x77DC,0x77E3,0x77E5,0x77ED,0x77F3,0x77F8,0x77FF,0x7801,0x7809,0x780C-0x780D,0x7814,0x7816,0x781D,0x781F,0x7825,0x7829,0x782C,0x7830,0x7834,0x7838,0x7845,0x784C,0x784E,0x7850,0x7855,0x785D,0x786A-0x786C,0x786E,0x788C,0x788E,0x789F,0x78A1,0x78A3,0x78B0-0x78B1,0x78C1,0x78C5,0x78C9,0x78F7,0x78FA,0x7924,0x793A,0x793E,0x7941,0x7948,0x7953,0x7956,0x795D-0x795E,0x7960,0x7962,0x7968,0x796D,0x7981,0x7985,0x79B3,0x79BB,0x79C0-0x79C1,0x79CB,0x79CD,0x79D1-0x79D2,0x79D8,0x79DF,0x79E4,0x79E6,0x79EF-0x79F0,0x79F8,0x79FB,0x7A0B,0x7A0D-0x7A0E,0x7A1E,0x7A20,0x7A37,0x7A39,0x7A3D,0x7A3F,0x7A51,0x7A70,0x7A74,0x7A76-0x7A77,0x7A79-0x7A7A,0x7A7F-0x7A81,0x7A83,0x7A92,0x7A96-0x7A98,0x7A9C-0x7A9D,0x7AA5,0x7AA8,0x7AAC,0x7ACB,0x7AD9,0x7ADF-0x7AE0,0x7AE5,0x7AEF,0x7AFA,0x7B0B,0x7B0F,0x7B11,0x7B24,0x7B26,0x7B28,0x7B2C,0x7B2E,0x7B3C,0x7B49,0x7B4B,0x7B52,0x7B54,0x7B56,0x7B5B,0x7B60,0x7B77,0x7B79,0x7B80,0x7B90,0x7B97,0x7BA1-0x7BA2,0x7BAC,0x7BB4,0x7BB8,0x7BC7,0x7BE1,0x7BE6,0x7BFE,0x7C07,0x7C2A,0x7C3F,0x7C41,0x7C4D,0x7C73-0x7C74,0x7C7B,0x7C89,0x7C97-0x7C98,0x7CA2,0x7CA5,0x7CAE,0x7CB9,0x7CBE,0x7CC1,0x7CC5,0x7CD6,0x7CDC,0x7CDF,0x7CF8,0x7CFB,0x7D20,0x7D22,0x7D27,0x7D2F,0x7D6E,0x7DAE,0x7E41,0x7E47,0x7E82,0x7E9B,0x7EA2,0x7EA4-0x7EA7,0x7EAF,0x7EB1-0x7EB3,0x7EB5-0x7EB7,0x7EBD,0x7EBF,0x7EC3-0x7EC4,0x7EC6,0x7EC8,0x7ECD,0x7ECF,0x7ED3,0x7ED5,0x7ED9,0x7EDC-0x7EDD,0x7EDF,0x7EE2-0x7EE3,0x7EE7,0x7EEA,0x7EED,0x7EF0,0x7EF2,0x7EF5,0x7EFC,0x7F00,0x7F09,0x7F0E-0x7F0F,0x7F13,0x7F16-0x7F17,0x7F20,0x7F29-0x7F2A,0x7F2F,0x7F32,0x7F34-0x7F36,0x7F3A,0x7F51,0x7F57,0x7F5A,0x7F62,0x7F6A,0x7F6E,0x7F71,0x7F8E,0x7F9E,0x7FA1,0x7FA4,0x7FA7,0x7FB8-0x7FB9,0x7FBD,0x7FC1,0x7FDF-0x7FE0,0x7FEE,0x7FFB,0x8001,0x8003,0x8005-0x8006,0x800C-0x800D,0x8010,0x8015,0x8017,0x8019,0x8028,0x8033,0x8036-0x8038,0x804A,0x8052,0x8054,0x8058,0x806A,0x8071,0x8083-0x8084,0x8086,0x8089,0x808B,0x809C,0x80A1-0x80A2,0x80A5,0x80A9,0x80AB,0x80AD-0x80AF,0x80B2,0x80B7,0x80BA,0x80C2,0x80C6,0x80CC-0x80CD,0x80D6-0x80D7,0x80DC-0x80DE,0x80E1,0x80E9,0x80EF,0x80F1-0x80F3,0x80F6,0x80F8,0x80FA,0x80FD,0x8109,0x810F,0x8111,0x8113-0x8114,0x8118,0x811A,0x811E,0x812F,0x8131,0x8138,0x813E,0x814A,0x814C,0x8154,0x8167,0x816E,0x8171,0x817E-0x8180,0x818A,0x8198,0x81A6,0x81AA,0x81BB,0x81C2,0x81E3,0x81E7,0x81EA,0x81ED,0x81F3-0x81F4,0x81FE,0x8202,0x8204,0x820C-0x820D,0x8212,0x8214,0x821C,0x8221,0x822A,0x822C,0x8231,0x8233,0x8239,0x8258,0x825F,0x826E-0x826F,0x8272,0x8274,0x827D-0x827F,0x8282,0x828B,0x828D-0x828E,0x8292,0x8298,0x829F,0x82A4-0x82A6,0x82A9-0x82AB,0x82AD-0x82AE,0x82B1,0x82B3-0x82B4,0x82BE,0x82CD,0x82CF,0x82D1-0x82D3,0x82D5,0x82D7,0x82DB,0x82DE,0x82E3-0x82E6,0x82EB,0x82EF,0x82F1,0x82F4,0x82F9,0x82FB,0x8301,0x8303-0x8304,0x8307-0x8308,0x8311,0x831B-0x831C,0x8327,0x832B-0x832D,0x8336,0x8343,0x8349,0x8351-0x8352,0x835B,0x8360-0x8361,0x8363-0x8365,0x8368,0x836F,0x837C-0x837D,0x8386,0x8389,0x838E,0x8398,0x839C,0x839E,0x83A0,0x83A8-0x83A9,0x83AB,0x83B1,0x83B7,0x83BD,0x83C0,0x83C5,0x83CC,0x83CF,0x83DC,0x83E5,0x83E9,0x83F2,0x83F8-0x83F9,0x83FD,0x8401,0x8406,0x840C,0x8411,0x8425,0x8428,0x843D,0x8457,0x845A,0x845C,0x8461,0x8463,0x846C-0x846D,0x8471,0x8476,0x848B,0x8499,0x849C,0x84A1,0x84AF,0x84B2,0x84BD,0x84BF,0x84C1,0x84C9-0x84CA,0x84D1,0x84DD,0x84FC,0x84FF,0x8511,0x8513,0x851A,0x851F,0x8521,0x852B,0x853D,0x8543,0x8548-0x854A,0x855E,0x8579-0x857A,0x8584,0x859B-0x859C,0x85C9,0x85CF-0x85D0,0x85E4,0x85FF,0x8616,0x8627,0x8650-0x8651,0x866B,0x8679,0x867D-0x867E,0x868C,0x8695,0x869D,0x86A3-0x86A4,0x86B0,0x86B4-0x86B5,0x86BA,0x86C6-0x86C7,0x86D1,0x86D9,0x86DE,0x86E4,0x86E9,0x86EE,0x86F8,0x86FE,0x870D,0x8712-0x8713,0x8715,0x871A,0x8721,0x8729,0x872E,0x8731,0x8734,0x8737,0x873B,0x873E,0x874E,0x8759,0x8764-0x8765,0x8776,0x8783,0x8785,0x878D,0x87AB,0x87C0,0x87C6,0x87CA,0x87D2,0x87E0,0x8803,0x8816,0x881B,0x8821-0x8822,0x883C,0x8840,0x884C,0x8859,0x8861,0x8865,0x8868,0x886E,0x8870,0x887F,0x8882,0x8885,0x888D,0x8892,0x8896,0x889C,0x88A2,0x88A4,0x88AB,0x88B7,0x88BC,0x88C1-0x88C2,0x88C5,0x88D2,0x88D9,0x88E2,0x88E8,0x88F3-0x88F4,0x88FC,0x890A,0x891A,0x8925,0x892A,0x8936,0x897F,0x8981,0x8983,0x8986,0x89C1-0x89C2,0x89C4,0x89C6,0x89C8-0x89C9,0x89D2,0x89D6,0x89DC,0x89E3,0x89F3,0x8A00,0x8A07,0x8A79,0x8A8A,0x8BA1-0x8BA2,0x8BA4,0x8BA8-0x8BA9,0x8BAD-0x8BB0,0x8BB2,0x8BB7-0x8BB8,0x8BBA,0x8BBC,0x8BBE-0x8BBF,0x8BC1,0x8BC4,0x8BC6,0x8BC8-0x8BC9,0x8BCD,0x8BCF,0x8BD5,0x8BD8,0x8BDD,0x8BE5-0x8BE6,0x8BED,0x8BEF-0x8BF0,0x8BF4-0x8BF7,0x8BF9-0x8BFB,0x8BFE,0x8C01,0x8C03,0x8C06,0x8C08,0x8C0B,0x8C0D,0x8C11,0x8C1C,0x8C20,0x8C22,0x8C2C,0x8C2E,0x8C31,0x8C37,0x8C41,0x8C46,0x8C49,0x8C5A,0x8C61,0x8C6A-0x8C6B,0x8C73,0x8C78,0x8C7A,0x8C89-0x8C8A,0x8C8C,0x8D1D,0x8D22-0x8D23,0x8D25,0x8D2B,0x8D2D,0x8D30,0x8D32,0x8D34-0x8D35,0x8D38-0x8D39,0x8D3C,0x8D3E,0x8D43-0x8D44,0x8D49,0x8D4F,0x8D56,0x8D5A-0x8D5B,0x8D5E,0x8D60,0x8D66,0x8D6B,0x8D70,0x8D76-0x8D77,0x8D81,0x8D84-0x8D85,0x8D8A,0x8D91,0x8D9F,0x8DA3,0x8DB3,0x8DB5,0x8DBC,0x8DBF,0x8DC3,0x8DCB-0x8DCC,0x8DD1,0x8DDB,0x8DDD-0x8DDF,0x8DE3-0x8DE4,0x8DE8,0x8DEB-0x8DEC,0x8DEF,0x8DF3,0x8E05,0x8E09,0x8E14,0x8E22-0x8E23,0x8E29-0x8E2A,0x8E31,0x8E39,0x8E42,0x8E44,0x8E4A,0x8E66,0x8E6D,0x8E72,0x8E74,0x8E76,0x8E7F,0x8E81,0x8E85,0x8E87,0x8E94,0x8EAB,0x8EB2,0x8EBA,0x8F66-0x8F68,0x8F6C,0x8F6E-0x8F70,0x8F74,0x8F7D,0x8F83,0x8F87,0x8F8D,0x8F8F,0x8F91,0x8F93,0x8F97,0x8F9B,0x8F9E-0x8F9F,0x8FA3,0x8FA8-0x8FA9,0x8FB0,0x8FB9,0x8FBD-0x8FBE,0x8FC1,0x8FC5,0x8FC7,0x8FCE,0x8FD0-0x8FD1,0x8FD4,0x8FD8-0x8FD9,0x8FDB-0x8FDC,0x8FDE-0x8FDF,0x8FE4-0x8FE6,0x8FE9,0x8FEB,0x8FED-0x8FEE,0x8FF0,0x8FF7,0x8FF9,0x8FFD,0x9000-0x9004,0x9006,0x9009-0x900A,0x900F-0x9010,0x9014,0x9017,0x901A-0x901B,0x901E-0x9022,0x902E-0x902F,0x903E,0x9041-0x9042,0x9047,0x904D,0x9053,0x9057,0x9063,0x906D,0x9075,0x907D,0x908B,0x9091,0x9093,0x909B,0x90A2-0x90A3,0x90A6,0x90AA,0x90B9-0x90BB,0x90C4-0x90C5,0x90C7,0x90CE,0x90D7,0x90DD,0x90E1-0x90E2,0x90E8,0x90ED,0x90F4,0x90FD,0x9131,0x9139,0x914D,0x9150,0x9152,0x9161-0x9162,0x916A,0x916C,0x916E,0x9177-0x9178,0x917E-0x917F,0x9187,0x9189,0x918B,0x9192,0x919B,0x91AE,0x91C7,0x91CC-0x91CF,0x91D1,0x92AE,0x93D6,0x946B,0x9488,0x948F,0x9494,0x9497,0x949E-0x949F,0x94A2,0x94A5-0x94A6,0x94AD-0x94AF,0x94B1,0x94BB,0x94BF,0x94C1,0x94C5,0x94CA,0x94DB,0x94E3-0x94E5,0x94EA-0x94EB,0x94ED,0x94F6,0x94FA,0x94FD-0x9501,0x950A,0x9510,0x9515,0x9519,0x9524,0x952C,0x952E,0x953B,0x9547,0x9550,0x9556,0x9561,0x9566,0x957F,0x95E8-0x95EA,0x95ED-0x95F0,0x95F2,0x95F4-0x95F5,0x95F7,0x95F9,0x95FB,0x95FD,0x960F,0x9614,0x9619-0x961A,0x961F,0x9622,0x962E,0x9632-0x9633,0x9635,0x963B,0x963D,0x963F-0x9640,0x9642,0x9644,0x9646-0x9648,0x964B,0x964D,0x9650,0x9655,0x965F,0x9662,0x9664,0x9669-0x966A,0x966C,0x9674-0x9676,0x9686,0x968B,0x968F-0x9690,0x9694,0x9697-0x9698,0x96A7,0x96B0,0x96B6,0x96B9,0x96BC-0x96BE,0x96C0,0x96C4-0x96C7,0x96C9,0x96E9-0x96EA,0x96F6-0x96F7,0x9700,0x9707,0x970D,0x971C,0x9730,0x9732,0x9738,0x973E,0x9752-0x9753,0x9759,0x975E,0x9760-0x9762,0x9769,0x978B,0x9794,0x9798,0x97A0,0x97A3,0x97AB,0x97B4,0x97E9,0x97F3,0x9875-0x9876,0x987A-0x987B,0x987F,0x9882,0x9884,0x9886-0x9889,0x988C,0x9891,0x9897-0x9898,0x989D,0x98A0-0x98A1,0x98A4,0x98CE,0x98D2,0x98D8,0x98DE-0x98DF,0x98E7,0x9910,0x992E,0x9967,0x996D,0x9976,0x997F,0x9981,0x9986-0x9988,0x9993,0x9995-0x9998,0x99A5,0x9A6C,0x9A6E,0x9A80,0x9A82,0x9A87,0x9A8C,0x9A97,0x9A9A,0x9AA0,0x9AB0-0x9AB1,0x9AB8,0x9AC1-0x9AC2,0x9ACB,0x9AD8,0x9ADF,0x9AEF,0x9AFB,0x9B13,0x9B32,0x9B3B-0x9B3C,0x9B41-0x9B42,0x9B44,0x9C81,0x9C85,0x9C91,0x9CA7,0x9CAD,0x9CC3,0x9E1F,0x9E23,0x9E3E-0x9E3F,0x9E44,0x9E4F,0x9E58,0x9E87,0x9E9F,0x9EA6,0x9EBB,0x9EBD,0x9EC4,0x9ED1,0x9EDD,0x9EE7,0x9EF9,0x9EFE,0x9F0E,0x9F50,0x9F88,0x9F99,0x9F9F,0xFF01,0xFF08-0xFF09,0xFF0C,0xFF1A-0xFF1B,0xFF1F,0xFF5E,0xFFE5 +0xB0,0xB7,0x2014,0x2018-0x2019,0x201C-0x201D,0x2026,0x3001-0x3002,0x3008-0x3011,0x3014-0x3015,0x4E00-0x4E01,0x4E03,0x4E07-0x4E0E,0x4E11,0x4E13-0x4E14,0x4E18-0x4E1C,0x4E1E,0x4E22,0x4E24-0x4E25,0x4E27,0x4E2A,0x4E2D,0x4E30,0x4E32,0x4E34,0x4E39-0x4E3B,0x4E3E-0x4E3F,0x4E43,0x4E45,0x4E47-0x4E49,0x4E4B,0x4E4D-0x4E50,0x4E56,0x4E58-0x4E59,0x4E5C-0x4E5D,0x4E5F,0x4E66,0x4E70-0x4E71,0x4E7E,0x4E86,0x4E88-0x4E89,0x4E8B-0x4E8C,0x4E8E-0x4E8F,0x4E91,0x4E94,0x4E98,0x4E9A-0x4E9B,0x4E9F,0x4EA1-0x4EA2,0x4EA4-0x4EA5,0x4EA7-0x4EA8,0x4EAB-0x4EAE,0x4EB2,0x4EBA,0x4EC0-0x4EC2,0x4EC5-0x4EC7,0x4ECA-0x4ECB,0x4ECD-0x4ECE,0x4ED1,0x4ED3-0x4ED4,0x4ED6,0x4EE1,0x4EE3-0x4EE5,0x4EEC,0x4EF0,0x4EF3,0x4EF6-0x4EF7,0x4EFB,0x4EFD,0x4EFF,0x4F09,0x4F0D-0x4F0E,0x4F10-0x4F11,0x4F17,0x4F1A,0x4F1E,0x4F20,0x4F24,0x4F26-0x4F27,0x4F2F,0x4F34,0x4F3A,0x4F3C-0x4F3D,0x4F43,0x4F46,0x4F4D-0x4F4F,0x4F53,0x4F55,0x4F57,0x4F59-0x4F5D,0x4F60,0x4F67,0x4F69,0x4F6C,0x4F70,0x4F73-0x4F74,0x4F7B-0x4F7C,0x4F7F,0x4F89,0x4F8B,0x4F8F,0x4F94,0x4F97,0x4F9B,0x4FA0,0x4FA5-0x4FA7,0x4FAC,0x4FAF,0x4FB5,0x4FBF,0x4FC3-0x4FC4,0x4FCA,0x4FCF,0x4FD7,0x4FDD-0x4FDF,0x4FE1,0x4FE9,0x4FEE,0x4FF8,0x4FFA,0x4FFE,0x500D,0x5012,0x5018-0x501A,0x501C,0x5026,0x5029-0x502A,0x502D,0x503A,0x503C,0x5047-0x5048,0x504C,0x504F,0x5055,0x505A,0x505C,0x506C,0x5076-0x5077,0x507B,0x5080,0x508D,0x50A8,0x50AC,0x50B2,0x50BB,0x50CF,0x50DA,0x50E7,0x50ED-0x50EE,0x510B,0x513F,0x5141,0x5143-0x5145,0x5148-0x5149,0x514B,0x514D,0x5151,0x5154,0x515A,0x5165,0x5168,0x516B-0x516D,0x5170-0x5171,0x5173-0x5179,0x517B-0x517C,0x5185,0x5188-0x5189,0x518C-0x518D,0x5192,0x5199,0x519B-0x519C,0x51A0,0x51A5,0x51AC,0x51AF-0x51B0,0x51B2-0x51B3,0x51B5,0x51B7,0x51BB-0x51BC,0x51C0,0x51C6,0x51D1,0x51DD,0x51E0-0x51E1,0x51E4,0x51ED,0x51EF,0x51F3,0x51F5-0x51F6,0x51F9-0x51FB,0x51FF-0x5200,0x5206-0x5207,0x520A,0x5212,0x5217-0x521B,0x521D,0x5220,0x5224,0x5228-0x5229,0x522B,0x522E,0x5230,0x5233,0x5236-0x523B,0x524A,0x524D,0x5256,0x5261,0x5265,0x5269,0x526F,0x527D,0x527F,0x5290,0x529B,0x529D-0x52A1,0x52A3,0x52A8-0x52AA,0x52B2-0x52B3,0x52BE-0x52BF,0x52C7,0x52C9,0x52D2,0x52D6,0x52D8,0x52DF,0x52E4,0x52FA,0x52FE-0x5300,0x5305-0x5306,0x5315-0x5317,0x5319,0x531D,0x5321,0x532A,0x532E,0x5339-0x533A,0x533F,0x5341,0x5343,0x5345,0x5347,0x534A,0x534E-0x534F,0x5351-0x5353,0x5355-0x5357,0x535C,0x535E-0x5361,0x5364,0x5366-0x5367,0x536B,0x536F-0x5371,0x5373-0x5375,0x5377-0x5378,0x5382,0x5385-0x5386,0x538B,0x5395,0x5398,0x539A,0x539D,0x539F,0x53A6,0x53B6,0x53BB,0x53BF,0x53C1-0x53C2,0x53C8,0x53CA-0x53CD,0x53D1,0x53D6-0x53D8,0x53DF-0x53E0,0x53E3-0x53E6,0x53E8-0x53ED,0x53EF-0x53F1,0x53F3,0x53F6-0x53F9,0x53FB,0x53FD,0x5401,0x5403-0x5404,0x5408,0x540C-0x540E,0x5411,0x5413,0x5416-0x5417,0x541B,0x541E-0x541F,0x5421,0x5426-0x5429,0x542B-0x542F,0x5431-0x5432,0x5434-0x5435,0x5439,0x543E,0x5440,0x5443,0x5446,0x5448,0x544A,0x5450,0x5452,0x5454-0x5455,0x5457-0x5458,0x5462,0x5468,0x5471-0x5473,0x5475-0x5477,0x547C-0x547D,0x5480,0x548B-0x548C,0x548E,0x5494,0x5496,0x54A3,0x54A6,0x54A9-0x54AA,0x54AC-0x54AD,0x54AF,0x54B1,0x54B3-0x54B4,0x54B8,0x54BB,0x54BD,0x54C0-0x54C1,0x54C6-0x54C8,0x54CC-0x54CD,0x54CF-0x54D0,0x54D5,0x54DD,0x54DF,0x54E6-0x54EA,0x54ED-0x54EE,0x54F2,0x54FA,0x54FC-0x54FD,0x5506-0x5507,0x550F-0x5511,0x5514,0x551B,0x5524,0x5527,0x552A,0x552C,0x552E,0x5530,0x5537,0x553C,0x5541,0x5543-0x5544,0x5546,0x5549-0x554A,0x5550,0x555C,0x5561,0x5565-0x5566,0x557B,0x5580-0x5581,0x5583-0x5584,0x5587-0x558B,0x558F,0x5594,0x5598-0x5599,0x559C-0x559D,0x559F,0x55B3,0x55B7,0x55BD,0x55C4,0x55CC-0x55CD,0x55D1-0x55D4,0x55D6,0x55DF,0x55E1,0x55E8,0x55EF,0x55F2,0x55FD,0x5600-0x5601,0x560E-0x560F,0x5618,0x561E,0x562C,0x5632,0x5634,0x563F,0x564C-0x564E,0x5662,0x5668,0x566A-0x566B,0x5671,0x5676,0x567B,0x5693,0x56A3,0x56AF,0x56B7,0x56BC,0x56CA,0x56D4,0x56D7,0x56DA-0x56DB,0x56DD-0x56DE,0x56E0-0x56E2,0x56E4,0x56ED,0x56F0-0x56F1,0x56F4,0x56FA,0x56FD-0x56FE,0x5708,0x571C,0x571F,0x5723,0x5728-0x572A,0x5730,0x5733,0x573A-0x573B,0x573E,0x5747,0x574E-0x5751,0x5757,0x5760-0x5761,0x5764,0x5766,0x5768,0x576B,0x576F,0x5773,0x5776-0x5777,0x577B,0x5782,0x5784,0x578C,0x5792,0x57A0,0x57A6-0x57A7,0x57A9,0x57AE,0x57B8,0x57C3,0x57CB,0x57CE-0x57CF,0x57D4,0x57DD,0x57DF,0x57E4,0x57EF,0x57F9-0x57FA,0x57FD,0x5802,0x5806-0x5807,0x580B,0x5815,0x5821,0x5824,0x582A,0x5835,0x5844,0x584C,0x5854,0x5858,0x585E,0x586B,0x5883,0x5885,0x5899,0x589E,0x58A8,0x58C5,0x58D1,0x58E4,0x58EC,0x58EE,0x58F0,0x58F3,0x58F9,0x5902,0x5904,0x5907,0x590D,0x590F,0x5915-0x5916,0x591A,0x591C,0x591F,0x5927,0x5929-0x592B,0x592D-0x592F,0x5931,0x5934,0x5938-0x593A,0x5947-0x5949,0x594B,0x594F,0x5951,0x5954,0x5956-0x5958,0x5960,0x5965,0x5973-0x5974,0x5976,0x5978-0x5979,0x597D,0x5981-0x5983,0x5986,0x5988,0x5996-0x5997,0x5999,0x599E,0x59AE-0x59AF,0x59B9,0x59BE,0x59C6,0x59CB,0x59D0,0x59D3,0x59DA,0x59E3,0x59E5,0x59EC,0x5A03,0x5A06,0x5A09,0x5A18,0x5A1C,0x5A29,0x5A36,0x5A46,0x5A55,0x5A5A,0x5A7A,0x5AAA,0x5AB2,0x5AB5,0x5AC2,0x5AD4,0x5AD6,0x5AE9,0x5B17,0x5B32,0x5B37,0x5B40,0x5B50,0x5B54,0x5B57-0x5B59,0x5B5B,0x5B5F,0x5B66,0x5B69,0x5B71,0x5B7D,0x5B81,0x5B83,0x5B85,0x5B88-0x5B89,0x5B8B-0x5B8C,0x5B93,0x5B95,0x5B97-0x5B98,0x5B9A-0x5B9B,0x5B9D-0x5B9E,0x5BA2-0x5BA3,0x5BAA,0x5BB0,0x5BB3,0x5BB6,0x5BB9,0x5BBD-0x5BBF,0x5BC6-0x5BC7,0x5BD2,0x5BDF,0x5BE1,0x5BE8,0x5BF0,0x5BF8-0x5BFC,0x5C01,0x5C04,0x5C06,0x5C09-0x5C0A,0x5C0F,0x5C11,0x5C14,0x5C1A,0x5C1D,0x5C22,0x5C25,0x5C2C,0x5C31,0x5C39-0x5C3A,0x5C3C-0x5C3F,0x5C42,0x5C45,0x5C48,0x5C4E-0x5C4F,0x5C55,0x5C5E,0x5C6E-0x5C6F,0x5C71,0x5C79,0x5C81-0x5C82,0x5C88,0x5C91,0x5C97,0x5C9B,0x5CB8,0x5CD2,0x5CD9,0x5CE4,0x5CE6,0x5D06-0x5D07,0x5D0E,0x5D14,0x5D1B,0x5D24,0x5D29,0x5D34,0x5D47,0x5D4A,0x5D4C,0x5D6F,0x5DB7,0x5DDB,0x5DDD-0x5DDE,0x5DE1-0x5DE2,0x5DE5-0x5DE8,0x5DEE,0x5DF1-0x5DF4,0x5DF7,0x5DFD,0x5E01-0x5E03,0x5E05,0x5E0C,0x5E10-0x5E11,0x5E14-0x5E16,0x5E18,0x5E1D,0x5E26-0x5E27,0x5E2E,0x5E31,0x5E38,0x5E45,0x5E55,0x5E62,0x5E72-0x5E74,0x5E76,0x5E78,0x5E7A,0x5E7C,0x5E7F,0x5E84,0x5E87,0x5E8A,0x5E93-0x5E95,0x5E97,0x5E99-0x5E9A,0x5E9C,0x5E9E-0x5E9F,0x5EA6-0x5EA7,0x5EAD,0x5EB3,0x5EB5-0x5EB7,0x5ECA,0x5ED1,0x5ED3,0x5ED6,0x5EF6-0x5EF7,0x5EFA,0x5F00-0x5F01,0x5F03-0x5F04,0x5F0F,0x5F15,0x5F18,0x5F1F-0x5F20,0x5F25,0x5F29,0x5F31,0x5F39-0x5F3A,0x5F40,0x5F52-0x5F53,0x5F55-0x5F57,0x5F61-0x5F62,0x5F66,0x5F69-0x5F6A,0x5F6C-0x5F6D,0x5F71,0x5F73,0x5F77,0x5F7B,0x5F80,0x5F85,0x5F88,0x5F8A-0x5F8C,0x5F92,0x5F97,0x5F99,0x5FA1,0x5FAA,0x5FB5,0x5FB7,0x5FBC,0x5FC3,0x5FC5,0x5FCD,0x5FCF-0x5FD2,0x5FD6-0x5FD7,0x5FD9,0x5FE0,0x5FEA-0x5FEB,0x5FEE,0x5FF1,0x5FF5,0x5FFD-0x5FFE,0x6000-0x6001,0x600D-0x600E,0x6012,0x6015,0x6019,0x601B-0x601D,0x6020,0x6025,0x6027-0x6028,0x602A-0x602B,0x6035,0x603B-0x603C,0x6041-0x6043,0x604D,0x6050,0x6052,0x605D,0x6062,0x6068-0x6069,0x606B,0x606F-0x6070,0x6073,0x6076,0x607C,0x607F,0x6084,0x609B,0x609D,0x60A3,0x60A8,0x60AC,0x60AF,0x60B2,0x60C5-0x60C6,0x60CA,0x60DD,0x60E0,0x60E8,0x60ED,0x60F0,0x60F3-0x60F4,0x60F9,0x6100-0x6101,0x6109,0x610D,0x610F,0x611F-0x6120,0x6123-0x6124,0x6127,0x613F,0x614A,0x614C,0x614E,0x615D,0x6162,0x6168,0x6177,0x618B,0x618E,0x61A7,0x61BE,0x61C2,0x61CA,0x61D2,0x61D4,0x6206,0x620C,0x620E-0x6211,0x6215-0x6216,0x6218,0x621A-0x621B,0x6221,0x6233-0x6234,0x6237,0x623F-0x6241,0x6243,0x6247,0x624B,0x624D-0x624E,0x6251-0x6254,0x6258,0x625B,0x6263,0x6267,0x6269-0x626B,0x626D-0x626E,0x6270,0x6273,0x6276,0x6279,0x627E-0x6280,0x6284,0x628A,0x6293,0x6295-0x6298,0x629B,0x629F-0x62A2,0x62A4-0x62A5,0x62A8,0x62AC,0x62B1,0x62B5,0x62B9,0x62BB-0x62BD,0x62C2,0x62C5-0x62C6,0x62C8-0x62CA,0x62CC-0x62CE,0x62D0,0x62D3-0x62D4,0x62D6-0x62D8,0x62DA-0x62DC,0x62DF,0x62E2,0x62E5-0x62E7,0x62E9,0x62EC,0x62EE,0x62F1,0x62F4,0x62F6-0x62F7,0x62FC-0x62FF,0x6301-0x6302,0x6307-0x6309,0x630E,0x6311,0x6316,0x631B,0x631D,0x631F-0x6321,0x6325,0x632A-0x632B,0x632F,0x6332,0x633A,0x6346,0x6349,0x634B-0x6350,0x635F,0x6362,0x6367,0x6369,0x636D-0x636E,0x6376-0x6377,0x637B,0x6380,0x6387-0x638A,0x638C,0x638E,0x6392,0x6398,0x63A0,0x63A2,0x63A5,0x63A7-0x63A8,0x63AA,0x63B4,0x63B8,0x63BA,0x63BE,0x63C4,0x63C9,0x63CD,0x63CF-0x63D0,0x63D2,0x63D6,0x63DE,0x63E1,0x63E3,0x63E9,0x63ED,0x63F2,0x63F4,0x6402,0x640B,0x6413,0x641B-0x641C,0x641E,0x6421,0x642C-0x642D,0x6441,0x6447,0x6454,0x6467,0x6469,0x647A,0x6485,0x6487,0x6492,0x6495-0x6496,0x6499,0x649E,0x64A4,0x64A9,0x64AD-0x64AE,0x64B0,0x64BA,0x64C2,0x64CD,0x64D0,0x64D2,0x64D7-0x64D8,0x64DE,0x64E6,0x6509,0x6512,0x6518,0x6525,0x652E-0x652F,0x6536,0x6539,0x653B,0x653E-0x653F,0x6545,0x6548,0x654C,0x654F,0x6551,0x6555-0x6556,0x6559,0x655E,0x6562-0x6563,0x6566,0x656B,0x6570,0x6574,0x6587,0x658C,0x6597,0x6599,0x659C,0x65A1,0x65A5,0x65A9,0x65AB,0x65AD,0x65AF-0x65B0,0x65B9,0x65BC-0x65BD,0x65C1,0x65C4,0x65CB,0x65CF,0x65E0,0x65E2,0x65E5-0x65E7,0x65E9,0x65EC,0x65EE,0x65F0,0x65F6,0x6602,0x6606,0x660C,0x660E-0x660F,0x6614-0x6615,0x6619,0x661F-0x6620,0x6625,0x6627-0x6628,0x662F,0x6635,0x663E,0x6641,0x6643,0x6652,0x665A,0x665F,0x6668,0x666E-0x666F,0x6682,0x6687,0x6696-0x6697,0x66A7-0x66A8,0x66B4,0x66DD,0x66E9,0x66F0,0x66F2,0x66F4,0x66F7,0x66F9,0x66FC,0x66FE,0x6700,0x6708-0x670B,0x670D,0x6710,0x6714,0x6717,0x671B,0x671D,0x671F,0x6726,0x6728,0x672A-0x672D,0x672F,0x6731,0x6734-0x6735,0x673A,0x6740,0x6742-0x6743,0x6749,0x674C,0x674E,0x6750-0x6751,0x6753,0x675C,0x675F-0x6761,0x6765,0x6768,0x676D,0x6777,0x677C,0x677E-0x677F,0x6784,0x6787,0x6789,0x678B,0x6790,0x6795,0x6797-0x6798,0x679C-0x679E,0x67AA,0x67AF,0x67B6,0x67B8,0x67C1,0x67CF-0x67D1,0x67D3-0x67D4,0x67D9-0x67DA,0x67DC,0x67DE,0x67E0,0x67E2,0x67E5,0x67F3-0x67F4,0x67FD,0x6805,0x6807,0x680E-0x680F,0x6811,0x6813,0x6816-0x6817,0x681D,0x6821,0x6829,0x6837-0x6839,0x683C,0x683E,0x6841-0x6843,0x6845,0x6848,0x684A,0x684C,0x6850-0x6851,0x6854,0x6861,0x6863,0x6865,0x6867,0x6881,0x6885,0x688F,0x6897,0x68A2,0x68A6-0x68A7,0x68AD,0x68C0,0x68C9,0x68CB,0x68CD,0x68D2,0x68E3,0x68EE,0x68F0-0x68F1,0x68F5,0x68F9,0x690E,0x6939,0x693F,0x6942,0x695A,0x695E,0x6960,0x696E,0x6971,0x6977,0x697C,0x6982,0x699C,0x69C1,0x69CC,0x69D0,0x69DB,0x69DF,0x69ED,0x69FD,0x69FF,0x6A18,0x6A21,0x6A2A,0x6A3D,0x6A50,0x6A59,0x6A80,0x6A90,0x6A97,0x6AA0,0x6AAB,0x6B21-0x6B23,0x6B27,0x6B39,0x6B3E,0x6B43,0x6B47,0x6B59,0x6B62-0x6B66,0x6B6A,0x6B79,0x6B7B,0x6B81,0x6B8B,0x6B8D,0x6B96,0x6BA1,0x6BB5,0x6BB7,0x6BBF,0x6BC1,0x6BCB,0x6BCD,0x6BCF,0x6BD2,0x6BD4-0x6BD5,0x6BDB,0x6BEB,0x6BF3,0x6BF9,0x6C0F-0x6C11,0x6C13-0x6C15,0x6C24,0x6C34-0x6C35,0x6C38,0x6C3D,0x6C40-0x6C42,0x6C47,0x6C49,0x6C55,0x6C57,0x6C5D,0x6C5F-0x6C60,0x6C64,0x6C69-0x6C6A,0x6C76,0x6C7D-0x6C7E,0x6C82-0x6C83,0x6C86,0x6C88-0x6C89,0x6C8C,0x6C8F,0x6C93,0x6C99,0x6C9F,0x6CA1,0x6CA6-0x6CA7,0x6CAE,0x6CB1,0x6CB3,0x6CB8,0x6CBB,0x6CBE,0x6CC4-0x6CC5,0x6CC9-0x6CCA,0x6CCC,0x6CD0,0x6CD4-0x6CD6,0x6CDB,0x6CDE,0x6CE2-0x6CE3,0x6CE5,0x6CE8,0x6CEA-0x6CEB,0x6CEF-0x6CF0,0x6CF5,0x6CF7,0x6CFA,0x6CFD,0x6D01,0x6D07,0x6D0B,0x6D12,0x6D17,0x6D1A-0x6D1B,0x6D1E,0x6D2A-0x6D2B,0x6D2E,0x6D32,0x6D35,0x6D3B-0x6D3E,0x6D41,0x6D45,0x6D4B,0x6D4D,0x6D4F,0x6D51-0x6D53,0x6D5A,0x6D5C,0x6D69-0x6D6A,0x6D77-0x6D78,0x6D82,0x6D85,0x6D88-0x6D89,0x6D8C,0x6D8E,0x6D91,0x6D93-0x6D94,0x6D9E,0x6DA1,0x6DA3,0x6DA6,0x6DA9-0x6DAA,0x6DAE,0x6DB2,0x6DCB-0x6DCC,0x6DD1,0x6DD6,0x6DD9,0x6DE0-0x6DE1,0x6DE6,0x6DEB-0x6DEC,0x6DEE,0x6DF1,0x6DF3,0x6DF7,0x6DFB,0x6E05,0x6E11,0x6E20,0x6E25,0x6E29,0x6E2B,0x6E2F,0x6E34,0x6E38,0x6E43,0x6E4D,0x6E53-0x6E54,0x6E5B,0x6E5F,0x6E6B,0x6E6E,0x6E7E,0x6E83,0x6E89,0x6E90,0x6E98,0x6E9F,0x6EA5,0x6EAA,0x6EAF,0x6EB1-0x6EB2,0x6EB4,0x6EB6,0x6EBA,0x6EC2,0x6EC7,0x6ECB,0x6ED1,0x6ED5,0x6EDA,0x6EE1,0x6EE8,0x6F02,0x6F06,0x6F0F,0x6F15,0x6F2B,0x6F2F,0x6F58,0x6F5C,0x6F62,0x6F66,0x6F6D-0x6F6E,0x6F7C,0x6F84,0x6F89,0x6F8D,0x6FA1,0x6FB3,0x6FB6,0x6FB9,0x6FC0,0x6FC2,0x6FDE,0x6FE1,0x6FEF,0x7011,0x7039,0x704C,0x706B-0x706D,0x706F-0x7070,0x7075,0x707E-0x707F,0x7085,0x708A,0x708E,0x7094-0x7096,0x70AE-0x70AF,0x70B8-0x70B9,0x70C1-0x70C2,0x70C8,0x70D9,0x70DB,0x70DF,0x70E4,0x70E7,0x70ED,0x7109,0x7116,0x7118,0x7126,0x712F,0x7131,0x7136,0x7166-0x7168,0x7184,0x718A,0x7199,0x719F,0x71A8,0x71AC,0x71C3,0x71D4,0x71E0,0x71E5,0x71F9,0x7206,0x721D,0x7228,0x722A,0x722C,0x7231,0x7237-0x7239,0x723B,0x723D,0x723F,0x7247-0x7248,0x724C,0x7259,0x725B,0x725F,0x7262,0x7269,0x7279,0x7284,0x728D,0x7292,0x72AF,0x72B4,0x72B6,0x72C2-0x72C4,0x72D7,0x72E0-0x72E1,0x72EC,0x72FB-0x72FC,0x730E,0x7313,0x7317,0x731B,0x732B,0x7334,0x7360,0x736C,0x737E,0x7384,0x7387,0x738B,0x739F,0x73A2,0x73A9,0x73AF-0x73B0,0x73CD,0x73DE,0x73E9,0x73ED,0x73F2,0x7403,0x7405-0x7406,0x7422,0x7428,0x7433-0x7434,0x743C,0x7457,0x7459,0x745E-0x745F,0x74D2,0x74DC,0x74E0,0x74E4,0x74E6,0x74EE,0x74F6,0x74FF,0x7504,0x7518,0x751A,0x751C,0x751F,0x7528-0x7529,0x752B-0x752D,0x7530-0x7532,0x7535,0x7537-0x7538,0x753A,0x753E,0x754C,0x7559-0x755A,0x755C,0x7565,0x756A,0x7578-0x7579,0x758B,0x7591-0x7592,0x7594,0x7597,0x7599,0x759F,0x75AE,0x75B4-0x75B5,0x75B8-0x75B9,0x75BC,0x75C5,0x75D5,0x75DB,0x75E6,0x75EA,0x7615,0x761F,0x7625,0x7633,0x7635,0x764C,0x765E,0x7678,0x767B,0x767D-0x767E,0x7684,0x7687,0x768B,0x7693,0x7696,0x76A4,0x76AE,0x76B4,0x76BF,0x76C5-0x76C6,0x76CD-0x76CE,0x76D1-0x76D2,0x76D6,0x76D8,0x76DB,0x76DF,0x76EE-0x76EF,0x76F2,0x76F4,0x76F8-0x76F9,0x76FC,0x76FE,0x7701,0x7708,0x770B,0x7719,0x771F-0x7720,0x7729,0x772D,0x7737-0x7738,0x773C,0x7740,0x7743,0x7747,0x7761-0x7763,0x776B,0x777D-0x7780,0x7791,0x77A0,0x77A2,0x77A5,0x77A7,0x77AA,0x77AC,0x77B5,0x77BF,0x77DB-0x77DC,0x77E3,0x77E5,0x77ED,0x77F3,0x77F8,0x77FF,0x7801,0x7809,0x780C-0x780D,0x7814,0x7816,0x781D,0x781F,0x7825,0x7829,0x782C,0x7830,0x7834,0x7838,0x7845,0x784C,0x784E,0x7850,0x7855,0x785D,0x786A-0x786C,0x786E,0x788C,0x788E,0x789F,0x78A1,0x78A3,0x78B0-0x78B1,0x78C1,0x78C5,0x78C9,0x78F7,0x78FA,0x7924,0x793A,0x793E,0x7941,0x7948,0x7953,0x7956,0x795D-0x795E,0x7960,0x7962,0x7968,0x796D,0x7981,0x7985,0x79B3,0x79BB,0x79C0-0x79C1,0x79CB,0x79CD,0x79D1-0x79D2,0x79D8,0x79DF,0x79E4,0x79E6,0x79EF-0x79F0,0x79F8,0x79FB,0x7A0B,0x7A0D-0x7A0E,0x7A1E,0x7A20,0x7A37,0x7A39,0x7A3D,0x7A3F,0x7A51,0x7A70,0x7A74,0x7A76-0x7A77,0x7A79-0x7A7A,0x7A7F-0x7A81,0x7A83,0x7A92,0x7A96-0x7A98,0x7A9C-0x7A9D,0x7AA5,0x7AA8,0x7AAC,0x7ACB,0x7AD9,0x7ADF-0x7AE0,0x7AE5,0x7AEF,0x7AFA,0x7B0B,0x7B0F,0x7B11,0x7B24,0x7B26,0x7B28,0x7B2C,0x7B2E,0x7B3C,0x7B49,0x7B4B,0x7B52,0x7B54,0x7B56,0x7B5B,0x7B60,0x7B77,0x7B79,0x7B80,0x7B90,0x7B97,0x7BA1-0x7BA2,0x7BAC,0x7BB4,0x7BB8,0x7BC7,0x7BE1,0x7BE6,0x7BFE,0x7C07,0x7C2A,0x7C3F,0x7C41,0x7C4D,0x7C73-0x7C74,0x7C7B,0x7C89,0x7C97-0x7C98,0x7CA2,0x7CA5,0x7CAE,0x7CB9,0x7CBE,0x7CC1,0x7CC5,0x7CD6,0x7CDC,0x7CDF,0x7CF8,0x7CFB,0x7D20,0x7D22,0x7D27,0x7D2F,0x7D6E,0x7DAE,0x7E41,0x7E47,0x7E82,0x7E9B,0x7EA2,0x7EA4-0x7EA7,0x7EAF,0x7EB1-0x7EB3,0x7EB5-0x7EB7,0x7EBD,0x7EBF,0x7EC3-0x7EC4,0x7EC6,0x7EC8,0x7ECD,0x7ECF,0x7ED3,0x7ED5,0x7ED9,0x7EDC-0x7EDD,0x7EDF,0x7EE2-0x7EE3,0x7EE7,0x7EEA,0x7EED,0x7EF0,0x7EF2,0x7EF5,0x7EFC,0x7F00,0x7F09,0x7F0E-0x7F0F,0x7F13,0x7F16-0x7F17,0x7F20,0x7F29-0x7F2A,0x7F2F,0x7F32,0x7F34-0x7F36,0x7F3A,0x7F51,0x7F57,0x7F5A,0x7F62,0x7F6A,0x7F6E,0x7F71,0x7F8E,0x7F9E,0x7FA1,0x7FA4,0x7FA7,0x7FB8-0x7FB9,0x7FBD,0x7FC1,0x7FDF-0x7FE0,0x7FEE,0x7FFB,0x8001,0x8003,0x8005-0x8006,0x800C-0x800D,0x8010,0x8015,0x8017,0x8019,0x8028,0x8033,0x8036-0x8038,0x804A,0x8052,0x8054,0x8058,0x806A,0x8071,0x8083-0x8084,0x8086,0x8089,0x808B,0x809C,0x80A1-0x80A2,0x80A5,0x80A9,0x80AB,0x80AD-0x80AF,0x80B2,0x80B7,0x80BA,0x80C2,0x80C6,0x80CC-0x80CD,0x80D6-0x80D7,0x80DC-0x80DE,0x80E1,0x80E9,0x80EF,0x80F1-0x80F3,0x80F6,0x80F8,0x80FA,0x80FD,0x8109,0x810F,0x8111,0x8113-0x8114,0x8118,0x811A,0x811E,0x812F,0x8131,0x8138,0x813E,0x814A,0x814C,0x8154,0x8167,0x816E,0x8171,0x817E-0x8180,0x818A,0x8198,0x81A6,0x81AA,0x81BB,0x81C2,0x81E3,0x81E7,0x81EA,0x81ED,0x81F3-0x81F4,0x81FE,0x8202,0x8204,0x820C-0x820D,0x8212,0x8214,0x821C,0x8221,0x822A,0x822C,0x8231,0x8233,0x8239,0x8258,0x825F,0x826E-0x826F,0x8272,0x8274,0x827D-0x827F,0x8282,0x828B,0x828D-0x828E,0x8292,0x8298,0x829F,0x82A4-0x82A6,0x82A9-0x82AB,0x82AD-0x82AF,0x82B1,0x82B3-0x82B4,0x82BE,0x82CD,0x82CF,0x82D1-0x82D3,0x82D5,0x82D7,0x82DB,0x82DE,0x82E3-0x82E6,0x82EB,0x82EF,0x82F1,0x82F4,0x82F9,0x82FB,0x8301,0x8303-0x8304,0x8307-0x8308,0x8311,0x831B-0x831C,0x8327,0x832B-0x832D,0x8336,0x8343,0x8349,0x8351-0x8352,0x835B,0x8360-0x8361,0x8363-0x8365,0x8368,0x836F,0x837C-0x837D,0x8386,0x8389,0x838E,0x8398,0x839C,0x839E,0x83A0,0x83A8-0x83A9,0x83AB,0x83B1,0x83B7,0x83BD,0x83C0,0x83C5,0x83CC,0x83CF,0x83DC,0x83E5,0x83E9,0x83F2,0x83F8-0x83F9,0x83FD,0x8401,0x8406,0x840C,0x8411,0x8425,0x8428,0x843D,0x8457,0x845A,0x845C,0x8461,0x8463,0x846C-0x846D,0x8471,0x8476,0x848B,0x8499,0x849C,0x84A1,0x84AF,0x84B2,0x84BD,0x84BF,0x84C1,0x84C9-0x84CA,0x84D1,0x84DD,0x84FC,0x84FF,0x8511,0x8513,0x851A,0x851F,0x8521,0x852B,0x853D,0x8543,0x8548-0x854A,0x855E,0x8579-0x857A,0x8584,0x859B-0x859C,0x85C9,0x85CF-0x85D0,0x85E4,0x85FF,0x8616,0x8627,0x8650-0x8651,0x866B,0x8679,0x867D-0x867E,0x868C,0x8695,0x869D,0x86A3-0x86A4,0x86B0,0x86B4-0x86B5,0x86BA,0x86C6-0x86C7,0x86D1,0x86D9,0x86DE,0x86E4,0x86E9,0x86EE,0x86F8,0x86FE,0x870D,0x8712-0x8713,0x8715,0x871A,0x8721,0x8729,0x872E,0x8731,0x8734,0x8737,0x873B,0x873E,0x874E,0x8759,0x8764-0x8765,0x8776,0x8783,0x8785,0x878D,0x87AB,0x87C0,0x87C6,0x87CA,0x87D2,0x87E0,0x8803,0x8816,0x881B,0x8821-0x8822,0x883C,0x8840,0x884C,0x8859,0x8861,0x8865,0x8868,0x886E,0x8870,0x887F,0x8882,0x8885,0x888D,0x8892,0x8896,0x889C,0x88A2,0x88A4,0x88AB,0x88B7,0x88BC,0x88C1-0x88C2,0x88C5,0x88D2,0x88D9,0x88E2,0x88E8,0x88F3-0x88F4,0x88FC,0x890A,0x891A,0x8925,0x892A,0x8936,0x897F,0x8981,0x8983,0x8986,0x89C1-0x89C2,0x89C4,0x89C6,0x89C8-0x89C9,0x89D2,0x89D6,0x89DC,0x89E3,0x89F3,0x8A00,0x8A07,0x8A79,0x8A8A,0x8BA1-0x8BA2,0x8BA4,0x8BA8-0x8BA9,0x8BAD-0x8BB0,0x8BB2,0x8BB7-0x8BB8,0x8BBA,0x8BBC,0x8BBE-0x8BBF,0x8BC1,0x8BC4,0x8BC6,0x8BC8-0x8BCA,0x8BCD,0x8BCF,0x8BD5,0x8BD8,0x8BDD,0x8BE5-0x8BE6,0x8BED,0x8BEF-0x8BF0,0x8BF4-0x8BF7,0x8BF9-0x8BFB,0x8BFE,0x8C01,0x8C03,0x8C06,0x8C08,0x8C0B,0x8C0D,0x8C11,0x8C1C,0x8C20,0x8C22,0x8C2C,0x8C2E,0x8C31,0x8C37,0x8C41,0x8C46,0x8C49,0x8C5A,0x8C61,0x8C6A-0x8C6B,0x8C73,0x8C78,0x8C7A,0x8C89-0x8C8A,0x8C8C,0x8D1D,0x8D22-0x8D23,0x8D25,0x8D2B,0x8D2D,0x8D30,0x8D32,0x8D34-0x8D35,0x8D38-0x8D39,0x8D3C,0x8D3E,0x8D43-0x8D44,0x8D49,0x8D4F,0x8D56,0x8D5A-0x8D5B,0x8D5E,0x8D60,0x8D66,0x8D6B,0x8D70,0x8D76-0x8D77,0x8D81,0x8D84-0x8D85,0x8D8A,0x8D91,0x8D9F,0x8DA3,0x8DB3,0x8DB5,0x8DBC,0x8DBF,0x8DC3,0x8DCB-0x8DCC,0x8DD1,0x8DDB,0x8DDD-0x8DDF,0x8DE3-0x8DE4,0x8DE8,0x8DEB-0x8DEC,0x8DEF,0x8DF3,0x8E05,0x8E09,0x8E14,0x8E22-0x8E23,0x8E29-0x8E2A,0x8E31,0x8E39,0x8E42,0x8E44,0x8E4A,0x8E66,0x8E6D,0x8E72,0x8E74,0x8E76,0x8E7F,0x8E81,0x8E85,0x8E87,0x8E94,0x8EAB,0x8EB2,0x8EBA,0x8F66-0x8F68,0x8F6C,0x8F6E-0x8F70,0x8F74,0x8F7D,0x8F83,0x8F87,0x8F8D,0x8F8F,0x8F91,0x8F93,0x8F97,0x8F9B,0x8F9E-0x8F9F,0x8FA3,0x8FA8-0x8FA9,0x8FB0,0x8FB9,0x8FBD-0x8FBE,0x8FC1,0x8FC5,0x8FC7,0x8FCE,0x8FD0-0x8FD1,0x8FD4,0x8FD8-0x8FD9,0x8FDB-0x8FDC,0x8FDE-0x8FDF,0x8FE4-0x8FE6,0x8FE9,0x8FEB,0x8FED-0x8FEE,0x8FF0,0x8FF7,0x8FF9,0x8FFD,0x9000-0x9004,0x9006,0x9009-0x900A,0x900F-0x9010,0x9014,0x9017,0x901A-0x901B,0x901E-0x9022,0x902E-0x902F,0x903E,0x9041-0x9042,0x9047,0x904D,0x9053,0x9057,0x9063,0x906D,0x9075,0x907D,0x908B,0x9091,0x9093,0x909B,0x90A2-0x90A3,0x90A6,0x90AA,0x90B9-0x90BB,0x90C4-0x90C5,0x90C7,0x90CE,0x90D7,0x90DD,0x90E1-0x90E2,0x90E8,0x90ED,0x90F4,0x90FD,0x9131,0x9139,0x914D,0x9150,0x9152,0x9161-0x9162,0x916A,0x916C,0x916E,0x9177-0x9178,0x917E-0x917F,0x9187,0x9189,0x918B,0x9192,0x919B,0x91AE,0x91C7,0x91CC-0x91CF,0x91D1,0x92AE,0x93D6,0x946B,0x9488,0x948F,0x9494,0x9497,0x949E-0x949F,0x94A2,0x94A5-0x94A6,0x94AD-0x94AF,0x94B1,0x94BB,0x94BF,0x94C1,0x94C5,0x94CA,0x94DB,0x94E3-0x94E5,0x94EA-0x94EB,0x94ED,0x94F6,0x94FA,0x94FD-0x9501,0x950A,0x9510,0x9515,0x9519,0x9524,0x952C,0x952E,0x953B,0x9547,0x9550,0x9556,0x9561,0x9566,0x957F,0x95E8-0x95EA,0x95ED-0x95F0,0x95F2,0x95F4-0x95F5,0x95F7,0x95F9,0x95FB,0x95FD,0x960F,0x9614,0x9619-0x961A,0x961F,0x9622,0x962E,0x9632-0x9633,0x9635,0x963B,0x963D,0x963F-0x9640,0x9642,0x9644,0x9646-0x9648,0x964B,0x964D,0x9650,0x9655,0x965F,0x9662,0x9664,0x9669-0x966A,0x966C,0x9674-0x9676,0x9686,0x968B,0x968F-0x9690,0x9694,0x9697-0x9698,0x96A7,0x96B0,0x96B6,0x96B9,0x96BC-0x96BE,0x96C0,0x96C4-0x96C7,0x96C9,0x96E9-0x96EA,0x96F6-0x96F7,0x9700,0x9707,0x970D,0x971C,0x9730,0x9732,0x9738,0x973E,0x9752-0x9753,0x9759,0x975E,0x9760-0x9762,0x9769,0x978B,0x9794,0x9798,0x97A0,0x97A3,0x97AB,0x97B4,0x97E9,0x97F3,0x9875-0x9876,0x9879-0x987B,0x987F,0x9882,0x9884,0x9886-0x9889,0x988C,0x9891,0x9897-0x9898,0x989D,0x98A0-0x98A1,0x98A4,0x98CE,0x98D2,0x98D8,0x98DE-0x98DF,0x98E7,0x9910,0x992E,0x9967,0x996D,0x9976,0x997F,0x9981,0x9986-0x9988,0x9993,0x9995-0x9998,0x99A5,0x9A6C,0x9A6E,0x9A80,0x9A82,0x9A87,0x9A8C,0x9A97,0x9A9A,0x9AA0,0x9AB0-0x9AB1,0x9AB8,0x9AC1-0x9AC2,0x9ACB,0x9AD8,0x9ADF,0x9AEF,0x9AFB,0x9B13,0x9B32,0x9B3B-0x9B3C,0x9B41-0x9B42,0x9B44,0x9C81,0x9C85,0x9C91,0x9CA7,0x9CAD,0x9CC3,0x9E1F,0x9E23,0x9E3E-0x9E3F,0x9E44,0x9E4F,0x9E58,0x9E87,0x9E9F,0x9EA6,0x9EBB,0x9EBD,0x9EC4,0x9ED1,0x9ED8,0x9EDD,0x9EE7,0x9EF9,0x9EFE,0x9F0E,0x9F50,0x9F88,0x9F99,0x9F9F,0xFF01,0xFF08-0xFF09,0xFF0C,0xFF1A-0xFF1B,0xFF1F,0xFF5E,0xFFE5 diff --git a/packs/zh-Hans/locales/zh-Hans/strings.tsv b/packs/zh-Hans/locales/zh-Hans/strings.tsv index e17a98ef..18476093 100644 --- a/packs/zh-Hans/locales/zh-Hans/strings.tsv +++ b/packs/zh-Hans/locales/zh-Hans/strings.tsv @@ -758,3 +758,72 @@ Start recording 开始记录 Stop recording 停止记录 Not recording 未在记录 Add KML files to the routes folder 将 KML 文件放入路线文件夹 + +# Agenda / Calendar +1 day before 提前 1 天 +1 hour before 提前 1 小时 +10 min before 提前 10 分钟 +30 min before 提前 30 分钟 +Agenda 日程 +Agenda storage unavailable 日程存储不可用 +Another reminder is already snoozed 已有其他提醒处于稍后提醒状态 +At time 准时 +Changes apply to the entire series 更改将应用于整个重复系列 +Choose on map 在地图上选择 +Could not read events 无法读取日程 +Could not save changes 无法保存更改 +Could not update reminder 无法更新提醒 +Current position 当前位置 +Current position unavailable 无法获取当前位置 +Daily 每天 +Date or time is out of range 日期或时间超出范围 +Delete this event? 删除此日程? +Discard 放弃 +Discard changes? 放弃更改? +Done 完成 +Edit Event 编辑日程 +End <= start: next day 结束不晚于开始:次日 +End time (optional) 结束时间(可选) +Enter a title and valid date/time 请输入标题及有效日期和时间 +Event Detail 日程详情 +Event is unavailable 日程不可用 +Event Reminder 日程提醒 +Fri 周五 +Invalid location 位置无效 +Keep editing 继续编辑 +Location is optional 位置为可选项 +Map is unavailable on this target. 此设备不支持地图。 +Mon 周一 +Monthly 每月 +More 更多 +Navigate 导航 +New Event 新建日程 +No upcoming events 暂无后续日程 +Note 备注 +Pick Date 选择日期 +Pick Time 选择时间 +Reminder 提醒 +Remove location 移除位置 +Repeat 重复 +Sat 周六 +Saving... 正在保存… +Set the device time first 请先设置设备时间 +Snooze 10 min 10 分钟后提醒 +Start time 开始时间 +Sun 周日 +Text exceeds the UTF-8 byte limit 文字超出 UTF-8 字节限制 +Thu 周四 +Title 标题 +Today 今天 +Tomorrow 明天 +Tue 周二 +Wed 周三 +Weekly 每周 +Yearly 每年 +Calendar 日历 +Pick 选定 +Could not read waypoints 无法读取航点 +First 首页 +No saved waypoints 没有已保存的航点 +Saved waypoint 已保存航点 +Waypoint unavailable 航点不可用 diff --git a/packs/zh-Hans/package.ini b/packs/zh-Hans/package.ini index 0fb67d2c..b7f5097f 100644 --- a/packs/zh-Hans/package.ini +++ b/packs/zh-Hans/package.ini @@ -1,7 +1,7 @@ kind=package id=zh-Hans package_type=locale-bundle -version=1.3.0 +version=1.4.0 display_name=Simplified Chinese summary=Tiered Simplified Chinese locale bundle with standard-display fonts and T-Deck Pro 16px 1bpp GNU Unifont variants, plus the built-in Pinyin IME backend. description=DESCRIPTION.txt diff --git a/packs/zh-Hant/fonts/zh-hant-cjk/charset.txt b/packs/zh-Hant/fonts/zh-hant-cjk/charset.txt index 8b3fdeb9..535d28d0 100644 --- a/packs/zh-Hant/fonts/zh-hant-cjk/charset.txt +++ b/packs/zh-Hant/fonts/zh-hant-cjk/charset.txt @@ -1,8 +1,9 @@ -°·—‘’“”…、。〈〉《》「」『』【】〔〕一上下不並中主交享京亮人仍令以仰件任份伍佇位低作你佳使來係保信個值偏停側偵備傳僅儲元 -充先克入內全六共再出分切列初別刪到制前剛剩力功加動勢包化北匯匹區十協南占卡危原參友取句只叭可台名向否命和員問啟喇喚單器噪回因固圖 -團在地型域執基增壓外多大天失夾套好如始子字存安完定容密實寫寬射將尋對小少尚就展層已常幕平年底度座庫廠廣延建式弱形影待後復必忽急性 -恢息態憶應成或所手找拼持指按振掃接描提插換握援搜摩撤播擇操據擬擴支收效救敗整數文料斯新斷方於日星是時暫暱更最會月有期未本東果查校 -格案槽標模橋機檔檢次止正此步比求池沒法泛洪活流消清測源準滿灣為無熱營爾版牙狀率現理用略當疊發的監盤目直相知短確碼磁示禁私秒移程稍 -稱空立符第等策管節米系級細組結絡統網緊緒線編縮繁繼缺置義考者聊聯聲聽能腦自致與航良色英蓋藍藏號螢行衛表裝裡複西要覆見視覽角解言計 -訊記訪設試話該詳誌認語誤說調請講證譜議護讀變象資距跡路跳蹤躍軌軟軸較載輪輯輸轉近返迷追退送這通連進逾遊過道適遲選還部配醒里重野量 -金針銷錄錯鍵鎖鏈鐘鑰長閉開閒間關阻附限除隊階隔隨險隱集雜離電需震靜面韓音響頁須預頻顆額顯風餘首驗體高麥點!(),:;?~¥ +°·—‘’“”…、。〈〉《》「」『』【】〔〕一三上下不並中主事二五交享京亮人今仍他令以仰件任份伍佇位低作你佳使來供係保信個候值偏 +停側偵備傳僅儲元充先克入內全六共其再出分切列初別刪到制前剛剩力功加動勢包化北匯匹區十協南占卡危原參及友取句只叭可台名向否命和員問 +啟喇喚單器噪四回因固圍圖團在地型域執基填增壓外多大天失夾套好如始子字存安完定容密實寫寬封射將尋對導小少尚就展層已常幕平年底度座庫 +廠廣延建式弱形影待後得復必忙忽急性恢息態憶應成或所手才找拔拼持指按振捨掃掉排探接描提插換握援搜摩撤播擇操據擬擴支收放效救敗整數文 +料斯新斷方於日明星是時晚晶暫暱曆更最會月有期未本束東果查校格案棄槽標模橋機檔檢次止正此步每比求池沒法泛洪活流消清測源準滑滿灣為無 +熱營爾片版牙狀率現理用略當疊發的監盤目直相知短碌確碼磁示禁私秒移程稍稱空立符第等策管節範米系級細組結絡統網緊緒線編縮繁繼續缺置義 +考者聊聯聲聽能腦自至致與航良色英蓋藍藏號螢行衛表裝裡複西要覆見視覽角解言計訊記訪設註試話該詳誌認語誤說調請講證譜議護讀變讓象資超 +距跡路跳蹤躍軌軟軸較載輪輯輸轉近返迷追退送這通連週進逾遊過道達適遲選還部配醒里重野量金針銷錄錯鍵鎖鏈鐘鑰長閉開閒間關阻附限除隊階 +隔隨險隱集雜離電需震靜面韓音響頁項須預頻顆題額顯風餘首驗體高麥點!(),:;?~¥ diff --git a/packs/zh-Hant/fonts/zh-hant-cjk/manifest.ini b/packs/zh-Hant/fonts/zh-hant-cjk/manifest.ini index 381aa568..bbacf7bf 100644 --- a/packs/zh-Hant/fonts/zh-hant-cjk/manifest.ini +++ b/packs/zh-Hant/fonts/zh-hant-cjk/manifest.ini @@ -2,7 +2,7 @@ kind=font id=zh-hant-cjk display_name=Traditional Chinese CJK usage=both -estimated_ram_bytes=10232 +estimated_ram_bytes=38912 source=binfont file=font.bin ranges=ranges.txt diff --git a/packs/zh-Hant/fonts/zh-hant-cjk/ranges.txt b/packs/zh-Hant/fonts/zh-hant-cjk/ranges.txt index 206690d4..3f88edea 100644 --- a/packs/zh-Hant/fonts/zh-hant-cjk/ranges.txt +++ b/packs/zh-Hant/fonts/zh-hant-cjk/ranges.txt @@ -1 +1 @@ -0xB0,0xB7,0x2014,0x2018-0x2019,0x201C-0x201D,0x2026,0x3001-0x3002,0x3008-0x3011,0x3014-0x3015,0x4E00,0x4E0A-0x4E0B,0x4E0D,0x4E26,0x4E2D,0x4E3B,0x4EA4,0x4EAB-0x4EAC,0x4EAE,0x4EBA,0x4ECD,0x4EE4-0x4EE5,0x4EF0,0x4EF6,0x4EFB,0x4EFD,0x4F0D,0x4F47,0x4F4D-0x4F4E,0x4F5C,0x4F60,0x4F73,0x4F7F,0x4F86,0x4FC2,0x4FDD,0x4FE1,0x500B,0x503C,0x504F,0x505C,0x5074-0x5075,0x5099,0x50B3,0x50C5,0x5132,0x5143,0x5145,0x5148,0x514B,0x5165,0x5167-0x5168,0x516D,0x5171,0x518D,0x51FA,0x5206-0x5207,0x5217,0x521D,0x5225,0x522A,0x5230,0x5236,0x524D,0x525B,0x5269,0x529B,0x529F-0x52A0,0x52D5,0x52E2,0x5305,0x5316-0x5317,0x532F,0x5339,0x5340-0x5341,0x5354,0x5357,0x5360-0x5361,0x5371,0x539F,0x53C3,0x53CB,0x53D6,0x53E5,0x53EA,0x53ED,0x53EF-0x53F0,0x540D,0x5411,0x5426,0x547D,0x548C,0x54E1,0x554F,0x555F,0x5587,0x559A,0x55AE,0x5668,0x566A,0x56DE,0x56E0,0x56FA,0x5716,0x5718,0x5728,0x5730,0x578B,0x57DF,0x57F7,0x57FA,0x589E,0x58D3,0x5916,0x591A,0x5927,0x5929,0x5931,0x593E,0x5957,0x597D,0x5982,0x59CB,0x5B50,0x5B57-0x5B58,0x5B89,0x5B8C,0x5B9A,0x5BB9,0x5BC6,0x5BE6,0x5BEB-0x5BEC,0x5C04,0x5C07,0x5C0B,0x5C0D,0x5C0F,0x5C11,0x5C1A,0x5C31,0x5C55,0x5C64,0x5DF2,0x5E38,0x5E55,0x5E73-0x5E74,0x5E95,0x5EA6-0x5EA7,0x5EAB,0x5EE0,0x5EE3,0x5EF6,0x5EFA,0x5F0F,0x5F31,0x5F62,0x5F71,0x5F85,0x5F8C,0x5FA9,0x5FC5,0x5FFD,0x6025,0x6027,0x6062,0x606F,0x614B,0x61B6,0x61C9,0x6210,0x6216,0x6240,0x624B,0x627E,0x62FC,0x6301,0x6307,0x6309,0x632F,0x6383,0x63A5,0x63CF-0x63D0,0x63D2,0x63DB,0x63E1,0x63F4,0x641C,0x6469,0x64A4,0x64AD,0x64C7,0x64CD,0x64DA,0x64EC,0x64F4,0x652F,0x6536,0x6548,0x6551,0x6557,0x6574,0x6578,0x6587,0x6599,0x65AF-0x65B0,0x65B7,0x65B9,0x65BC,0x65E5,0x661F,0x662F,0x6642,0x66AB,0x66B1,0x66F4,0x6700,0x6703,0x6708-0x6709,0x671F,0x672A,0x672C,0x6771,0x679C,0x67E5,0x6821,0x683C,0x6848,0x69FD,0x6A19,0x6A21,0x6A4B,0x6A5F,0x6A94,0x6AA2,0x6B21,0x6B62-0x6B65,0x6BD4,0x6C42,0x6C60,0x6C92,0x6CD5,0x6CDB,0x6D2A,0x6D3B,0x6D41,0x6D88,0x6E05,0x6E2C,0x6E90,0x6E96,0x6EFF,0x7063,0x70BA,0x7121,0x71B1,0x71DF,0x723E,0x7248,0x7259,0x72C0,0x7387,0x73FE,0x7406,0x7528,0x7565,0x7576,0x758A,0x767C,0x7684,0x76E3-0x76E4,0x76EE,0x76F4,0x76F8,0x77E5,0x77ED,0x78BA,0x78BC,0x78C1,0x793A,0x7981,0x79C1,0x79D2,0x79FB,0x7A0B,0x7A0D,0x7A31,0x7A7A,0x7ACB,0x7B26,0x7B2C,0x7B49,0x7B56,0x7BA1,0x7BC0,0x7C73,0x7CFB,0x7D1A,0x7D30,0x7D44,0x7D50,0x7D61,0x7D71,0x7DB2,0x7DCA,0x7DD2,0x7DDA,0x7DE8,0x7E2E,0x7E41,0x7E7C,0x7F3A,0x7F6E,0x7FA9,0x8003,0x8005,0x804A,0x806F,0x8072,0x807D,0x80FD,0x8166,0x81EA,0x81F4,0x8207,0x822A,0x826F,0x8272,0x82F1,0x84CB,0x85CD,0x85CF,0x865F,0x87A2,0x884C,0x885B,0x8868,0x88DD,0x88E1,0x8907,0x897F,0x8981,0x8986,0x898B,0x8996,0x89BD,0x89D2,0x89E3,0x8A00,0x8A08,0x8A0A,0x8A18,0x8A2A,0x8A2D,0x8A66,0x8A71-0x8A73,0x8A8C-0x8A8D,0x8A9E,0x8AA4,0x8AAA,0x8ABF,0x8ACB,0x8B1B,0x8B49,0x8B5C,0x8B70,0x8B77,0x8B80,0x8B8A,0x8C61,0x8CC7,0x8DDD,0x8DE1,0x8DEF,0x8DF3,0x8E64,0x8E8D,0x8ECC,0x8EDF,0x8EF8,0x8F03,0x8F09,0x8F2A,0x8F2F,0x8F38,0x8F49,0x8FD1,0x8FD4,0x8FF7,0x8FFD,0x9000-0x9001,0x9019-0x901A,0x9023,0x9032,0x903E,0x904A,0x904E,0x9053,0x9069,0x9072,0x9078,0x9084,0x90E8,0x914D,0x9192,0x91CC-0x91CF,0x91D1,0x91DD,0x92B7,0x9304,0x932F,0x9375,0x9396,0x93C8,0x9418,0x9470,0x9577,0x9589,0x958B,0x9592-0x9593,0x95DC,0x963B,0x9644,0x9650,0x9664,0x968A,0x968E,0x9694,0x96A8,0x96AA,0x96B1,0x96C6,0x96DC,0x96E2,0x96FB,0x9700,0x9707,0x975C,0x9762,0x97D3,0x97F3,0x97FF,0x9801,0x9808,0x9810,0x983B,0x9846,0x984D,0x986F,0x98A8,0x9918,0x9996,0x9A57,0x9AD4,0x9AD8,0x9EA5,0x9EDE,0xFF01,0xFF08-0xFF09,0xFF0C,0xFF1A-0xFF1B,0xFF1F,0xFF5E,0xFFE5 +0xB0,0xB7,0x2014,0x2018-0x2019,0x201C-0x201D,0x2026,0x3001-0x3002,0x3008-0x3011,0x3014-0x3015,0x4E00,0x4E09-0x4E0B,0x4E0D,0x4E26,0x4E2D,0x4E3B,0x4E8B-0x4E8C,0x4E94,0x4EA4,0x4EAB-0x4EAC,0x4EAE,0x4EBA,0x4ECA,0x4ECD,0x4ED6,0x4EE4-0x4EE5,0x4EF0,0x4EF6,0x4EFB,0x4EFD,0x4F0D,0x4F47,0x4F4D-0x4F4E,0x4F5C,0x4F60,0x4F73,0x4F7F,0x4F86,0x4F9B,0x4FC2,0x4FDD,0x4FE1,0x500B,0x5019,0x503C,0x504F,0x505C,0x5074-0x5075,0x5099,0x50B3,0x50C5,0x5132,0x5143,0x5145,0x5148,0x514B,0x5165,0x5167-0x5168,0x516D,0x5171,0x5176,0x518D,0x51FA,0x5206-0x5207,0x5217,0x521D,0x5225,0x522A,0x5230,0x5236,0x524D,0x525B,0x5269,0x529B,0x529F-0x52A0,0x52D5,0x52E2,0x5305,0x5316-0x5317,0x532F,0x5339,0x5340-0x5341,0x5354,0x5357,0x5360-0x5361,0x5371,0x539F,0x53C3,0x53CA-0x53CB,0x53D6,0x53E5,0x53EA,0x53ED,0x53EF-0x53F0,0x540D,0x5411,0x5426,0x547D,0x548C,0x54E1,0x554F,0x555F,0x5587,0x559A,0x55AE,0x5668,0x566A,0x56DB,0x56DE,0x56E0,0x56FA,0x570D,0x5716,0x5718,0x5728,0x5730,0x578B,0x57DF,0x57F7,0x57FA,0x586B,0x589E,0x58D3,0x5916,0x591A,0x5927,0x5929,0x5931,0x593E,0x5957,0x597D,0x5982,0x59CB,0x5B50,0x5B57-0x5B58,0x5B89,0x5B8C,0x5B9A,0x5BB9,0x5BC6,0x5BE6,0x5BEB-0x5BEC,0x5C01,0x5C04,0x5C07,0x5C0B,0x5C0D-0x5C0F,0x5C11,0x5C1A,0x5C31,0x5C55,0x5C64,0x5DF2,0x5E38,0x5E55,0x5E73-0x5E74,0x5E95,0x5EA6-0x5EA7,0x5EAB,0x5EE0,0x5EE3,0x5EF6,0x5EFA,0x5F0F,0x5F31,0x5F62,0x5F71,0x5F85,0x5F8C,0x5F97,0x5FA9,0x5FC5,0x5FD9,0x5FFD,0x6025,0x6027,0x6062,0x606F,0x614B,0x61B6,0x61C9,0x6210,0x6216,0x6240,0x624B,0x624D,0x627E,0x62D4,0x62FC,0x6301,0x6307,0x6309,0x632F,0x6368,0x6383,0x6389,0x6392,0x63A2,0x63A5,0x63CF-0x63D0,0x63D2,0x63DB,0x63E1,0x63F4,0x641C,0x6469,0x64A4,0x64AD,0x64C7,0x64CD,0x64DA,0x64EC,0x64F4,0x652F,0x6536,0x653E,0x6548,0x6551,0x6557,0x6574,0x6578,0x6587,0x6599,0x65AF-0x65B0,0x65B7,0x65B9,0x65BC,0x65E5,0x660E,0x661F,0x662F,0x6642,0x665A,0x6676,0x66AB,0x66B1,0x66C6,0x66F4,0x6700,0x6703,0x6708-0x6709,0x671F,0x672A,0x672C,0x675F,0x6771,0x679C,0x67E5,0x6821,0x683C,0x6848,0x68C4,0x69FD,0x6A19,0x6A21,0x6A4B,0x6A5F,0x6A94,0x6AA2,0x6B21,0x6B62-0x6B65,0x6BCF,0x6BD4,0x6C42,0x6C60,0x6C92,0x6CD5,0x6CDB,0x6D2A,0x6D3B,0x6D41,0x6D88,0x6E05,0x6E2C,0x6E90,0x6E96,0x6ED1,0x6EFF,0x7063,0x70BA,0x7121,0x71B1,0x71DF,0x723E,0x7247-0x7248,0x7259,0x72C0,0x7387,0x73FE,0x7406,0x7528,0x7565,0x7576,0x758A,0x767C,0x7684,0x76E3-0x76E4,0x76EE,0x76F4,0x76F8,0x77E5,0x77ED,0x788C,0x78BA,0x78BC,0x78C1,0x793A,0x7981,0x79C1,0x79D2,0x79FB,0x7A0B,0x7A0D,0x7A31,0x7A7A,0x7ACB,0x7B26,0x7B2C,0x7B49,0x7B56,0x7BA1,0x7BC0,0x7BC4,0x7C73,0x7CFB,0x7D1A,0x7D30,0x7D44,0x7D50,0x7D61,0x7D71,0x7DB2,0x7DCA,0x7DD2,0x7DDA,0x7DE8,0x7E2E,0x7E41,0x7E7C,0x7E8C,0x7F3A,0x7F6E,0x7FA9,0x8003,0x8005,0x804A,0x806F,0x8072,0x807D,0x80FD,0x8166,0x81EA,0x81F3-0x81F4,0x8207,0x822A,0x826F,0x8272,0x82F1,0x84CB,0x85CD,0x85CF,0x865F,0x87A2,0x884C,0x885B,0x8868,0x88DD,0x88E1,0x8907,0x897F,0x8981,0x8986,0x898B,0x8996,0x89BD,0x89D2,0x89E3,0x8A00,0x8A08,0x8A0A,0x8A18,0x8A2A,0x8A2D,0x8A3B,0x8A66,0x8A71-0x8A73,0x8A8C-0x8A8D,0x8A9E,0x8AA4,0x8AAA,0x8ABF,0x8ACB,0x8B1B,0x8B49,0x8B5C,0x8B70,0x8B77,0x8B80,0x8B8A,0x8B93,0x8C61,0x8CC7,0x8D85,0x8DDD,0x8DE1,0x8DEF,0x8DF3,0x8E64,0x8E8D,0x8ECC,0x8EDF,0x8EF8,0x8F03,0x8F09,0x8F2A,0x8F2F,0x8F38,0x8F49,0x8FD1,0x8FD4,0x8FF7,0x8FFD,0x9000-0x9001,0x9019-0x901A,0x9023,0x9031-0x9032,0x903E,0x904A,0x904E,0x9053-0x9054,0x9069,0x9072,0x9078,0x9084,0x90E8,0x914D,0x9192,0x91CC-0x91CF,0x91D1,0x91DD,0x92B7,0x9304,0x932F,0x9375,0x9396,0x93C8,0x9418,0x9470,0x9577,0x9589,0x958B,0x9592-0x9593,0x95DC,0x963B,0x9644,0x9650,0x9664,0x968A,0x968E,0x9694,0x96A8,0x96AA,0x96B1,0x96C6,0x96DC,0x96E2,0x96FB,0x9700,0x9707,0x975C,0x9762,0x97D3,0x97F3,0x97FF,0x9801,0x9805,0x9808,0x9810,0x983B,0x9846,0x984C-0x984D,0x986F,0x98A8,0x9918,0x9996,0x9A57,0x9AD4,0x9AD8,0x9EA5,0x9EDE,0xFF01,0xFF08-0xFF09,0xFF0C,0xFF1A-0xFF1B,0xFF1F,0xFF5E,0xFFE5 diff --git a/packs/zh-Hant/locales/zh-Hant/strings.tsv b/packs/zh-Hant/locales/zh-Hant/strings.tsv index 1e3f507c..0713dee3 100644 --- a/packs/zh-Hant/locales/zh-Hant/strings.tsv +++ b/packs/zh-Hant/locales/zh-Hant/strings.tsv @@ -758,3 +758,72 @@ Start recording 開始記錄 Stop recording 停止記錄 Not recording 未在記錄 Add KML files to the routes folder 請將 KML 檔案放入路線資料夾 + +# Agenda / Calendar +1 day before 提前 1 天 +1 hour before 提前 1 小時 +10 min before 提前 10 分鐘 +30 min before 提前 30 分鐘 +Agenda 行程 +Agenda storage unavailable 行程儲存空間無法使用 +Another reminder is already snoozed 已有其他提醒正在延後 +At time 準時 +Changes apply to the entire series 變更將套用至整個重複系列 +Choose on map 在地圖上選取 +Could not read events 無法讀取行程 +Could not save changes 無法儲存變更 +Could not update reminder 無法更新提醒 +Current position 目前位置 +Current position unavailable 無法取得目前位置 +Daily 每天 +Date or time is out of range 日期或時間超出範圍 +Delete this event? 刪除此行程? +Discard 捨棄 +Discard changes? 捨棄變更? +Done 完成 +Edit Event 編輯行程 +End <= start: next day 結束不晚於開始:隔天 +End time (optional) 結束時間(選填) +Enter a title and valid date/time 請輸入標題及有效的日期和時間 +Event Detail 行程詳細資訊 +Event is unavailable 行程無法使用 +Event Reminder 行程提醒 +Fri 週五 +Invalid location 位置無效 +Keep editing 繼續編輯 +Location is optional 位置為選填項目 +Map is unavailable on this target. 此裝置不支援地圖。 +Mon 週一 +Monthly 每月 +More 更多 +Navigate 導航 +New Event 新增行程 +No upcoming events 目前沒有後續行程 +Note 備註 +Pick Date 選取日期 +Pick Time 選取時間 +Reminder 提醒 +Remove location 移除位置 +Repeat 重複 +Sat 週六 +Saving... 正在儲存… +Set the device time first 請先設定裝置時間 +Snooze 10 min 10 分鐘後提醒 +Start time 開始時間 +Sun 週日 +Text exceeds the UTF-8 byte limit 文字超出 UTF-8 位元組限制 +Thu 週四 +Title 標題 +Today 今天 +Tomorrow 明天 +Tue 週二 +Wed 週三 +Weekly 每週 +Yearly 每年 +Calendar 行事曆 +Pick 選取 +Could not read waypoints 無法讀取航點 +First 首頁 +No saved waypoints 沒有已儲存的航點 +Saved waypoint 已儲存航點 +Waypoint unavailable 航點無法使用 diff --git a/packs/zh-Hant/package.ini b/packs/zh-Hant/package.ini index 19549bce..05499174 100644 --- a/packs/zh-Hant/package.ini +++ b/packs/zh-Hant/package.ini @@ -1,7 +1,7 @@ kind=package id=zh-Hant package_type=locale-bundle -version=1.2.0 +version=1.3.0 display_name=Traditional Chinese (Taiwan) summary=Traditional Chinese (Taiwan) review locale bundle with CJK font coverage; display-only until native review and a real Zhuyin-focused IME are available. description=DESCRIPTION.txt diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/agenda_clock.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/agenda_clock.h new file mode 100644 index 00000000..6b39e779 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/agenda_clock.h @@ -0,0 +1,12 @@ +#pragma once + +#include "agenda/ports/agenda_clock.h" + +namespace platform::esp::arduino_common +{ +class AgendaClock final : public agenda::IAgendaClock +{ + public: + agenda::ClockSample sample() const override; +}; +} // namespace platform::esp::arduino_common diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/agenda_storage.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/agenda_storage.h new file mode 100644 index 00000000..d86236dd --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/agenda_storage.h @@ -0,0 +1,14 @@ +#pragma once +#include + +namespace platform::esp::arduino_common::storage +{ +// Requires application-owned SD storage already mounted by the shared runtime. +// Creates only the record directory; never mounts, formats or uses internal flash. +bool prepare_agenda_storage(); +// Logical mount/ownership gate; physical media health is owned by SD runtime. +bool agenda_storage_available(); +uint32_t agenda_storage_session(); +constexpr const char* kAgendaFile = "/agenda/events.dat"; +constexpr const char* kAgendaInitializationFile = "/agenda/events.init"; +} // namespace platform::esp::arduino_common::storage diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_card_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_card_runtime.h index 839aebfa..d947d800 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_card_runtime.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_card_runtime.h @@ -70,6 +70,22 @@ bool mount_sdmmc_card(int clock, int command, int data0); #endif bool sd_card_ready(); +// Changes on mount/reset and ownership transitions, even if both transitions +// occur between a consumer's polls. Equality is a session check, not a card ID. +uint32_t sd_media_session(); +enum class SdMediaStatus : uint8_t +{ + Ready, + Busy, + Unavailable, + IoError, +}; +// Rate-limited physical read, not a filesystem-cache lookup. I/O failure is +// latched for this session; recovery requires a new validated mount/session. +SdMediaStatus sd_probe_media(); +// Bounded-frequency recovery through the existing board mount policy. Never +// formats media or takes ownership from USB. False includes deferred/absent. +bool sd_recover_media(); bool sd_card_uses_sdfat(); bool sd_card_is_exfat(); SdCardBackend sd_card_backend(); @@ -95,6 +111,7 @@ bool sd_mkdir(const char* path); bool sd_rmdir(const char* path); bool sd_remove(const char* path); bool sd_rename(const char* old_path, const char* new_path); +bool sd_rename(const char* old_path, const char* new_path, uint32_t expected_session); enum class SdFileReadStatus : uint8_t { @@ -138,6 +155,8 @@ class SdRuntimeFile SdRuntimeFile& operator=(const SdRuntimeFile&) = delete; bool open(const char* path, const char* mode); + // Reject a replaced or faulted medium under the filesystem lock. + bool open(const char* path, const char* mode, uint32_t expected_session); void close(); bool is_open() const; int available() const; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_file_lifetime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_file_lifetime.h new file mode 100644 index 00000000..29cf1629 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_file_lifetime.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +namespace platform::esp::arduino_common::storage +{ +// SdFat keeps FatFile/ExFatFile state inline. Abandoning a stale handle must +// release that state without syncing its old directory entry to a new medium. +template +void abandon_sd_file(File& file) +{ + static_assert(DESTRUCTOR_CLOSES_FILE == 0, "Abandoning stale files must not write to a new medium"); + file.~File(); + new (&file) File; +} +} // namespace platform::esp::arduino_common::storage diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_record_file_io.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_record_file_io.h new file mode 100644 index 00000000..01ee3214 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_record_file_io.h @@ -0,0 +1,28 @@ +#pragma once + +#include "platform/esp/common/storage/record_file_io.h" + +namespace platform::esp::arduino_common::storage +{ +// Uses the shared SD runtime for both shared-SPI and SDMMC cards. +// No mounts, formatting, direct driver access or internal-flash fallback. +class SdRecordFileIo final : public platform::esp::storage::RecordFileIo +{ + public: + // Bind only when the owner starts validating a new storage session. + void bindSession(uint32_t session) { session_ = session; } + OpenResult open(const char* path, Mode mode) override; + bool close(Handle file) override; + bool size(Handle file, uint64_t& bytes) override; + bool seek(Handle file, uint64_t offset) override; + std::size_t read(Handle file, void* buffer, std::size_t bytes) override; + std::size_t write(Handle file, const void* buffer, std::size_t bytes) override; + bool sync(Handle file) override; + bool publish(const char* temporary, const char* destination) override; + + private: + bool available() const; + bool usable(Handle file) const; + uint32_t session_ = 0; +}; +} // namespace platform::esp::arduino_common::storage diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/waypoint_storage.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/waypoint_storage.h new file mode 100644 index 00000000..3194128c --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/waypoint_storage.h @@ -0,0 +1,7 @@ +#pragma once +namespace platform::esp::arduino_common::storage +{ +// Independent record file on the same required SD card as Agenda. +constexpr const char* kWaypointFile = "/agenda/waypoints.dat"; +constexpr const char* kWaypointInitializationFile = "/agenda/waypoints.init"; +} // namespace platform::esp::arduino_common::storage diff --git a/platform/esp/arduino_common/src/agenda_clock.cpp b/platform/esp/arduino_common/src/agenda_clock.cpp new file mode 100644 index 00000000..9743a602 --- /dev/null +++ b/platform/esp/arduino_common/src/agenda_clock.cpp @@ -0,0 +1,25 @@ +#include "platform/esp/arduino_common/agenda_clock.h" + +#include "platform/ui/time_runtime.h" + +#include +#include + +namespace platform::esp::arduino_common +{ +agenda::ClockSample AgendaClock::sample() const +{ + agenda::ClockSample result; + const auto utc = ::time(nullptr); + result.monotonic_seconds = static_cast(esp_timer_get_time()) / 1000000; + result.valid = utc >= 1577836800; // Same time-authority threshold as time_runtime. + if (!result.valid) return result; + const auto local = platform::ui::time::apply_timezone_offset_for_utc(utc); + result.calendar_seconds = static_cast(local); + // The scheduler also detects wall/monotonic divergence (including DST). + // This identifies explicit profile changes even when offsets coincide. + result.revision = (static_cast(platform::ui::time::timezone_profile_id()) << 16) ^ + static_cast(platform::ui::time::timezone_offset_min() + 1440); + return result; +} +} // namespace platform::esp::arduino_common diff --git a/platform/esp/arduino_common/src/storage/agenda_storage.cpp b/platform/esp/arduino_common/src/storage/agenda_storage.cpp new file mode 100644 index 00000000..dd9090ad --- /dev/null +++ b/platform/esp/arduino_common/src/storage/agenda_storage.cpp @@ -0,0 +1,36 @@ +#include "platform/esp/arduino_common/storage/agenda_storage.h" +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" + +namespace platform::esp::arduino_common::storage +{ +uint32_t agenda_storage_session() +{ + return sd_media_session(); +} +bool agenda_storage_available() +{ + if (sd_external_block_owner_active()) return false; + if (!sd_card_ready()) + { + sd_recover_media(); + return false; // Observe unavailable before validating a recovered store. + } + const auto status = sd_probe_media(); + if (status == SdMediaStatus::IoError || status == SdMediaStatus::Unavailable) + { + sd_recover_media(); + return false; + } + // Contention is not a media lifetime boundary. Individual record operations + // retain their own locking/error results and cannot claim an unwritten save. + return status == SdMediaStatus::Ready || status == SdMediaStatus::Busy; +} +bool prepare_agenda_storage() +{ + if (!sd_card_ready() || sd_external_block_owner_active()) return false; + if (sd_is_directory("/agenda")) return true; + // The shared runtime owns mounting and SPI/SDMMC access policy. + // An I/O failure must never cause formatting or an internal-flash fallback. + return sd_mkdir("/agenda") && sd_is_directory("/agenda"); +} +} // namespace platform::esp::arduino_common::storage diff --git a/platform/esp/arduino_common/src/storage/sd_card_runtime.cpp b/platform/esp/arduino_common/src/storage/sd_card_runtime.cpp index da510ab8..55eb56a5 100644 --- a/platform/esp/arduino_common/src/storage/sd_card_runtime.cpp +++ b/platform/esp/arduino_common/src/storage/sd_card_runtime.cpp @@ -16,10 +16,12 @@ #ifndef DISABLE_FS_H_WARNING #define DISABLE_FS_H_WARNING 1 #endif +#include "platform/esp/arduino_common/storage/sd_file_lifetime.h" #include "platform/esp/arduino_common/storage/sd_file_probe.h" #include "platform/esp/arduino_common/storage/sdmmc_block_device.h" #include #include +#include #include #include #include @@ -73,6 +75,8 @@ ArduinoSdmmcBlockDevice s_sdmmc; SdFs s_sdfat; #endif SdCardInfo s_info{}; +std::atomic s_media_session{0}; +std::atomic s_faulted_media_session{0}; bool s_sdfat_mounted = false; volatile bool s_external_block_owner_active = false; uint32_t s_last_sd_io_log_ms = 0; @@ -474,7 +478,7 @@ oflag_t sdfat_open_flags(const char* mode) // Returning false preserves the mounted state when the recovery fence cannot // be acquired; clearing bookkeeping after an unfenced SPIClass::end() would // turn a recoverable Busy condition into a silent bus-ownership violation. -bool clear_sdfat() +bool clear_sdfat(bool discard_files = false) { if (!s_sdfat_mounted) { @@ -490,7 +494,8 @@ bool clear_sdfat() if (s_transient_file != nullptr && *s_transient_file) { - (void)s_transient_file->close(); + if (discard_files) abandon_sd_file(*s_transient_file); + else (void)s_transient_file->close(); } s_sdfat.end(); #if defined(TRAIL_MATE_SDFAT_SDMMC) @@ -513,6 +518,7 @@ bool clear_sdfat() void reset_info() { + ++s_media_session; s_info = SdCardInfo{}; } @@ -609,6 +615,7 @@ bool sd_preflight_go_idle(int sd_cs, SPIClass& spi) void record_sdfat_info(uint32_t initialized_spi_hz) { + ++s_media_session; const uint32_t info_start_ms = millis(); Serial.println("[SD][mount] info begin"); s_info = SdCardInfo{}; @@ -880,6 +887,72 @@ bool sd_card_ready() s_info.card_type != kRuntimeCardNone; } +uint32_t sd_media_session() +{ + return s_media_session.load(); +} + +SdMediaStatus sd_probe_media() +{ + if (!sd_card_ready() || sd_external_block_owner_active()) return SdMediaStatus::Unavailable; + if (s_faulted_media_session.load() == sd_media_session()) return SdMediaStatus::IoError; + SdRuntimeOperationGuard guard("sd_media_probe", sys::runtime::BusAccessPolicy::BackgroundWorkerBounded, 0); + if (!guard.locked()) return SdMediaStatus::Busy; + if (!sd_card_ready() || sd_external_block_owner_active()) return SdMediaStatus::Unavailable; + // Access these scalars only under the filesystem mutex. No sector buffer + // is permanently resident or placed on an ESP task stack. + static uint32_t checked_session = 0; + static uint32_t checked_at = 0; + static SdMediaStatus previous = SdMediaStatus::Unavailable; + const auto session = sd_media_session(); + const auto now = sys::millis_now(); + if (checked_session == session) + { + if (previous == SdMediaStatus::IoError) return previous; + if (previous == SdMediaStatus::Ready && static_cast(now - checked_at) < 1000U) return previous; + } + auto* scratch = static_cast(heap_caps_malloc(kSdSectorSize, MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA)); + if (!scratch) return SdMediaStatus::Busy; + SdIoErrorScope errors; +#if defined(TRAIL_MATE_SDFAT_SDMMC) + const bool read = s_sdmmc.readSector(0, scratch); +#else + const bool read = s_sdfat.card() && s_sdfat.card()->readSector(0, scratch); +#endif + heap_caps_free(scratch); + // The shared-SPI hook acquires its bus lazily during readSector(). A failed + // acquisition is not evidence that the physical card is missing. + if (guard.busStatus() != sys::runtime::BusAcquireStatus::Acquired) return SdMediaStatus::Busy; + checked_session = session; + checked_at = now; + previous = read && errors.error() == 0 ? SdMediaStatus::Ready : SdMediaStatus::IoError; + if (previous == SdMediaStatus::IoError) s_faulted_media_session.store(session); + return previous; +} + +bool sd_recover_media() +{ + if (sd_external_block_owner_active()) return false; + { + SdRuntimeOperationGuard guard("sd_media_recovery", sys::runtime::BusAccessPolicy::RecoveryExclusive, 0); + if (!guard.locked() || sd_external_block_owner_active()) return false; + if (sd_card_ready() && s_faulted_media_session.load() != sd_media_session()) return true; + static uint32_t last_attempt = 0; + static bool attempted = false; + const auto now = sys::millis_now(); + if (attempted && static_cast(now - last_attempt) < 3000U) return false; + attempted = true; + last_attempt = now; + // Do not sync old cached metadata against a replacement card. The + // cleanup still uses the existing exclusive shared-SPI bus fence. + if (!clear_sdfat(true)) return false; + reset_info(); + } + // Board policy supplies wiring, power and startup gating. Its mount guard + // rechecks ownership if USB acquired the device after cleanup released it. + return platform::esp::boards::initializeStorage(); +} + bool sd_card_uses_sdfat() { return s_info.backend == SdCardBackend::SdFat; @@ -963,6 +1036,7 @@ bool sd_set_external_block_owner_active(bool active) { return false; } + if (s_external_block_owner_active != active) ++s_media_session; s_external_block_owner_active = active; xSemaphoreGiveRecursive(s_filesystem_mutex); return true; @@ -1266,6 +1340,11 @@ bool sd_remove(const char* path) } bool sd_rename(const char* old_path, const char* new_path) +{ + return sd_rename(old_path, new_path, sd_media_session()); +} + +bool sd_rename(const char* old_path, const char* new_path, uint32_t expected_session) { const char* normalized_old = normalize_sd_path(old_path); const char* normalized_new = normalize_sd_path(new_path); @@ -1284,6 +1363,12 @@ bool sd_rename(const char* old_path, const char* new_path) sd_io_end("rename", normalized_old, start_ms, false, 0, -2); return false; } + if (expected_session != sd_media_session() || expected_session == s_faulted_media_session.load() || + sd_external_block_owner_active()) + { + sd_io_end("rename", normalized_old, start_ms, false, 0, -1); + return false; + } if (s_info.backend == SdCardBackend::SdFat) { result = s_sdfat.rename(normalized_old, normalized_new); @@ -1298,6 +1383,7 @@ class SdRuntimeFile::Impl { public: FsFile sdfat_file; + uint32_t session = 0; SdCardBackend backend = SdCardBackend::None; char path[128]{}; char mode[8]{}; @@ -1315,6 +1401,11 @@ SdRuntimeFile::~SdRuntimeFile() } bool SdRuntimeFile::open(const char* path, const char* mode) +{ + return open(path, mode, sd_media_session()); +} + +bool SdRuntimeFile::open(const char* path, const char* mode, uint32_t expected_session) { close(); if (impl_ == nullptr || path_empty(path)) @@ -1344,6 +1435,13 @@ bool SdRuntimeFile::open(const char* path, const char* mode) } if (s_info.backend == SdCardBackend::SdFat) { + if (expected_session != sd_media_session() || expected_session == s_faulted_media_session.load() || + sd_external_block_owner_active()) + { + sd_io_end("file_open", impl_->path, start_ms, false, 0, -1); + return false; + } + impl_->session = expected_session; impl_->sdfat_file = s_sdfat.open(normalized, sdfat_open_flags(mode)); impl_->backend = impl_->sdfat_file ? SdCardBackend::SdFat : SdCardBackend::None; sd_io_end("file_open", impl_->path, start_ms, impl_->backend == SdCardBackend::SdFat); @@ -1369,7 +1467,7 @@ void SdRuntimeFile::close() mutating ? sys::runtime::BusAccessPolicy::DurableCommit : sys::runtime::BusAccessPolicy::BackgroundWorkerBounded, mutating ? kSdDurableLockWaitMs : kSdRuntimeLockWaitMs); - if (guard.locked()) + if (guard.locked() && is_open()) { impl_->sdfat_file.close(); sd_io_end("file_close", impl_->path, start_ms, true); @@ -1379,6 +1477,9 @@ void SdRuntimeFile::close() sd_io_end("file_close", impl_->path, start_ms, false, 0, -2); } } + // A stale/contended handle must not flush from its later destructor or open. + // This SdFat configuration owns file state inline and has a non-I/O destructor. + abandon_sd_file(impl_->sdfat_file); impl_->backend = SdCardBackend::None; impl_->path[0] = '\0'; impl_->mode[0] = '\0'; @@ -1386,7 +1487,9 @@ void SdRuntimeFile::close() bool SdRuntimeFile::is_open() const { - return impl_ != nullptr && impl_->backend != SdCardBackend::None; + return impl_ != nullptr && impl_->backend != SdCardBackend::None && + impl_->session == sd_media_session() && + impl_->session != s_faulted_media_session.load() && !sd_external_block_owner_active(); } int SdRuntimeFile::available() const @@ -1398,7 +1501,7 @@ int SdRuntimeFile::available() const if (impl_->backend == SdCardBackend::SdFat) { SdRuntimeOperationGuard guard("sd_file_available"); - if (!guard.locked()) + if (!guard.locked() || !is_open()) { return 0; } @@ -1417,7 +1520,7 @@ int SdRuntimeFile::read(void* buffer, std::size_t bytes_to_read) { const uint32_t start_ms = sd_io_begin("file_read", impl_->path, bytes_to_read); SdRuntimeOperationGuard guard("sd_file_read"); - if (!guard.locked()) + if (!guard.locked() || !is_open()) { sd_io_end("file_read", impl_->path, start_ms, false, bytes_to_read, -2); return -1; @@ -1468,7 +1571,7 @@ int SdRuntimeFile::read_byte() if (impl_->backend == SdCardBackend::SdFat) { SdRuntimeOperationGuard guard("sd_file_read_byte"); - if (!guard.locked()) + if (!guard.locked() || !is_open()) { return -1; } @@ -1487,7 +1590,7 @@ std::size_t SdRuntimeFile::read_bytes(char* buffer, std::size_t bytes_to_read) { const uint32_t start_ms = sd_io_begin("file_read_bytes", impl_->path, bytes_to_read); SdRuntimeOperationGuard guard("sd_file_read_bytes"); - if (!guard.locked()) + if (!guard.locked() || !is_open()) { sd_io_end("file_read_bytes", impl_->path, start_ms, false, bytes_to_read, -2); return 0; @@ -1544,7 +1647,7 @@ std::size_t SdRuntimeFile::write(const void* buffer, std::size_t bytes_to_write) "sd_file_write", sys::runtime::BusAccessPolicy::DurableCommit, kSdDurableLockWaitMs); - if (!guard.locked()) + if (!guard.locked() || !is_open()) { sd_io_end("file_write", impl_->path, start_ms, false, bytes_to_write, -2); return 0; @@ -1591,7 +1694,7 @@ std::size_t SdRuntimeFile::write_byte(uint8_t value) "sd_file_write_byte", sys::runtime::BusAccessPolicy::DurableCommit, kSdDurableLockWaitMs); - if (!guard.locked()) + if (!guard.locked() || !is_open()) { return 0; } @@ -1626,7 +1729,7 @@ std::size_t SdRuntimeFile::print(double value, int digits) "sd_file_print", sys::runtime::BusAccessPolicy::DurableCommit, kSdDurableLockWaitMs); - if (!guard.locked()) + if (!guard.locked() || !is_open()) { return 0; } @@ -1677,7 +1780,7 @@ bool SdRuntimeFile::seek(uint64_t offset) if (impl_->backend == SdCardBackend::SdFat) { SdRuntimeOperationGuard guard("sd_file_seek"); - if (!guard.locked()) + if (!guard.locked() || !is_open()) { return false; } @@ -1695,7 +1798,7 @@ uint64_t SdRuntimeFile::position() const if (impl_->backend == SdCardBackend::SdFat) { SdRuntimeOperationGuard guard("sd_file_position"); - if (!guard.locked()) + if (!guard.locked() || !is_open()) { return 0; } @@ -1713,7 +1816,7 @@ uint64_t SdRuntimeFile::size() const if (impl_->backend == SdCardBackend::SdFat) { SdRuntimeOperationGuard guard("sd_file_size"); - if (!guard.locked()) + if (!guard.locked() || !is_open()) { return 0; } @@ -1739,7 +1842,7 @@ bool SdRuntimeFile::flush() "sd_file_flush", sys::runtime::BusAccessPolicy::DurableCommit, kSdDurableLockWaitMs); - if (!guard.locked()) + if (!guard.locked() || !is_open()) { sd_io_end("file_flush", impl_->path, start_ms, false, 0, -2); return false; diff --git a/platform/esp/arduino_common/src/storage/sd_record_file_io.cpp b/platform/esp/arduino_common/src/storage/sd_record_file_io.cpp new file mode 100644 index 00000000..1fdb5ee7 --- /dev/null +++ b/platform/esp/arduino_common/src/storage/sd_record_file_io.cpp @@ -0,0 +1,80 @@ +#include "platform/esp/arduino_common/storage/sd_record_file_io.h" +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include + +namespace platform::esp::arduino_common::storage +{ +namespace +{ +SdRuntimeFile* file_of(void* file) { return static_cast(file); } +bool absent(const char* path) +{ + // Ask the existing runtime for semantic absence, not sd_exists(false), + // which also covers lock contention and media errors. One byte is enough: + // an existing larger file reports Invalid, never Missing. + uint8_t probe = 0; + return sd_read_file(path, &probe, 1).status == SdFileReadStatus::Missing; +} +} // namespace + +bool SdRecordFileIo::available() const +{ + return session_ == sd_media_session() && sd_card_ready() && !sd_external_block_owner_active(); +} +bool SdRecordFileIo::usable(Handle file) const +{ + return file && available() && file_of(file)->is_open(); +} + +SdRecordFileIo::OpenResult SdRecordFileIo::open(const char* path, Mode mode) +{ + if (!path || !*path) return {}; + if (!available()) return {nullptr, OpenStatus::Unavailable}; + auto* file = new (std::nothrow) SdRuntimeFile; + if (!file) return {}; + const char* flags = mode == Mode::Read ? "rb" : mode == Mode::Update ? "r+b" + : "wb"; + if (file->open(path, flags, session_)) return {file, OpenStatus::Ready}; + delete file; + if (!available()) return {nullptr, OpenStatus::Unavailable}; + return {nullptr, mode == Mode::Read && absent(path) ? OpenStatus::Missing : OpenStatus::IoError}; +} +bool SdRecordFileIo::close(Handle file) +{ + if (!file) return false; + const bool ready = usable(file); + // SdRuntimeFile has no close status; durability must be established by + // sync before this release. Never manufacture a durable write result here. + delete file_of(file); + return ready; +} +bool SdRecordFileIo::size(Handle file, uint64_t& bytes) +{ + bytes = 0; + if (!usable(file)) return false; + bytes = file_of(file)->size(); + return true; +} +bool SdRecordFileIo::seek(Handle file, uint64_t offset) +{ + return usable(file) && file_of(file)->seek(offset); +} +std::size_t SdRecordFileIo::read(Handle file, void* buffer, std::size_t bytes) +{ + if (!usable(file)) return 0; + const int count = file_of(file)->read(buffer, bytes); + return count > 0 ? static_cast(count) : 0; +} +std::size_t SdRecordFileIo::write(Handle file, const void* buffer, std::size_t bytes) +{ + return usable(file) ? file_of(file)->write(buffer, bytes) : 0; +} +bool SdRecordFileIo::sync(Handle file) +{ + return usable(file) && file_of(file)->flush(); +} +bool SdRecordFileIo::publish(const char* temporary, const char* destination) +{ + return temporary && destination && available() && absent(destination) && sd_rename(temporary, destination, session_); +} +} // namespace platform::esp::arduino_common::storage diff --git a/platform/esp/common/include/platform/esp/common/storage/agenda_file_store.h b/platform/esp/common/include/platform/esp/common/storage/agenda_file_store.h new file mode 100644 index 00000000..561985ac --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/storage/agenda_file_store.h @@ -0,0 +1,48 @@ +#pragma once + +#include "agenda/domain/event_codec.h" +#include "agenda/ports/agenda_store.h" + +#include "platform/esp/common/storage/stdio_record_file_io.h" + +namespace platform::esp::storage +{ + +// Fixed logical slots backed by two CRC-protected physical banks each. Only +// the older bank is overwritten, so a torn record write leaves the other copy. +// Caller supplies file transport and creates the parent directory. Never formats. +// Paths must outlive this single-owner adapter. No SDK or UI ownership here. +class AgendaFileStore final : public agenda::IAgendaStore +{ + public: + static constexpr std::size_t kHeaderBytes = 256; + static constexpr std::size_t kFileBytes = kHeaderBytes + agenda::kMaxActiveEvents * 2 * agenda::kSlotBytes; + + AgendaFileStore(const char* path, const char* initialization_path, RecordFileIo& io = stdioRecordFileIo()) + : io_(io), path_(path), initialization_path_(initialization_path) {} + agenda::StoreResult begin(); + agenda::StoreResult readSlot(uint16_t slot, agenda::EventRecord& out) override; + agenda::StoreResult writeSlot(uint16_t slot, const agenda::EventRecord& record) override; + agenda::StoreResult eraseSlot(uint16_t slot) override; + uint16_t slotCount() const override { return agenda::kMaxActiveEvents; } + + private: + struct Bank + { + uint64_t generation = 0; + agenda::StoreResult result = agenda::StoreResult::Corrupt; + }; + agenda::StoreResult initialize(); + agenda::StoreResult validateHeader(RecordFileIo::Handle file); + Bank readBank(RecordFileIo::Handle file, uint16_t slot, uint8_t bank, agenda::EventRecord& out); + agenda::StoreResult choose(RecordFileIo::Handle file, uint16_t slot, agenda::EventRecord& out, + uint8_t& current_bank, uint64_t& generation); + RecordFileIo& io_; + const char* path_; + const char* initialization_path_; + uint8_t bytes_[agenda::kSlotBytes]{}; + bool ready_ = false; +}; + +static_assert(sizeof(AgendaFileStore) <= 288, "Agenda storage adapter exceeded idle RAM budget"); +} // namespace platform::esp::storage diff --git a/platform/esp/common/include/platform/esp/common/storage/record_file_io.h b/platform/esp/common/include/platform/esp/common/storage/record_file_io.h new file mode 100644 index 00000000..87c9ed1a --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/storage/record_file_io.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +namespace platform::esp::storage +{ +// File transport for bounded record stores. Record layout, CRC and recovery +// stay in the store; bus policy, ownership and device errors stay in adapters. +// Handles are operation-scoped and must be closed before returning to the UI. +class RecordFileIo +{ + public: + using Handle = void*; + enum class OpenStatus : uint8_t + { + Ready, + Missing, + Unavailable, + IoError + }; + struct OpenResult + { + Handle handle = nullptr; + OpenStatus status = OpenStatus::IoError; + }; + enum class Mode : uint8_t + { + Read, + Update, + CreateTemporary + }; + virtual ~RecordFileIo() = default; + // Missing must mean verified absence, never a failed lock/open/read. + virtual OpenResult open(const char* path, Mode mode) = 0; + virtual bool close(Handle file) = 0; + virtual bool size(Handle file, uint64_t& bytes) = 0; + virtual bool seek(Handle file, uint64_t offset) = 0; + virtual std::size_t read(Handle file, void* buffer, std::size_t bytes) = 0; + virtual std::size_t write(Handle file, const void* buffer, std::size_t bytes) = 0; + // A successful write alone is not a durable commit. + virtual bool sync(Handle file) = 0; + // Publish an initialized temporary file only if destination is absent. + virtual bool publish(const char* temporary, const char* destination) = 0; +}; +} // namespace platform::esp::storage diff --git a/platform/esp/common/include/platform/esp/common/storage/stdio_record_file_io.h b/platform/esp/common/include/platform/esp/common/storage/stdio_record_file_io.h new file mode 100644 index 00000000..848b70e1 --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/storage/stdio_record_file_io.h @@ -0,0 +1,76 @@ +#pragma once +#include "platform/esp/common/storage/record_file_io.h" +#include +#include +#include +#if defined(_WIN32) +#include +#else +#include +#endif + +namespace platform::esp::storage +{ +// Explicit native filesystem backend for host recovery tests. Not a fallback +// selected in response to an SD error. +class StdioRecordFileIo final : public RecordFileIo +{ + public: + OpenResult open(const char* path, Mode mode) override + { + auto* file = std::fopen(path, mode == Mode::Read ? "rb" : mode == Mode::Update ? "r+b" + : "wb"); + return {file, file ? OpenStatus::Ready : errno == ENOENT ? OpenStatus::Missing + : OpenStatus::IoError}; + } + bool close(Handle file) override { return std::fclose(static_cast(file)) == 0; } + bool size(Handle file, uint64_t& bytes) override + { + auto* stream = static_cast(file); + bytes = 0; + if (std::fseek(stream, 0, SEEK_END)) return false; + const auto end = std::ftell(stream); + if (end < 0) return false; + bytes = static_cast(end); + return true; + } + bool seek(Handle file, uint64_t offset) override + { + return offset <= static_cast(std::numeric_limits::max()) && + std::fseek(static_cast(file), static_cast(offset), SEEK_SET) == 0; + } + std::size_t read(Handle file, void* buffer, std::size_t bytes) override + { + return std::fread(buffer, 1, bytes, static_cast(file)); + } + std::size_t write(Handle file, const void* buffer, std::size_t bytes) override + { + return std::fwrite(buffer, 1, bytes, static_cast(file)); + } + bool sync(Handle file) override + { + auto* stream = static_cast(file); + if (std::fflush(stream)) return false; +#if defined(_WIN32) + return ::_commit(_fileno(stream)) == 0; +#else + return ::fsync(::fileno(stream)) == 0; +#endif + } + bool publish(const char* temporary, const char* destination) override + { + const auto existing = open(destination, Mode::Read); + if (existing.handle) + { + close(existing.handle); + return false; + } + return existing.status == OpenStatus::Missing && std::rename(temporary, destination) == 0; + } +}; +inline RecordFileIo& stdioRecordFileIo() +{ + static StdioRecordFileIo io; + return io; +} +} // namespace platform::esp::storage diff --git a/platform/esp/common/include/platform/esp/common/storage/waypoint_file_store.h b/platform/esp/common/include/platform/esp/common/storage/waypoint_file_store.h new file mode 100644 index 00000000..272ece68 --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/storage/waypoint_file_store.h @@ -0,0 +1,31 @@ +#pragma once +#include "platform/esp/common/storage/stdio_record_file_io.h" +#include "waypoint/record_codec.h" + +namespace platform::esp::storage +{ +// Single-owner record store over injected file transport. Caller prepares the +// directory. No formatting, bus policy or retained file handle. +class WaypointFileStore final : public waypoint::IStore +{ + public: + static constexpr long kFileBytes = 64 + 64 * 2 * waypoint::kMaxWaypoints; + WaypointFileStore(const char* path, const char* staging, RecordFileIo& io = stdioRecordFileIo()) : io_(io), path_(path), staging_(staging) {} + waypoint::Result begin(); + uint16_t slotCount() const override { return waypoint::kMaxWaypoints; } + waypoint::Result read(uint16_t slot, waypoint::Record& out) override; + waypoint::Result create(const waypoint::Record& value, uint32_t& id) override; + waypoint::Result update(const waypoint::Record& value) override; + waypoint::Result remove(uint32_t id) override; + + private: + waypoint::Result choose(RecordFileIo::Handle file, uint16_t slot, waypoint::Slot& out, uint8_t& bank); + waypoint::Result mutate(const waypoint::Record& value, bool creating, bool deleting, uint32_t& id); + RecordFileIo& io_; + const char* path_; + const char* staging_; + uint8_t bytes_[waypoint::kEncodedBytes]{}; + bool ready_ = false; +}; +static_assert(sizeof(WaypointFileStore) <= 96, "Waypoint store must not retain a database"); +} // namespace platform::esp::storage diff --git a/platform/esp/common/library.json b/platform/esp/common/library.json index ca3dc4fc..2a4bcf69 100644 --- a/platform/esp/common/library.json +++ b/platform/esp/common/library.json @@ -9,6 +9,10 @@ "srcDir": "src" }, "dependencies": [ + { "name": "core_waypoint" }, + { + "name": "core_agenda" + }, { "name": "core_sys" }, diff --git a/platform/esp/common/src/storage/agenda_file_store.cpp b/platform/esp/common/src/storage/agenda_file_store.cpp new file mode 100644 index 00000000..558ed9c0 --- /dev/null +++ b/platform/esp/common/src/storage/agenda_file_store.cpp @@ -0,0 +1,218 @@ +#include "platform/esp/common/storage/agenda_file_store.h" + +#include +#include + +namespace platform::esp::storage +{ +namespace +{ +using agenda::StoreResult; +constexpr std::size_t kGenerationOffset = 208; +constexpr std::size_t kSlotIndexOffset = 216; +constexpr std::size_t kCrcOffset = agenda::kSlotBytes - 4; + +void put(uint8_t* out, uint64_t value, unsigned count) +{ + for (unsigned i = 0; i < count; ++i) out[i] = static_cast(value >> (8 * i)); +} +uint64_t get(const uint8_t* in, unsigned count) +{ + uint64_t value = 0; + for (unsigned i = 0; i < count; ++i) value |= static_cast(in[i]) << (8 * i); + return value; +} +long offset(uint16_t slot, uint8_t bank) +{ + return static_cast(AgendaFileStore::kHeaderBytes + (slot * 2 + bank) * agenda::kSlotBytes); +} +StoreResult decodedResult(agenda::DecodeResult result) +{ + switch (result) + { + case agenda::DecodeResult::Ok: + return StoreResult::Ok; + case agenda::DecodeResult::UnsupportedVersion: + return StoreResult::UnsupportedVersion; + case agenda::DecodeResult::InvalidRecord: + return StoreResult::InvalidRecord; + default: + return StoreResult::Corrupt; + } +} +} // namespace + +agenda::StoreResult AgendaFileStore::validateHeader(RecordFileIo::Handle file) +{ + uint64_t bytes = 0; + if (!io_.size(file, bytes)) return StoreResult::IoError; + if (bytes != kFileBytes) return StoreResult::Corrupt; + if (!io_.seek(file, 0) || io_.read(file, bytes_, sizeof(bytes_)) != sizeof(bytes_)) + return StoreResult::IoError; + if (std::memcmp(bytes_, "TMAG", 4) != 0 || + get(bytes_ + kCrcOffset, 4) != agenda::recordCrc32(bytes_, kCrcOffset)) + return StoreResult::Corrupt; + if (bytes_[4] != 1) return StoreResult::UnsupportedVersion; + if (get(bytes_ + 8, 2) != agenda::kMaxActiveEvents || get(bytes_ + 10, 2) != agenda::kSlotBytes || bytes_[12] != 2) + return StoreResult::UnsupportedVersion; + return StoreResult::Ok; +} + +agenda::StoreResult AgendaFileStore::initialize() +{ + RecordFileIo::Handle file = io_.open(initialization_path_, RecordFileIo::Mode::CreateTemporary).handle; + if (!file) return StoreResult::IoError; + std::memset(bytes_, 0, sizeof(bytes_)); + std::memcpy(bytes_, "TMAG", 4); + bytes_[4] = 1; + put(bytes_ + 8, agenda::kMaxActiveEvents, 2); + put(bytes_ + 10, agenda::kSlotBytes, 2); + bytes_[12] = 2; + put(bytes_ + kCrcOffset, agenda::recordCrc32(bytes_, kCrcOffset), 4); + bool ok = io_.write(file, bytes_, sizeof(bytes_)) == sizeof(bytes_); + // Initialization alone needs an empty record. It is not an idle cache. + const agenda::EventRecord empty{}; + for (uint16_t slot = 0; ok && slot < agenda::kMaxActiveEvents; ++slot) + { + ok = agenda::encodeEvent(empty, bytes_); + put(bytes_ + kSlotIndexOffset, slot, 2); + put(bytes_ + kCrcOffset, agenda::recordCrc32(bytes_, kCrcOffset), 4); + for (unsigned bank = 0; ok && bank < 2; ++bank) + ok = io_.write(file, bytes_, sizeof(bytes_)) == sizeof(bytes_); + } + if (ok) ok = io_.sync(file); + if (!io_.close(file)) ok = false; + if (!ok) return StoreResult::IoError; + // No existing database is replaced. begin() enters here only on ENOENT. + return io_.publish(initialization_path_, path_) ? StoreResult::Ok : StoreResult::IoError; +} + +agenda::StoreResult AgendaFileStore::begin() +{ + ready_ = false; + if (!path_ || !initialization_path_ || std::strcmp(path_, initialization_path_) == 0) return StoreResult::InvalidRecord; + const auto opened = io_.open(path_, RecordFileIo::Mode::Read); + auto* file = opened.handle; + if (!file) + { + if (opened.status != RecordFileIo::OpenStatus::Missing) return StoreResult::IoError; + const auto result = initialize(); + if (result != StoreResult::Ok) return result; + file = io_.open(path_, RecordFileIo::Mode::Read).handle; + if (!file) return StoreResult::IoError; + } + const auto result = validateHeader(file); + const bool closed = io_.close(file); + ready_ = result == StoreResult::Ok && closed; + return closed ? result : StoreResult::IoError; +} + +AgendaFileStore::Bank AgendaFileStore::readBank(RecordFileIo::Handle file, uint16_t slot, uint8_t bank, agenda::EventRecord& out) +{ + out = {}; + Bank value; + if (!io_.seek(file, offset(slot, bank)) || + io_.read(file, bytes_, sizeof(bytes_)) != sizeof(bytes_)) + { + value.result = StoreResult::IoError; + return value; + } + value.result = decodedResult(agenda::decodeEvent(bytes_, out)); + if (value.result != StoreResult::Ok) return value; + if (get(bytes_ + kSlotIndexOffset, 2) != slot) + { + out = {}; + value.result = StoreResult::Corrupt; + return value; + } + value.generation = get(bytes_ + kGenerationOffset, 8); + return value; +} + +agenda::StoreResult AgendaFileStore::choose(RecordFileIo::Handle file, uint16_t slot, agenda::EventRecord& out, + uint8_t& current_bank, uint64_t& generation) +{ + const auto first = readBank(file, slot, 0, out); + const auto second = readBank(file, slot, 1, out); + // Never downgrade or overwrite an unfamiliar future format or unreadable bank. + if (first.result == StoreResult::UnsupportedVersion || second.result == StoreResult::UnsupportedVersion) + return StoreResult::UnsupportedVersion; + if (first.result == StoreResult::IoError || second.result == StoreResult::IoError) return StoreResult::IoError; + if (first.result != StoreResult::Ok && second.result != StoreResult::Ok) return StoreResult::Corrupt; + if (second.result == StoreResult::Ok && (first.result != StoreResult::Ok || second.generation > first.generation)) + { + current_bank = 1; + generation = second.generation; + } + else + { + // The second read reused the caller's output. Prefer one extra bounded + // flash read over a permanent second EventRecord (208 bytes). Both + // banks were validated above before selecting or permitting a write. + const auto selected = readBank(file, slot, 0, out); + if (selected.result != StoreResult::Ok) return selected.result; + if (selected.generation != first.generation) return StoreResult::Corrupt; + current_bank = 0; + generation = first.generation; + } + return StoreResult::Ok; +} + +agenda::StoreResult AgendaFileStore::readSlot(uint16_t slot, agenda::EventRecord& out) +{ + out = {}; + if (slot >= slotCount()) return StoreResult::OutOfRange; + if (!ready_) return StoreResult::IoError; + RecordFileIo::Handle file = io_.open(path_, RecordFileIo::Mode::Read).handle; + if (!file) return StoreResult::IoError; + uint8_t bank = 0; + uint64_t generation = 0; + auto result = choose(file, slot, out, bank, generation); + if (!io_.close(file)) result = StoreResult::IoError; + if (result != StoreResult::Ok) out = {}; + return result; +} + +agenda::StoreResult AgendaFileStore::writeSlot(uint16_t slot, const agenda::EventRecord& record) +{ + if (slot >= slotCount()) return StoreResult::OutOfRange; + if (!agenda::validEvent(record)) return StoreResult::InvalidRecord; + if (!ready_) return StoreResult::IoError; + RecordFileIo::Handle file = io_.open(path_, RecordFileIo::Mode::Update).handle; + if (!file) return StoreResult::IoError; + uint8_t bank = 0; + uint64_t generation = 0; + // One bounded automatic record; never a full database or protocol buffer. + agenda::EventRecord previous; + auto result = choose(file, slot, previous, bank, generation); + if (result == StoreResult::Ok) + { + if (generation == std::numeric_limits::max()) result = StoreResult::OutOfRange; + else if (!agenda::encodeEvent(record, bytes_)) result = StoreResult::InvalidRecord; + else + { + put(bytes_ + kGenerationOffset, generation + 1, 8); + put(bytes_ + kSlotIndexOffset, slot, 2); + put(bytes_ + kCrcOffset, agenda::recordCrc32(bytes_, kCrcOffset), 4); + if (!io_.seek(file, offset(slot, bank ^ 1)) || + io_.write(file, bytes_, sizeof(bytes_)) != sizeof(bytes_) || !io_.sync(file)) + result = StoreResult::IoError; + } + } + if (!io_.close(file)) result = StoreResult::IoError; + return result; +} + +agenda::StoreResult AgendaFileStore::eraseSlot(uint16_t slot) +{ + agenda::EventRecord record; + const auto result = readSlot(slot, record); + if (result != StoreResult::Ok) return result; + const uint32_t id = record.id; + record = {}; + record.id = id; + record.state = agenda::RecordState::Deleted; + return writeSlot(slot, record); +} + +} // namespace platform::esp::storage diff --git a/platform/esp/common/src/storage/waypoint_file_store.cpp b/platform/esp/common/src/storage/waypoint_file_store.cpp new file mode 100644 index 00000000..57e10ed1 --- /dev/null +++ b/platform/esp/common/src/storage/waypoint_file_store.cpp @@ -0,0 +1,137 @@ +#include "platform/esp/common/storage/waypoint_file_store.h" +#include +#include + +namespace platform::esp::storage +{ +namespace +{ +using waypoint::Result; +constexpr uint8_t header[64] = {'T', 'M', 'W', 'P', 1, 64, 64, 2}; +long offset(uint16_t slot, uint8_t bank) { return 64 + (slot * 2 + bank) * 64; } +Result close(RecordFileIo& io, RecordFileIo::Handle file, Result result) +{ + return io.close(file) ? result : Result::IoError; +} +} // namespace + +Result WaypointFileStore::begin() +{ + ready_ = false; + if (!path_ || !staging_ || !std::strcmp(path_, staging_)) return Result::Invalid; + const auto opened = io_.open(path_, RecordFileIo::Mode::Read); + auto* file = opened.handle; + if (!file) + { + if (opened.status != RecordFileIo::OpenStatus::Missing) return Result::IoError; + file = io_.open(staging_, RecordFileIo::Mode::CreateTemporary).handle; + if (!file) return Result::IoError; + bool ok = io_.write(file, header, sizeof(header)) == sizeof(header); + waypoint::Slot empty; + empty.generation = 1; + ok = ok && waypoint::encode(empty, bytes_, sizeof(bytes_)); + for (unsigned i = 0; ok && i < slotCount() * 2; ++i) + ok = io_.write(file, bytes_, sizeof(bytes_)) == sizeof(bytes_); + ok = ok && io_.sync(file); + const auto result = close(io_, file, ok ? Result::Ok : Result::IoError); + if (result != Result::Ok) return result; + if (!io_.publish(staging_, path_)) return Result::IoError; + file = io_.open(path_, RecordFileIo::Mode::Read).handle; + if (!file) return Result::IoError; + } + Result result = Result::Ok; + uint64_t bytes = 0; + if (!io_.size(file, bytes)) result = Result::IoError; + else if (bytes != kFileBytes) result = Result::Corrupt; + else if (!io_.seek(file, 0) || io_.read(file, bytes_, sizeof(bytes_)) != sizeof(bytes_)) result = Result::IoError; + else if (std::memcmp(header, bytes_, sizeof(header))) result = Result::Corrupt; + result = close(io_, file, result); + ready_ = result == Result::Ok; + return result; +} + +Result WaypointFileStore::choose(RecordFileIo::Handle file, uint16_t slot, waypoint::Slot& out, uint8_t& bank) +{ + bool found = false; + waypoint::Slot candidate; + for (uint8_t index = 0; index < 2; ++index) + { + if (!io_.seek(file, offset(slot, index)) || io_.read(file, bytes_, sizeof(bytes_)) != sizeof(bytes_)) return Result::IoError; + // An unfamiliar version must not be silently overwritten via fallback. + if (bytes_[0] == 'W' && bytes_[1] == 'P' && bytes_[2] != 1) return Result::Corrupt; + if (!waypoint::decode(bytes_, sizeof(bytes_), candidate)) continue; + if (!found || candidate.generation > out.generation) + { + out = candidate; + bank = index; + found = true; + } + } + return found ? Result::Ok : Result::Corrupt; +} + +Result WaypointFileStore::read(uint16_t slot, waypoint::Record& out) +{ + out = {}; + if (slot >= slotCount()) return Result::Invalid; + if (!ready_) return Result::IoError; + auto* file = io_.open(path_, RecordFileIo::Mode::Read).handle; + if (!file) return Result::IoError; + waypoint::Slot selected; + uint8_t bank = 0; + const auto result = close(io_, file, choose(file, slot, selected, bank)); + if (result == Result::Ok && selected.state == waypoint::SlotState::Active) out = selected.record; + return result; +} + +Result WaypointFileStore::mutate(const waypoint::Record& value, bool creating, bool deleting, uint32_t& id) +{ + id = 0; + auto validation = value; + if (creating) validation.id = 1; + if ((!deleting && !waypoint::valid(validation)) || (deleting && !value.id)) return Result::Invalid; + if (!ready_) return Result::IoError; + auto* file = io_.open(path_, RecordFileIo::Mode::Update).handle; + if (!file) return Result::IoError; + uint16_t target = slotCount(); + uint32_t largest = 0; + waypoint::Slot previous; + uint8_t bank = 0; + for (uint16_t slot = 0; slot < slotCount(); ++slot) + { + const auto result = choose(file, slot, previous, bank); + if (result != Result::Ok) return close(io_, file, result); + if (previous.record.id > largest) largest = previous.record.id; + if (creating ? (previous.state != waypoint::SlotState::Active && target == slotCount()) + : (previous.state == waypoint::SlotState::Active && previous.record.id == value.id)) target = slot; + } + if (target == slotCount()) return close(io_, file, creating ? Result::Full : Result::NotFound); + if (creating && largest == std::numeric_limits::max()) return close(io_, file, Result::Full); + auto result = choose(file, target, previous, bank); + if (result != Result::Ok) return close(io_, file, result); + if (previous.generation == std::numeric_limits::max()) return close(io_, file, Result::Full); + ++previous.generation; + previous.record = value; + previous.record.id = creating ? largest + 1 : value.id; + previous.state = deleting ? waypoint::SlotState::Deleted : waypoint::SlotState::Active; + if (!waypoint::encode(previous, bytes_, sizeof(bytes_))) return close(io_, file, Result::Invalid); + const bool ok = io_.seek(file, offset(target, bank ^ 1)) && + io_.write(file, bytes_, sizeof(bytes_)) == sizeof(bytes_) && io_.sync(file); + result = close(io_, file, ok ? Result::Ok : Result::IoError); + if (result == Result::Ok) id = previous.record.id; + return result; +} +Result WaypointFileStore::create(const waypoint::Record& value, uint32_t& id) { return mutate(value, true, false, id); } +Result WaypointFileStore::update(const waypoint::Record& value) +{ + uint32_t id; + return mutate(value, false, false, id); +} +Result WaypointFileStore::remove(uint32_t id) +{ + waypoint::Record value; + value.id = id; + uint32_t ignored; + return mutate(value, false, true, ignored); +} +} // namespace platform::esp::storage diff --git a/scripts/build_pack_repository.py b/scripts/build_pack_repository.py index 10e077ce..9b8bc679 100644 --- a/scripts/build_pack_repository.py +++ b/scripts/build_pack_repository.py @@ -220,6 +220,8 @@ def build_font_if_missing( ] if npm_exe: command.extend(["--npm-exe", npm_exe]) + for fallback in split_csv(build.get("fallback_font")): + command.extend(["--fallback-font", str((repo_root / fallback).resolve())]) if parse_bool(build.get("no_compress"), default=False): command.append("--no-compress") if parse_bool(build.get("no_prefilter"), default=False): diff --git a/scripts/check_ota_slot_size.py b/scripts/check_ota_slot_size.py new file mode 100644 index 00000000..5e03cc56 --- /dev/null +++ b/scripts/check_ota_slot_size.py @@ -0,0 +1,35 @@ +"""Validate firmware against the smallest OTA app slot in its built partition table.""" +import argparse +from pathlib import Path +import struct + + +def ota_capacity(table: bytes) -> int: + sizes = [] + for offset in range(0, len(table) - 31, 32): + magic, kind, subtype, _, size = struct.unpack_from(' int: and "const SdSpiBusConfig& spi_bus" in sd_runtime_header and "s_active_spi_bus.spi->begin" in sd_runtime and "s_sdfat_mounted = sdfat_ok;" in sd_runtime - and "bool clear_sdfat()" in sd_runtime + and "bool clear_sdfat(" in sd_runtime and "SdFat cleanup deferred: shared SPI unavailable" in sd_runtime and "sd_spi_bus_acquire(bus_token)" in between( - sd_runtime, "bool clear_sdfat()", "void reset_info()" + sd_runtime, "bool clear_sdfat(", "void reset_info()" ) and "if (!clear_sdfat())" in between( sd_runtime, "void unmount_sd_card()", "bool sd_card_ready()" diff --git a/tests/map_gesture/CMakeLists.txt b/tests/map_gesture/CMakeLists.txt index 181453d3..beb4be69 100644 --- a/tests/map_gesture/CMakeLists.txt +++ b/tests/map_gesture/CMakeLists.txt @@ -25,6 +25,33 @@ string(SUBSTRING "${SOURCE}" ${FIRST} ${LENGTH} CALLBACKS) string(REGEX MATCH "constexpr int kGestureDragStartPx = [0-9]+;" THRESHOLD "${SOURCE}") file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/map_gesture_actual.inc" "${THRESHOLD}\n${HELPERS}\n${CALLBACKS}") +# Compile the production marker cache/visitor/draw/binding implementation with +# a deterministic projector and a counting source; no duplicated state machine. +string(FIND "${SOURCE}" "struct MarkerSession" FIRST) +string(FIND "${SOURCE}" "struct RuntimeImpl" LAST) +math(EXPR LENGTH "${LAST} - ${FIRST}") +string(SUBSTRING "${SOURCE}" ${FIRST} ${LENGTH} MARKER_STATE) +string(FIND "${SOURCE}" "void collect_marker(" FIRST) +string(FIND "${SOURCE}" "void render_overlay(" LAST) +math(EXPR LENGTH "${LAST} - ${FIRST}") +string(SUBSTRING "${SOURCE}" ${FIRST} ${LENGTH} MARKER_CALLBACKS) +string(FIND "${SOURCE}" "void set_marker_binding(" FIRST) +string(FIND "${SOURCE}" "void apply_overlay(" LAST) +math(EXPR LENGTH "${LAST} - ${FIRST}") +string(SUBSTRING "${SOURCE}" ${FIRST} ${LENGTH} MARKER_BINDING) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/map_marker_state.inc" "${MARKER_STATE}") +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/map_marker_actual.inc" "${MARKER_CALLBACKS}\n${MARKER_BINDING}") +add_executable(map_marker_regression test_markers.cpp) +target_include_directories(map_marker_regression PRIVATE + "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/modules/ui_shared/include" + "${REPO}/modules/ui_presentation/include") +target_link_libraries(map_marker_regression PRIVATE lvgl) +if(MSVC) + target_compile_options(map_marker_regression PRIVATE /UNDEBUG) +else() + target_compile_options(map_marker_regression PRIVATE -UNDEBUG -Wall -Wextra) +endif() + add_executable(map_gesture_regression test_gesture.cpp) target_include_directories(map_gesture_regression PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/modules/ui_shared/include" @@ -36,6 +63,7 @@ else() target_compile_options(map_gesture_regression PRIVATE -UNDEBUG -Wall -Wextra) endif() enable_testing() +add_test(NAME map_marker_regression COMMAND map_marker_regression) add_test(NAME map_gesture_regression COMMAND map_gesture_regression) set_tests_properties(map_gesture_regression PROPERTIES TIMEOUT 30) add_subdirectory("${REPO}/tests/settings_navigation" settings_navigation) diff --git a/tests/map_gesture/test_markers.cpp b/tests/map_gesture/test_markers.cpp new file mode 100644 index 00000000..b93ee09a --- /dev/null +++ b/tests/map_gesture/test_markers.cpp @@ -0,0 +1,179 @@ +#include "ui/widgets/map/map_viewport.h" +#include "ui_presentation/map/map_marker_cache.h" +#include +#include +#include +#include +#include +#include +#include + +namespace ui::fonts +{ +const lv_font_t* content_font(const char*, const lv_font_t* font) { return font; } +} // namespace ui::fonts +namespace ui::widgets::map +{ +#include "map_marker_state.inc" +struct RuntimeImpl +{ + Widgets widgets; + Model model; + struct + { + bool valid = true; + } anchor; + const ui::map::MapMarkerBinding* marker_binding = nullptr; + MarkerSession* markers = nullptr; + uint32_t marker_retry_at = 0; + int marker_drag_dx = 0; + int marker_drag_dy = 0; +}; +RuntimeImpl* fixture = nullptr; +bool is_runtime_alive(const RuntimeImpl& impl) { return lv_obj_is_valid(impl.widgets.root); } +bool project_geo_point(const RuntimeImpl& impl, const GeoPoint& point, lv_point_t& out) +{ + if (!point.valid) return false; + out.x = static_cast(std::round(point.lon * 1e7)) + impl.model.pan_x + 160; + out.y = static_cast(std::round(point.lat * 1e7)) + impl.model.pan_y + 120; + return true; +} +#include "map_marker_actual.inc" +Widgets create(Runtime& runtime, lv_obj_t* parent, uint32_t) +{ + fixture = runtime.impl_ = new RuntimeImpl{}; + auto* surface = lv_obj_create(parent); + lv_obj_remove_style_all(surface); + lv_obj_set_size(surface, 320, 240); + lv_obj_clear_flag(surface, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_update_layout(surface); + fixture->widgets.root = fixture->widgets.overlay_layer = surface; + fixture->model.focus_point = {true, 0, 0}; + return fixture->widgets; +} +void destroy(Runtime& runtime) +{ + if (!runtime.impl_) return; + set_marker_binding(runtime, nullptr); + lv_obj_delete(runtime.impl_->widgets.root); + delete runtime.impl_; + runtime.impl_ = nullptr; + fixture = nullptr; +} +Runtime::~Runtime() { destroy(*this); } +} // namespace ui::widgets::map +namespace +{ +unsigned allocations = 0; +unsigned releases = 0; +bool allocation_failure = false; +void* allocate(std::size_t bytes) +{ + ++allocations; + return allocation_failure ? nullptr : std::malloc(bytes); +} +void release(void* pointer) +{ + ++releases; + std::free(pointer); +} +struct Source : ui::map::IMapMarkerSource +{ + ui::map::MapMarkerStatus state{100, 1, true, true}; + unsigned scans = 0; + unsigned count = 3; + bool fail = false; + ui::map::MapMarkerStatus markerStatus() const override { return state; } + bool visitMarkers(ui::map::MapMarkerVisitor visit, void* context, void*, std::size_t) override + { + ++scans; + for (unsigned i = 0; i < count; ++i) + { + ui::map::MapMarker marker; + marker.id = i + 1; + marker.longitude_e7 = static_cast(i * 5); + marker.active_until = i ? 200 : 50; + std::strcpy(marker.title, "Event"); + visit(marker, context); + } + return !fail; + } +}; +} // namespace +int main() +{ + using namespace ui::widgets::map; + lv_init(); + auto* display = lv_display_create(320, 240); + lv_display_set_color_format(display, LV_COLOR_FORMAT_RGB565); + std::vector pixels(320 * 240); + lv_display_set_buffers(display, pixels.data(), nullptr, pixels.size() * 2, LV_DISPLAY_RENDER_MODE_FULL); + lv_display_set_flush_cb(display, [](lv_display_t* d, const lv_area_t*, uint8_t*) + { lv_display_flush_ready(d); }); + Source source; + const ui::map::MapMarkerBinding binding{&source, allocate, release}; + { + Runtime runtime; + create(runtime, lv_screen_active(), 50); + set_marker_binding(runtime, &binding); + assert(allocations == 1 && source.scans == 1 && fixture->markers->cache.count == 3); + for (int i = 0; i < 20; ++i) + { + refresh_markers(*fixture); + lv_obj_invalidate(fixture->widgets.root); + lv_refr_now(display); + } + assert(source.scans == 1 && allocations == 1); + assert(lv_obj_get_child_count(fixture->widgets.root) == 0); // Direct draw, no per-marker widgets. + fixture->model.pan_x = 40; + refresh_markers(*fixture); + assert(source.scans == 1); // Still inside the query margin. + fixture->model.pan_x = 65; + refresh_markers(*fixture); + assert(source.scans == 2); + fixture->model.zoom++; + refresh_markers(*fixture); + assert(source.scans == 3); + source.count = 40; + fixture->model.pan_x = 0; + ++source.state.revision; + refresh_markers(*fixture); + assert(fixture->markers->cache.count == 32 && fixture->markers->cache.truncated); + const auto before_move = source.scans; + fixture->model.pan_x = -10; + refresh_markers(*fixture); + assert(source.scans == before_move + 1); // Overflow selection follows the viewport. + source.fail = true; + ++source.state.revision; + refresh_markers(*fixture); + assert(!fixture->markers->cache.valid && fixture->markers->cache.count == 0); + const auto failed_scan = source.scans; + refresh_markers(*fixture); + assert(source.scans == failed_scan); // Read failure retry is bounded. + source.fail = false; + lv_tick_inc(3001); + refresh_markers(*fixture); + assert(fixture->markers->cache.valid && source.scans == failed_scan + 1); + source.state.ready = false; + refresh_markers(*fixture); + assert(!fixture->markers && releases == 1); + source.state.ready = true; + ++source.state.revision; + allocation_failure = true; + refresh_markers(*fixture); + const auto failed_allocation = allocations; + refresh_markers(*fixture); + assert(!fixture->markers && allocations == failed_allocation); + allocation_failure = false; + lv_tick_inc(3001); + refresh_markers(*fixture); + assert(fixture->markers); + source.state.now = 201; + const auto before_expiry = source.scans; + refresh_markers(*fixture); + assert(source.scans == before_expiry + 1); + } + assert(releases == 2 && fixture == nullptr); + lv_display_delete(display); + std::printf("Production marker query/draw/binding checks passed; session=%u bytes\n", static_cast(sizeof(MarkerSession))); +} diff --git a/tests/sd_record_io/CMakeLists.txt b/tests/sd_record_io/CMakeLists.txt new file mode 100644 index 00000000..7a1e8824 --- /dev/null +++ b/tests/sd_record_io/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.20) +project(SdRecordIoSessionTests LANGUAGES CXX) +enable_testing() +add_executable(sd_record_io_test test_session.cpp + ../../platform/esp/arduino_common/src/storage/sd_record_file_io.cpp) +target_include_directories(sd_record_io_test PRIVATE stubs + ../../platform/esp/arduino_common/include ../../platform/esp/common/include) +target_compile_features(sd_record_io_test PRIVATE cxx_std_17) +if(MSVC) + target_compile_options(sd_record_io_test PRIVATE /UNDEBUG) +else() + target_compile_options(sd_record_io_test PRIVATE -UNDEBUG) +endif() +add_test(NAME sd_record_io_session COMMAND sd_record_io_test) diff --git a/tests/sd_record_io/stubs/platform/esp/arduino_common/storage/sd_card_runtime.h b/tests/sd_record_io/stubs/platform/esp/arduino_common/storage/sd_card_runtime.h new file mode 100644 index 00000000..d6a3845e --- /dev/null +++ b/tests/sd_record_io/stubs/platform/esp/arduino_common/storage/sd_card_runtime.h @@ -0,0 +1,34 @@ +#pragma once +#include +#include +namespace platform::esp::arduino_common::storage +{ +bool sd_card_ready(); +bool sd_external_block_owner_active(); +uint32_t sd_media_session(); +enum class SdFileReadStatus +{ + Missing, + Invalid +}; +struct SdFileReadResult +{ + SdFileReadStatus status; +}; +SdFileReadResult sd_read_file(const char*, void*, std::size_t); +bool sd_rename(const char*, const char*, uint32_t); +class SdRuntimeFile +{ + public: + bool open(const char*, const char*, uint32_t); + bool is_open() const; + uint64_t size() const { return 16; } + bool seek(uint64_t) { return is_open(); } + int read(void*, std::size_t bytes) { return is_open() ? static_cast(bytes) : 0; } + std::size_t write(const void*, std::size_t bytes) { return is_open() ? bytes : 0; } + bool flush() { return is_open(); } + + private: + uint32_t session_ = 0; +}; +} // namespace platform::esp::arduino_common::storage diff --git a/tests/sd_record_io/test_session.cpp b/tests/sd_record_io/test_session.cpp new file mode 100644 index 00000000..02ce0f7c --- /dev/null +++ b/tests/sd_record_io/test_session.cpp @@ -0,0 +1,88 @@ +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/arduino_common/storage/sd_record_file_io.h" +#include +namespace io = platform::esp::arduino_common::storage; +namespace +{ +uint32_t session = 1; +bool external = false; +bool replace_on_open = false; +bool replace_on_probe = false; +unsigned opens = 0; +unsigned publishes = 0; +} // namespace +namespace platform::esp::arduino_common::storage +{ +bool sd_card_ready() { return true; } +bool sd_external_block_owner_active() { return external; } +uint32_t sd_media_session() { return session; } +SdFileReadResult sd_read_file(const char*, void*, std::size_t) +{ + if (replace_on_probe) + { + ++session; + replace_on_probe = false; + } + return {SdFileReadStatus::Missing}; +} +bool sd_rename(const char*, const char*, uint32_t expected) +{ + if (expected != session || external) return false; + ++publishes; + return true; +} +bool SdRuntimeFile::open(const char*, const char*, uint32_t expected) +{ + if (replace_on_open) + { + ++session; + replace_on_open = false; + } + if (expected != session || external) return false; + session_ = expected; + ++opens; + return true; +} +bool SdRuntimeFile::is_open() const { return session_ == session && !external; } +} // namespace platform::esp::arduino_common::storage +int main() +{ + io::SdRecordFileIo files; + using Mode = io::SdRecordFileIo::Mode; + using Status = io::SdRecordFileIo::OpenStatus; + assert(files.open("events", Mode::Update).status == Status::Unavailable); + files.bindSession(session); + auto opened = files.open("events", Mode::Update); + assert(opened.status == Status::Ready); + assert(files.sync(opened.handle)); + assert(files.close(opened.handle)); + ++session; + // Closing one file does not permit the next part of an old transaction + // to open or publish on a replacement card. + assert(files.open("events", Mode::Update).status == Status::Unavailable); + assert(!files.publish("pending", "events")); + assert(opens == 1 && publishes == 0); + files.bindSession(session); + replace_on_open = true; + assert(files.open("pending", Mode::CreateTemporary).status == Status::Unavailable); + assert(opens == 1); + files.bindSession(session); + replace_on_probe = true; + assert(!files.publish("pending", "events")); + assert(publishes == 0); + files.bindSession(session); + opened = files.open("events", Mode::Update); + assert(opened.status == Status::Ready); + ++session; + char byte = 0; + assert(files.write(opened.handle, &byte, 1) == 0); + assert(!files.sync(opened.handle)); + assert(!files.close(opened.handle)); + files.bindSession(session); + external = true; + assert(files.open("events", Mode::Read).status == Status::Unavailable); + assert(!files.publish("pending", "events")); + external = false; + assert(files.publish("pending", "events")); + assert(publishes == 1); +} diff --git a/tests/sd_transport/test_sd_filesystem.cpp b/tests/sd_transport/test_sd_filesystem.cpp index 3975dd3b..fcbe3128 100644 --- a/tests/sd_transport/test_sd_filesystem.cpp +++ b/tests/sd_transport/test_sd_filesystem.cpp @@ -1,3 +1,4 @@ +#include "platform/esp/arduino_common/storage/sd_file_lifetime.h" #include "platform/esp/arduino_common/storage/sd_file_probe.h" #include "platform/esp/arduino_common/storage/sd_transfer_policy.h" #include "platform/esp/arduino_common/storage/sdmmc_block_device.h" @@ -27,6 +28,7 @@ namespace uint32_t capacity = 0; std::unordered_map> sectors; size_t largest_read = 0, largest_write = 0, read_calls = 0; +size_t write_calls = 0; bool fail_reads = false; } // namespace @@ -59,6 +61,7 @@ esp_err_t sdmmc_read_sectors(sdmmc_card_t*, void* out, size_t sector, size_t cou } esp_err_t sdmmc_write_sectors(sdmmc_card_t*, const void* input, size_t sector, size_t count) { + ++write_calls; CHECK(sector <= capacity && count <= capacity - sector); largest_write = std::max(largest_write, count); const auto* src = static_cast(input); @@ -208,6 +211,18 @@ void round_trip(uint8_t fat_type, uint32_t card_sectors) device.clearIoError(); CHECK(probe_sd_path(volume, "/maps/base/not there.png", probe) == SdPathEvidence::Absent); CHECK(device.ioError() == ESP_OK); + // Dirty file metadata must not be flushed by stale-handle disposal after + // a media boundary. Exercise the same helper used by SdRuntimeFile. + file = volume.open("abandon.bin", O_CREAT | O_RDWR | O_TRUNC); + CHECK(file); + CHECK(file.write("pending", 7) == 7); + const auto before_abandon_reads = read_calls; + const auto before_abandon_writes = write_calls; + abandon_sd_file(file); + CHECK(!file); + CHECK(read_calls == before_abandon_reads && write_calls == before_abandon_writes); + CHECK(file.close()); + CHECK(read_calls == before_abandon_reads && write_calls == before_abandon_writes); volume.end(); } diff --git a/tests/test_ota_slot_size.py b/tests/test_ota_slot_size.py new file mode 100644 index 00000000..8e856bc5 --- /dev/null +++ b/tests/test_ota_slot_size.py @@ -0,0 +1,31 @@ +import importlib.util +from pathlib import Path +import struct +import unittest + +spec = importlib.util.spec_from_file_location('ota', Path(__file__).resolve().parents[1] / 'scripts/check_ota_slot_size.py') +ota = importlib.util.module_from_spec(spec) +spec.loader.exec_module(ota) + + +def entry(kind, subtype, size): + return struct.pack(' face.charToGlyphIndex(ch) !== 0); + remaining = remaining.filter(ch => face.charToGlyphIndex(ch) === 0); + if (supported.length) args.font.push({ source_path, source_bin, ranges: [{ symbols: supported.join('') }] }); + } + if (!args.font.length) throw new Error('No requested glyphs exist in the configured font sources'); + } files = await convert(args); } for (const [outputPath, data] of Object.entries(files)) { @@ -106,6 +121,7 @@ def is_bdf_source(path: Path) -> bool: def main() -> int: parser = argparse.ArgumentParser(description="Generate an LVGL binary font from a charset file via lv_font_conv.") parser.add_argument("--font", type=Path, required=True, help="Source font file, for example tools/fonts/NotoSansCJKsc-Regular.otf") + parser.add_argument("--fallback-font", type=Path, action="append", default=[], help="Outline font used only for glyphs missing from earlier sources; repeatable") parser.add_argument("--charset-file", type=Path, required=True, help="UTF-8 charset.txt file listing glyphs to include") parser.add_argument("--output", type=Path, required=True, help="Output font.bin path") parser.add_argument("--size", type=int, default=16, help="Font size in pixels") @@ -130,11 +146,17 @@ def main() -> int: font_path = args.font.resolve() charset_path = args.charset_file.resolve() output_path = args.output.resolve() + fallback_paths = [path.resolve() for path in args.fallback_font] if not font_path.exists(): raise FileNotFoundError(f"font file not found: {font_path}") if not charset_path.exists(): raise FileNotFoundError(f"charset file not found: {charset_path}") + for path in fallback_paths: + if not path.exists(): + raise FileNotFoundError(f"fallback font file not found: {path}") + if fallback_paths and any(is_bdf_source(path) for path in [font_path, *fallback_paths]): + raise ValueError("Fallback sources require outline fonts; native BDF glyphs must not be resampled") with tempfile.TemporaryDirectory(prefix="lv-font-conv-") as temp_dir_name: temp_dir = Path(temp_dir_name) @@ -174,6 +196,7 @@ def main() -> int: { "package_dir": str(package_dir), "font": str(font_path), + "fallback_fonts": [str(path) for path in fallback_paths], "charset_file": str(charset_path), "direct_font_data": str(direct_font_data_path) if direct_font_data_path else None, "output": str(output_path),