Centralize legacy document open bridge

This commit is contained in:
2026-06-04 13:20:14 +02:00
parent 1984b71a0a
commit d980b81bd7
9 changed files with 336 additions and 64 deletions

View File

@@ -84,6 +84,8 @@ set(PP_PANOPAINTER_APP_SOURCES
src/app_vr.cpp
src/legacy_cloud_services.cpp
src/legacy_cloud_services.h
src/legacy_document_open_services.cpp
src/legacy_document_open_services.h
src/platform_legacy/legacy_platform_services.cpp
src/platform_legacy/legacy_platform_services.h
src/version.cpp

View File

@@ -583,10 +583,15 @@ Known local toolchain state:
decisions, plus bulk upload progress visibility, zero-file, and clamped
progress-total decisions.
- `pp_app_core_document_session_tests` covers clean and dirty app session,
document-open action planning, save-request, save-before-workflow,
new-document target/resolution/overwrite planning, document file target,
combined save-file overwrite planning, and save-version target decisions
without requiring a window, canvas, or message box.
document-open action planning and executor dispatch/rejection, save-request,
save-before-workflow, new-document target/resolution/overwrite planning,
document file target, combined save-file overwrite planning, and save-version
target decisions without requiring a window, canvas, or message box.
- `src/legacy_document_open_services.*` is the current app-shell bridge between
`pp_app_core` document-open plans and live ABR/PPBR import prompts,
unsaved-project discard prompts, project opening, layer UI refresh, title
updates, and action-history clearing; remaining legacy execution ownership is
tracked by `DEBT-0039`.
- `src/legacy_history_services.*` is the current app-shell bridge between
`pp_app_core` history plans and legacy `ActionManager`; toolbar and
`NodeCanvas` hotkeys share it while document-history extraction remains

View File

@@ -56,6 +56,7 @@ agent or engineer to remove them without reconstructing context from chat.
| DEBT-0036 | Open | Modernization | `pp_renderer_api`, `pp_paint_renderer`, `pano_cli plan-paint-feedback`, and `pano_cli plan-stroke-composite` can choose backend-neutral complex paint feedback strategies for fixed-function blending, framebuffer-fetch-capable renderers, or ping-pong render targets. OpenGL extension detection now stores `pp::renderer::RenderDeviceFeatures` through `ShaderManager`, using `pp_renderer_gl::render_device_features` as the backend conversion point. `pp_paint_renderer::plan_canvas_blend_gate` owns the compatibility mapping from persisted layer/brush blend indices to the extracted stroke-composite planner, and live `Canvas::draw_merge` plus `NodeCanvas` panorama rendering both call it with the stored renderer-neutral feature set for their existing shader-blend gates and destination-copy versus framebuffer-fetch decisions. `pp_paint_renderer::plan_canvas_stroke_feedback` also owns the current destination-feedback decision, and live `Canvas::stroke_draw`, thumbnail layer blending, and `NodeStrokePreview` brush-preview rendering use it for framebuffer-fetch versus destination-copy decisions. Actual live stroke rasterization, dual-brush compositing, pattern feedback math, thumbnail layer compositing, and brush-preview compositing still use legacy OpenGL canvas/UI execution | Preserve current painting behavior while the renderer boundary matures for OpenGL parity and later Vulkan/Metal experiments | `pp_renderer_api_tests`; `pp_renderer_gl_capabilities_tests`; `pp_paint_renderer_compositor_tests`; `pano_cli plan-paint-feedback --framebuffer-fetch --explicit-transitions --render-only`; `pano_cli plan-paint-feedback --texture-copy`; `pano_cli plan-stroke-composite --stroke-blend 10 --framebuffer-fetch --explicit-transitions --render-only`; `pano_cli plan-stroke-composite --layer-blend 4 --dual-blend --texture-copy`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter` | Live stroke/layer compositing chooses its feedback path through `pp_paint_renderer` and renderer services, with OpenGL golden parity and Vulkan/Metal lab tests covering framebuffer-fetch and ping-pong behavior |
| DEBT-0037 | Open | Modernization | Recording lifecycle/export planning and execution dispatch now consume pure `pp_app_core` through `App::rec_start`, `App::rec_stop`, `App::rec_clear`, `App::rec_export`, `pano_cli plan-recording-session`, and the `RecordingServices` boundary; live execution is centralized in `src/legacy_recording_services.*`, but the bridge still owns legacy recording thread startup/shutdown, platform recorded-file cleanup, progress UI, PBO readback through `App::rec_loop`, and `MP4Encoder::write_mp4` execution | Preserve current timelapse/MP4 behavior while recording moves toward app/document/renderer/video services | `pp_app_core_document_recording_tests`; `pano_cli plan-recording-session --running --frame-count 12`; `pano_cli plan-recording-session --platform-clears-files`; `ctest --preset desktop-fast --build-config Debug` | Recording thread lifecycle, frame readback, platform cleanup, progress reporting, and MP4 writing are owned by injected app/renderer/video services with `App` methods acting only as adapters |
| DEBT-0038 | Open | Modernization | Cloud upload/browse/bulk planning and execution dispatch now consume pure `pp_app_core` through `App::cloud_upload`, `App::cloud_upload_all`, `App::cloud_browse`, `pano_cli plan-cloud-upload`, `pano_cli plan-cloud-upload-all`, `pano_cli plan-cloud-browse`, and the `CloudServices` boundary; live execution is centralized in `src/legacy_cloud_services.*`, but the bridge still uses legacy save-before-upload, `upload`/`download` network helpers, progress/message UI, OpenGL context guarding, `NodeDialogCloud`, `Canvas` project open, layer refresh, and `ActionManager` reset | Preserve current cloud behavior while cloud/network/document import flows move toward app/document/platform services | `pp_app_core_document_cloud_tests`; `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`; `ctest --preset desktop-fast --build-config Debug` | Cloud upload/download, save-before-upload, progress reporting, cloud browse dialog, downloaded project opening, layer refresh, OpenGL context ownership, and action-history reset are owned by injected app/document/network/platform/renderer services with `App` methods acting only as adapters |
| DEBT-0039 | Open | Modernization | Document-open planning and execution dispatch now consume pure `pp_app_core` through `App::open_document`, `pano_cli plan-open-route`, `DocumentOpenServices`, and `src/legacy_document_open_services.*`, but the bridge still opens ABR/PPBR import prompts, launches legacy brush preset import threads, applies unsaved-project discard prompts, calls legacy project-open execution, refreshes layer UI, updates the app title, and clears legacy history directly | Preserve current file-open/import behavior while document loading and brush import move toward app/document/asset/UI services | `pp_app_core_document_route_tests`; `pp_app_core_document_session_tests`; `pano_cli plan-open-route --path D:/Paint/Scenes/demo.ppi --unsaved`; `pano_cli plan-open-route --path D:/Paint/Brushes/clouds.ABR --unsaved`; `ctest --preset desktop-fast --build-config Debug` | Brush import prompting/execution, project-open execution, unsaved-project discard prompting, layer refresh, title updates, and history clearing are owned by injected app/document/asset/UI services with `App::open_document` acting only as an adapter |
## Closed Debt

View File

@@ -728,6 +728,11 @@ layer-refresh, and action-history work remains tracked under `DEBT-0038`.
`pano_cli parse-layout` exercises the XML layout path. Continue expanding
document behavior toward legacy Canvas parity and then port OpenGL classes
behind the renderer boundary.
`App::open_document` now routes through the app-core document-open executor and
`src/legacy_document_open_services.*`, preserving ABR/PPBR import prompts,
unsaved-project discard prompts, project open, layer refresh, title updates,
and history clearing while those live effects remain tracked under
`DEBT-0039`.
Implementation tasks:
@@ -1260,6 +1265,13 @@ Results:
- `scripts/automation/package-smoke.ps1 -Preset windows-msvc-default
-Configuration Debug` passed executable/data checks after the cloud bridge
split; package target migration blockers remain under `DEBT-0011`.
- `PanoPainter`, `pp_app_core_document_session_tests`, and `pano_cli` built
after `App::open_document` moved live execution behind the document-open
services bridge. A clean rebuild was required once because MSVC reported the
known Debug PDB `LNK1103` corruption, after which the build passed.
- Focused document-open CTest coverage passed for
`pp_app_core_document_route_tests`, `pp_app_core_document_session_tests`, and
the `pano_cli_plan_open_route_*` smoke tests after the live bridge split.
- `pp_app_core_document_recording_tests` passed, covering recording start/stop,
clear, platform recorded-file cleanup, frame-count reset, export progress
totals, and oversized progress-total clamping.

View File

@@ -10,7 +10,7 @@
#include "app_core/document_recording.h"
#include "app_core/document_route.h"
#include "app_core/document_session.h"
#include "legacy_history_services.h"
#include "legacy_document_open_services.h"
#include "legacy_recording_services.h"
#include "platform_api/platform_services.h"
#include "renderer_gl/opengl_capabilities.h"
@@ -197,65 +197,9 @@ void App::open_document(std::string path)
const bool has_unsaved_project =
route.value().kind == pp::app::DocumentOpenKind::open_project && Canvas::I->m_unsaved;
const auto open_plan = pp::app::plan_document_open(route.value().kind, has_unsaved_project);
if (open_plan == pp::app::DocumentOpenPlanAction::prompt_import_abr)
{
auto mb = message_box("Import ABR", "Would you like to import the brushes?", true);
mb->on_submit = [this, path] (Node* target) {
std::thread(&NodePanelBrushPreset::import_abr, presets, path).detach();
target->destroy();
};
}
else if (open_plan == pp::app::DocumentOpenPlanAction::prompt_import_ppbr)
{
auto mb = message_box("Import PPBR", "Would you like to import the brushes?", true);
mb->on_submit = [this, path] (Node* target) {
std::thread(&NodePanelBrushPreset::import_ppbr, presets, path).detach();
target->destroy();
};
}
else
{
const std::string base = route.value().directory;
const std::string name = route.value().name;
auto open_action = [this, path, base, name] {
doc_name = name;
doc_dir = base;
doc_path = path;
canvas->reset_camera();
layers->clear();
canvas->m_canvas->project_open(path, [this](bool success){
// on complete
if (success)
{
title_update();
for (int layer_index = 0; layer_index < canvas->m_canvas->m_layers.size(); layer_index++)
{
auto l = layers->add_layer(canvas->m_canvas->m_layers[layer_index]->m_name.c_str(), false);
l->m_visibility->set_value(canvas->m_canvas->m_layers[layer_index]->m_visible);
}
}
else
{
message_box("Open Document Error",
"There was an error opening the document.\n"
"It may be inaccessible or corrupted.");
}
});
pp::panopainter::clear_legacy_history();
};
if (open_plan == pp::app::DocumentOpenPlanAction::open_project_now)
{
open_action();
}
else
{
auto mb = message_box("Unsaved document", "Do you want to close the unsaved document before opening the file?", true);
mb->on_submit = [this, open_action] (Node* target) {
open_action();
target->destroy();
};
}
}
const auto status = pp::panopainter::execute_legacy_document_open_plan(*this, open_plan, route.value());
if (!status.ok())
LOG("Document open action failed: %s", status.message);
}
bool App::request_close()

View File

@@ -55,6 +55,16 @@ enum class DocumentOpenPlanAction {
prompt_import_ppbr,
};
class DocumentOpenServices {
public:
virtual ~DocumentOpenServices() = default;
virtual void prompt_import_abr(const DocumentOpenRoute& route) = 0;
virtual void prompt_import_ppbr(const DocumentOpenRoute& route) = 0;
virtual void open_project_now(const DocumentOpenRoute& route) = 0;
virtual void prompt_discard_unsaved_project(const DocumentOpenRoute& route) = 0;
};
struct DocumentFileTarget {
std::string name;
std::string directory;
@@ -102,6 +112,41 @@ struct NewDocumentPlan {
return DocumentOpenPlanAction::open_project_now;
}
[[nodiscard]] inline pp::foundation::Status execute_document_open_plan(
DocumentOpenPlanAction action,
const DocumentOpenRoute& route,
DocumentOpenServices& services)
{
switch (action) {
case DocumentOpenPlanAction::open_project_now:
if (route.kind != DocumentOpenKind::open_project) {
return pp::foundation::Status::invalid_argument("open-project action requires a project route");
}
services.open_project_now(route);
return pp::foundation::Status::success();
case DocumentOpenPlanAction::prompt_discard_unsaved_project:
if (route.kind != DocumentOpenKind::open_project) {
return pp::foundation::Status::invalid_argument("discard prompt requires a project route");
}
services.prompt_discard_unsaved_project(route);
return pp::foundation::Status::success();
case DocumentOpenPlanAction::prompt_import_abr:
if (route.kind != DocumentOpenKind::import_abr) {
return pp::foundation::Status::invalid_argument("ABR import prompt requires an ABR route");
}
services.prompt_import_abr(route);
return pp::foundation::Status::success();
case DocumentOpenPlanAction::prompt_import_ppbr:
if (route.kind != DocumentOpenKind::import_ppbr) {
return pp::foundation::Status::invalid_argument("PPBR import prompt requires a PPBR route");
}
services.prompt_import_ppbr(route);
return pp::foundation::Status::success();
}
return pp::foundation::Status::invalid_argument("unknown document open action");
}
[[nodiscard]] constexpr CloseRequestDecision plan_close_request(
bool has_unsaved_changes,
bool close_prompt_already_open) noexcept

View File

@@ -0,0 +1,101 @@
#include "pch.h"
#include "legacy_document_open_services.h"
#include "app.h"
#include "legacy_history_services.h"
#include "node_panel_brush.h"
#include "node_panel_layer.h"
namespace pp::panopainter {
namespace {
void open_legacy_project(App& app, const pp::app::DocumentOpenRoute& route)
{
app.doc_name = route.name;
app.doc_dir = route.directory;
app.doc_path = route.path;
app.canvas->reset_camera();
app.layers->clear();
app.canvas->m_canvas->project_open(route.path, [&app](bool success) {
if (success)
{
app.title_update();
for (std::size_t layer_index = 0; layer_index < app.canvas->m_canvas->m_layers.size(); ++layer_index)
{
auto layer = app.layers->add_layer(app.canvas->m_canvas->m_layers[layer_index]->m_name.c_str(), false);
layer->m_visibility->set_value(app.canvas->m_canvas->m_layers[layer_index]->m_visible);
}
}
else
{
app.message_box(
"Open Document Error",
"There was an error opening the document.\n"
"It may be inaccessible or corrupted.");
}
});
pp::panopainter::clear_legacy_history();
}
class LegacyDocumentOpenServices final : public pp::app::DocumentOpenServices {
public:
explicit LegacyDocumentOpenServices(App& app) noexcept
: app_(app)
{
}
void prompt_import_abr(const pp::app::DocumentOpenRoute& route) override
{
auto* app = &app_;
auto mb = app_.message_box("Import ABR", "Would you like to import the brushes?", true);
mb->on_submit = [app, path = route.path](Node* target) {
std::thread(&NodePanelBrushPreset::import_abr, app->presets, path).detach();
target->destroy();
};
}
void prompt_import_ppbr(const pp::app::DocumentOpenRoute& route) override
{
auto* app = &app_;
auto mb = app_.message_box("Import PPBR", "Would you like to import the brushes?", true);
mb->on_submit = [app, path = route.path](Node* target) {
std::thread(&NodePanelBrushPreset::import_ppbr, app->presets, path).detach();
target->destroy();
};
}
void open_project_now(const pp::app::DocumentOpenRoute& route) override
{
open_legacy_project(app_, route);
}
void prompt_discard_unsaved_project(const pp::app::DocumentOpenRoute& route) override
{
auto* app = &app_;
auto mb = app_.message_box(
"Unsaved document",
"Do you want to close the unsaved document before opening the file?",
true);
mb->on_submit = [app, route](Node* target) {
open_legacy_project(*app, route);
target->destroy();
};
}
private:
App& app_;
};
} // namespace
pp::foundation::Status execute_legacy_document_open_plan(
App& app,
pp::app::DocumentOpenPlanAction action,
const pp::app::DocumentOpenRoute& route)
{
LegacyDocumentOpenServices services(app);
return pp::app::execute_document_open_plan(action, route, services);
}
} // namespace pp::panopainter

View File

@@ -0,0 +1,15 @@
#pragma once
#include "app_core/document_session.h"
#include "foundation/result.h"
class App;
namespace pp::panopainter {
[[nodiscard]] pp::foundation::Status execute_legacy_document_open_plan(
App& app,
pp::app::DocumentOpenPlanAction action,
const pp::app::DocumentOpenRoute& route);
} // namespace pp::panopainter

View File

@@ -1,8 +1,81 @@
#include "app_core/document_session.h"
#include "test_harness.h"
#include <string>
namespace {
class FakeDocumentOpenServices final : public pp::app::DocumentOpenServices {
public:
void prompt_import_abr(const pp::app::DocumentOpenRoute& route) override
{
abr_prompts += 1;
last_path = route.path;
call_order += "abr;";
}
void prompt_import_ppbr(const pp::app::DocumentOpenRoute& route) override
{
ppbr_prompts += 1;
last_path = route.path;
call_order += "ppbr;";
}
void open_project_now(const pp::app::DocumentOpenRoute& route) override
{
project_opens += 1;
last_path = route.path;
call_order += "open;";
}
void prompt_discard_unsaved_project(const pp::app::DocumentOpenRoute& route) override
{
discard_prompts += 1;
last_path = route.path;
call_order += "discard;";
}
int abr_prompts = 0;
int ppbr_prompts = 0;
int project_opens = 0;
int discard_prompts = 0;
std::string last_path;
std::string call_order;
};
[[nodiscard]] pp::app::DocumentOpenRoute project_route()
{
return {
.kind = pp::app::DocumentOpenKind::open_project,
.path = "D:/Paint/demo.ppi",
.directory = "D:/Paint",
.name = "demo",
.extension = "ppi",
};
}
[[nodiscard]] pp::app::DocumentOpenRoute abr_route()
{
return {
.kind = pp::app::DocumentOpenKind::import_abr,
.path = "D:/Paint/clouds.abr",
.directory = "D:/Paint",
.name = "clouds",
.extension = "abr",
};
}
[[nodiscard]] pp::app::DocumentOpenRoute ppbr_route()
{
return {
.kind = pp::app::DocumentOpenKind::import_ppbr,
.path = "D:/Paint/brush.ppbr",
.directory = "D:/Paint",
.name = "brush",
.extension = "ppbr",
};
}
void project_open_clean_document_executes_immediately(pp::tests::Harness& harness)
{
PP_EXPECT(harness, pp::app::plan_project_open(false) == pp::app::ProjectOpenDecision::open_now);
@@ -47,6 +120,78 @@ void document_open_brush_imports_prompt_regardless_of_unsaved_state(pp::tests::H
== pp::app::DocumentOpenPlanAction::prompt_import_ppbr);
}
void document_open_executor_dispatches_all_actions(pp::tests::Harness& harness)
{
FakeDocumentOpenServices services;
const auto project = project_route();
const auto abr = abr_route();
const auto ppbr = ppbr_route();
PP_EXPECT(
harness,
pp::app::execute_document_open_plan(
pp::app::DocumentOpenPlanAction::open_project_now,
project,
services)
.ok());
PP_EXPECT(
harness,
pp::app::execute_document_open_plan(
pp::app::DocumentOpenPlanAction::prompt_discard_unsaved_project,
project,
services)
.ok());
PP_EXPECT(
harness,
pp::app::execute_document_open_plan(
pp::app::DocumentOpenPlanAction::prompt_import_abr,
abr,
services)
.ok());
PP_EXPECT(
harness,
pp::app::execute_document_open_plan(
pp::app::DocumentOpenPlanAction::prompt_import_ppbr,
ppbr,
services)
.ok());
PP_EXPECT(harness, services.project_opens == 1);
PP_EXPECT(harness, services.discard_prompts == 1);
PP_EXPECT(harness, services.abr_prompts == 1);
PP_EXPECT(harness, services.ppbr_prompts == 1);
PP_EXPECT(harness, services.last_path == "D:/Paint/brush.ppbr");
PP_EXPECT(harness, services.call_order == "open;discard;abr;ppbr;");
}
void document_open_executor_rejects_mismatched_routes(pp::tests::Harness& harness)
{
FakeDocumentOpenServices services;
PP_EXPECT(
harness,
!pp::app::execute_document_open_plan(
pp::app::DocumentOpenPlanAction::open_project_now,
abr_route(),
services)
.ok());
PP_EXPECT(
harness,
!pp::app::execute_document_open_plan(
pp::app::DocumentOpenPlanAction::prompt_import_abr,
project_route(),
services)
.ok());
PP_EXPECT(
harness,
!pp::app::execute_document_open_plan(
pp::app::DocumentOpenPlanAction::prompt_import_ppbr,
abr_route(),
services)
.ok());
PP_EXPECT(harness, services.call_order.empty());
}
void close_clean_document_executes_immediately(pp::tests::Harness& harness)
{
PP_EXPECT(
@@ -335,6 +480,8 @@ int main()
harness.run(
"document open brush imports prompt regardless of unsaved state",
document_open_brush_imports_prompt_regardless_of_unsaved_state);
harness.run("document open executor dispatches all actions", document_open_executor_dispatches_all_actions);
harness.run("document open executor rejects mismatched routes", document_open_executor_rejects_mismatched_routes);
harness.run("close clean document executes immediately", close_clean_document_executes_immediately);
harness.run("close dirty document opens one prompt", close_dirty_document_opens_one_prompt);
harness.run("save clean existing document is no op", save_clean_existing_document_is_no_op);