Plan new document creation in app core

This commit is contained in:
2026-06-02 23:14:35 +02:00
parent fd1772a417
commit 853307697a
10 changed files with 284 additions and 21 deletions

View File

@@ -397,6 +397,10 @@ Known local toolchain state:
- `pano_cli plan-open-route` exposes `pp_app_core` document-open action
planning as JSON and is covered for clean project open, dirty project
discard-prompt, and ABR import-prompt states.
- `pano_cli plan-new-document` exposes `pp_app_core` new-document target,
legacy resolution-index mapping, and overwrite-prompt planning as JSON and is
covered for save-now, existing-target overwrite, and invalid-resolution
states.
- `pano_cli plan-document-file` exposes `pp_app_core` document-name
validation, legacy `.ppi` path construction, and overwrite-prompt decisions
as JSON and is covered for save-now and existing-target overwrite states.
@@ -420,9 +424,10 @@ Known local toolchain state:
directory/stem targets, picked-directory stems, MP4 suggested names, and
invalid export naming inputs.
- `pp_app_core_document_session_tests` covers clean and dirty app session,
document-open action planning, save-request, save-before-workflow, document
file target, overwrite, and save-version target decisions without requiring
a window, canvas, or message box.
document-open action planning, save-request, save-before-workflow,
new-document target/resolution/overwrite planning, document file target,
overwrite, and save-version target decisions without requiring a window,
canvas, or message box.
- `pp_ui_core` consumes vcpkg tinyxml2 only when `PP_USE_VCPKG_TINYXML2=ON`
through the vcpkg preset; default and Android validation still use the
retained vendored fallback tracked by DEBT-0012.

View File

@@ -13,7 +13,7 @@ and validation command.
| --- | --- | --- | --- |
| PPI open/save | `Canvas`, serializer, dialogs | `pp_document`, `pp_assets`, `pano_cli` | Round-trip tiny project, old-version fixture, corrupt/truncated fixture |
| Open-document routing | `App::open_document` | `pp_app_core`, `pano_cli`, `pp_panopainter_ui`, `pp_document`, `pp_assets` | Project/ABR/PPBR route tests, malformed path tests, open-action plan tests, CLI route/action smoke, app open smoke |
| Document session decisions | `App::open_document`, `App::request_close`, save hotkeys, file menu, dialogs | `pp_app_core`, `pano_cli`, `pp_panopainter_ui` | Clean/dirty/prompt-open/save/save-as/save-version/save-before-workflow/name/overwrite/version-target decision tests, CLI session, document-file, and document-version smoke, app close/open/save/new/browse smoke |
| Document session decisions | `App::open_document`, `App::request_close`, save hotkeys, file menu, dialogs | `pp_app_core`, `pano_cli`, `pp_panopainter_ui` | Clean/dirty/prompt-open/save/save-as/save-version/save-before-workflow/name/new-document resolution/overwrite/version-target decision tests, CLI session, new-document, document-file, and document-version smoke, app close/open/save/new/browse smoke |
| Version metadata | `scripts/pre-build.py`, `version.*` | build system, `pp_foundation` | Generated header smoke test, missing-tag behavior |
| Thumbnail generation/read | `Canvas`, `Image` | `pp_assets`, `pp_paint_renderer` | Golden thumbnail, corrupt input |
| Save-as, overwrite prompts | App/dialogs | `pp_app_core`, `pp_panopainter_ui`, `pp_platform_*` | Decision tests, UI automation, and platform smoke |

View File

@@ -22,7 +22,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`, file-menu save actions, `NodeCanvas` save hotkeys, new/open/browse dirty-document workflow prompts, new/save-as document file naming and overwrite decisions, save-version target decisions, export target naming/path decisions, `pano_cli classify-open`, `pano_cli plan-open-route`, `pano_cli plan-document-file`, `pano_cli plan-document-version`, `pano_cli plan-export-target`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session/export contracts, but document loading, brush import execution, saving, and export execution still reach legacy `Canvas::I`/UI singletons | Avoid behavior changes while introducing component boundaries | App launch and component tests; `pp_app_core_document_route_tests`; `pp_app_core_document_export_tests`; `pp_app_core_document_session_tests`; `pano_cli classify-open --path D:/Paint/demo.ppi`; `pano_cli plan-open-route --path D:/Paint/demo.ppi --unsaved`; `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-target --kind file --work-dir D:/Paint --doc-name demo --extension .png`; `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`, file-menu save actions, `NodeCanvas` save 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 target naming/path decisions, `pano_cli classify-open`, `pano_cli plan-open-route`, `pano_cli plan-new-document`, `pano_cli plan-document-file`, `pano_cli plan-document-version`, `pano_cli plan-export-target`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session/export contracts, but document creation/loading, brush import execution, saving, and export execution still reach legacy `Canvas::I`/UI singletons | Avoid behavior changes while introducing component boundaries | App launch and component tests; `pp_app_core_document_route_tests`; `pp_app_core_document_export_tests`; `pp_app_core_document_session_tests`; `pano_cli classify-open --path D:/Paint/demo.ppi`; `pano_cli plan-open-route --path D:/Paint/demo.ppi --unsaved`; `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-target --kind file --work-dir D:/Paint --doc-name demo --extension .png`; `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 |

View File

@@ -428,9 +428,12 @@ document-open action plan for clean project open, dirty project prompt, and
brush-import prompt flows. `pano_cli simulate-app-session` exposes the pure
`pp_app_core` session decisions used by project-open, app-close, save, save-as,
and save-version flows, plus the save-before-continue workflow gate used by
new-document/open/browse dialogs. `pano_cli plan-document-file` exposes the
same app-core document-name validation, legacy `.ppi` path construction, and
overwrite prompt decision used by new-document and save-as dialogs.
new-document/open/browse dialogs. `pano_cli plan-new-document` exposes the
same app-core new-document target, legacy resolution-index mapping, and
overwrite decision used by the live new-document dialog, including invalid
resolution rejection. `pano_cli plan-document-file` exposes the same app-core
document-name validation, legacy `.ppi` path construction, and overwrite
prompt decision used by save-as dialogs.
`pano_cli plan-document-version` exposes the save-version suffix parsing,
candidate generation, collision skipping, and no-slot failure behavior used by
the live save-version dialog.

View File

@@ -3,6 +3,7 @@
#include "app_core/document_route.h"
#include "foundation/result.h"
#include <array>
#include <cctype>
#include <cstdio>
#include <string>
@@ -65,6 +66,12 @@ struct DocumentVersionTarget {
std::string path;
};
struct NewDocumentPlan {
DocumentFileTarget target;
int resolution = 0;
DocumentFileWriteDecision write_decision = DocumentFileWriteDecision::save_now;
};
[[nodiscard]] constexpr ProjectOpenDecision plan_project_open(bool has_unsaved_changes) noexcept
{
return has_unsaved_changes
@@ -175,6 +182,41 @@ struct DocumentVersionTarget {
: DocumentFileWriteDecision::save_now;
}
[[nodiscard]] constexpr pp::foundation::Result<int> document_resolution_from_index(int index) noexcept
{
constexpr std::array<int, 6> resolutions{ 512, 1024, 1536, 2048, 4096, 8192 };
if (index < 0 || static_cast<std::size_t>(index) >= resolutions.size()) {
return pp::foundation::Result<int>::failure(
pp::foundation::Status::out_of_range("document resolution index is out of range"));
}
return pp::foundation::Result<int>::success(resolutions[static_cast<std::size_t>(index)]);
}
template <typename ExistsPredicate>
[[nodiscard]] pp::foundation::Result<NewDocumentPlan> plan_new_document(
std::string_view work_directory,
std::string_view document_name,
int resolution_index,
ExistsPredicate&& exists)
{
const auto resolution = document_resolution_from_index(resolution_index);
if (!resolution) {
return pp::foundation::Result<NewDocumentPlan>::failure(resolution.status());
}
auto target = make_document_file_target(work_directory, document_name);
if (!target) {
return pp::foundation::Result<NewDocumentPlan>::failure(target.status());
}
NewDocumentPlan plan;
plan.target = std::move(target.value());
plan.resolution = resolution.value();
plan.write_decision = plan_document_file_write(exists(plan.target.path));
return pp::foundation::Result<NewDocumentPlan>::success(std::move(plan));
}
[[nodiscard]] inline bool has_legacy_two_character_version_suffix(std::string_view document_name) noexcept
{
const auto dot = document_name.rfind('.');

View File

@@ -2,6 +2,7 @@
#include "app.h"
#include "action.h"
#include "app_core/document_export.h"
#include "app_core/document_session.h"
#include "settings.h"
#include "node_dialog_open.h"
#include "node_dialog_browse.h"
@@ -158,24 +159,32 @@ void App::dialog_newdoc()
dialog->btn_ok->on_click = [this, dialog](Node*)
{
std::string name = dialog->input->m_text;
const auto target = pp::app::make_document_file_target(work_path, name);
if (!target)
const auto plan = pp::app::plan_new_document(
work_path,
name,
dialog->m_resolution->m_current_index,
[](const std::string& path) {
return Asset::exist(path);
});
if (!plan)
{
message_box("Warning", "You need to specify a name to file.");
const bool missing_name =
plan.status().code == pp::foundation::StatusCode::invalid_argument;
message_box(
"Warning",
missing_name ? "You need to specify a name to file." : plan.status().message);
return;
}
auto action = [this, dialog, target = target.value()] {
std::array<int, 6> resolutions{ 512, 1024, 1536, 2048, 4096, 8192 };
int res = resolutions[dialog->m_resolution->m_current_index];
doc_name = target.name;
doc_path = target.path;
doc_filename = target.name + ".ppi";
doc_dir = target.directory;
auto action = [this, dialog, plan = plan.value()] {
doc_name = plan.target.name;
doc_path = plan.target.path;
doc_filename = plan.target.name + ".ppi";
doc_dir = plan.target.directory;
layers->clear();
canvas->m_canvas->m_layers.clear();
canvas->m_canvas->resize(res, res);
canvas->m_canvas->resize(plan.resolution, plan.resolution);
canvas->reset_camera();
ActionManager::clear();
@@ -189,8 +198,7 @@ void App::dialog_newdoc()
App::I->hideKeyboard();
};
const auto write_decision = pp::app::plan_document_file_write(Asset::exist(target.value().path));
if (write_decision == pp::app::DocumentFileWriteDecision::prompt_overwrite)
if (plan.value().write_decision == pp::app::DocumentFileWriteDecision::prompt_overwrite)
{
// ask confirm is file already exist
auto msgbox = new NodeMessageBox();

View File

@@ -411,6 +411,26 @@ if(TARGET pano_cli)
LABELS "app;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"command\":\"plan-document-file\".*\"path\":\"D:/Paint/demo.ppi\".*\"exists\":true.*\"decision\":\"prompt-overwrite\"")
add_test(NAME pano_cli_plan_new_document_smoke
COMMAND pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3)
set_tests_properties(pano_cli_plan_new_document_smoke PROPERTIES
LABELS "app;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"command\":\"plan-new-document\".*\"path\":\"D:/Paint/demo.ppi\".*\"exists\":false.*\"resolution\":2048.*\"decision\":\"save-now\"")
add_test(NAME pano_cli_plan_new_document_overwrite_smoke
COMMAND pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 1 --target-exists)
set_tests_properties(pano_cli_plan_new_document_overwrite_smoke PROPERTIES
LABELS "app;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"command\":\"plan-new-document\".*\"path\":\"D:/Paint/demo.ppi\".*\"exists\":true.*\"resolution\":1024.*\"decision\":\"prompt-overwrite\"")
add_test(NAME pano_cli_plan_new_document_rejects_invalid_resolution
COMMAND "${CMAKE_COMMAND}"
-DPANO_CLI=$<TARGET_FILE:pano_cli>
"-DEXPECTED_OUTPUT=document resolution index is out of range"
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/expect_pano_cli_plan_new_document_failure.cmake")
set_tests_properties(pano_cli_plan_new_document_rejects_invalid_resolution PROPERTIES
LABELS "app;integration;desktop-fast;fuzz")
add_test(NAME pano_cli_plan_document_version_first_smoke
COMMAND pano_cli plan-document-version --directory D:/Paint --doc-name demo)
set_tests_properties(pano_cli_plan_document_version_first_smoke PROPERTIES

View File

@@ -175,6 +175,72 @@ void document_file_write_prompts_only_for_existing_targets(pp::tests::Harness& h
== pp::app::DocumentFileWriteDecision::prompt_overwrite);
}
void document_resolution_index_maps_legacy_choices(pp::tests::Harness& harness)
{
const auto first = pp::app::document_resolution_from_index(0);
const auto last = pp::app::document_resolution_from_index(5);
PP_EXPECT(harness, first);
PP_EXPECT(harness, first.value() == 512);
PP_EXPECT(harness, last);
PP_EXPECT(harness, last.value() == 8192);
}
void document_resolution_index_rejects_out_of_range(pp::tests::Harness& harness)
{
const auto negative = pp::app::document_resolution_from_index(-1);
const auto too_large = pp::app::document_resolution_from_index(6);
PP_EXPECT(harness, !negative);
PP_EXPECT(harness, negative.status().code == pp::foundation::StatusCode::out_of_range);
PP_EXPECT(harness, !too_large);
PP_EXPECT(harness, too_large.status().code == pp::foundation::StatusCode::out_of_range);
}
void new_document_plan_builds_target_resolution_and_write_decision(pp::tests::Harness& harness)
{
const auto plan = pp::app::plan_new_document(
"D:/Paint",
"demo",
2,
[](const std::string&) { return false; });
PP_EXPECT(harness, plan);
PP_EXPECT(harness, plan.value().target.name == "demo");
PP_EXPECT(harness, plan.value().target.path == "D:/Paint/demo.ppi");
PP_EXPECT(harness, plan.value().resolution == 1536);
PP_EXPECT(harness, plan.value().write_decision == pp::app::DocumentFileWriteDecision::save_now);
}
void new_document_plan_prompts_for_existing_target(pp::tests::Harness& harness)
{
const auto plan = pp::app::plan_new_document(
"D:/Paint",
"demo",
1,
[](const std::string& path) { return path == "D:/Paint/demo.ppi"; });
PP_EXPECT(harness, plan);
PP_EXPECT(harness, plan.value().resolution == 1024);
PP_EXPECT(
harness,
plan.value().write_decision == pp::app::DocumentFileWriteDecision::prompt_overwrite);
}
void new_document_plan_rejects_invalid_inputs(pp::tests::Harness& harness)
{
const auto missing_name = pp::app::plan_new_document(
"D:/Paint",
"",
0,
[](const std::string&) { return false; });
const auto invalid_resolution = pp::app::plan_new_document(
"D:/Paint",
"demo",
99,
[](const std::string&) { return false; });
PP_EXPECT(harness, !missing_name);
PP_EXPECT(harness, missing_name.status().code == pp::foundation::StatusCode::invalid_argument);
PP_EXPECT(harness, !invalid_resolution);
PP_EXPECT(harness, invalid_resolution.status().code == pp::foundation::StatusCode::out_of_range);
}
void document_version_target_starts_at_first_version(pp::tests::Harness& harness)
{
const auto target = pp::app::find_next_document_version_target(
@@ -254,6 +320,13 @@ int main()
harness.run("document file target rejects empty name", document_file_target_rejects_empty_name);
harness.run("document file target builds legacy ppi path", document_file_target_builds_legacy_ppi_path);
harness.run("document file write prompts only for existing targets", document_file_write_prompts_only_for_existing_targets);
harness.run("document resolution index maps legacy choices", document_resolution_index_maps_legacy_choices);
harness.run("document resolution index rejects out of range", document_resolution_index_rejects_out_of_range);
harness.run(
"new document plan builds target resolution and write decision",
new_document_plan_builds_target_resolution_and_write_decision);
harness.run("new document plan prompts for existing target", new_document_plan_prompts_for_existing_target);
harness.run("new document plan rejects invalid inputs", new_document_plan_rejects_invalid_inputs);
harness.run("document version target starts at first version", document_version_target_starts_at_first_version);
harness.run("document version target increments existing suffix", document_version_target_increments_existing_suffix);
harness.run("document version target skips existing paths", document_version_target_skips_existing_paths);

View File

@@ -0,0 +1,26 @@
if(NOT DEFINED PANO_CLI)
message(FATAL_ERROR "PANO_CLI is required")
endif()
if(NOT DEFINED EXPECTED_OUTPUT)
message(FATAL_ERROR "EXPECTED_OUTPUT is required")
endif()
execute_process(
COMMAND "${PANO_CLI}" plan-new-document
--work-dir D:/Paint
--name demo
--resolution-index 99
RESULT_VARIABLE result
OUTPUT_VARIABLE output
ERROR_VARIABLE error)
if(result EQUAL 0)
message(FATAL_ERROR "pano_cli plan-new-document unexpectedly succeeded: ${output}${error}")
endif()
string(FIND "${output}${error}" "${EXPECTED_OUTPUT}" found_at)
if(found_at EQUAL -1)
message(FATAL_ERROR
"pano_cli plan-new-document failure output did not contain expected text.\nExpected: ${EXPECTED_OUTPUT}\nOutput: ${output}${error}")
endif()

View File

@@ -108,6 +108,13 @@ struct PlanDocumentFileArgs {
bool target_exists = false;
};
struct PlanNewDocumentArgs {
std::string work_directory;
std::string name;
std::uint32_t resolution_index = 0;
bool target_exists = false;
};
struct PlanDocumentVersionArgs {
std::string directory;
std::string document_name;
@@ -399,6 +406,7 @@ void print_help()
<< " inspect-project --path FILE\n"
<< " classify-open --path FILE\n"
<< " plan-open-route --path FILE [--unsaved]\n"
<< " plan-new-document --work-dir DIR --name NAME [--resolution-index N] [--target-exists]\n"
<< " plan-document-file --work-dir DIR --name NAME [--target-exists]\n"
<< " plan-document-version --directory DIR --doc-name NAME [--existing-path FILE]\n"
<< " plan-export-target --kind file|collection|stem|name --doc-name NAME [--work-dir DIR] [--directory DIR] [--extension EXT] [--suffix SUFFIX]\n"
@@ -1355,6 +1363,80 @@ int plan_document_file(int argc, char** argv)
return 0;
}
pp::foundation::Status parse_plan_new_document_args(
int argc,
char** argv,
PlanNewDocumentArgs& args)
{
for (int i = 2; i < argc; ++i) {
const std::string_view key(argv[i]);
if (key == "--work-dir") {
if (i + 1 >= argc) {
return pp::foundation::Status::invalid_argument("missing value for option");
}
args.work_directory = argv[++i];
} else if (key == "--name") {
if (i + 1 >= argc) {
return pp::foundation::Status::invalid_argument("missing value for option");
}
args.name = argv[++i];
} else if (key == "--resolution-index") {
if (i + 1 >= argc) {
return pp::foundation::Status::invalid_argument("missing value for option");
}
const auto parsed = pp::foundation::parse_u32(argv[++i]);
if (!parsed) {
return parsed.status();
}
args.resolution_index = parsed.value();
} else if (key == "--target-exists") {
args.target_exists = true;
} else {
return pp::foundation::Status::invalid_argument("unknown option");
}
}
if (args.work_directory.empty()) {
return pp::foundation::Status::invalid_argument("work directory must not be empty");
}
return pp::foundation::Status::success();
}
int plan_new_document(int argc, char** argv)
{
PlanNewDocumentArgs args;
const auto status = parse_plan_new_document_args(argc, argv, args);
if (!status.ok()) {
print_error("plan-new-document", status.message);
return 2;
}
const auto plan = pp::app::plan_new_document(
args.work_directory,
args.name,
static_cast<int>(args.resolution_index),
[&args](const std::string&) {
return args.target_exists;
});
if (!plan) {
print_error("plan-new-document", plan.status().message);
return 2;
}
std::cout << "{\"ok\":true,\"command\":\"plan-new-document\""
<< ",\"target\":{\"name\":\"" << json_escape(plan.value().target.name)
<< "\",\"directory\":\"" << json_escape(plan.value().target.directory)
<< "\",\"path\":\"" << json_escape(plan.value().target.path)
<< "\",\"exists\":" << json_bool(args.target_exists)
<< "},\"document\":{\"resolution\":" << plan.value().resolution
<< ",\"resolutionIndex\":" << args.resolution_index
<< "},\"decision\":\""
<< document_file_write_decision_name(plan.value().write_decision)
<< "\"}\n";
return 0;
}
pp::foundation::Status parse_plan_document_version_args(
int argc,
char** argv,
@@ -3559,6 +3641,10 @@ int main(int argc, char** argv)
return plan_document_file(argc, argv);
}
if (command == "plan-new-document") {
return plan_new_document(argc, argv);
}
if (command == "plan-document-version") {
return plan_document_version(argc, argv);
}