Route layer merge through app core

This commit is contained in:
2026-06-03 20:20:07 +02:00
parent b184b3e075
commit 16a1d1e15b
7 changed files with 287 additions and 5 deletions

View File

@@ -49,7 +49,7 @@ agent or engineer to remove them without reconstructing context from chat.
| DEBT-0029 | Open | Modernization | Image import route planning and execution dispatch now consume pure `pp_app_core` through the File menu, `pano_cli plan-image-import`, and the `DocumentImageImportServices` boundary, but the live adapter still loads images with legacy `Image`, calls legacy `Canvas::import_equirectangular`, or configures legacy import transform mode directly | Preserve current File > Import behavior while image import moves toward document/app/asset command services | `pp_app_core_document_import_tests`; `pano_cli plan-image-import --width 4096 --height 2048`; `pano_cli plan-image-import --width 1024 --height 1024`; `ctest --preset desktop-fast --build-config Debug` | Image loading, equirectangular import, transform-placement import, and failure reporting are owned by injected document/app/asset services with File-menu callbacks acting only as adapters and no legacy image-import adapter |
| DEBT-0030 | Open | Modernization | File export menu action planning and execution dispatch now consume pure `pp_app_core` through the File menu, `pano_cli plan-export-menu`, and the `DocumentExportMenuServices` boundary, but the live adapter still opens legacy export dialogs and then reaches legacy canvas/render/video export code | Preserve current export menu behavior while export command execution moves toward document/app/renderer/video services | `pp_app_core_document_export_tests`; `pano_cli plan-export-menu --kind png`; `pano_cli plan-export-menu --kind animation-mp4 --demo`; `pano_cli plan-export-menu --kind layers --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Export menu routing, license gating, target creation, image/layer/cube/depth/animation/timelapse execution, and error reporting are owned by injected document/app/renderer/video services with File-menu callbacks acting only as UI adapters and no legacy export adapter |
| DEBT-0031 | Open | Modernization | Top-level File menu command planning and execution dispatch now consume pure `pp_app_core` through `App::init_menu_file`, `pano_cli plan-file-menu`, and the `FileMenuServices` boundary, but the live adapter still invokes legacy dialogs, platform pickers, cloud code, share code, and canvas import/export paths directly | Preserve File menu behavior while app workflows move toward app/document/platform command services | `pp_app_core_file_menu_tests`; `pano_cli plan-file-menu --command save-as`; `pano_cli plan-file-menu --command import`; `pano_cli plan-file-menu --command cloud-upload`; `ctest --preset desktop-fast --build-config Debug` | File menu routing, picker dispatch, save/share/cloud/resize/export execution, and image/project import execution are owned by injected app/document/platform services with `App::init_menu_file` acting only as a UI adapter and no legacy File menu adapter |
| DEBT-0032 | Open | Modernization | Layer menu command planning and execution dispatch now consume pure `pp_app_core` through `App::init_menu_layer`, `pano_cli plan-layer-menu`, and the `DocumentLayerMenuServices` boundary, and Layer menu clear now reuses the `DocumentCanvasClearServices` executor, but the live adapter still calls `App::dialog_layer_rename`, `NodePanelLayer::merge`, and reads `Canvas::I` animation/layer state directly | Preserve existing Layer menu behavior while layer commands move toward document/app services | `pp_app_core_document_layer_tests`; `pano_cli plan-layer-menu --command clear --current-index 1 --current-name Paint`; `pano_cli plan-layer-menu --command merge --current-index 2 --lower-name Paint`; `pano_cli plan-layer-menu --command rename --no-current-layer`; `ctest --preset desktop-fast --build-config Debug` | Layer rename, merge-down execution, animation gating, and selected-layer state are owned by injected document/app services with Layer-menu callbacks acting only as UI adapters and no legacy Layer menu adapter |
| DEBT-0032 | Open | Modernization | Layer menu command planning and execution dispatch now consume pure `pp_app_core` through `App::init_menu_layer`, `pano_cli plan-layer-menu`, and the `DocumentLayerMenuServices` boundary, Layer menu clear reuses the `DocumentCanvasClearServices` executor, and Layer menu merge validates/dispatches through `DocumentLayerMergeServices`, but the live adapter still calls `App::dialog_layer_rename`, `NodePanelLayer::merge`, and reads `Canvas::I` animation/layer state directly | Preserve existing Layer menu behavior while layer commands move toward document/app services | `pp_app_core_document_layer_tests`; `pano_cli plan-layer-menu --command clear --current-index 1 --current-name Paint`; `pano_cli plan-layer-menu --command merge --current-index 2 --lower-name Paint`; `pano_cli plan-layer-merge --layer-count 3 --from-index 2 --to-index 1`; `pano_cli plan-layer-merge --layer-count 3 --from-index 2 --to-index 1 --animation-duration 3`; `pano_cli plan-layer-menu --command rename --no-current-layer`; `ctest --preset desktop-fast --build-config Debug` | Layer rename, merge-down execution, animation gating, and selected-layer state are owned by injected document/app services with Layer-menu callbacks acting only as UI adapters and no legacy Layer menu adapter |
| DEBT-0033 | Open | Modernization | Tools menu planning and direct command execution dispatch now consume pure `pp_app_core` through `App::init_menu_tools`, `pano_cli plan-tools-menu`, `pano_cli plan-tools-panel`, and the `ToolsMenuServices` boundary, but live adapters still construct legacy `NodePanelFloating` panels, mutate legacy panel nodes, clear `CanvasModeGrid`, reset `NodeCanvas` camera state, open legacy shortcuts UI, and call the iOS SonarPen bridge directly | Preserve current Tools menu behavior while UI shell actions move toward app/UI/platform services | `pp_app_core_tools_menu_tests`; `pano_cli plan-tools-menu --command shortcuts`; `pano_cli plan-tools-panel --panel layers`; `pano_cli plan-tools-panel --panel animation --already-visible`; `ctest --preset desktop-fast --build-config Debug` | Tools panel creation, submenu routing, grid clear, camera reset, shortcuts dialog, and SonarPen dispatch are owned by injected app/UI/platform services with `App::init_menu_tools` acting only as a UI adapter and no legacy Tools adapter |
| DEBT-0034 | Open | Modernization | About menu command planning and execution dispatch now consume pure `pp_app_core` through `App::init_menu_about`, `pano_cli plan-about-menu`, and the `AboutMenuServices` boundary, but the live adapter still opens legacy About/manual/what's-new dialogs, invokes the injected crash hook, and runs the legacy Canvas stroke performance test directly | Preserve About menu behavior while dialogs and diagnostics move toward app/UI/platform services | `pp_app_core_about_menu_tests`; `pano_cli plan-about-menu --command news --version-major 2 --version-minor 5 --version-fix 7`; `pano_cli plan-about-menu --command performance --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | About/manual/what's-new dialog dispatch, crash-test dispatch, and performance-test execution are owned by injected app/UI/platform services with `App::init_menu_about` acting only as a UI adapter and no legacy About adapter |
| DEBT-0035 | Open | Modernization | Main toolbar/status command planning and execution dispatch now consume pure `pp_app_core` through `App::init_toolbar_main`, `pano_cli plan-main-toolbar`, and the `MainToolbarServices` boundary, and history/canvas commands now hand off through `HistoryUiServices` and `DocumentCanvasClearServices`, but the live adapter still opens legacy open/save/settings/message-box dialogs and delegates to legacy history/canvas adapters | Preserve reachable toolbar/status behavior while app shell commands move toward app/document/UI services | `pp_app_core_main_toolbar_tests`; `pano_cli plan-main-toolbar --command undo --undo-count 2`; `pano_cli plan-main-toolbar --command clear-canvas --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Open/save/settings/message-box routing, undo/redo/clear-history execution, and canvas-clear execution are owned by injected app/document/UI services with `App::init_toolbar_main` acting only as a UI adapter and no legacy toolbar adapter |

View File

@@ -501,6 +501,8 @@ rename, and merge-down labels/actions, and direct Layer menu commands now
dispatch through `DocumentLayerMenuServices` before the legacy canvas/layer UI
adapter continues execution. Layer menu clear now routes through the shared
`DocumentCanvasClearServices` executor before the legacy canvas-clear adapter
continues, and Layer menu merge now validates and dispatches through
`DocumentLayerMergeServices` before the legacy layer-panel merge adapter
continues.
`pano_cli plan-animation-operation` exposes app-core planning for animation
frame add, duplicate, remove, duration adjustment, timeline moves, timeline
@@ -1249,8 +1251,9 @@ Results:
layer operation side-effect dispatch, no-op operation preservation,
malformed operation rejection, Layer menu labels/actions, merge-down routing,
animated merge blocking, missing selection handling, bad Layer menu state
rejection, Layer menu executor dispatch, and no-op menu execution
preservation.
rejection, Layer menu executor dispatch, no-op menu execution preservation,
merge-plan validation, unsupported animated merge rejection, merge executor
dispatch, and malformed merge-plan rejection.
- `pano_cli_plan_layer_rename_smoke`,
`pano_cli_plan_layer_rename_no_op_smoke`, and
`pano_cli_plan_layer_rename_rejects_empty_name` passed and expose live
@@ -1261,6 +1264,9 @@ Results:
`pano_cli_plan_layer_menu_missing_selection_smoke`, and
`pano_cli_plan_layer_menu_rejects_bad_state` passed and expose live Layer
menu planning as JSON automation.
- `pano_cli_plan_layer_merge_smoke` and
`pano_cli_plan_layer_merge_animated_rejected` passed and expose live
merge execution planning as JSON automation.
- `pano_cli_plan_layer_operation_add_smoke`,
`pano_cli_plan_layer_operation_reorder_no_op_smoke`,
`pano_cli_plan_layer_operation_highlight_smoke`, and

View File

@@ -78,6 +78,12 @@ struct DocumentLayerMenuPlan {
int to_index = 0;
};
struct DocumentLayerMergePlan {
int from_index = 0;
int to_index = 0;
bool create_history = true;
};
class DocumentLayerMenuServices {
public:
virtual ~DocumentLayerMenuServices() = default;
@@ -115,6 +121,13 @@ public:
virtual void update_title() = 0;
};
class DocumentLayerMergeServices {
public:
virtual ~DocumentLayerMergeServices() = default;
virtual void merge_layers(int from_index, int to_index, bool create_history) = 0;
};
[[nodiscard]] inline pp::foundation::Status validate_layer_index(
int layer_count,
int index) noexcept
@@ -447,6 +460,45 @@ public:
return pp::foundation::Result<DocumentLayerMenuPlan>::success(std::move(plan));
}
[[nodiscard]] inline pp::foundation::Result<DocumentLayerMergePlan> plan_document_layer_merge(
int layer_count,
int from_index,
int to_index,
int animation_duration,
bool create_history = true)
{
auto index_status = validate_layer_index(layer_count, from_index);
if (!index_status.ok()) {
return pp::foundation::Result<DocumentLayerMergePlan>::failure(index_status);
}
index_status = validate_layer_index(layer_count, to_index);
if (!index_status.ok()) {
return pp::foundation::Result<DocumentLayerMergePlan>::failure(index_status);
}
if (animation_duration < 0) {
return pp::foundation::Result<DocumentLayerMergePlan>::failure(
pp::foundation::Status::out_of_range("animation duration must not be negative"));
}
if (animation_duration > 1) {
return pp::foundation::Result<DocumentLayerMergePlan>::failure(
pp::foundation::Status::invalid_argument("animated layer merge is not supported"));
}
if (from_index <= to_index) {
return pp::foundation::Result<DocumentLayerMergePlan>::failure(
pp::foundation::Status::invalid_argument("layer merge source must be above the destination"));
}
DocumentLayerMergePlan plan;
plan.from_index = from_index;
plan.to_index = to_index;
plan.create_history = create_history;
return pp::foundation::Result<DocumentLayerMergePlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Status execute_document_layer_rename_plan(
const DocumentLayerRenamePlan& plan,
DocumentLayerRenameServices& services)
@@ -545,6 +597,19 @@ inline void execute_document_layer_operation_side_effects(
return pp::foundation::Status::success();
}
[[nodiscard]] inline pp::foundation::Status execute_document_layer_merge_plan(
const DocumentLayerMergePlan& plan,
DocumentLayerMergeServices& services)
{
if (plan.from_index <= plan.to_index) {
return pp::foundation::Status::invalid_argument(
"layer merge source must be above the destination");
}
services.merge_layers(plan.from_index, plan.to_index, plan.create_history);
return pp::foundation::Status::success();
}
[[nodiscard]] inline pp::foundation::Status execute_document_layer_menu_plan(
const DocumentLayerMenuPlan& plan,
DocumentLayerMenuServices& services)

View File

@@ -167,6 +167,8 @@ void execute_document_canvas_clear_plan(App& app, const pp::app::DocumentCanvasC
LOG("Canvas clear failed: %s", status.message);
}
void execute_document_layer_merge_plan(App& app, const pp::app::DocumentLayerMergePlan& plan);
bool apply_document_export_menu_plan(App& app, pp::app::DocumentExportMenuKind kind)
{
class LegacyDocumentExportMenuServices final : public pp::app::DocumentExportMenuServices {
@@ -605,8 +607,21 @@ public:
void merge_with_lower_layer(int from_index, int to_index) override
{
if (app_.layers)
app_.layers->merge(from_index, to_index, true);
const int layer_count = app_.canvas && app_.canvas->m_canvas
? static_cast<int>(app_.canvas->m_canvas->m_layers.size())
: 0;
const int animation_duration = Canvas::I
? Canvas::I->anim_duration()
: 0;
const auto plan = pp::app::plan_document_layer_merge(
layer_count,
from_index,
to_index,
animation_duration);
if (!plan)
return;
execute_document_layer_merge_plan(app_, plan.value());
}
void show_merge_animated_not_supported() override
@@ -618,6 +633,23 @@ private:
App& app_;
};
class LegacyDocumentLayerMergeServices final : public pp::app::DocumentLayerMergeServices {
public:
explicit LegacyDocumentLayerMergeServices(App& app) noexcept
: app_(app)
{
}
void merge_layers(int from_index, int to_index, bool create_history) override
{
if (app_.layers)
app_.layers->merge(from_index, to_index, create_history);
}
private:
App& app_;
};
class LegacyDocumentLayerOperationServices final : public pp::app::DocumentLayerOperationServices {
public:
LegacyDocumentLayerOperationServices(
@@ -786,6 +818,14 @@ void execute_document_layer_menu_plan(App& app, const pp::app::DocumentLayerMenu
LOG("Layer menu action failed: %s", status.message);
}
void execute_document_layer_merge_plan(App& app, const pp::app::DocumentLayerMergePlan& plan)
{
LegacyDocumentLayerMergeServices services(app);
const auto status = pp::app::execute_document_layer_merge_plan(plan, services);
if (!status.ok())
LOG("Layer merge failed: %s", status.message);
}
void execute_document_layer_operation_plan(
App& app,
const pp::app::DocumentLayerOperationPlan& plan,

View File

@@ -1016,6 +1016,18 @@ if(TARGET pano_cli)
LABELS "app;document;integration;desktop-fast;fuzz"
WILL_FAIL TRUE)
add_test(NAME pano_cli_plan_layer_merge_smoke
COMMAND pano_cli plan-layer-merge --layer-count 3 --from-index 2 --to-index 1)
set_tests_properties(pano_cli_plan_layer_merge_smoke PROPERTIES
LABELS "app;document;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"command\":\"plan-layer-merge\".*\"layerCount\":3.*\"fromIndex\":2.*\"toIndex\":1.*\"createHistory\":true")
add_test(NAME pano_cli_plan_layer_merge_animated_rejected
COMMAND pano_cli plan-layer-merge --layer-count 3 --from-index 2 --to-index 1 --animation-duration 3)
set_tests_properties(pano_cli_plan_layer_merge_animated_rejected PROPERTIES
LABELS "app;document;integration;desktop-fast;fuzz"
WILL_FAIL TRUE)
add_test(NAME pano_cli_plan_layer_operation_add_smoke
COMMAND pano_cli plan-layer-operation --kind add --layer-count 2 --index 1 --name Paint)
set_tests_properties(pano_cli_plan_layer_operation_add_smoke PROPERTIES

View File

@@ -152,6 +152,22 @@ public:
std::string last_name;
};
class FakeDocumentLayerMergeServices final : public pp::app::DocumentLayerMergeServices {
public:
void merge_layers(int from_index, int to_index, bool create_history) override
{
merge_calls += 1;
last_from_index = from_index;
last_to_index = to_index;
last_create_history = create_history;
}
int merge_calls = 0;
int last_from_index = -1;
int last_to_index = -1;
bool last_create_history = false;
};
void layer_rename_records_changed_name(pp::tests::Harness& harness)
{
const auto plan = pp::app::plan_document_layer_rename("Base", "Paint");
@@ -724,6 +740,63 @@ void layer_menu_executor_preserves_no_op_actions(pp::tests::Harness& harness)
PP_EXPECT(harness, services.total_calls() == 0);
}
void layer_merge_plan_validates_supported_merge(pp::tests::Harness& harness)
{
const auto plan = pp::app::plan_document_layer_merge(3, 2, 1, 1);
PP_EXPECT(harness, plan);
if (plan) {
PP_EXPECT(harness, plan.value().from_index == 2);
PP_EXPECT(harness, plan.value().to_index == 1);
PP_EXPECT(harness, plan.value().create_history);
}
const auto no_history = pp::app::plan_document_layer_merge(3, 2, 0, 1, false);
PP_EXPECT(harness, no_history);
if (no_history) {
PP_EXPECT(harness, !no_history.value().create_history);
}
}
void layer_merge_plan_rejects_bad_or_unsupported_state(pp::tests::Harness& harness)
{
PP_EXPECT(harness, !pp::app::plan_document_layer_merge(0, 0, 0, 1));
PP_EXPECT(harness, !pp::app::plan_document_layer_merge(3, 3, 1, 1));
PP_EXPECT(harness, !pp::app::plan_document_layer_merge(3, 1, 3, 1));
PP_EXPECT(harness, !pp::app::plan_document_layer_merge(3, 1, 1, 1));
PP_EXPECT(harness, !pp::app::plan_document_layer_merge(3, 0, 1, 1));
PP_EXPECT(harness, !pp::app::plan_document_layer_merge(3, 2, 1, -1));
PP_EXPECT(harness, !pp::app::plan_document_layer_merge(3, 2, 1, 2));
}
void layer_merge_executor_dispatches_merge(pp::tests::Harness& harness)
{
FakeDocumentLayerMergeServices services;
const auto plan = pp::app::plan_document_layer_merge(3, 2, 1, 1);
PP_EXPECT(harness, plan);
if (plan) {
PP_EXPECT(harness, pp::app::execute_document_layer_merge_plan(plan.value(), services).ok());
PP_EXPECT(harness, services.merge_calls == 1);
PP_EXPECT(harness, services.last_from_index == 2);
PP_EXPECT(harness, services.last_to_index == 1);
PP_EXPECT(harness, services.last_create_history);
}
}
void layer_merge_executor_rejects_malformed_plan(pp::tests::Harness& harness)
{
FakeDocumentLayerMergeServices services;
pp::app::DocumentLayerMergePlan malformed;
malformed.from_index = 1;
malformed.to_index = 1;
const auto status = pp::app::execute_document_layer_merge_plan(malformed, services);
PP_EXPECT(harness, !status.ok());
PP_EXPECT(harness, status.code == pp::foundation::StatusCode::invalid_argument);
PP_EXPECT(harness, services.merge_calls == 0);
}
}
int main()
@@ -750,5 +823,9 @@ int main()
harness.run("layer menu handles missing selection and bad state", layer_menu_handles_missing_selection_and_bad_state);
harness.run("layer menu executor dispatches menu actions", layer_menu_executor_dispatches_menu_actions);
harness.run("layer menu executor preserves no op actions", layer_menu_executor_preserves_no_op_actions);
harness.run("layer merge plan validates supported merge", layer_merge_plan_validates_supported_merge);
harness.run("layer merge plan rejects bad or unsupported state", layer_merge_plan_rejects_bad_or_unsupported_state);
harness.run("layer merge executor dispatches merge", layer_merge_executor_dispatches_merge);
harness.run("layer merge executor rejects malformed plan", layer_merge_executor_rejects_malformed_plan);
return harness.finish();
}

View File

@@ -287,6 +287,14 @@ struct PlanLayerMenuArgs {
std::string lower_name = "Layer 0";
};
struct PlanLayerMergeArgs {
int layer_count = 2;
int from_index = 1;
int to_index = 0;
int animation_duration = 1;
bool create_history = true;
};
struct PlanAnimationOperationArgs {
std::string kind = "goto";
int frame_count = 1;
@@ -1779,6 +1787,7 @@ void print_help()
<< " plan-document-resize [--current-resolution N] [--selected-resolution-index N]\n"
<< " plan-layer-rename --old-name NAME --new-name NAME\n"
<< " plan-layer-menu --command clear|rename|merge [--no-current-layer] [--current-index N] [--animation-duration N] [--current-name NAME] [--lower-name NAME]\n"
<< " plan-layer-merge [--layer-count N] [--from-index N] [--to-index N] [--animation-duration N] [--no-history]\n"
<< " plan-layer-operation --kind add|duplicate|select|reorder|remove|opacity|visibility|alpha-lock|blend-mode|highlight [--layer-count N] [--index N] [--from-index N] [--to-index N] [--source-index N] [--name NAME] [--opacity N] [--blend-mode N] [--enabled]\n"
<< " plan-animation-operation --kind add|duplicate|remove|duration|move|select|goto|next|prev|playback|toggle-playback|onion [--frame-count N] [--total-duration N] [--current-frame N] [--selected-frame N] [--layer-index N] [--layer-id N] [--current-duration N] [--delta N] [--offset N] [--onion-size N] [--playing]\n"
<< " plan-animation-panel-action --action goto|next|prev|playback|toggle-playback [--total-duration N] [--current-frame N] [--target-frame N] [--playing]\n"
@@ -3959,6 +3968,75 @@ int plan_layer_menu(int argc, char** argv)
return 0;
}
pp::foundation::Status parse_plan_layer_merge_args(
int argc,
char** argv,
PlanLayerMergeArgs& args)
{
for (int i = 2; i < argc; ++i) {
const std::string_view key(argv[i]);
if (key == "--layer-count" || key == "--from-index" || key == "--to-index"
|| key == "--animation-duration") {
if (i + 1 >= argc) {
return pp::foundation::Status::invalid_argument("missing value for option");
}
const auto value = parse_i32_arg(argv[++i]);
if (!value) {
return value.status();
}
if (key == "--layer-count") {
args.layer_count = value.value();
} else if (key == "--from-index") {
args.from_index = value.value();
} else if (key == "--to-index") {
args.to_index = value.value();
} else {
args.animation_duration = value.value();
}
} else if (key == "--no-history") {
args.create_history = false;
} else {
return pp::foundation::Status::invalid_argument("unknown option");
}
}
return pp::foundation::Status::success();
}
int plan_layer_merge(int argc, char** argv)
{
PlanLayerMergeArgs args;
const auto status = parse_plan_layer_merge_args(argc, argv, args);
if (!status.ok()) {
print_error("plan-layer-merge", status.message);
return 2;
}
const auto plan = pp::app::plan_document_layer_merge(
args.layer_count,
args.from_index,
args.to_index,
args.animation_duration,
args.create_history);
if (!plan) {
print_error("plan-layer-merge", plan.status().message);
return 2;
}
const auto& value = plan.value();
std::cout << "{\"ok\":true,\"command\":\"plan-layer-merge\""
<< ",\"state\":{\"layerCount\":" << args.layer_count
<< ",\"fromIndex\":" << args.from_index
<< ",\"toIndex\":" << args.to_index
<< ",\"animationDuration\":" << args.animation_duration
<< ",\"createHistory\":" << json_bool(args.create_history)
<< "},\"plan\":{\"fromIndex\":" << value.from_index
<< ",\"toIndex\":" << value.to_index
<< ",\"createHistory\":" << json_bool(value.create_history)
<< "}}\n";
return 0;
}
pp::foundation::Status parse_plan_layer_operation_args(
int argc,
char** argv,
@@ -8215,6 +8293,10 @@ int main(int argc, char** argv)
return plan_layer_menu(argc, argv);
}
if (command == "plan-layer-merge") {
return plan_layer_merge(argc, argv);
}
if (command == "plan-layer-operation") {
return plan_layer_operation(argc, argv);
}