diff --git a/CMakeLists.txt b/CMakeLists.txt index cd7f0a5c..483a279b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,6 +112,10 @@ add_executable(decawm_mode_test experiments/core/decawm_mode_test.cpp) target_link_libraries(decawm_mode_test PRIVATE termforge_core) termforge_warnings(decawm_mode_test) +add_executable(view_mapping_test experiments/core/view_mapping_test.cpp) +target_link_libraries(view_mapping_test PRIVATE termforge_core) +termforge_warnings(view_mapping_test) + add_executable(reply_test experiments/core/reply_test.cpp) target_link_libraries(reply_test PRIVATE termforge_core) termforge_warnings(reply_test) @@ -325,6 +329,7 @@ if(BUILD_TESTING) add_test(NAME search_reorder_core COMMAND search_reorder_test) set_tests_properties(search_reorder_core PROPERTIES TIMEOUT 10) add_test(NAME buffer_state_core COMMAND buffer_state_test) +add_test(NAME view_mapping_core COMMAND view_mapping_test) add_test(NAME alternate_mode_core COMMAND alternate_mode_test) add_test(NAME decawm_mode_core COMMAND decawm_mode_test) add_test(NAME reply_core COMMAND reply_test) diff --git a/experiments/core/view_mapping.hpp b/experiments/core/view_mapping.hpp new file mode 100644 index 00000000..32fa6ea2 --- /dev/null +++ b/experiments/core/view_mapping.hpp @@ -0,0 +1,91 @@ +// Value-only source coordinates published with one immutable display page. +#pragma once +#include "text_document.hpp" + +namespace termforge { +enum class ViewAffinity { leading, trailing }; +struct ViewCellSpan { + size_t row = 0, column_begin = 0, column_end = 0; +}; +struct ViewSourceSpan { + size_t row = 0, column_begin = 0, column_end = 0; + TextRange source; +}; + +class ViewMapping { + size_t columns_ = 0; + std::vector sources_; + + // Identities do not order the document: retained sequence does. Document + // order therefore comes from TextDocument::compare, which stays correct for + // reordered rows and rejects positions captured at an older buffer epoch. + static bool before(const TextDocument& document, TextPosition a, TextPosition b) { + const auto order = document.compare(a, b); + return order.has_value() && *order < 0; + } + bool valid(const TextDocument& document, const ViewSourceSpan& span) const { + return span.column_begin <= span.column_end && span.column_end <= columns_ && + span.source.begin.line_id == span.source.end.line_id && + span.source.begin.version == span.source.end.version && + span.source.begin.column <= span.source.end.column && + document.valid(span.source.begin) && document.valid(span.source.end); + } +public: + ViewMapping() = default; + ViewMapping(size_t columns, std::vector sources) + : columns_(columns), sources_(std::move(sources)) {} + + std::optional hit(const TextDocument& document, size_t row, size_t column, + ViewAffinity affinity) const { + if (column > columns_) return std::nullopt; + const ViewSourceSpan* first = nullptr; + const ViewSourceSpan* previous = nullptr; + for (const auto& span : sources_) { + if (span.row != row) continue; + if (!valid(document, span)) return std::nullopt; + if (!first) first = &span; + } + if (!first) return std::nullopt; + for (const auto& span : sources_) { + if (span.row != row) continue; + if (column <= span.column_begin) { + if (previous && affinity == ViewAffinity::trailing) return previous->source.end; + return span.source.begin; + } + if (column < span.column_end) { + auto result = span.source.begin; + const auto source_width = span.source.end.column - result.column; + const auto display_width = span.column_end - span.column_begin; + // Ordinary wide-cell interiors keep their physical boundary. + // TextDocument normalizes a complete drag range afterward. + if (source_width == display_width) { + result.column += column - span.column_begin; + return result; + } + return affinity == ViewAffinity::leading ? span.source.begin : span.source.end; + } + if (column == span.column_end && affinity == ViewAffinity::trailing) return span.source.end; + previous = &span; + } + // Synthetic trailing padding has no independent source coordinates. + return previous ? std::optional{previous->source.end} : std::nullopt; + } + + std::vector project(const TextDocument& document, TextRange range) const { + const auto normalized = document.normalize(range); + if (!normalized || normalized->begin == normalized->end) return {}; + std::vector result; + for (const auto& span : sources_) { + if (!valid(document, span)) return {}; + if (!before(document, span.source.begin, normalized->end) || + !before(document, normalized->begin, span.source.end)) + continue; + if (span.column_begin == span.column_end) continue; + if (!result.empty() && result.back().row == span.row && + result.back().column_end == span.column_begin) result.back().column_end = span.column_end; + else result.push_back({span.row, span.column_begin, span.column_end}); + } + return result; + } +}; +} // namespace termforge diff --git a/experiments/core/view_mapping_test.cpp b/experiments/core/view_mapping_test.cpp new file mode 100644 index 00000000..be8c3651 --- /dev/null +++ b/experiments/core/view_mapping_test.cpp @@ -0,0 +1,85 @@ +// Display-page coordinates published beside one immutable view: a display row +// is composed from source ranges, so hit-testing and range projection must map +// back to document coordinates instead of assuming they are the same thing. +#include "terminal.hpp" +#include "text_document.hpp" +#include "view_mapping.hpp" +#include +#include + +using namespace termforge; + +static void check(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +static void interior_columns_map_by_offset() { + Parser parser(3, 4, 8); + parser.feed("ABCD\r\nEF"); + const TextDocument document(parser.screen); + const auto begin = document.position(0, 0); + const auto end = document.position(0, 4); + check(begin.has_value() && end.has_value(), "source positions for the first row"); + + ViewMapping mapping(4, {ViewSourceSpan{0, 0, 4, TextRange{*begin, *end}}}); + const auto hit = mapping.hit(document, 0, 2, ViewAffinity::leading); + check(hit.has_value() && document.valid(*hit) && hit->column == 2, + "an equal-width span maps a display column onto the same source column"); + + const auto projected = mapping.project(document, TextRange{*begin, *end}); + check(projected.size() == 1, "one composed row yields one display span"); + check(projected[0].row == 0 && projected[0].column_begin == 0 && projected[0].column_end == 4, + "projection returns the mapped display range"); +} + +static void trailing_padding_has_no_source_coordinates() { + Parser parser(3, 4, 8); + parser.feed("ABCD"); + const TextDocument document(parser.screen); + const auto begin = document.position(0, 0); + const auto end = document.position(0, 4); + check(begin.has_value() && end.has_value(), "source positions for the padded row"); + + // Six display columns, but only the first four carry source coordinates. + ViewMapping mapping(6, {ViewSourceSpan{0, 0, 4, TextRange{*begin, *end}}}); + const auto padded = mapping.hit(document, 0, 5, ViewAffinity::leading); + check(padded.has_value() && *padded == *end, + "synthetic padding reports the end of the last mapped source span"); + const auto beyond = mapping.hit(document, 0, 7, ViewAffinity::leading); + check(!beyond.has_value(), "columns past the page width have no mapping"); +} + +static void stale_epoch_positions_do_not_hit() { + Parser parser(3, 4, 8); + parser.feed("ABCD"); + const TextDocument current(parser.screen); + const auto begin = current.position(0, 0); + const auto end = current.position(0, 4); + check(begin.has_value() && end.has_value(), "source positions before the epoch change"); + + ViewMapping mapping(4, {ViewSourceSpan{0, 0, 4, TextRange{*begin, *end}}}); + + // Alternate-buffer activation advances the buffer epoch, so positions + // captured against the old page are stale even though the ids look usable. + parser.screen.activate_buffer(ScreenBuffer::alternate); + parser.feed("ALT"); + const TextDocument refreshed(parser.screen); + + const auto hit = mapping.hit(refreshed, 0, 2, ViewAffinity::leading); + check(!hit.has_value(), "a mapping captured at an older epoch must not resolve"); + const auto projected = mapping.project(refreshed, TextRange{*begin, *end}); + check(projected.empty(), "projection over stale coordinates returns nothing"); +} + +int main() { + try { + interior_columns_map_by_offset(); + trailing_padding_has_no_source_coordinates(); + stale_epoch_positions_do_not_hit(); + std::cout << "view mapping checks passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +}