diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 8f1a857..ec718aa 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -839,8 +839,9 @@ Known local toolchain state: - `pp_app_core_app_frame_tests` covers the legacy initial surface default, idle/redraw/animation update gating, canvas-stroke draw eligibility, VR UI visibility, main UI suppression in VR-only mode, tick layout selection, - resize render-target/redraw projection, invalid resize rejection, and redraw - reset planning. + resize render-target/redraw projection, invalid resize rejection, redraw + reset planning, UI observer clipping, on-screen enter/leave transition + decisions, scissor projection, and malformed observer geometry rejection. - `pp_app_core_app_thread_tests` covers render/UI task dispatch, immediate same-thread execution, unique queued-task replacement, stopped-worker no-wait behavior, render queue context wrapping, UI tick redraw scheduling, diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 0d6283c..8ab8f1e 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -94,10 +94,13 @@ agent or engineer to remove them without reconstructing context from chat. - 2026-06-05: DEBT-0003 was narrowed. Initial surface sizing, redraw/animation update gating, layout tick selection, resize render-target recreation, canvas-stroke draw eligibility, VR UI pass selection, main UI pass selection, - and redraw reset are now tested `pp_app_core` frame plans consumed by - `App::create`, `App::tick`, `App::resize`, `App::update`, `App::draw`, and - `pano_cli plan-app-frame`; retained layout traversal, toolbar widget writes, - render-target recreation, and OpenGL/UI drawing remain in the legacy app. + UI observer clipping/on-screen transition/scissor projection, and redraw + reset are now tested `pp_app_core` frame plans consumed by `App::create`, + `App::tick`, `App::resize`, `App::update`, `App::draw`, + `App::update_ui_observer`, and `pano_cli plan-app-frame`; retained layout + traversal, toolbar widget writes, render-target recreation, `Node` parent + walking, on-screen callback execution, and OpenGL/UI drawing remain in the + legacy app. - 2026-06-05: DEBT-0003 was narrowed. Pointer coordinate normalization, mouse designer-first routing, gesture midpoint/delta math, touch/key main-layout routing, VR spacebar camera-sync intent, UI visibility toggling, @@ -153,7 +156,7 @@ agent or engineer to remove them without reconstructing context from chat. | --- | --- | --- | --- | --- | --- | --- | | DEBT-0001 | Open | Modernization | Existing platform build files remain alongside new CMake | Required for incremental migration without losing platform coverage | Existing platform builds plus new CMake configure | Remove after all platform builds consume shared CMake targets | | DEBT-0002 | Open | Modernization | Vendored SDK and patched libraries retained initially | Some dependencies are SDK-only, patched, or have platform-specific binaries | Dependency inventory and platform build smoke tests | Replace with vcpkg packages or document permanent vendored status after triplet evaluation | -| DEBT-0003 | Open | Modernization | Existing singletons remain during initial split; `App::open_document`, `App::request_close`, `App::share_file`, `App::cloud_upload`, `App::cloud_upload_all`, `App::cloud_browse`, `App::rec_start`, `App::rec_stop`, `App::rec_clear`, `App::rec_export`, `App::rec_loop`, `App::render_task*`, `App::ui_task*`, `App::render_thread_*`, `App::ui_thread_*`, file-menu save actions, `NodeCanvas` canvas hotkeys, new/open/browse dirty-document workflow prompts, new-document target/resolution/overwrite decisions, save-as document file naming and overwrite decisions, save-version target decisions, export start/menu/target naming/path decisions, share-file saved-path decisions, file/image/save/directory picker selected-path decisions, display-file external-open decisions, virtual-keyboard visibility decisions, recording lifecycle/export progress/worker decisions, cloud-upload prompt/save-before-upload decisions, cloud-browse availability and selected-download decisions, bulk cloud-upload progress decisions, tools/options app preference decisions, app status/display and renderer diagnostic decisions, app thread/task orchestration decisions, document resize decisions, layer rename/menu decisions, Tools menu/panel decisions, About menu/diagnostic decisions, main toolbar/status decisions, `pano_cli classify-open`, `pano_cli plan-open-route`, `pano_cli plan-file-menu`, `pano_cli plan-new-document`, `pano_cli plan-document-file`, `pano_cli plan-document-version`, `pano_cli plan-export-start`, `pano_cli plan-export-menu`, `pano_cli plan-export-target`, `pano_cli plan-recording-session`, `pano_cli plan-app-preferences`, `pano_cli plan-app-status`, `pano_cli plan-app-thread`, `pano_cli plan-tools-menu`, `pano_cli plan-tools-panel`, `pano_cli plan-about-menu`, `pano_cli plan-main-toolbar`, `pano_cli plan-document-resize`, `pano_cli plan-layer-rename`, `pano_cli plan-layer-menu`, `pano_cli plan-canvas-hotkey`, `pano_cli plan-share-file`, `pano_cli plan-picked-path`, `pano_cli plan-display-file`, `pano_cli plan-keyboard-visibility`, `pano_cli plan-cloud-upload`, `pano_cli plan-cloud-browse`, `pano_cli plan-cloud-upload-all`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session/export/recording/preferences/status/thread/share/platform-I/O/display/keyboard/cloud/resize/layer/tools/about/toolbar/canvas-command contracts, but document creation/loading, brush import execution, saving, export execution, tools/options UI execution, Tools panel creation/execution, About dialog/diagnostic execution, toolbar/status dialog/history/canvas execution, status/display UI rendering, renderer diagnostic capability adaptation, app task/thread execution, document resize execution, layer rename/menu execution, settings persistence, platform share service execution, picker service execution, display-file service execution, keyboard service execution, recording/MP4/PBO execution, cloud upload execution, and cloud browse/download execution still reach legacy `Canvas::I`/UI/network/video/platform singletons | Avoid behavior changes while introducing component boundaries | App launch and component tests; `pp_app_core_document_route_tests`; `pp_app_core_file_menu_tests`; `pp_app_core_document_export_tests`; `pp_app_core_document_recording_tests`; `pp_app_core_app_preferences_tests`; `pp_app_core_app_status_tests`; `pp_app_core_app_thread_tests`; `pp_app_core_tools_menu_tests`; `pp_app_core_about_menu_tests`; `pp_app_core_main_toolbar_tests`; `pp_app_core_document_resize_tests`; `pp_app_core_document_layer_tests`; `pp_app_core_document_sharing_tests`; `pp_app_core_document_platform_io_tests`; `pp_app_core_document_cloud_tests`; `pp_app_core_document_session_tests`; `pp_app_core_canvas_hotkey_tests`; `pano_cli classify-open --path D:/Paint/demo.ppi`; `pano_cli plan-open-route --path D:/Paint/demo.ppi --unsaved`; `pano_cli plan-file-menu --command save-as`; `pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3 --target-exists`; `pano_cli plan-document-file --work-dir D:/Paint --name demo --target-exists`; `pano_cli plan-document-version --directory D:/Paint --doc-name demo.01 --existing-path D:/Paint/demo.02.ppi`; `pano_cli plan-export-start --requires-license --demo`; `pano_cli plan-export-menu --kind animation-mp4 --demo`; `pano_cli plan-export-target --kind file --work-dir D:/Paint --doc-name demo --extension .png`; `pano_cli plan-recording-session --running --frame-count 12`; `pano_cli plan-recording-session --running --no-encoder`; `pano_cli plan-app-preferences --ui-scale 1.5 --display-density 2 --current-scale 1.6 --scale-option 1 --scale-option 1.5 --rtl`; `pano_cli plan-app-status --doc-name demo --unsaved --resolution 2048 --resolution-index 3 --zoom 1.25 --history-bytes 1572864 --recording-running --encoder-available --encoded-frames 12 --framebuffer-fetch --float32 --float32-linear --float16`; `pano_cli plan-app-thread --kind ui-loop --live-reload`; `pano_cli plan-tools-menu --command shortcuts`; `pano_cli plan-tools-panel --panel layers`; `pano_cli plan-about-menu --command news --version-major 2 --version-minor 5 --version-fix 7`; `pano_cli plan-main-toolbar --command undo --undo-count 2`; `pano_cli plan-document-resize --current-resolution 2048 --selected-resolution-index 4`; `pano_cli plan-layer-rename --old-name Base --new-name Paint`; `pano_cli plan-layer-menu --command merge --current-index 2 --lower-name Paint`; `pano_cli plan-canvas-hotkey --event key-up --key z --ctrl --undo-count 2`; `pano_cli plan-share-file --path D:/Paint/demo.ppi`; `pano_cli plan-picked-path --path D:/Paint/demo.ppi`; `pano_cli plan-display-file --path D:/Paint/export.png`; `pano_cli plan-keyboard-visibility --visible`; `pano_cli plan-cloud-upload --new-document --unsaved`; `pano_cli plan-cloud-browse --selected-file demo.ppi`; `pano_cli plan-cloud-upload-all --file-count 3`; `pano_cli simulate-app-session --unsaved --save-intent save-dirty-version`; `pano_cli simulate-app-session --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Replace singleton reaches with context/service injection at component boundaries | +| DEBT-0003 | Open | Modernization | Existing singletons remain during initial split; `App::open_document`, `App::request_close`, `App::share_file`, `App::cloud_upload`, `App::cloud_upload_all`, `App::cloud_browse`, `App::rec_start`, `App::rec_stop`, `App::rec_clear`, `App::rec_export`, `App::rec_loop`, `App::update_ui_observer`, `App::render_task*`, `App::ui_task*`, `App::render_thread_*`, `App::ui_thread_*`, file-menu save actions, `NodeCanvas` canvas hotkeys, new/open/browse dirty-document workflow prompts, new-document target/resolution/overwrite decisions, save-as document file naming and overwrite decisions, save-version target decisions, export start/menu/target naming/path decisions, share-file saved-path decisions, file/image/save/directory picker selected-path decisions, display-file external-open decisions, virtual-keyboard visibility decisions, recording lifecycle/export progress/worker decisions, cloud-upload prompt/save-before-upload decisions, cloud-browse availability and selected-download decisions, bulk cloud-upload progress decisions, tools/options app preference decisions, app status/display and renderer diagnostic decisions, app frame/UI-observer decisions, app thread/task orchestration decisions, document resize decisions, layer rename/menu decisions, Tools menu/panel decisions, About menu/diagnostic decisions, main toolbar/status decisions, `pano_cli classify-open`, `pano_cli plan-open-route`, `pano_cli plan-file-menu`, `pano_cli plan-new-document`, `pano_cli plan-document-file`, `pano_cli plan-document-version`, `pano_cli plan-export-start`, `pano_cli plan-export-menu`, `pano_cli plan-export-target`, `pano_cli plan-recording-session`, `pano_cli plan-app-preferences`, `pano_cli plan-app-status`, `pano_cli plan-app-thread`, `pano_cli plan-tools-menu`, `pano_cli plan-tools-panel`, `pano_cli plan-about-menu`, `pano_cli plan-main-toolbar`, `pano_cli plan-document-resize`, `pano_cli plan-layer-rename`, `pano_cli plan-layer-menu`, `pano_cli plan-canvas-hotkey`, `pano_cli plan-share-file`, `pano_cli plan-picked-path`, `pano_cli plan-display-file`, `pano_cli plan-keyboard-visibility`, `pano_cli plan-cloud-upload`, `pano_cli plan-cloud-browse`, `pano_cli plan-cloud-upload-all`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session/export/recording/preferences/status/thread/share/platform-I/O/display/keyboard/cloud/resize/layer/tools/about/toolbar/canvas-command contracts, but document creation/loading, brush import execution, saving, export execution, tools/options UI execution, Tools panel creation/execution, About dialog/diagnostic execution, toolbar/status dialog/history/canvas execution, status/display UI rendering, renderer diagnostic capability adaptation, app task/thread execution, UI observer parent walking/callback execution, document resize execution, layer rename/menu execution, settings persistence, platform share service execution, picker service execution, display-file service execution, keyboard service execution, recording/MP4/PBO execution, cloud upload execution, and cloud browse/download execution still reach legacy `Canvas::I`/UI/network/video/platform singletons | Avoid behavior changes while introducing component boundaries | App launch and component tests; `pp_app_core_document_route_tests`; `pp_app_core_file_menu_tests`; `pp_app_core_document_export_tests`; `pp_app_core_document_recording_tests`; `pp_app_core_app_frame_tests`; `pp_app_core_app_preferences_tests`; `pp_app_core_app_status_tests`; `pp_app_core_app_thread_tests`; `pp_app_core_tools_menu_tests`; `pp_app_core_about_menu_tests`; `pp_app_core_main_toolbar_tests`; `pp_app_core_document_resize_tests`; `pp_app_core_document_layer_tests`; `pp_app_core_document_sharing_tests`; `pp_app_core_document_platform_io_tests`; `pp_app_core_document_cloud_tests`; `pp_app_core_document_session_tests`; `pp_app_core_canvas_hotkey_tests`; `pano_cli classify-open --path D:/Paint/demo.ppi`; `pano_cli plan-open-route --path D:/Paint/demo.ppi --unsaved`; `pano_cli plan-file-menu --command save-as`; `pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3 --target-exists`; `pano_cli plan-document-file --work-dir D:/Paint --name demo --target-exists`; `pano_cli plan-document-version --directory D:/Paint --doc-name demo.01 --existing-path D:/Paint/demo.02.ppi`; `pano_cli plan-export-start --requires-license --demo`; `pano_cli plan-export-menu --kind animation-mp4 --demo`; `pano_cli plan-export-target --kind file --work-dir D:/Paint --doc-name demo --extension .png`; `pano_cli plan-recording-session --running --frame-count 12`; `pano_cli plan-recording-session --running --no-encoder`; `pano_cli plan-app-preferences --ui-scale 1.5 --display-density 2 --current-scale 1.6 --scale-option 1 --scale-option 1.5 --rtl`; `pano_cli plan-app-status --doc-name demo --unsaved --resolution 2048 --resolution-index 3 --zoom 1.25 --history-bytes 1572864 --recording-running --encoder-available --encoded-frames 12 --framebuffer-fetch --float32 --float32-linear --float16`; `pano_cli plan-app-frame`; `pano_cli plan-app-thread --kind ui-loop --live-reload`; `pano_cli plan-tools-menu --command shortcuts`; `pano_cli plan-tools-panel --panel layers`; `pano_cli plan-about-menu --command news --version-major 2 --version-minor 5 --version-fix 7`; `pano_cli plan-main-toolbar --command undo --undo-count 2`; `pano_cli plan-document-resize --current-resolution 2048 --selected-resolution-index 4`; `pano_cli plan-layer-rename --old-name Base --new-name Paint`; `pano_cli plan-layer-menu --command merge --current-index 2 --lower-name Paint`; `pano_cli plan-canvas-hotkey --event key-up --key z --ctrl --undo-count 2`; `pano_cli plan-share-file --path D:/Paint/demo.ppi`; `pano_cli plan-picked-path --path D:/Paint/demo.ppi`; `pano_cli plan-display-file --path D:/Paint/export.png`; `pano_cli plan-keyboard-visibility --visible`; `pano_cli plan-cloud-upload --new-document --unsaved`; `pano_cli plan-cloud-browse --selected-file demo.ppi`; `pano_cli plan-cloud-upload-all --file-count 3`; `pano_cli simulate-app-session --unsaved --save-intent save-dirty-version`; `pano_cli simulate-app-session --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Replace singleton reaches with context/service injection at component boundaries | | DEBT-0004 | Open | Modernization | Android, Linux, WebGL, Apple, and AppX build files remain platform-specific until root CMake alignment reaches them | Prevent platform regressions during incremental migration; raw Windows `.sln/.vcxproj` files were removed on 2026-05-31 by user decision | `cmake --preset windows-msvc-default`; platform-specific configure/build smoke checks as each platform is migrated | Root CMake owns every platform source list and package path | | DEBT-0005 | Open | Modernization | Temporary local CTest harness is used before Catch2 is wired through vcpkg | `vcpkg` is not currently on PATH, but headless tests need to run now | `ctest --preset desktop-fast --build-config Debug` | Replace `tests/test_harness.h` tests with Catch2 tests once vcpkg toolchain/presets are validated | | DEBT-0007 | Open | Modernization | `vcpkg.json` and `windows-msvc-vcpkg-headless` are validated for the headless Windows component matrix, but app targets still use vendored libraries and Android/Apple triplets are not proven | Dependency migration must stay incremental while SDK/patched/vendor dependencies remain in use | `$env:VCPKG_ROOT="C:\Program Files\Microsoft Visual Studio\2022\Community\VC\vcpkg"; cmake --preset windows-msvc-vcpkg-headless`; `ctest --preset desktop-fast-vcpkg --build-config Debug` | Component targets consume vcpkg packages where reliable and desktop app, Android, and Apple triplets are validated or explicitly documented as permanent vendor exceptions | diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 5bd648a..0d9a618 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -200,10 +200,12 @@ and floating-point render targets; `App::title_update`, legacy UI nodes still render the strings and status lights. Frame-level app decisions for the initial surface size, redraw/animation update gating, layout ticking, resize render-target recreation, canvas-stroke drawing, -VR UI drawing, main UI drawing, and redraw reset now live in `pp_app_core`; +VR UI drawing, main UI drawing, UI observer clipping/on-screen transition/scissor +projection, and redraw reset now live in `pp_app_core`; `App::create`, `App::tick`, `App::resize`, `App::update`, `App::draw`, and `pano_cli plan-app-frame` consume those plans while retained layout traversal, -render-target recreation, and OpenGL/UI drawing stay in the legacy app. +render-target recreation, `Node` parent walking, on-screen callbacks, and +OpenGL/UI drawing stay in the legacy app. App input dispatch decisions for pointer coordinate normalization, mouse designer-first routing, gesture midpoint/delta math, touch/key main-layout routing, VR spacebar camera-sync intent, UI visibility toggling, and stylus @@ -1682,7 +1684,11 @@ Results: `pano_cli_plan_app_frame_vr_smoke`, and `pano_cli_plan_app_frame_idle_missing_canvas_smoke`, with resize automation covered by `pano_cli_plan_app_frame_resize_smoke` and - `pano_cli_plan_app_frame_rejects_bad_resize`. + `pano_cli_plan_app_frame_rejects_bad_resize`. On 2026-06-05, UI observer + clipping/on-screen/scissor projection coverage was added through + `pp_app_core_app_frame_tests`, `pano_cli_plan_app_frame_observer_smoke`, + `pano_cli_plan_app_frame_observer_clipped_smoke`, and + `pano_cli_plan_app_frame_rejects_bad_observer`. - `PanoPainter`, `pp_app_core_app_input_tests`, and `pano_cli` built after app input routing and normalization moved into `pp_app_core`. - Focused app-input CTest coverage passed for `pp_app_core_app_input_tests`, diff --git a/src/app.cpp b/src/app.cpp index 024709a..a518585 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -495,54 +495,67 @@ void App::async_swap() bool App::update_ui_observer(Node *n) { - if (n && n->m_display) - { - auto box = n->m_clip_uncut; - Node* p = n->m_parent; - while (p) - { - float pt = YGNodeLayoutGetPadding(p->y_node, YGEdgeTop); - float pr = YGNodeLayoutGetPadding(p->y_node, YGEdgeRight); - float pb = YGNodeLayoutGetPadding(p->y_node, YGEdgeBottom); - float pl = YGNodeLayoutGetPadding(p->y_node, YGEdgeLeft); - glm::vec2 off_p(pl, pt); - glm::vec2 off_s(pr, pb); - //glm::vec2 parent_offset = p->m_parent ? -p->m_parent->m_pos_offset_childred : glm::vec2(0.f); - glm::vec4 pclip = { xy(p->m_clip_uncut) + off_p, zw(p->m_clip_uncut) - off_s - off_p/* + parent_offset*/ }; - box = rect_intersection(box, pclip); - p = p->m_parent; + std::vector parent_clips; + if (n) { + for (Node* p = n->m_parent; p; p = p->m_parent) { + parent_clips.push_back(pp::app::AppUiObserverParentClip { + .clip = pp::app::AppUiObserverRect { + .x = p->m_clip_uncut.x, + .y = p->m_clip_uncut.y, + .width = p->m_clip_uncut.z, + .height = p->m_clip_uncut.w, + }, + .padding_top = YGNodeLayoutGetPadding(p->y_node, YGEdgeTop), + .padding_right = YGNodeLayoutGetPadding(p->y_node, YGEdgeRight), + .padding_bottom = YGNodeLayoutGetPadding(p->y_node, YGEdgeBottom), + .padding_left = YGNodeLayoutGetPadding(p->y_node, YGEdgeLeft), + }); } - //auto box = n->m_clip; - //glm::ivec4 c = glm::vec4((int)box.x - 1, (int)(height / zoom - box.y - box.w) - 1, (int)box.z + 2, (int)box.w + 2) * zoom; - glm::vec2 parent_offset = n->m_parent ? n->m_parent->m_pos_offset_childred : glm::vec2(0.f); - if (box.z <= 0 || box.w <= 0) - { - if (n->m_on_screen) - { - if (dynamic_cast(n)) - p = p; - n->handle_on_screen(true, false); - n->m_on_screen = false; - } - return false; - } - if (!n->m_on_screen) - { - n->handle_on_screen(false, true); - n->m_on_screen = true; - } - glm::ivec4 c = glm::vec4(box.x - 1, (height / zoom - box.y - box.w - 1), box.z + 2, box.w + 2) * zoom; - apply_app_scissor(pp::renderer::gl::OpenGlScissorRect { - .enabled = 1U, - .x = static_cast(floorf(c.x + off_x)), - .y = static_cast(floorf(c.y + off_y)), - .width = static_cast(ceilf(c.z)), - .height = static_cast(ceilf(c.w)), - }); - n->draw(); - return true; } - return false; + + const auto plan = pp::app::plan_app_ui_observer( + n != nullptr, + n && n->m_display, + n && n->m_on_screen, + n + ? pp::app::AppUiObserverRect { + .x = n->m_clip_uncut.x, + .y = n->m_clip_uncut.y, + .width = n->m_clip_uncut.z, + .height = n->m_clip_uncut.w, + } + : pp::app::AppUiObserverRect {}, + parent_clips, + height, + zoom, + off_x, + off_y); + if (!plan) { + LOG("UI observer plan failed: %s", plan.status().message); + return false; + } + + if (!n) + return false; + + if (plan.value().notify_leave_screen) + n->handle_on_screen(true, false); + if (plan.value().notify_enter_screen) + n->handle_on_screen(false, true); + n->m_on_screen = plan.value().next_on_screen; + + if (!plan.value().draw_node) + return false; + + apply_app_scissor(pp::renderer::gl::OpenGlScissorRect { + .enabled = 1U, + .x = plan.value().scissor_x, + .y = plan.value().scissor_y, + .width = plan.value().scissor_width, + .height = plan.value().scissor_height, + }); + n->draw(); + return true; } void App::draw(float dt) diff --git a/src/app_core/app_frame.h b/src/app_core/app_frame.h index 8df88bc..f701eeb 100644 --- a/src/app_core/app_frame.h +++ b/src/app_core/app_frame.h @@ -3,7 +3,9 @@ #include "foundation/result.h" #include +#include #include +#include namespace pp::app { @@ -39,6 +41,33 @@ struct AppResizePlan { bool request_redraw = true; }; +struct AppUiObserverRect { + float x = 0.0F; + float y = 0.0F; + float width = 0.0F; + float height = 0.0F; +}; + +struct AppUiObserverParentClip { + AppUiObserverRect clip; + float padding_top = 0.0F; + float padding_right = 0.0F; + float padding_bottom = 0.0F; + float padding_left = 0.0F; +}; + +struct AppUiObserverPlan { + bool draw_node = false; + bool notify_enter_screen = false; + bool notify_leave_screen = false; + bool next_on_screen = false; + AppUiObserverRect visible_clip; + std::int32_t scissor_x = 0; + std::int32_t scissor_y = 0; + std::int32_t scissor_width = 0; + std::int32_t scissor_height = 0; +}; + [[nodiscard]] constexpr AppInitialSurfacePlan plan_app_initial_surface() noexcept { return AppInitialSurfacePlan { @@ -110,4 +139,102 @@ struct AppResizePlan { }); } +[[nodiscard]] constexpr AppUiObserverRect intersect_app_ui_observer_rect( + AppUiObserverRect a, + AppUiObserverRect b) noexcept +{ + const float x0 = a.x > b.x ? a.x : b.x; + const float y0 = a.y > b.y ? a.y : b.y; + const float x1 = (a.x + a.width) < (b.x + b.width) ? (a.x + a.width) : (b.x + b.width); + const float y1 = (a.y + a.height) < (b.y + b.height) ? (a.y + a.height) : (b.y + b.height); + return AppUiObserverRect { + .x = x0, + .y = y0, + .width = x1 - x0, + .height = y1 - y0, + }; +} + +[[nodiscard]] inline pp::foundation::Result plan_app_ui_observer( + bool has_node, + bool display, + bool was_on_screen, + AppUiObserverRect node_clip, + std::span parent_clips, + float surface_height, + float zoom, + float offset_x, + float offset_y) +{ + if (!has_node || !display) { + return pp::foundation::Result::success(AppUiObserverPlan { + .draw_node = false, + .next_on_screen = was_on_screen, + .visible_clip = node_clip, + }); + } + + const auto finite_rect = [](AppUiObserverRect rect) noexcept { + return std::isfinite(rect.x) && std::isfinite(rect.y) + && std::isfinite(rect.width) && std::isfinite(rect.height); + }; + + if (!finite_rect(node_clip) || !std::isfinite(surface_height) + || !std::isfinite(zoom) || !std::isfinite(offset_x) || !std::isfinite(offset_y)) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("UI observer geometry must be finite")); + } + + if (surface_height < 1.0F || zoom <= 0.0F) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("UI observer surface height and zoom must be positive")); + } + + AppUiObserverRect visible = node_clip; + for (const auto& parent : parent_clips) { + if (!finite_rect(parent.clip) + || !std::isfinite(parent.padding_top) + || !std::isfinite(parent.padding_right) + || !std::isfinite(parent.padding_bottom) + || !std::isfinite(parent.padding_left)) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("UI observer parent geometry must be finite")); + } + + const AppUiObserverRect padded { + .x = parent.clip.x + parent.padding_left, + .y = parent.clip.y + parent.padding_top, + .width = parent.clip.width - parent.padding_right - parent.padding_left, + .height = parent.clip.height - parent.padding_bottom - parent.padding_top, + }; + visible = intersect_app_ui_observer_rect(visible, padded); + } + + if (visible.width <= 0.0F || visible.height <= 0.0F) { + return pp::foundation::Result::success(AppUiObserverPlan { + .draw_node = false, + .notify_leave_screen = was_on_screen, + .next_on_screen = false, + .visible_clip = visible, + }); + } + + const float projected_x = (visible.x - 1.0F) * zoom; + const float projected_y = (surface_height / zoom - visible.y - visible.height - 1.0F) * zoom; + const float projected_width = (visible.width + 2.0F) * zoom; + const float projected_height = (visible.height + 2.0F) * zoom; + + return pp::foundation::Result::success(AppUiObserverPlan { + .draw_node = true, + .notify_enter_screen = !was_on_screen, + .notify_leave_screen = false, + .next_on_screen = true, + .visible_clip = visible, + .scissor_x = static_cast(std::floor(projected_x + offset_x)), + .scissor_y = static_cast(std::floor(projected_y + offset_y)), + .scissor_width = static_cast(std::ceil(projected_width)), + .scissor_height = static_cast(std::ceil(projected_height)), + }); +} + } // namespace pp::app diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 31a5f6e..d52de8c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1028,6 +1028,25 @@ if(TARGET pano_cli) WILL_FAIL TRUE PASS_REGULAR_EXPRESSION "\"command\":\"plan-app-frame\".*\"message\":\"resize dimensions") + add_test(NAME pano_cli_plan_app_frame_observer_smoke + COMMAND pano_cli plan-app-frame) + set_tests_properties(pano_cli_plan_app_frame_observer_smoke PROPERTIES + LABELS "app;ui;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-app-frame\".*\"observer\":\\{\"drawNode\":true,\"notifyEnterScreen\":true,\"notifyLeaveScreen\":false,\"nextOnScreen\":true,\"visibleWidth\":65,\"visibleHeight\":35,\"scissorX\":28,\"scissorY\":433,\"scissorWidth\":134,\"scissorHeight\":74\\}") + + add_test(NAME pano_cli_plan_app_frame_observer_clipped_smoke + COMMAND pano_cli plan-app-frame --observer-on-screen --observer-clipped-out) + set_tests_properties(pano_cli_plan_app_frame_observer_clipped_smoke PROPERTIES + LABELS "app;ui;integration;desktop-fast;fuzz" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-app-frame\".*\"observer\":\\{\"drawNode\":false,\"notifyEnterScreen\":false,\"notifyLeaveScreen\":true,\"nextOnScreen\":false") + + add_test(NAME pano_cli_plan_app_frame_rejects_bad_observer + COMMAND pano_cli plan-app-frame --observer-bad-geometry) + set_tests_properties(pano_cli_plan_app_frame_rejects_bad_observer PROPERTIES + LABELS "app;ui;integration;desktop-fast;fuzz" + WILL_FAIL TRUE + PASS_REGULAR_EXPRESSION "\"command\":\"plan-app-frame\".*\"message\":\"UI observer geometry") + add_test(NAME pano_cli_plan_app_thread_dispatch_smoke COMMAND pano_cli plan-app-thread --kind dispatch --unique --queued-tasks 2 --wait) set_tests_properties(pano_cli_plan_app_thread_dispatch_smoke PROPERTIES diff --git a/tests/app_core/app_frame_tests.cpp b/tests/app_core/app_frame_tests.cpp index 846b72e..f1b2ae4 100644 --- a/tests/app_core/app_frame_tests.cpp +++ b/tests/app_core/app_frame_tests.cpp @@ -101,6 +101,129 @@ void resize_plan_rejects_invalid_dimensions(pp::tests::Harness& harness) PP_EXPECT(harness, !pp::app::plan_app_resize(std::nanf(""), 720.0F)); } +void ui_observer_plan_projects_visible_clip_to_scissor(pp::tests::Harness& harness) +{ + const pp::app::AppUiObserverParentClip parents[] { + { + .clip = { + .x = 0.0F, + .y = 0.0F, + .width = 80.0F, + .height = 60.0F, + }, + .padding_top = 5.0F, + .padding_right = 5.0F, + .padding_bottom = 5.0F, + .padding_left = 5.0F, + }, + }; + + const auto plan = pp::app::plan_app_ui_observer( + true, + true, + false, + { + .x = 10.0F, + .y = 20.0F, + .width = 100.0F, + .height = 50.0F, + }, + parents, + 540.0F, + 2.0F, + 10.0F, + 5.0F); + + PP_EXPECT(harness, plan); + if (plan) { + PP_EXPECT(harness, plan.value().draw_node); + PP_EXPECT(harness, plan.value().notify_enter_screen); + PP_EXPECT(harness, !plan.value().notify_leave_screen); + PP_EXPECT(harness, plan.value().next_on_screen); + PP_EXPECT(harness, plan.value().visible_clip.width == 65.0F); + PP_EXPECT(harness, plan.value().visible_clip.height == 35.0F); + PP_EXPECT(harness, plan.value().scissor_x == 28); + PP_EXPECT(harness, plan.value().scissor_y == 433); + PP_EXPECT(harness, plan.value().scissor_width == 134); + PP_EXPECT(harness, plan.value().scissor_height == 74); + } +} + +void ui_observer_plan_notifies_leave_for_clipped_visible_node(pp::tests::Harness& harness) +{ + const pp::app::AppUiObserverParentClip parents[] { + { + .clip = { + .x = 0.0F, + .y = 0.0F, + .width = 10.0F, + .height = 10.0F, + }, + }, + }; + + const auto plan = pp::app::plan_app_ui_observer( + true, + true, + true, + { + .x = 100.0F, + .y = 100.0F, + .width = 10.0F, + .height = 10.0F, + }, + parents, + 540.0F, + 1.0F, + 0.0F, + 0.0F); + + PP_EXPECT(harness, plan); + if (plan) { + PP_EXPECT(harness, !plan.value().draw_node); + PP_EXPECT(harness, plan.value().notify_leave_screen); + PP_EXPECT(harness, !plan.value().next_on_screen); + } +} + +void ui_observer_plan_rejects_bad_geometry(pp::tests::Harness& harness) +{ + PP_EXPECT( + harness, + !pp::app::plan_app_ui_observer( + true, + true, + false, + { + .x = 0.0F, + .y = 0.0F, + .width = 10.0F, + .height = 10.0F, + }, + {}, + 540.0F, + 0.0F, + 0.0F, + 0.0F)); + PP_EXPECT( + harness, + !pp::app::plan_app_ui_observer( + true, + true, + false, + { + .x = std::nanf(""), + .y = 0.0F, + .width = 10.0F, + .height = 10.0F, + }, + {}, + 540.0F, + 1.0F, + 0.0F, + 0.0F)); +} + } // namespace int main() @@ -115,5 +238,8 @@ int main() harness.run("tick plan selects available layouts", tick_plan_selects_available_layouts); harness.run("resize plan projects render target and redraw", resize_plan_projects_render_target_and_redraw); harness.run("resize plan rejects invalid dimensions", resize_plan_rejects_invalid_dimensions); + harness.run("ui observer plan projects visible clip to scissor", ui_observer_plan_projects_visible_clip_to_scissor); + harness.run("ui observer plan notifies leave for clipped visible node", ui_observer_plan_notifies_leave_for_clipped_visible_node); + harness.run("ui observer plan rejects bad geometry", ui_observer_plan_rejects_bad_geometry); return harness.finish(); } diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index c6761ea..79f7f01 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -265,6 +265,10 @@ struct PlanAppFrameArgs { float resize_width = 1280.0F; float resize_height = 720.0F; bool bad_resize = false; + bool observer_display = true; + bool observer_on_screen = false; + bool observer_clipped_out = false; + bool observer_bad_geometry = false; }; struct PlanAppThreadArgs { @@ -2096,7 +2100,7 @@ void print_help() << " plan-app-preferences [--ui-scale N] [--display-density N] [--current-scale N] [--scale-option N] [--viewport-scale N] [--rtl] [--timelapse-disabled] [--recording-running] [--vr-controllers-disabled] [--cursor-mode N]\n" << " plan-app-startup [--run-counter N] [--auto-timelapse-disabled] [--vr-controllers-disabled] [--license-invalid]\n" << " plan-app-startup-resources [--width N] [--height N] [--bad-size]\n" - << " plan-app-frame [--redraw] [--animate] [--no-designer-layout] [--no-main-layout] [--no-canvas] [--no-canvas-document] [--vr-active] [--ui-hidden] [--vr-only] [--resize-width N] [--resize-height N] [--bad-resize]\n" + << " plan-app-frame [--redraw] [--animate] [--no-designer-layout] [--no-main-layout] [--no-canvas] [--no-canvas-document] [--vr-active] [--ui-hidden] [--vr-only] [--resize-width N] [--resize-height N] [--bad-resize] [--observer-hidden] [--observer-on-screen] [--observer-clipped-out] [--observer-bad-geometry]\n" << " plan-app-thread --kind dispatch|render-drain|ui-drain|ui-tick|ui-loop|redraw|start|stop [--on-target-thread] [--unique] [--worker-stopped] [--wait] [--request-redraw] [--redraw] [--live-reload] [--not-joinable] [--queued-tasks N] [--rendered-frames N] [--dt N] [--frame-accumulator N] [--fps-accumulator N] [--reload-accumulator N] [--bad-timer]\n" << " plan-app-input --kind pointer|gesture|cancel|main|key|ui-toggle|stylus [--x N] [--y N] [--x1 N] [--y1 N] [--prev-x N] [--prev-y N] [--prev-x1 N] [--prev-y1 N] [--zoom N] [--no-designer-layout] [--no-main-layout] [--spacebar] [--vr-active] [--key-up] [--ui-hidden] [--no-canvas] [--main-child-count N] [--panel-child-count N] [--bad-float]\n" << " plan-app-shutdown\n" @@ -3866,6 +3870,14 @@ pp::foundation::Status parse_plan_app_frame_args( args.resize_height = value.value(); } else if (key == "--bad-resize") { args.bad_resize = true; + } else if (key == "--observer-hidden") { + args.observer_display = false; + } else if (key == "--observer-on-screen") { + args.observer_on_screen = true; + } else if (key == "--observer-clipped-out") { + args.observer_clipped_out = true; + } else if (key == "--observer-bad-geometry") { + args.observer_bad_geometry = true; } else { return pp::foundation::Status::invalid_argument("unknown option"); } @@ -3899,6 +3911,39 @@ int plan_app_frame(int argc, char** argv) print_error("plan-app-frame", resize.status().message); return 2; } + const pp::app::AppUiObserverParentClip parent_clips[] { + { + .clip = { + .x = 0.0F, + .y = 0.0F, + .width = args.observer_clipped_out ? 10.0F : 80.0F, + .height = args.observer_clipped_out ? 10.0F : 60.0F, + }, + .padding_top = args.observer_clipped_out ? 0.0F : 5.0F, + .padding_right = args.observer_clipped_out ? 0.0F : 5.0F, + .padding_bottom = args.observer_clipped_out ? 0.0F : 5.0F, + .padding_left = args.observer_clipped_out ? 0.0F : 5.0F, + }, + }; + const auto observer = pp::app::plan_app_ui_observer( + true, + args.observer_display, + args.observer_on_screen, + { + .x = args.observer_bad_geometry ? std::nanf("") : (args.observer_clipped_out ? 100.0F : 10.0F), + .y = args.observer_clipped_out ? 100.0F : 20.0F, + .width = args.observer_clipped_out ? 10.0F : 100.0F, + .height = args.observer_clipped_out ? 10.0F : 50.0F, + }, + parent_clips, + 540.0F, + args.observer_bad_geometry ? 0.0F : 2.0F, + 10.0F, + 5.0F); + if (!observer) { + print_error("plan-app-frame", observer.status().message); + return 2; + } std::cout << "{\"ok\":true,\"command\":\"plan-app-frame\"" << ",\"surface\":{\"width\":" << surface.width @@ -3918,6 +3963,16 @@ int plan_app_frame(int argc, char** argv) << ",\"renderTargetHeight\":" << resize.value().render_target_height << ",\"recreateUiRenderTarget\":" << json_bool(resize.value().recreate_ui_render_target) << ",\"requestRedraw\":" << json_bool(resize.value().request_redraw) + << "},\"observer\":{\"drawNode\":" << json_bool(observer.value().draw_node) + << ",\"notifyEnterScreen\":" << json_bool(observer.value().notify_enter_screen) + << ",\"notifyLeaveScreen\":" << json_bool(observer.value().notify_leave_screen) + << ",\"nextOnScreen\":" << json_bool(observer.value().next_on_screen) + << ",\"visibleWidth\":" << observer.value().visible_clip.width + << ",\"visibleHeight\":" << observer.value().visible_clip.height + << ",\"scissorX\":" << observer.value().scissor_x + << ",\"scissorY\":" << observer.value().scissor_y + << ",\"scissorWidth\":" << observer.value().scissor_width + << ",\"scissorHeight\":" << observer.value().scissor_height << "}}\n"; return 0; }