Extract layer operation planning

This commit is contained in:
2026-06-03 10:20:37 +02:00
parent 07ed23c2d1
commit fdc1defaba
7 changed files with 752 additions and 38 deletions

View File

@@ -38,7 +38,7 @@ agent or engineer to remove them without reconstructing context from chat.
| DEBT-0017 | Open | Modernization | Startup storage path preparation, `App::clipboard_get_text`, `App::clipboard_set_text`, `App::show_cursor`, `App::hide_cursor`, `App::showKeyboard`, `App::hideKeyboard`, `App::display_file`, `App::share_file`, native app/window close, UI-thread lifecycle hooks, render-context acquire/release/present hooks, render-target binding hooks, render platform hint hooks, render debug callback hooks, render-capture frame hooks, recording cleanup, live asset/layout reload policy, diagnostic stacktrace/crash hooks, per-frame platform hooks, `App::pick_image`, `App::pick_file`, the non-writer `App::pick_file_save`, `App::pick_dir`, and prepared-file save/download handoff now call the SDK-free `pp::platform::PlatformServices` interface, and Windows injects `WindowsPlatformServices` from `src/platform_windows/windows_platform_services.*`; non-Windows live implementations still use `src/platform_legacy/legacy_platform_services.*`, a named fallback adapter that forwards to retained Apple/Android/Linux/Web bridge functions and retained no-op branches | Preserve behavior while moving platform execution behind a testable service boundary before platform shell implementations are injected | `pp_platform_api_tests`; `pp_app_core_document_platform_io_tests`; `ctest --preset desktop-fast --build-config Debug`; `powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -Preset windows-msvc-default -Configuration Debug` | Replace `src/platform_legacy/legacy_platform_services.*` with injected `pp_platform_*` service implementations owned by each non-Windows platform shell |
| DEBT-0019 | Open | Modernization | MSVC warning C4100 is muted globally through `pp_project_warnings` with `/wd4100` | Legacy callbacks, virtual hooks, serializer methods, and platform/API compatibility functions carry many intentionally unused parameters during the component split; muting this keeps stricter warning builds focused on higher-signal migration issues | `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter`; `ctest --preset desktop-fast --build-config Debug` | Remove `/wd4100`, mark intentionally unused parameters with names/comments or `[[maybe_unused]]`, and make the Windows app and headless tests pass without C4100 warnings |
| DEBT-0020 | Open | Modernization | Document resize dialog state and selected-resolution planning now consume pure `pp_app_core` through `NodeDialogResize`, `App::dialog_resize`, and `pano_cli plan-document-resize`, but live resize execution still calls legacy `Canvas::resize` and clears legacy `ActionManager` history directly | Preserve existing layer/frame GPU resize behavior while the document model and canvas execution boundary are extracted incrementally | `pp_app_core_document_resize_tests`; `pano_cli plan-document-resize --current-resolution 2048 --selected-resolution-index 4`; `ctest --preset desktop-fast --build-config Debug` | Document resize execution is owned by a document/app boundary with legacy `Canvas` acting only as an adapter or removed entirely |
| DEBT-0021 | Open | Modernization | Layer rename planning now consumes pure `pp_app_core` through `App::dialog_layer_rename` and `pano_cli plan-layer-rename`, but live rename execution still mutates legacy `Canvas` layer state, `NodeLayer`, and `ActionManager` undo entries directly | Preserve existing UI/canvas behavior while document layer commands and undo history are extracted incrementally | `pp_app_core_document_layer_tests`; `pano_cli plan-layer-rename --old-name Base --new-name Paint`; `ctest --preset desktop-fast --build-config Debug` | Layer rename execution is owned by the document/app command boundary with legacy `Canvas`/UI nodes acting only as adapters or removed entirely |
| DEBT-0021 | Open | Modernization | Layer rename and layer panel operation planning now consume pure `pp_app_core` through `App::dialog_layer_rename`, `App::init_sidebar` layer callbacks, `pano_cli plan-layer-rename`, and `pano_cli plan-layer-operation`, but live execution still mutates legacy `Canvas` layer state, `NodeLayer`/`NodePanelLayer`, and `ActionManager` undo entries directly | Preserve existing UI/canvas behavior while document layer commands and undo history are extracted incrementally | `pp_app_core_document_layer_tests`; `pano_cli plan-layer-rename --old-name Base --new-name Paint`; `pano_cli plan-layer-operation --kind add --layer-count 2 --index 1 --name Paint`; `ctest --preset desktop-fast --build-config Debug` | Layer command execution is owned by the document/app command boundary with legacy `Canvas`/UI nodes acting only as adapters or removed entirely |
## Closed Debt

View File

@@ -250,7 +250,8 @@ Implementation tasks:
- Set C++23 through target features, not raw compiler flags.
- Add warning profiles:
- MSVC: `/W4 /permissive- /Zc:__cplusplus /Zc:preprocessor`.
- MSVC: `/W4 /permissive- /Zc:__cplusplus /Zc:preprocessor`, with
`C4100` muted temporarily under `DEBT-0019`.
- Optional MSVC analysis preset: `/analyze`.
- Clang/GCC: `-Wall -Wextra -Wpedantic -Wconversion -Wshadow
-Wnull-dereference`.
@@ -289,7 +290,8 @@ Gate:
- Desktop library targets compile with strict diagnostics.
- New warnings caused by refactor are fixed or locally justified.
- No global blanket warning suppression for project code.
- Any global warning suppression must have an open debt entry, validation
command, and removal condition.
## Phase 3: Test Harness And Agent-Ready Automation
@@ -484,6 +486,10 @@ legacy `Canvas` resize execution and `ActionManager` history clearing continue.
`pano_cli plan-layer-rename` exposes the app-core layer rename decision used by
the live layer rename dialog before legacy `Canvas` layer mutation and
`ActionManager` undo wiring continue.
`pano_cli plan-layer-operation` exposes app-core planning for layer add,
duplicate, select, reorder, remove, opacity, visibility, alpha-lock, blend-mode,
and highlight actions used by the live layer panel before legacy `Canvas` and
UI layer execution continue.
`pp_platform_api` now owns a headless `PlatformServices` interface for
startup storage path preparation, clipboard text, cursor visibility,
virtual-keyboard visibility, UI-thread lifecycle hooks, render-context
@@ -1091,11 +1097,19 @@ Results:
`pano_cli_plan_document_resize_rejects_invalid_selection` passed and expose
live document-resize planning as JSON automation.
- `pp_app_core_document_layer_tests` passed, covering changed layer rename,
unchanged no-op rename, empty-name rejection, and overlong-name rejection.
unchanged no-op rename, empty-name rejection, overlong-name rejection, layer
add/duplicate/select/reorder/remove planning, metadata planning, bad-index
rejection, bad-opacity rejection, bad-blend-mode rejection, and transient
highlight behavior.
- `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
layer-rename 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
`pano_cli_plan_layer_operation_rejects_bad_opacity` passed and expose live
layer-panel operation planning as JSON automation.
- `pp_app_core_document_sharing_tests` passed, covering saved-path gating before
platform share execution.
- `pano_cli_plan_share_file_unsaved_smoke` and

View File

@@ -2,7 +2,9 @@
#include "foundation/result.h"
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>
#include <utility>
@@ -10,18 +12,79 @@
namespace pp::app {
inline constexpr std::size_t document_layer_name_max_length = 128;
inline constexpr int document_layer_legacy_blend_mode_count = 5;
enum class DocumentLayerRenameAction {
no_op_same_name,
rename_and_record_undo,
};
enum class DocumentLayerOperation {
add,
duplicate,
select,
reorder,
remove,
set_opacity,
set_visibility,
set_alpha_lock,
set_blend_mode,
set_highlight,
};
struct DocumentLayerRenamePlan {
std::string old_name;
std::string new_name;
DocumentLayerRenameAction action = DocumentLayerRenameAction::no_op_same_name;
};
struct DocumentLayerOperationPlan {
DocumentLayerOperation operation = DocumentLayerOperation::select;
int index = 0;
int from_index = 0;
int to_index = 0;
int insert_index = 0;
int source_index = 0;
std::string name;
float opacity = 1.0F;
bool flag = false;
int blend_mode = 0;
bool mutates_document = false;
bool marks_unsaved = false;
bool reloads_animation_layers = false;
bool updates_title = false;
};
[[nodiscard]] inline pp::foundation::Status validate_layer_index(
int layer_count,
int index) noexcept
{
if (layer_count <= 0) {
return pp::foundation::Status::invalid_argument("document must contain at least one layer");
}
if (index < 0 || index >= layer_count) {
return pp::foundation::Status::out_of_range("layer index is outside the document");
}
return pp::foundation::Status::success();
}
[[nodiscard]] inline pp::foundation::Status validate_layer_insert_index(
int layer_count,
int index) noexcept
{
if (layer_count < 0) {
return pp::foundation::Status::invalid_argument("layer count must not be negative");
}
if (index < 0 || index > layer_count) {
return pp::foundation::Status::out_of_range("layer insert index is outside the document");
}
return pp::foundation::Status::success();
}
[[nodiscard]] inline pp::foundation::Result<DocumentLayerRenamePlan> plan_document_layer_rename(
std::string_view old_name,
std::string_view requested_name)
@@ -45,4 +108,224 @@ struct DocumentLayerRenamePlan {
return pp::foundation::Result<DocumentLayerRenamePlan>::success(std::move(plan));
}
[[nodiscard]] inline pp::foundation::Result<DocumentLayerOperationPlan> plan_document_layer_add(
int layer_count,
int insert_index,
std::string_view name)
{
const auto index_status = validate_layer_insert_index(layer_count, insert_index);
if (!index_status.ok()) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(index_status);
}
const auto rename = plan_document_layer_rename({}, name);
if (!rename) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(rename.status());
}
DocumentLayerOperationPlan plan;
plan.operation = DocumentLayerOperation::add;
plan.insert_index = insert_index;
plan.name = std::string(name);
plan.mutates_document = true;
plan.marks_unsaved = true;
plan.reloads_animation_layers = true;
plan.updates_title = true;
return pp::foundation::Result<DocumentLayerOperationPlan>::success(std::move(plan));
}
[[nodiscard]] inline pp::foundation::Result<DocumentLayerOperationPlan> plan_document_layer_duplicate(
int layer_count,
int source_index)
{
const auto index_status = validate_layer_index(layer_count, source_index);
if (!index_status.ok()) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(index_status);
}
DocumentLayerOperationPlan plan;
plan.operation = DocumentLayerOperation::duplicate;
plan.source_index = source_index;
plan.insert_index = source_index + 1;
plan.mutates_document = true;
plan.marks_unsaved = true;
plan.reloads_animation_layers = true;
plan.updates_title = true;
return pp::foundation::Result<DocumentLayerOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentLayerOperationPlan> plan_document_layer_select(
int layer_count,
int index)
{
const auto index_status = validate_layer_index(layer_count, index);
if (!index_status.ok()) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(index_status);
}
DocumentLayerOperationPlan plan;
plan.operation = DocumentLayerOperation::select;
plan.index = index;
plan.reloads_animation_layers = true;
return pp::foundation::Result<DocumentLayerOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentLayerOperationPlan> plan_document_layer_reorder(
int layer_count,
int from_index,
int to_index)
{
auto index_status = validate_layer_index(layer_count, from_index);
if (!index_status.ok()) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(index_status);
}
index_status = validate_layer_index(layer_count, to_index);
if (!index_status.ok()) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(index_status);
}
DocumentLayerOperationPlan plan;
plan.operation = DocumentLayerOperation::reorder;
plan.from_index = from_index;
plan.to_index = to_index;
plan.mutates_document = from_index != to_index;
plan.marks_unsaved = plan.mutates_document;
plan.reloads_animation_layers = plan.mutates_document;
plan.updates_title = plan.mutates_document;
return pp::foundation::Result<DocumentLayerOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentLayerOperationPlan> plan_document_layer_remove(
int layer_count,
int index)
{
const auto index_status = validate_layer_index(layer_count, index);
if (!index_status.ok()) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(index_status);
}
if (layer_count <= 1) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(
pp::foundation::Status::invalid_argument("document must keep at least one layer"));
}
DocumentLayerOperationPlan plan;
plan.operation = DocumentLayerOperation::remove;
plan.index = index;
plan.mutates_document = true;
plan.marks_unsaved = true;
plan.reloads_animation_layers = true;
plan.updates_title = true;
return pp::foundation::Result<DocumentLayerOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentLayerOperationPlan> plan_document_layer_opacity(
int layer_count,
int index,
float opacity)
{
const auto index_status = validate_layer_index(layer_count, index);
if (!index_status.ok()) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(index_status);
}
if (!std::isfinite(opacity) || opacity < 0.0F || opacity > 1.0F) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(
pp::foundation::Status::out_of_range("layer opacity must be finite and within 0..1"));
}
DocumentLayerOperationPlan plan;
plan.operation = DocumentLayerOperation::set_opacity;
plan.index = index;
plan.opacity = opacity;
plan.mutates_document = true;
plan.marks_unsaved = true;
plan.updates_title = true;
return pp::foundation::Result<DocumentLayerOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentLayerOperationPlan> plan_document_layer_visibility(
int layer_count,
int index,
bool visible)
{
const auto index_status = validate_layer_index(layer_count, index);
if (!index_status.ok()) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(index_status);
}
DocumentLayerOperationPlan plan;
plan.operation = DocumentLayerOperation::set_visibility;
plan.index = index;
plan.flag = visible;
plan.mutates_document = true;
plan.marks_unsaved = true;
plan.reloads_animation_layers = true;
plan.updates_title = true;
return pp::foundation::Result<DocumentLayerOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentLayerOperationPlan> plan_document_layer_alpha_lock(
int layer_count,
int index,
bool locked)
{
const auto index_status = validate_layer_index(layer_count, index);
if (!index_status.ok()) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(index_status);
}
DocumentLayerOperationPlan plan;
plan.operation = DocumentLayerOperation::set_alpha_lock;
plan.index = index;
plan.flag = locked;
plan.mutates_document = true;
plan.marks_unsaved = true;
plan.updates_title = true;
return pp::foundation::Result<DocumentLayerOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentLayerOperationPlan> plan_document_layer_blend_mode(
int layer_count,
int index,
int blend_mode)
{
const auto index_status = validate_layer_index(layer_count, index);
if (!index_status.ok()) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(index_status);
}
if (blend_mode < 0 || blend_mode >= document_layer_legacy_blend_mode_count) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(
pp::foundation::Status::out_of_range("layer blend mode is outside the supported range"));
}
DocumentLayerOperationPlan plan;
plan.operation = DocumentLayerOperation::set_blend_mode;
plan.index = index;
plan.blend_mode = blend_mode;
plan.mutates_document = true;
plan.marks_unsaved = true;
plan.updates_title = true;
return pp::foundation::Result<DocumentLayerOperationPlan>::success(plan);
}
[[nodiscard]] inline pp::foundation::Result<DocumentLayerOperationPlan> plan_document_layer_highlight(
int layer_count,
int index,
bool highlight)
{
const auto index_status = validate_layer_index(layer_count, index);
if (!index_status.ok()) {
return pp::foundation::Result<DocumentLayerOperationPlan>::failure(index_status);
}
DocumentLayerOperationPlan plan;
plan.operation = DocumentLayerOperation::set_highlight;
plan.index = index;
plan.flag = highlight;
return pp::foundation::Result<DocumentLayerOperationPlan>::success(plan);
}
}

View File

@@ -7,6 +7,7 @@
#include "node_dialog_picker.h"
#include "node_panel_floating.h"
#include "app_core/app_preferences.h"
#include "app_core/document_layer.h"
#include "app_core/app_status.h"
#include "settings.h"
#include "serializer.h"
@@ -182,17 +183,30 @@ void App::init_sidebar()
};
layers->on_layer_add = [this](Node*, std::shared_ptr<class Layer> layer, int index) {
Canvas::I->layer_add(layers->m_layers.back()->m_label_text.c_str(), layer, index);
Canvas::I->m_unsaved = true;
const auto plan = pp::app::plan_document_layer_add(
static_cast<int>(Canvas::I->m_layers.size()),
index,
layers->m_layers.back()->m_label_text);
if (!plan)
return;
Canvas::I->layer_add(plan.value().name, layer, plan.value().insert_index);
Canvas::I->m_unsaved = plan.value().marks_unsaved;
Canvas::I->anim_update();
animation->load_layers();
title_update();
if (plan.value().reloads_animation_layers)
animation->load_layers();
if (plan.value().updates_title)
title_update();
};
layers->on_layer_duplicate = [this](Node*, int source_index) {
Canvas::I->layer_add(layers->m_layers.back()->m_label_text.c_str(), nullptr, source_index + 1);
auto& dst = Canvas::I->m_layers[source_index + 1];
auto& src = Canvas::I->m_layers[source_index];
const auto plan = pp::app::plan_document_layer_duplicate(
static_cast<int>(Canvas::I->m_layers.size()),
source_index);
if (!plan)
return;
Canvas::I->layer_add(layers->m_layers.back()->m_label_text.c_str(), nullptr, plan.value().insert_index);
auto& dst = Canvas::I->m_layers[plan.value().insert_index];
auto& src = Canvas::I->m_layers[plan.value().source_index];
for (int i = 1; i < src->frames_count(); i++)
dst->add_frame();
Canvas::I->anim_update();
@@ -217,57 +231,115 @@ void App::init_sidebar()
dst->m_opacity = src->m_opacity;
dst->m_blend_mode = src->m_blend_mode;
dst->m_alpha_locked = src->m_alpha_locked;
Canvas::I->m_unsaved = true;
animation->load_layers();
title_update();
Canvas::I->m_unsaved = plan.value().marks_unsaved;
if (plan.value().reloads_animation_layers)
animation->load_layers();
if (plan.value().updates_title)
title_update();
};
layers->on_layer_change = [this](Node*, int old_idx, int new_idx) {
canvas->m_canvas->m_current_layer_idx = new_idx;
animation->load_layers();
const auto plan = pp::app::plan_document_layer_select(
static_cast<int>(canvas->m_canvas->m_layers.size()),
new_idx);
if (!plan)
return;
canvas->m_canvas->m_current_layer_idx = plan.value().index;
if (plan.value().reloads_animation_layers)
animation->load_layers();
};
layers->on_layer_order = [this](Node*, int old_idx, int new_idx) {
canvas->m_canvas->layer_order(old_idx, new_idx);
canvas->m_canvas->m_unsaved = true;
animation->load_layers();
title_update();
const auto plan = pp::app::plan_document_layer_reorder(
static_cast<int>(canvas->m_canvas->m_layers.size()),
old_idx,
new_idx);
if (!plan || !plan.value().mutates_document)
return;
canvas->m_canvas->layer_order(plan.value().from_index, plan.value().to_index);
canvas->m_canvas->m_unsaved = plan.value().marks_unsaved;
if (plan.value().reloads_animation_layers)
animation->load_layers();
if (plan.value().updates_title)
title_update();
};
layers->on_layer_delete = [this](Node*, int idx) {
canvas->m_canvas->layer_remove(idx);
canvas->m_canvas->m_unsaved = true;
animation->load_layers();
title_update();
const auto plan = pp::app::plan_document_layer_remove(
static_cast<int>(canvas->m_canvas->m_layers.size()),
idx);
if (!plan)
return;
canvas->m_canvas->layer_remove(plan.value().index);
canvas->m_canvas->m_unsaved = plan.value().marks_unsaved;
if (plan.value().reloads_animation_layers)
animation->load_layers();
if (plan.value().updates_title)
title_update();
};
layers->on_layer_opacity_changed = [this](Node*, int idx, float value) {
canvas->m_canvas->m_layers[idx]->m_opacity = value;
canvas->m_canvas->m_unsaved = true;
title_update();
const auto plan = pp::app::plan_document_layer_opacity(
static_cast<int>(canvas->m_canvas->m_layers.size()),
idx,
value);
if (!plan)
return;
canvas->m_canvas->m_layers[plan.value().index]->m_opacity = plan.value().opacity;
canvas->m_canvas->m_unsaved = plan.value().marks_unsaved;
if (plan.value().updates_title)
title_update();
};
layers->on_layer_visibility_changed = [this](Node*, int idx, bool visible) {
canvas->m_canvas->m_layers[idx]->m_visible = visible;
canvas->m_canvas->m_unsaved = true;
animation->load_layers();
title_update();
const auto plan = pp::app::plan_document_layer_visibility(
static_cast<int>(canvas->m_canvas->m_layers.size()),
idx,
visible);
if (!plan)
return;
canvas->m_canvas->m_layers[plan.value().index]->m_visible = plan.value().flag;
canvas->m_canvas->m_unsaved = plan.value().marks_unsaved;
if (plan.value().reloads_animation_layers)
animation->load_layers();
if (plan.value().updates_title)
title_update();
};
layers->on_layer_alpha_lock_changed = [this](Node*, int idx, bool locked) {
canvas->m_canvas->m_layers[idx]->m_alpha_locked = locked;
canvas->m_canvas->m_unsaved = true;
title_update();
const auto plan = pp::app::plan_document_layer_alpha_lock(
static_cast<int>(canvas->m_canvas->m_layers.size()),
idx,
locked);
if (!plan)
return;
canvas->m_canvas->m_layers[plan.value().index]->m_alpha_locked = plan.value().flag;
canvas->m_canvas->m_unsaved = plan.value().marks_unsaved;
if (plan.value().updates_title)
title_update();
};
layers->on_layer_blend_mode_changed = [this](Node*, int idx, int mode) {
canvas->m_canvas->m_layers[idx]->m_blend_mode = mode;
canvas->m_canvas->m_unsaved = true;
title_update();
const auto plan = pp::app::plan_document_layer_blend_mode(
static_cast<int>(canvas->m_canvas->m_layers.size()),
idx,
mode);
if (!plan)
return;
canvas->m_canvas->m_layers[plan.value().index]->m_blend_mode = plan.value().blend_mode;
canvas->m_canvas->m_unsaved = plan.value().marks_unsaved;
if (plan.value().updates_title)
title_update();
};
layers->on_layer_highlight_changed = [this](Node*, int idx, bool highlight) {
canvas->m_canvas->m_layers[idx]->m_hightlight = highlight;
const auto plan = pp::app::plan_document_layer_highlight(
static_cast<int>(canvas->m_canvas->m_layers.size()),
idx,
highlight);
if (!plan)
return;
canvas->m_canvas->m_layers[plan.value().index]->m_hightlight = plan.value().flag;
};
if (auto* button = layout[main_id]->find<NodeButtonCustom>("btn-stroke"))
{

View File

@@ -718,6 +718,30 @@ if(TARGET pano_cli)
LABELS "app;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
LABELS "app;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"command\":\"plan-layer-operation\".*\"operation\":\"add\".*\"insertIndex\":1.*\"name\":\"Paint\".*\"marksUnsaved\":true.*\"reloadsAnimationLayers\":true")
add_test(NAME pano_cli_plan_layer_operation_reorder_no_op_smoke
COMMAND pano_cli plan-layer-operation --kind reorder --layer-count 3 --from-index 1 --to-index 1)
set_tests_properties(pano_cli_plan_layer_operation_reorder_no_op_smoke PROPERTIES
LABELS "app;integration;desktop-fast;fuzz"
PASS_REGULAR_EXPRESSION "\"command\":\"plan-layer-operation\".*\"operation\":\"reorder\".*\"fromIndex\":1.*\"toIndex\":1.*\"mutatesDocument\":false.*\"marksUnsaved\":false")
add_test(NAME pano_cli_plan_layer_operation_highlight_smoke
COMMAND pano_cli plan-layer-operation --kind highlight --layer-count 2 --index 1 --enabled)
set_tests_properties(pano_cli_plan_layer_operation_highlight_smoke PROPERTIES
LABELS "app;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"command\":\"plan-layer-operation\".*\"operation\":\"set-highlight\".*\"index\":1.*\"flag\":true.*\"mutatesDocument\":false.*\"updatesTitle\":false")
add_test(NAME pano_cli_plan_layer_operation_rejects_bad_opacity
COMMAND pano_cli plan-layer-operation --kind opacity --layer-count 2 --index 1 --opacity 1.5)
set_tests_properties(pano_cli_plan_layer_operation_rejects_bad_opacity PROPERTIES
LABELS "app;integration;desktop-fast;fuzz"
WILL_FAIL TRUE)
add_test(NAME pano_cli_plan_share_file_unsaved_smoke
COMMAND pano_cli plan-share-file)
set_tests_properties(pano_cli_plan_share_file_unsaved_smoke PROPERTIES

View File

@@ -1,6 +1,7 @@
#include "app_core/document_layer.h"
#include "test_harness.h"
#include <cmath>
#include <string>
namespace {
@@ -50,6 +51,133 @@ void layer_rename_rejects_overlong_name(pp::tests::Harness& harness)
}
}
void layer_add_validates_insert_index_and_name(pp::tests::Harness& harness)
{
const auto plan = pp::app::plan_document_layer_add(2, 1, "Paint");
PP_EXPECT(harness, plan);
if (plan) {
PP_EXPECT(harness, plan.value().operation == pp::app::DocumentLayerOperation::add);
PP_EXPECT(harness, plan.value().insert_index == 1);
PP_EXPECT(harness, plan.value().name == "Paint");
PP_EXPECT(harness, plan.value().marks_unsaved);
PP_EXPECT(harness, plan.value().reloads_animation_layers);
}
PP_EXPECT(harness, !pp::app::plan_document_layer_add(2, -1, "Paint"));
PP_EXPECT(harness, !pp::app::plan_document_layer_add(2, 3, "Paint"));
PP_EXPECT(harness, !pp::app::plan_document_layer_add(2, 1, ""));
}
void layer_duplicate_select_and_reorder_validate_indices(pp::tests::Harness& harness)
{
const auto duplicate = pp::app::plan_document_layer_duplicate(3, 1);
PP_EXPECT(harness, duplicate);
if (duplicate) {
PP_EXPECT(harness, duplicate.value().source_index == 1);
PP_EXPECT(harness, duplicate.value().insert_index == 2);
PP_EXPECT(harness, duplicate.value().marks_unsaved);
}
const auto select = pp::app::plan_document_layer_select(3, 2);
PP_EXPECT(harness, select);
if (select) {
PP_EXPECT(harness, select.value().index == 2);
PP_EXPECT(harness, !select.value().marks_unsaved);
PP_EXPECT(harness, select.value().reloads_animation_layers);
}
const auto reorder = pp::app::plan_document_layer_reorder(3, 2, 0);
PP_EXPECT(harness, reorder);
if (reorder) {
PP_EXPECT(harness, reorder.value().from_index == 2);
PP_EXPECT(harness, reorder.value().to_index == 0);
PP_EXPECT(harness, reorder.value().marks_unsaved);
}
const auto no_op_reorder = pp::app::plan_document_layer_reorder(3, 1, 1);
PP_EXPECT(harness, no_op_reorder);
if (no_op_reorder) {
PP_EXPECT(harness, !no_op_reorder.value().mutates_document);
PP_EXPECT(harness, !no_op_reorder.value().marks_unsaved);
}
PP_EXPECT(harness, !pp::app::plan_document_layer_duplicate(3, 3));
PP_EXPECT(harness, !pp::app::plan_document_layer_select(3, -1));
PP_EXPECT(harness, !pp::app::plan_document_layer_reorder(3, 0, 3));
}
void layer_remove_keeps_at_least_one_layer(pp::tests::Harness& harness)
{
const auto plan = pp::app::plan_document_layer_remove(2, 0);
PP_EXPECT(harness, plan);
if (plan) {
PP_EXPECT(harness, plan.value().operation == pp::app::DocumentLayerOperation::remove);
PP_EXPECT(harness, plan.value().index == 0);
PP_EXPECT(harness, plan.value().marks_unsaved);
PP_EXPECT(harness, plan.value().reloads_animation_layers);
}
PP_EXPECT(harness, !pp::app::plan_document_layer_remove(1, 0));
PP_EXPECT(harness, !pp::app::plan_document_layer_remove(2, 2));
}
void layer_metadata_plans_validate_values(pp::tests::Harness& harness)
{
const auto opacity = pp::app::plan_document_layer_opacity(2, 1, 0.25F);
PP_EXPECT(harness, opacity);
if (opacity) {
PP_EXPECT(harness, opacity.value().operation == pp::app::DocumentLayerOperation::set_opacity);
PP_EXPECT(harness, opacity.value().opacity == 0.25F);
PP_EXPECT(harness, opacity.value().marks_unsaved);
PP_EXPECT(harness, !opacity.value().reloads_animation_layers);
}
const auto visibility = pp::app::plan_document_layer_visibility(2, 1, false);
PP_EXPECT(harness, visibility);
if (visibility) {
PP_EXPECT(harness, visibility.value().operation == pp::app::DocumentLayerOperation::set_visibility);
PP_EXPECT(harness, !visibility.value().flag);
PP_EXPECT(harness, visibility.value().reloads_animation_layers);
}
const auto alpha_lock = pp::app::plan_document_layer_alpha_lock(2, 1, true);
PP_EXPECT(harness, alpha_lock);
if (alpha_lock) {
PP_EXPECT(harness, alpha_lock.value().operation == pp::app::DocumentLayerOperation::set_alpha_lock);
PP_EXPECT(harness, alpha_lock.value().flag);
}
const auto blend = pp::app::plan_document_layer_blend_mode(2, 1, 4);
PP_EXPECT(harness, blend);
if (blend) {
PP_EXPECT(harness, blend.value().operation == pp::app::DocumentLayerOperation::set_blend_mode);
PP_EXPECT(harness, blend.value().blend_mode == 4);
}
PP_EXPECT(harness, !pp::app::plan_document_layer_opacity(2, 1, -0.1F));
PP_EXPECT(harness, !pp::app::plan_document_layer_opacity(2, 1, 1.1F));
PP_EXPECT(harness, !pp::app::plan_document_layer_opacity(2, 1, std::nanf("")));
PP_EXPECT(harness, !pp::app::plan_document_layer_blend_mode(2, 1, -1));
PP_EXPECT(harness, !pp::app::plan_document_layer_blend_mode(2, 1, 5));
PP_EXPECT(harness, !pp::app::plan_document_layer_visibility(2, 2, true));
PP_EXPECT(harness, !pp::app::plan_document_layer_alpha_lock(2, 2, true));
}
void layer_highlight_is_transient(pp::tests::Harness& harness)
{
const auto plan = pp::app::plan_document_layer_highlight(2, 1, true);
PP_EXPECT(harness, plan);
if (plan) {
PP_EXPECT(harness, plan.value().operation == pp::app::DocumentLayerOperation::set_highlight);
PP_EXPECT(harness, plan.value().flag);
PP_EXPECT(harness, !plan.value().mutates_document);
PP_EXPECT(harness, !plan.value().marks_unsaved);
PP_EXPECT(harness, !plan.value().updates_title);
}
PP_EXPECT(harness, !pp::app::plan_document_layer_highlight(2, 2, true));
}
}
int main()
@@ -59,5 +187,10 @@ int main()
harness.run("layer rename ignores unchanged name", layer_rename_ignores_unchanged_name);
harness.run("layer rename rejects empty name", layer_rename_rejects_empty_name);
harness.run("layer rename rejects overlong name", layer_rename_rejects_overlong_name);
harness.run("layer add validates insert index and name", layer_add_validates_insert_index_and_name);
harness.run("layer duplicate select and reorder validate indices", layer_duplicate_select_and_reorder_validate_indices);
harness.run("layer remove keeps at least one layer", layer_remove_keeps_at_least_one_layer);
harness.run("layer metadata plans validate values", layer_metadata_plans_validate_values);
harness.run("layer highlight is transient", layer_highlight_is_transient);
return harness.finish();
}

View File

@@ -225,6 +225,19 @@ struct PlanLayerRenameArgs {
std::string new_name;
};
struct PlanLayerOperationArgs {
std::string kind = "select";
int layer_count = 1;
int index = 0;
int from_index = 0;
int to_index = 0;
int source_index = 0;
std::string name = "Layer";
float opacity = 1.0F;
bool flag = false;
int blend_mode = 0;
};
struct SimulateAppSessionArgs {
bool has_canvas = true;
bool new_document = false;
@@ -470,6 +483,34 @@ const char* document_layer_rename_action_name(pp::app::DocumentLayerRenameAction
return "no-op-same-name";
}
const char* document_layer_operation_name(pp::app::DocumentLayerOperation operation) noexcept
{
switch (operation) {
case pp::app::DocumentLayerOperation::add:
return "add";
case pp::app::DocumentLayerOperation::duplicate:
return "duplicate";
case pp::app::DocumentLayerOperation::select:
return "select";
case pp::app::DocumentLayerOperation::reorder:
return "reorder";
case pp::app::DocumentLayerOperation::remove:
return "remove";
case pp::app::DocumentLayerOperation::set_opacity:
return "set-opacity";
case pp::app::DocumentLayerOperation::set_visibility:
return "set-visibility";
case pp::app::DocumentLayerOperation::set_alpha_lock:
return "set-alpha-lock";
case pp::app::DocumentLayerOperation::set_blend_mode:
return "set-blend-mode";
case pp::app::DocumentLayerOperation::set_highlight:
return "set-highlight";
}
return "select";
}
const char* document_file_write_decision_name(pp::app::DocumentFileWriteDecision decision) noexcept
{
switch (decision) {
@@ -708,6 +749,7 @@ void print_help()
<< " plan-app-status [--doc-name NAME] [--unsaved] [--resolution N] [--resolution-index N] [--zoom N] [--history-bytes N] [--recording-running] [--encoder-available] [--encoded-frames N]\n"
<< " plan-document-resize [--current-resolution N] [--selected-resolution-index N]\n"
<< " plan-layer-rename --old-name NAME --new-name NAME\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-share-file [--path FILE]\n"
<< " plan-picked-path [--path FILE]\n"
<< " plan-display-file [--path FILE]\n"
@@ -2351,6 +2393,148 @@ int plan_layer_rename(int argc, char** argv)
return 0;
}
pp::foundation::Status parse_plan_layer_operation_args(
int argc,
char** argv,
PlanLayerOperationArgs& args)
{
for (int i = 2; i < argc; ++i) {
const std::string_view key(argv[i]);
if (key == "--kind" || key == "--name") {
if (i + 1 >= argc) {
return pp::foundation::Status::invalid_argument("missing value for option");
}
if (key == "--kind") {
args.kind = argv[++i];
} else {
args.name = argv[++i];
}
} else if (key == "--layer-count" || key == "--index" || key == "--from-index"
|| key == "--to-index" || key == "--source-index" || key == "--blend-mode") {
if (i + 1 >= argc) {
return pp::foundation::Status::invalid_argument("missing value for option");
}
const auto value = pp::foundation::parse_u32(argv[++i]);
if (!value) {
return value.status();
}
if (key == "--layer-count") {
args.layer_count = static_cast<int>(value.value());
} else if (key == "--index") {
args.index = static_cast<int>(value.value());
} else if (key == "--from-index") {
args.from_index = static_cast<int>(value.value());
} else if (key == "--to-index") {
args.to_index = static_cast<int>(value.value());
} else if (key == "--source-index") {
args.source_index = static_cast<int>(value.value());
} else {
args.blend_mode = static_cast<int>(value.value());
}
} else if (key == "--opacity") {
if (i + 1 >= argc) {
return pp::foundation::Status::invalid_argument("missing value for option");
}
const auto value = parse_float_arg(argv[++i]);
if (!value) {
return value.status();
}
args.opacity = value.value();
} else if (key == "--enabled") {
args.flag = true;
} else if (key == "--disabled") {
args.flag = false;
} else {
return pp::foundation::Status::invalid_argument("unknown option");
}
}
return pp::foundation::Status::success();
}
pp::foundation::Result<pp::app::DocumentLayerOperationPlan> make_layer_operation_plan(
const PlanLayerOperationArgs& args)
{
if (args.kind == "add") {
return pp::app::plan_document_layer_add(args.layer_count, args.index, args.name);
}
if (args.kind == "duplicate") {
return pp::app::plan_document_layer_duplicate(args.layer_count, args.source_index);
}
if (args.kind == "select") {
return pp::app::plan_document_layer_select(args.layer_count, args.index);
}
if (args.kind == "reorder") {
return pp::app::plan_document_layer_reorder(args.layer_count, args.from_index, args.to_index);
}
if (args.kind == "remove") {
return pp::app::plan_document_layer_remove(args.layer_count, args.index);
}
if (args.kind == "opacity") {
return pp::app::plan_document_layer_opacity(args.layer_count, args.index, args.opacity);
}
if (args.kind == "visibility") {
return pp::app::plan_document_layer_visibility(args.layer_count, args.index, args.flag);
}
if (args.kind == "alpha-lock") {
return pp::app::plan_document_layer_alpha_lock(args.layer_count, args.index, args.flag);
}
if (args.kind == "blend-mode") {
return pp::app::plan_document_layer_blend_mode(args.layer_count, args.index, args.blend_mode);
}
if (args.kind == "highlight") {
return pp::app::plan_document_layer_highlight(args.layer_count, args.index, args.flag);
}
return pp::foundation::Result<pp::app::DocumentLayerOperationPlan>::failure(
pp::foundation::Status::invalid_argument("unknown layer operation kind"));
}
int plan_layer_operation(int argc, char** argv)
{
PlanLayerOperationArgs args;
const auto status = parse_plan_layer_operation_args(argc, argv, args);
if (!status.ok()) {
print_error("plan-layer-operation", status.message);
return 2;
}
const auto plan = make_layer_operation_plan(args);
if (!plan) {
print_error("plan-layer-operation", plan.status().message);
return 2;
}
const auto& value = plan.value();
std::cout << "{\"ok\":true,\"command\":\"plan-layer-operation\""
<< ",\"state\":{\"kind\":\"" << json_escape(args.kind)
<< "\",\"layerCount\":" << args.layer_count
<< ",\"index\":" << args.index
<< ",\"fromIndex\":" << args.from_index
<< ",\"toIndex\":" << args.to_index
<< ",\"sourceIndex\":" << args.source_index
<< ",\"name\":\"" << json_escape(args.name)
<< "\",\"opacity\":" << args.opacity
<< ",\"flag\":" << json_bool(args.flag)
<< ",\"blendMode\":" << args.blend_mode
<< "},\"plan\":{\"operation\":\"" << document_layer_operation_name(value.operation)
<< "\",\"index\":" << value.index
<< ",\"fromIndex\":" << value.from_index
<< ",\"toIndex\":" << value.to_index
<< ",\"insertIndex\":" << value.insert_index
<< ",\"sourceIndex\":" << value.source_index
<< ",\"name\":\"" << json_escape(value.name)
<< "\",\"opacity\":" << value.opacity
<< ",\"flag\":" << json_bool(value.flag)
<< ",\"blendMode\":" << value.blend_mode
<< ",\"mutatesDocument\":" << json_bool(value.mutates_document)
<< ",\"marksUnsaved\":" << json_bool(value.marks_unsaved)
<< ",\"reloadsAnimationLayers\":" << json_bool(value.reloads_animation_layers)
<< ",\"updatesTitle\":" << json_bool(value.updates_title)
<< "}}\n";
return 0;
}
pp::foundation::Status parse_plan_share_file_args(
int argc,
char** argv,
@@ -4759,6 +4943,10 @@ int main(int argc, char** argv)
return plan_layer_rename(argc, argv);
}
if (command == "plan-layer-operation") {
return plan_layer_operation(argc, argv);
}
if (command == "plan-share-file") {
return plan_share_file(argc, argv);
}