From d9945bce2406c203a4aa0483103f24fd6fb5dc77 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:43:08 +0000 Subject: [PATCH 1/2] feat: add bounded NomadNet table rendering --- lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp | 87 +++++- lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h | 71 ++++- lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp | 265 ++++++++++++++++- lib/tdeck_ui/UI/LXMF/NomadNetDocument.h | 37 ++- lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp | 224 +++++++++++++++ lib/tdeck_ui/UI/LXMF/NomadNetScreen.h | 15 +- lib/tdeck_ui/UI/LXMF/UIManager.cpp | 13 +- tests/native/test_app_launcher_nomadnet.cpp | 281 ++++++++++++++++++- tests/native/test_app_launcher_nomadnet.py | 31 ++ 9 files changed, 1012 insertions(+), 12 deletions(-) diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp index 283bdebd..b2392087 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp @@ -37,11 +37,19 @@ bool CompactPage::append_display(const std::string& value, uint32_t& offset, uin bool CompactPage::assign(const Document& document) { clear(); try { - const bool reserve_notice = document.truncated; + const bool reserve_notice = document.truncated || document.unsupported; const std::size_t block_limit = MAX_BLOCKS - (reserve_notice ? 1 : 0); const std::size_t run_limit = MAX_RUNS - (reserve_notice ? 1 : 0); const std::size_t block_count = std::min(document.blocks.size(), block_limit); const std::size_t link_count = std::min(document.links.size(), MAX_LINKS); + const std::size_t table_count = std::min(document.tables.size(), MAX_TABLES); + std::size_t table_cell_count = 0; + for (std::size_t i = 0; i < table_count; ++i) { + const std::size_t cells = static_cast(document.tables[i].row_count) * + document.tables[i].column_count; + table_cell_count += std::min(cells, + MAX_TABLE_CELLS - std::min(table_cell_count, MAX_TABLE_CELLS)); + } std::size_t anchor_count = 0; for (const auto& anchor : document.anchors) { if (anchor_count >= MAX_ANCHORS) break; @@ -61,6 +69,14 @@ bool CompactPage::assign(const Document& document) { arena_size += bytes; } } + const std::size_t table_run_count = std::min(document.table_runs.size(), + run_limit - std::min(run_count, run_limit)); + for (std::size_t r = 0; r < table_run_count; ++r) { + const std::size_t bytes = document.table_runs[r].text.size() + 1; + if (bytes > MAX_ARENA_BYTES - std::min(arena_size, MAX_ARENA_BYTES)) return false; + arena_size += bytes; + } + run_count += table_run_count; for (std::size_t i = 0; i < link_count; ++i) { const std::size_t bytes = document.links[i].target.size() + (document.links[i].fields.empty() ? 0 : document.links[i].fields.size() + 1) + 1; @@ -82,6 +98,8 @@ bool CompactPage::assign(const Document& document) { _runs.reserve(std::min(run_count, run_limit)); _links.reserve(link_count); _anchors.reserve(anchor_count); + _tables.reserve(table_count); + _table_cells.reserve(table_cell_count); for (std::size_t i = 0; i < link_count; ++i) { LinkRecord link; @@ -122,6 +140,9 @@ bool CompactPage::assign(const Document& document) { block.depth = source_block.depth; block.alignment = source_block.alignment; block.divider_codepoint = source_block.divider_codepoint; + block.table_index = source_block.table_index >= 0 && + static_cast(source_block.table_index) < table_count + ? source_block.table_index : -1; for (const auto& source_run : source_block.runs) { if (_runs.size() >= run_limit || block.run_count == std::numeric_limits::max()) { _truncated = true; @@ -148,12 +169,72 @@ bool CompactPage::assign(const Document& document) { _blocks.push_back(block); } + for (std::size_t i = 0; i < table_count; ++i) { + const auto& source_table = document.tables[i]; + const std::size_t source_cells = static_cast(source_table.row_count) * + source_table.column_count; + if (source_table.first_cell > document.table_cells.size() || + source_cells > document.table_cells.size() - source_table.first_cell) { + clear(); + return false; + } + TableRecord table; + table.first_cell = static_cast(_table_cells.size()); + table.column_count = source_table.column_count; + table.alignment = source_table.alignment; + table.max_width = source_table.max_width; + const std::size_t cells_left = MAX_TABLE_CELLS - std::min(_table_cells.size(), MAX_TABLE_CELLS); + const std::size_t retained_cells = std::min(source_cells, cells_left); + for (std::size_t c = 0; c < retained_cells; ++c) { + const auto& source_cell = document.table_cells[source_table.first_cell + c]; + if (source_cell.first_run > document.table_runs.size() || + source_cell.run_count > document.table_runs.size() - source_cell.first_run) { + clear(); + return false; + } + TableCellRecord cell; + cell.first_run = static_cast(_runs.size()); + cell.alignment = source_cell.alignment; + for (std::size_t r = 0; r < source_cell.run_count; ++r) { + if (_runs.size() >= run_limit) { + _truncated = true; + break; + } + const auto& source_run = document.table_runs[source_cell.first_run + r]; + RunRecord run; + if (!append_display(source_run.text, run.text_offset, run.text_length)) { + clear(); + return false; + } + run.link_index = source_run.link_index >= 0 && + static_cast(source_run.link_index) < _links.size() + ? static_cast(source_run.link_index) : -1; + if (source_run.bold) run.style |= BOLD; + if (source_run.italic) run.style |= ITALIC; + if (source_run.underline) run.style |= UNDERLINE; + if (source_run.has_foreground) run.style |= HAS_FOREGROUND; + if (source_run.has_background) run.style |= HAS_BACKGROUND; + run.foreground = source_run.foreground; + run.background = source_run.background; + _runs.push_back(run); + ++cell.run_count; + } + _table_cells.push_back(cell); + } + table.row_count = table.column_count == 0 ? 0 : + static_cast(retained_cells / table.column_count); + if (retained_cells != source_cells) _truncated = true; + _tables.push_back(table); + } + _has_background = document.has_background; _background = document.background; _has_foreground = document.has_foreground; _foreground = document.foreground; _truncated = _truncated || document.truncated || document.blocks.size() > block_count || - document.links.size() > link_count || document.anchors.size() > anchor_count; + document.links.size() > link_count || document.anchors.size() > anchor_count || + document.tables.size() > table_count || document.table_cells.size() > table_cell_count || + document.table_runs.size() > table_run_count; _unsupported = document.unsupported; return true; } catch (const std::bad_alloc&) { @@ -168,6 +249,8 @@ void CompactPage::clear() { ExternalVector().swap(_runs); ExternalVector().swap(_links); ExternalVector().swap(_anchors); + ExternalVector().swap(_tables); + ExternalVector().swap(_table_cells); _has_background = false; _background = 0; _has_foreground = false; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h index 96c3ca76..ec2a5097 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h @@ -16,7 +16,55 @@ inline bool layout_content_truncated(std::size_t fragment_count, } inline bool block_has_layout_content(BlockType type, uint16_t run_count) { - return type == BlockType::DIVIDER || type == BlockType::HEADING || run_count != 0; + return type == BlockType::DIVIDER || type == BlockType::HEADING || + type == BlockType::TABLE || run_count != 0; +} + +enum class TableLayoutTier : uint8_t { FIT, REFLOW }; + +inline TableLayoutTier choose_table_layout(int32_t structural_minimum_width, + int32_t content_width) { + return structural_minimum_width <= content_width + ? TableLayoutTier::FIT : TableLayoutTier::REFLOW; +} + +inline int16_t fit_table_columns(int16_t* widths, uint8_t column_count, + int16_t minimum_width, int16_t target_width) { + if (!widths || column_count == 0 || + column_count > DocumentParser::MAX_TABLE_COLUMNS || minimum_width <= 0) + return 0; + int32_t natural_width = 0; + for (uint8_t column = 0; column < column_count; ++column) { + widths[column] = std::max(minimum_width, widths[column]); + natural_width += widths[column]; + } + const int32_t structural_minimum = static_cast(minimum_width) * column_count; + if (structural_minimum > INT16_MAX) return 0; + const int32_t bounded_target = std::max( + structural_minimum, std::min(target_width, INT16_MAX)); + if (natural_width <= bounded_target) return static_cast(natural_width); + + uint8_t order[DocumentParser::MAX_TABLE_COLUMNS] = {0}; + for (uint8_t column = 0; column < column_count; ++column) { + uint8_t position = column; + while (position > 0 && widths[order[position - 1]] < widths[column]) { + order[position] = order[position - 1]; + --position; + } + order[position] = column; + } + int32_t excess = natural_width - bounded_target; + for (uint8_t position = 0; position < column_count && excess > 0; ++position) { + const uint8_t column = order[position]; + const int32_t reduction = std::min( + excess, widths[column] - minimum_width); + widths[column] = static_cast(widths[column] - reduction); + excess -= reduction; + } + int32_t fitted_width = 0; + for (uint8_t column = 0; column < column_count; ++column) + fitted_width += widths[column]; + return static_cast(fitted_width); } inline uint8_t heading_display_level(uint8_t depth) { @@ -44,6 +92,8 @@ public: static constexpr std::size_t MAX_RUNS = DocumentParser::MAX_TOTAL_RUNS; static constexpr std::size_t MAX_LINKS = DocumentParser::MAX_LINKS; static constexpr std::size_t MAX_ANCHORS = DocumentParser::MAX_ANCHORS; + static constexpr std::size_t MAX_TABLES = DocumentParser::MAX_TABLES; + static constexpr std::size_t MAX_TABLE_CELLS = DocumentParser::MAX_TOTAL_TABLE_CELLS; // Text runs, link targets, and anchor names originate in the bounded source. // Link targets also remain visible in runs, while anchor declarations are // zero-width. Account for both bounded copies and one terminator per record. @@ -68,6 +118,7 @@ public: uint8_t depth = 0; Alignment alignment = Alignment::LEFT; uint32_t divider_codepoint = 0x2500; + int16_t table_index = -1; }; struct RunRecord { @@ -90,6 +141,20 @@ public: uint16_t block_index = 0; }; + struct TableRecord { + uint32_t first_cell = 0; + uint16_t row_count = 0; + uint8_t column_count = 0; + Alignment alignment = Alignment::LEFT; + uint16_t max_width = DocumentParser::DEFAULT_TABLE_WIDTH; + }; + + struct TableCellRecord { + uint32_t first_run = 0; + uint16_t run_count = 0; + Alignment alignment = Alignment::LEFT; + }; + struct TextView { const char* value = nullptr; std::size_t length = 0; @@ -110,6 +175,8 @@ public: const ExternalVector& runs() const { return _runs; } const ExternalVector& links() const { return _links; } const ExternalVector& anchors() const { return _anchors; } + const ExternalVector& tables() const { return _tables; } + const ExternalVector& table_cells() const { return _table_cells; } TextView text(const RunRecord& run) const; TextView target(std::size_t index) const; bool find_anchor(const std::string& name, uint16_t& block_index) const; @@ -131,6 +198,8 @@ private: ExternalVector _runs; ExternalVector _links; ExternalVector _anchors; + ExternalVector _tables; + ExternalVector _table_cells; bool _has_background = false; uint32_t _background = 0; bool _has_foreground = false; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp index 4c8e8437..b4cd95a7 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp @@ -294,6 +294,177 @@ void parse_inline(Document& doc, Block& block, const std::string& line, Style& s block.alignment = style.alignment; } +std::string trim_table_cell(const std::string& value) { + std::size_t first = 0; + while (first < value.size() && std::isspace(static_cast(value[first]))) ++first; + std::size_t last = value.size(); + while (last > first && std::isspace(static_cast(value[last - 1]))) --last; + return value.substr(first, last - first); +} + +std::vector parse_table_row(const std::string& source) { + std::size_t first = 0; + std::size_t last = source.size(); + while (first < last && std::isspace(static_cast(source[first]))) ++first; + while (last > first && std::isspace(static_cast(source[last - 1]))) --last; + if (first < last && source[first] == '|') ++first; + if (last > first && source[last - 1] == '|') --last; + + std::vector cells; + std::string current; + bool escaped = false; + for (std::size_t i = first; i < last; ++i) { + const char value = source[i]; + if (escaped) { + current.push_back(value); + escaped = false; + } else if (value == '\\') { + escaped = true; + } else if (value == '|') { + cells.push_back(trim_table_cell(current)); + current.clear(); + } else { + current.push_back(value); + } + } + cells.push_back(trim_table_cell(current)); + return cells; +} + +Alignment table_cell_alignment(const std::string& source) { + const std::string value = trim_table_cell(source); + if (!value.empty() && value.front() == ':' && value.back() == ':') return Alignment::CENTER; + if (!value.empty() && value.back() == ':') return Alignment::RIGHT; + return Alignment::LEFT; +} + +void append_malformed_table(Document& doc, const std::vector& lines, + std::size_t& total_runs) { + if (doc.blocks.size() >= DocumentParser::MAX_BLOCKS) { + doc.mark_truncated(TruncationReason::BLOCKS); + return; + } + if (total_runs >= DocumentParser::MAX_TOTAL_RUNS) { + doc.mark_truncated(TruncationReason::TOTAL_RUNS); + return; + } + Block block; + block.type = BlockType::UNSUPPORTED; + Run run; + run.text = "[Malformed table]"; + for (std::size_t i = 0; i < lines.size(); ++i) { + const std::string separator = i == 0 ? " " : " | "; + const std::size_t remaining = DocumentParser::MAX_TABLE_FALLBACK_BYTES - + std::min(run.text.size(), DocumentParser::MAX_TABLE_FALLBACK_BYTES); + if (separator.size() + lines[i].size() <= remaining) { + run.text += separator; + run.text += lines[i]; + continue; + } + if (remaining > separator.size()) { + run.text += separator; + std::size_t retained = remaining - separator.size(); + retained = std::min(retained, lines[i].size()); + while (retained > 0 && retained < lines[i].size() && + (static_cast(lines[i][retained]) & 0xc0) == 0x80) --retained; + run.text.append(lines[i], 0, retained); + } + doc.mark_truncated(TruncationReason::TABLE_FALLBACK_BYTES); + break; + } + block.runs.push_back(std::move(run)); + doc.blocks.push_back(std::move(block)); + ++total_runs; + doc.malformed = true; +} + +bool append_table(Document& doc, const std::vector& lines, + Alignment table_alignment, uint16_t max_width, + Style& style, std::size_t& total_runs) { + if (lines.size() < 2) { + append_malformed_table(doc, lines, total_runs); + return false; + } + if (doc.tables.size() >= DocumentParser::MAX_TABLES) { + doc.mark_truncated(TruncationReason::TABLES); + return false; + } + if (doc.blocks.size() >= DocumentParser::MAX_BLOCKS) { + doc.mark_truncated(TruncationReason::BLOCKS); + return false; + } + + const auto header = parse_table_row(lines.front()); + const auto alignment_cells = parse_table_row(lines[1]); + const std::size_t columns = std::min(header.size(), DocumentParser::MAX_TABLE_COLUMNS); + if (header.size() > columns) doc.mark_truncated(TruncationReason::TABLE_COLUMNS); + if (columns == 0) { + append_malformed_table(doc, lines, total_runs); + return false; + } + + Table table; + table.first_cell = static_cast(doc.table_cells.size()); + table.column_count = static_cast(columns); + table.alignment = table_alignment; + table.max_width = max_width; + const std::size_t source_rows = lines.size() - 1; + std::size_t rows = std::min(source_rows, DocumentParser::MAX_TABLE_ROWS); + if (source_rows > rows) doc.mark_truncated(TruncationReason::TABLE_ROWS); + const std::size_t document_cells_left = DocumentParser::MAX_TOTAL_TABLE_CELLS - + std::min(doc.table_cells.size(), DocumentParser::MAX_TOTAL_TABLE_CELLS); + const std::size_t table_cells_left = std::min(DocumentParser::MAX_TABLE_CELLS, document_cells_left); + const std::size_t cell_bounded_rows = table_cells_left / columns; + if (rows > cell_bounded_rows) { + rows = cell_bounded_rows; + doc.mark_truncated(TruncationReason::TABLE_CELLS); + } + if (rows == 0) return false; + + for (std::size_t row = 0; row < rows; ++row) { + const auto cells = row == 0 ? header : parse_table_row(lines[row + 1]); + for (std::size_t column = 0; column < columns; ++column) { + TableCell cell; + cell.first_run = static_cast(doc.table_runs.size()); + cell.alignment = row == 0 ? Alignment::LEFT : + (column < alignment_cells.size() ? table_cell_alignment(alignment_cells[column]) : Alignment::LEFT); + Block parsed; + if (column < cells.size()) { + std::string cell_source = cells[column]; + const bool cell_truncated = cell_source.size() > DocumentParser::MAX_TABLE_CELL_BYTES; + const Style style_before_cell = style; + if (cell_truncated) { + std::size_t retained = DocumentParser::MAX_TABLE_CELL_BYTES; + while (retained > 0 && retained < cell_source.size() && + (static_cast(cell_source[retained]) & 0xc0) == 0x80) --retained; + cell_source.resize(retained); + doc.mark_truncated(TruncationReason::TABLE_CELL_BYTES); + } + parse_inline(doc, parsed, cell_source, style); + if (cell_truncated) style = style_before_cell; + } + if (total_runs + parsed.runs.size() > DocumentParser::MAX_TOTAL_RUNS) { + parsed.runs.resize(DocumentParser::MAX_TOTAL_RUNS - total_runs); + doc.mark_truncated(TruncationReason::TOTAL_RUNS); + } + for (auto& run : parsed.runs) doc.table_runs.push_back(std::move(run)); + cell.run_count = static_cast(doc.table_runs.size() - cell.first_run); + total_runs += cell.run_count; + doc.table_cells.push_back(cell); + } + ++table.row_count; + if (total_runs >= DocumentParser::MAX_TOTAL_RUNS) break; + } + + doc.tables.push_back(table); + Block block; + block.type = BlockType::TABLE; + block.table_index = static_cast(doc.tables.size() - 1); + block.alignment = table_alignment; + doc.blocks.push_back(std::move(block)); + return true; +} + } // namespace Document DocumentParser::parse(const std::string& source) const { @@ -320,6 +491,12 @@ Document DocumentParser::parse(const char* source, std::size_t size) const { } Style style; bool literal = false; + bool table_mode = false; + bool table_alignment_explicit = false; + Alignment table_alignment = Alignment::LEFT; + uint16_t table_width = DEFAULT_TABLE_WIDTH; + std::size_t table_bytes = 0; + std::vector table_lines; uint8_t section_depth = 0; std::size_t total_runs = 0; std::size_t offset = 0; @@ -352,13 +529,13 @@ Document DocumentParser::parse(const char* source, std::size_t size) const { } else doc.malformed = true; continue; } - if (line.rfind("#!bg=", 0) == 0) { + if (!table_mode && line.rfind("#!bg=", 0) == 0) { uint32_t value = 0; if (parse_micron_color(line.substr(5), value)) { doc.has_background = true; doc.background = value; } else doc.malformed = true; continue; } - if (line.rfind("#!fg=", 0) == 0) { + if (!table_mode && line.rfind("#!fg=", 0) == 0) { uint32_t value = 0; if (parse_micron_color(line.substr(5), value)) { doc.has_foreground = true; doc.foreground = value; } else doc.malformed = true; @@ -383,12 +560,71 @@ Document DocumentParser::parse(const char* source, std::size_t size) const { ++total_runs; continue; } + if (line.empty()) continue; + bool pre_escaped_table_line = false; + if (!literal && line[0] == '\\' && + (table_mode || line.rfind("\\`t", 0) == 0)) { + line.erase(0, 1); + pre_escaped_table_line = true; + } + if (!literal && !pre_escaped_table_line && !line.empty() && line[0] == '#') continue; + if (!literal && line.rfind("`t", 0) == 0) { + if (table_mode) { + const bool canonical_table_rendered = table_lines.size() >= 2; + append_table(doc, table_lines, table_alignment, table_width, style, total_runs); + table_mode = false; + table_lines.clear(); + table_bytes = 0; + if (table_alignment_explicit && canonical_table_rendered) + style.alignment = Alignment::LEFT; + table_alignment_explicit = false; + table_alignment = style.alignment; + table_width = DEFAULT_TABLE_WIDTH; + } else { + table_mode = true; + table_lines.clear(); + table_bytes = 0; + table_alignment_explicit = false; + table_alignment = style.alignment; + table_width = DEFAULT_TABLE_WIDTH; + std::size_t option = 2; + if (option < line.size() && (line[option] == 'l' || line[option] == 'c' || line[option] == 'r')) { + table_alignment_explicit = true; + table_alignment = line[option] == 'c' ? Alignment::CENTER : + line[option] == 'r' ? Alignment::RIGHT : Alignment::LEFT; + ++option; + } + if (option < line.size()) { + const char* width_start = line.c_str() + option; + char* width_end = nullptr; + const long long parsed = std::strtoll(width_start, &width_end, 10); + while (width_end && *width_end && + std::isspace(static_cast(*width_end))) ++width_end; + if (width_end && width_end != width_start && *width_end == '\0') { + if (parsed < 0) table_width = 1; + else if (parsed > 0) table_width = static_cast( + std::min( + static_cast(parsed), MAX_TABLE_WIDTH)); + } + } + } + continue; + } + if (!literal && table_mode) { + const std::size_t row_bytes = line.size() + 1; + if (row_bytes <= MAX_TABLE_BYTES - std::min(table_bytes, MAX_TABLE_BYTES)) { + table_lines.push_back(line); + table_bytes += row_bytes; + } else { + doc.mark_truncated(TruncationReason::TABLE_BYTES); + } + continue; + } while (!line.empty() && line[0] == '<') { section_depth = 0; line.erase(0, 1); } if (line.empty()) continue; - if (!literal && line[0] == '#') continue; if (doc.blocks.size() >= MAX_BLOCKS) { doc.mark_truncated(TruncationReason::BLOCKS); break; @@ -418,7 +654,7 @@ Document DocumentParser::parse(const char* source, std::size_t size) const { line.size() == 1 + codepoint_bytes && codepoint >= 32) { block.divider_codepoint = codepoint; } - } else if (line.rfind("`t", 0) == 0 || line.rfind("`{", 0) == 0 || line.find("`<") != std::string::npos) { + } else if (line.rfind("`{", 0) == 0 || line.find("`<") != std::string::npos) { block.type = BlockType::UNSUPPORTED; Run run; run.text = "[Unsupported Micron content]"; @@ -439,6 +675,7 @@ Document DocumentParser::parse(const char* source, std::size_t size) const { } if (offset <= retained && doc.source_lines >= MAX_SOURCE_LINES) doc.mark_truncated(TruncationReason::SOURCE_LINES); + if (table_mode) append_malformed_table(doc, table_lines, total_runs); if (literal) doc.malformed = true; return doc; } @@ -471,6 +708,26 @@ std::string truncation_notice(const Document& document) { if (document.has_truncation(TruncationReason::ANCHORS)) return "[Page truncated: more than " + std::to_string(DocumentParser::MAX_ANCHORS) + " anchors]"; + if (document.has_truncation(TruncationReason::TABLE_FALLBACK_BYTES)) + return "[Page truncated: malformed table fallback exceeds " + + std::to_string(DocumentParser::MAX_TABLE_FALLBACK_BYTES) + " bytes]"; + if (document.has_truncation(TruncationReason::TABLE_CELL_BYTES)) + return "[Page truncated: table cell exceeds " + + std::to_string(DocumentParser::MAX_TABLE_CELL_BYTES) + " bytes]"; + if (document.has_truncation(TruncationReason::TABLE_BYTES)) + return "[Page truncated: table exceeds " + + std::to_string(DocumentParser::MAX_TABLE_BYTES / 1024) + " KiB]"; + if (document.has_truncation(TruncationReason::TABLE_COLUMNS)) + return "[Page truncated: more than " + + std::to_string(DocumentParser::MAX_TABLE_COLUMNS) + " table columns]"; + if (document.has_truncation(TruncationReason::TABLE_ROWS)) + return "[Page truncated: more than " + + std::to_string(DocumentParser::MAX_TABLE_ROWS) + " table rows]"; + if (document.has_truncation(TruncationReason::TABLE_CELLS)) + return "[Page truncated: too many table cells]"; + if (document.has_truncation(TruncationReason::TABLES)) + return "[Page truncated: more than " + + std::to_string(DocumentParser::MAX_TABLES) + " tables]"; return "[Page truncated to device safety limits]"; } diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h index ea36a1f1..650abeba 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h @@ -7,7 +7,7 @@ namespace UI::LXMF::NomadNet { -enum class BlockType { TEXT, HEADING, DIVIDER, UNSUPPORTED }; +enum class BlockType { TEXT, HEADING, DIVIDER, TABLE, UNSUPPORTED }; enum class Alignment { LEFT, CENTER, RIGHT }; enum class TruncationReason : uint16_t { @@ -20,6 +20,13 @@ enum class TruncationReason : uint16_t { LINKS = 1 << 6, ANCHORS = 1 << 7, ANCHOR_NAME_BYTES = 1 << 8, + TABLES = 1 << 9, + TABLE_ROWS = 1 << 10, + TABLE_COLUMNS = 1 << 11, + TABLE_CELL_BYTES = 1 << 12, + TABLE_BYTES = 1 << 13, + TABLE_CELLS = 1 << 14, + TABLE_FALLBACK_BYTES = 1 << 15, }; struct Run { @@ -34,11 +41,26 @@ struct Run { int link_index = -1; }; +struct TableCell { + uint32_t first_run = 0; + uint16_t run_count = 0; + Alignment alignment = Alignment::LEFT; +}; + +struct Table { + uint32_t first_cell = 0; + uint16_t row_count = 0; + uint8_t column_count = 0; + Alignment alignment = Alignment::LEFT; + uint16_t max_width = 100; +}; + struct Block { BlockType type = BlockType::TEXT; uint8_t depth = 0; Alignment alignment = Alignment::LEFT; uint32_t divider_codepoint = 0x2500; + int16_t table_index = -1; std::vector runs; }; @@ -61,6 +83,9 @@ struct Document { std::vector blocks; std::vector links; std::vector anchors; + std::vector tables; + std::vector table_cells; + std::vector table_runs; uint32_t cache_seconds = 0; bool has_background = false; uint32_t background = 0; @@ -93,6 +118,16 @@ public: static constexpr std::size_t MAX_LINKS = 128; static constexpr std::size_t MAX_ANCHORS = 128; static constexpr std::size_t MAX_ANCHOR_NAME_BYTES = 64; + static constexpr std::size_t MAX_TABLES = 16; + static constexpr std::size_t MAX_TABLE_ROWS = 32; + static constexpr std::size_t MAX_TABLE_COLUMNS = 8; + static constexpr std::size_t MAX_TABLE_CELLS = 256; + static constexpr std::size_t MAX_TOTAL_TABLE_CELLS = 512; + static constexpr std::size_t MAX_TABLE_CELL_BYTES = 512; + static constexpr std::size_t MAX_TABLE_FALLBACK_BYTES = 1024; + static constexpr std::size_t MAX_TABLE_BYTES = 16 * 1024; + static constexpr uint16_t DEFAULT_TABLE_WIDTH = 100; + static constexpr uint16_t MAX_TABLE_WIDTH = UINT16_MAX; static constexpr uint32_t MAX_CACHE_SECONDS = 7 * 24 * 60 * 60; Document parse(const std::string& source) const; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp index bb8e040c..6c85b5b3 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp @@ -448,6 +448,218 @@ bool NomadNetScreen::commit_line(int32_t line_y,int16_t line_height, return true; } +bool NomadNetScreen::layout_table_cell(const NomadNet::CompactPage::TableCellRecord& cell, + int16_t left,int16_t available,int32_t top, + int32_t window_top,int32_t window_bottom, + bool emit,int32_t& height){ + if(available<1||cell.first_run>_page.runs().size()|| + cell.run_count>_page.runs().size()-cell.first_run)return false; + int16_t x=left; + int16_t line_h=16; + int32_t line_y=top; + bool line_started=false; + _line_layout.clear(); + auto finish_line=[&](){ + if(emit){ + if(!commit_line(line_y,line_h,cell.alignment,left,available,0, + window_top,window_bottom))return false; + }else _line_layout.clear(); + line_y+=line_h; + x=left; + line_h=16; + line_started=false; + return true; + }; + for(uint16_t r=0;r(cell.first_run+r); + const auto& run=_page.runs()[run_index]; + const auto text=_page.text(run); + const lv_font_t* font=page_run_font(run,false); + const int16_t run_h=static_cast(font->line_height+3); + line_h=std::max(line_h,run_h); + std::size_t offset=0; + while(offset(lv_txt_get_width(text.data()+offset, + static_cast(end-offset),font,0,LV_TEXT_FLAG_NONE)); + if(whitespace&&x+fragment_w>left+available){offset=end;continue;} + if(!whitespace&&x>left&&x+fragment_w>left+available){ + if(!finish_line())return false; + line_h=run_h; + } + if(fragment_w>available){ + end=offset; + fragment_w=0; + while(end(end); + _lv_txt_encoded_next(text.data(),&next_index); + end=next_index; + const int16_t candidate=static_cast(lv_txt_get_width(text.data()+offset, + static_cast(end-offset),font,0,LV_TEXT_FLAG_NONE)); + if(candidate>available&&previous>offset){end=previous;break;} + fragment_w=candidate; + } + } + if(!(whitespace&&x==left)){ + if(emit){ + if(run.link_index>=0&&static_cast(run.link_index)<_link_y.size()){ + if(_link_y[run.link_index]<0)_link_y[run.link_index]=line_y; + _link_bottom[run.link_index]=std::max(_link_bottom[run.link_index],line_y+run_h); + } + if(!append_line_fragment(LayoutFragment(run_index,static_cast(offset), + static_cast(end-offset),run.link_index,x,0,fragment_w,run_h,false)))return false; + } + x=static_cast(x+fragment_w); + line_started=true; + } + offset=end; + } + } + if(line_started||line_y==top){ + if(!finish_line())return false; + } + height=std::max(16,line_y-top); + return true; +} + +bool NomadNetScreen::layout_table_fit(const NomadNet::CompactPage::TableRecord& table, + const int16_t* column_widths,int16_t table_fit_width, + int32_t& y,int32_t window_top,int32_t window_bottom){ + constexpr int16_t content_width=304; + int16_t table_left=table.alignment==NomadNet::Alignment::CENTER? + static_cast((content_width-table_fit_width)/2): + table.alignment==NomadNet::Alignment::RIGHT? + static_cast(content_width-table_fit_width):0; + for(uint16_t row=0;row(row)*table.column_count+column; + if(cell_index>=_page.table_cells().size())return false; + int32_t cell_height=0; + if(!layout_table_cell(_page.table_cells()[cell_index],0, + std::max(1,column_widths[column]-8),0, + window_top,window_bottom,false,cell_height))return false; + row_height=std::max(row_height,cell_height+6); + } + int16_t cell_left=table_left; + for(uint8_t column=0;column(row)*table.column_count+column; + if(y+row_height>=window_top&&y=MAX_WINDOW_FRAGMENTS)return false; + LayoutFragment box(UINT16_MAX,0,0,-1,cell_left, + static_cast(y-window_top),column_widths[column], + static_cast(row_height),false); + box.table_cell=true; + box.table_header=row==0; + _page_layout.push_back(box); + } + int32_t ignored=0; + if(!layout_table_cell(_page.table_cells()[cell_index], + static_cast(cell_left+4), + std::max(1,column_widths[column]-8),y+3, + window_top,window_bottom,true,ignored))return false; + cell_left=static_cast(cell_left+column_widths[column]); + } + y+=row_height; + } + y+=3; + return true; +} + +bool NomadNetScreen::layout_table_reflow(const NomadNet::CompactPage::TableRecord& table, + int32_t& y,int32_t window_top,int32_t window_bottom){ + constexpr int16_t card_width=304; + constexpr int16_t text_left=4; + constexpr int16_t text_width=296; + const uint16_t data_rows=table.row_count>1?static_cast(table.row_count-1):1; + for(uint16_t data_row=0;data_row1? + table.first_cell+static_cast(data_row+1)*table.column_count+column: + header_index; + if(header_index>=_page.table_cells().size()||value_index>=_page.table_cells().size())return false; + const std::size_t pair_count=table.row_count>1?2:1; + const std::size_t indices[2]={header_index,value_index}; + for(std::size_t part=0;part=window_top&&y=MAX_WINDOW_FRAGMENTS)return false; + LayoutFragment box(UINT16_MAX,0,0,-1,0, + static_cast(y-window_top),card_width, + static_cast(box_height),false); + box.table_cell=true; + box.table_header=part==0; + _page_layout.push_back(box); + } + int32_t ignored=0; + if(!layout_table_cell(_page.table_cells()[indices[part]],text_left,text_width,y+3, + window_top,window_bottom,true,ignored))return false; + y+=box_height; + } + } + y+=4; + } + return true; +} + +bool NomadNetScreen::layout_table(const NomadNet::CompactPage::BlockRecord& block, + int32_t& y,int32_t window_top,int32_t window_bottom){ + constexpr int16_t content_width=304; + if(block.table_index<0||static_cast(block.table_index)>=_page.tables().size())return false; + const auto& table=_page.tables()[block.table_index]; + if(table.column_count==0||table.column_count>NomadNet::DocumentParser::MAX_TABLE_COLUMNS|| + table.row_count==0)return false; + int16_t column_widths[NomadNet::DocumentParser::MAX_TABLE_COLUMNS]={0}; + const int16_t minimum_width=static_cast(lv_txt_get_width( + " ",3,&nomadnet_font_12,0,LV_TEXT_FLAG_NONE)+8); + for(uint8_t column=0;column(row)*table.column_count+column; + if(cell_index>=_page.table_cells().size())return false; + const auto& cell=_page.table_cells()[cell_index]; + if(cell.first_run>_page.runs().size()||cell.run_count>_page.runs().size()-cell.first_run)return false; + int32_t measured=8; + for(uint16_t r=0;r(text.size()), + page_run_font(run,false),0,LV_TEXT_FLAG_NONE); + measured=std::min(measured,INT16_MAX); + } + column_widths[column]=std::max(column_widths[column], + static_cast(measured)); + } + } + int32_t natural_width=0; + for(uint8_t column=0;column(1,lv_txt_get_width( + " ",1,&nomadnet_font_12,0,LV_TEXT_FLAG_NONE)); + const int32_t metadata_width=std::min(content_width, + static_cast(table.max_width)*space_width); + const int32_t structural_minimum=static_cast(minimum_width)*table.column_count; + if(NomadNet::choose_table_layout(structural_minimum,content_width)== + NomadNet::TableLayoutTier::FIT){ + const int16_t target_width=static_cast(std::max( + structural_minimum,std::min(metadata_width,content_width))); + const int16_t table_fit_width=NomadNet::fit_table_columns( + column_widths,table.column_count,minimum_width,target_width); + return layout_table_fit(table,column_widths,table_fit_width,y,window_top,window_bottom); + } + return layout_table_reflow(table,y,window_top,window_bottom); +} + bool NomadNetScreen::layout_from(std::size_t start_block,int32_t start_y, int32_t window_top,int32_t window_bottom, bool build_index){ @@ -472,6 +684,10 @@ bool NomadNetScreen::layout_from(std::size_t start_block,int32_t start_y, } y+=divider_height;continue; } + if(block.type==NomadNet::BlockType::TABLE){ + if(!layout_table(block,y,window_top,window_bottom))return false; + continue; + } const bool heading=block.type==NomadNet::BlockType::HEADING; const bool has_runs=block.run_count!=0&&block.first_run<_page.runs().size(); if(!heading&&!has_runs)continue; @@ -648,6 +864,14 @@ void NomadNetScreen::draw_page(lv_event_t* event){ } continue; } + if(fragment.table_cell){ + lv_draw_rect_dsc_t cell;lv_draw_rect_dsc_init(&cell); + cell.bg_color=fragment.table_header?Theme::surfaceContainer():Theme::surface(); + cell.border_color=Theme::border(); + cell.border_width=1; + lv_draw_rect(draw_ctx,&cell,&area); + continue; + } if(fragment.heading_starts_band()){ lv_area_t band_area{content_area.x1,static_cast(draw_y),content_area.x2, static_cast(draw_y+std::max(fragment.height,1)-1)}; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h index a10ebede..5b4a1bda 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h @@ -43,7 +43,7 @@ private: // Only the visible region plus bounded overscan is retained. The parser // admits at most 1024 runs, so this also covers a pathological viewport // containing every styled run plus bounded dividers. - static constexpr std::size_t MAX_WINDOW_FRAGMENTS = 1056; + static constexpr std::size_t MAX_WINDOW_FRAGMENTS = 1600; static constexpr int32_t MAX_PHYSICAL_SCROLL_EXTENT = 30000; struct LayoutFragment { uint16_t run_index = 0; @@ -57,6 +57,8 @@ private: uint32_t divider_codepoint = 0x2500; bool divider = false; bool large_font = false; + bool table_cell = false; + bool table_header = false; uint8_t heading_style = 0; LayoutFragment() = default; LayoutFragment(uint16_t run, uint16_t offset, uint16_t length, int16_t link, @@ -111,6 +113,17 @@ private: void clear_directory(); bool layout_page(); bool layout_window(int32_t logical_scroll); + bool layout_table(const NomadNet::CompactPage::BlockRecord& block, + int32_t& y, int32_t window_top, int32_t window_bottom); + bool layout_table_fit(const NomadNet::CompactPage::TableRecord& table, + const int16_t* column_widths, int16_t table_fit_width, + int32_t& y, int32_t window_top, int32_t window_bottom); + bool layout_table_reflow(const NomadNet::CompactPage::TableRecord& table, + int32_t& y, int32_t window_top, int32_t window_bottom); + bool layout_table_cell(const NomadNet::CompactPage::TableCellRecord& cell, + int16_t left, int16_t available, int32_t top, + int32_t window_top, int32_t window_bottom, + bool emit, int32_t& height); bool layout_from(std::size_t start_block, int32_t start_y, int32_t window_top, int32_t window_bottom, bool build_index); diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index e90cc7e1..e4be1e8e 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -2303,8 +2303,17 @@ void UIManager::nomad_update() { } nomad_heap_checkpoint("response-normalized"); const auto& bytes = _nomad_response.bytes(); - const NomadNet::Document document = _nomad_parser.parse( - reinterpret_cast(bytes.data()), bytes.size()); + NomadNet::Document document; + try { + document = _nomad_parser.parse( + reinterpret_cast(bytes.data()), bytes.size()); + } catch (const std::bad_alloc&) { + _nomad_response.clear(); + nomad_stop_transport(); + LVGL_LOCK(); + _nomadnet_screen->set_status("Page is too large for available memory"); + break; + } nomad_heap_checkpoint("response-parsed"); if (!(document.malformed && document.blocks.empty())) { std::vector heading_runs; diff --git a/tests/native/test_app_launcher_nomadnet.cpp b/tests/native/test_app_launcher_nomadnet.cpp index e6723906..311794ef 100644 --- a/tests/native/test_app_launcher_nomadnet.cpp +++ b/tests/native/test_app_launcher_nomadnet.cpp @@ -345,6 +345,270 @@ int main(int argc, char** argv) { compact.has_background() == doc.has_background && compact.background() == doc.background && compact.has_foreground() == doc.has_foreground && compact.foreground() == doc.foreground); + const auto table_doc = parser.parse( + "`tc30\n" + "| Name | Price | Qty |\n" + "| ---- | :---: | --: |\n" + "| `F3a3Apple`f | Free | `!5`! |\n" + "| Orange | Ask, nicely | 3 |\n" + "`t\n" + "After table"); + check("bounded Micron table syntax produces one semantic table block", + table_doc.tables.size() == 1 && table_doc.blocks.size() == 2 && + table_doc.blocks[0].type == BlockType::TABLE && table_doc.blocks[0].table_index == 0); + check("table metadata preserves rows columns alignment and maximum width", + table_doc.tables[0].row_count == 3 && table_doc.tables[0].column_count == 3 && + table_doc.tables[0].alignment == Alignment::CENTER && table_doc.tables[0].max_width == 30); + check("separator alignment applies only to data cells", + table_doc.table_cells.size() == 9 && + table_doc.table_cells[0].alignment == Alignment::LEFT && + table_doc.table_cells[3].alignment == Alignment::LEFT && + table_doc.table_cells[4].alignment == Alignment::CENTER && + table_doc.table_cells[5].alignment == Alignment::RIGHT); + bool saw_table_color = false; + bool saw_table_bold = false; + for (const auto& run : table_doc.table_runs) { + if (run.text == "Apple") saw_table_color = run.has_foreground && run.foreground == 0x33aa33; + if (run.text == "5") saw_table_bold = run.bold; + } + check("table cells preserve inline formatting", saw_table_color && saw_table_bold); + check("content after a closed table remains ordinary page content", + table_doc.blocks[1].runs.size() == 1 && table_doc.blocks[1].runs[0].text == "After table"); + const auto inherited_table_alignment = parser.parse( + "`c\n`t\nH\n---\nV\n`t\nAfter"); + check("bare table tags preserve the active canonical alignment", + inherited_table_alignment.tables.size() == 1 && + inherited_table_alignment.tables[0].alignment == Alignment::CENTER && + inherited_table_alignment.blocks.back().alignment == Alignment::CENTER); + const auto explicit_table_alignment = parser.parse( + "`r\n`tl\nH\n---\nV\n`t\nAfter"); + check("explicit table alignment resets following content to canonical left alignment", + explicit_table_alignment.tables.size() == 1 && + explicit_table_alignment.tables[0].alignment == Alignment::LEFT && + explicit_table_alignment.blocks.back().alignment == Alignment::LEFT); + const auto spaced_table_width = parser.parse("`tc 30\nH\n---\nV\n`t"); + const auto signed_table_width = parser.parse("`tc+30\nH\n---\nV\n`t"); + const auto negative_table_width = parser.parse("`tc-30\nH\n---\nV\n`t"); + const auto zero_table_width = parser.parse("`tc0\nH\n---\nV\n`t"); + const auto malformed_table_width = parser.parse("`tc30x\nH\n---\nV\n`t"); + const auto huge_table_width = parser.parse( + "`tc999999999999999999999999999999999999\nH\n---\nV\n`t"); + check("canonical table width conversion is accepted before bounded normalization", + spaced_table_width.tables.size() == 1 && spaced_table_width.tables[0].max_width == 30 && + signed_table_width.tables.size() == 1 && signed_table_width.tables[0].max_width == 30 && + negative_table_width.tables[0].max_width == 1 && + zero_table_width.tables[0].max_width == DocumentParser::DEFAULT_TABLE_WIDTH && + malformed_table_width.tables[0].max_width == DocumentParser::DEFAULT_TABLE_WIDTH && + huge_table_width.tables[0].max_width == DocumentParser::MAX_TABLE_WIDTH); + const auto malformed_aligned_table = parser.parse( + "`r\n`tc\nonly one row\n`t\nAfter"); + check("malformed explicit tables preserve the prior canonical alignment", + malformed_aligned_table.tables.empty() && malformed_aligned_table.malformed && + malformed_aligned_table.blocks.back().alignment == Alignment::RIGHT); + const auto blank_comment_table = parser.parse( + "`t\nH\n---\n# ignored\n\nV\n`t"); + check("comments are discarded and blank source lines remain outside the canonical table buffer", + blank_comment_table.tables.size() == 1 && blank_comment_table.tables[0].row_count == 2 && + blank_comment_table.blocks.size() == 2 && + blank_comment_table.blocks.front().type == BlockType::TEXT && + blank_comment_table.blocks.back().type == BlockType::TABLE); + const auto table_page_directives = parser.parse( + "`t\nH\n---\n#!bg=f00\n#!fg=0f0\nV\n`t"); + check("page color directives inside tables remain canonical comments", + table_page_directives.tables.size() == 1 && + table_page_directives.tables[0].row_count == 2 && + !table_page_directives.has_background && !table_page_directives.has_foreground); + const auto linked_table = parser.parse( + "`t\nAction\n---\n`[Open`:/page/details.mu]\n`t"); + check("links inside table cells retain normal link semantics", + linked_table.tables.size() == 1 && linked_table.links.size() == 1 && + linked_table.table_runs.size() == 2 && linked_table.table_runs.back().link_index == 0 && + linked_table.links[0].target == ":/page/details.mu"); + CompactPage linked_table_page; + const bool linked_table_assigned = linked_table_page.assign(linked_table); + const auto linked_target = linked_table_page.target(0); + check("compact table storage preserves cell-link targets", + linked_table_assigned && linked_table_page.links().size() == 1 && + std::string(linked_target.data(), linked_target.size()) == ":/page/details.mu" && + !linked_table_page.runs().empty() && linked_table_page.runs().back().link_index == 0); + check("table blocks remain virtual-layout content without ordinary runs", + block_has_layout_content(BlockType::TABLE, 0)); + CompactPage table_page; + check("compact page retains bounded table and cell records", + table_page.assign(table_doc) && table_page.tables().size() == 1 && + table_page.table_cells().size() == 9 && table_page.blocks()[0].table_index == 0); + const auto& compact_apple_cell = table_page.table_cells()[3]; + check("compact table cells address formatted runs in the shared arena", + compact_apple_cell.run_count != 0 && + std::string(table_page.text(table_page.runs()[compact_apple_cell.first_run]).data(), + table_page.text(table_page.runs()[compact_apple_cell.first_run]).size()) == "Apple"); + + UI::LXMF::NomadNet::Document unsupported_full_table; + unsupported_full_table.unsupported = true; + UI::LXMF::NomadNet::Table full_table; + full_table.first_cell = 0; + full_table.row_count = 1; + full_table.column_count = 1; + unsupported_full_table.tables.push_back(full_table); + UI::LXMF::NomadNet::TableCell full_cell; + full_cell.first_run = 0; + full_cell.run_count = CompactPage::MAX_RUNS; + unsupported_full_table.table_cells.push_back(full_cell); + unsupported_full_table.table_runs.resize(CompactPage::MAX_RUNS); + UI::LXMF::NomadNet::Block full_table_block; + full_table_block.type = BlockType::TABLE; + full_table_block.table_index = 0; + unsupported_full_table.blocks.push_back(full_table_block); + CompactPage unsupported_full_page; + check("unsupported pages reserve notice capacity when table runs fill the global budget", + unsupported_full_page.assign(unsupported_full_table) && unsupported_full_page.truncated() && + unsupported_full_page.append_notice("[Unsupported Micron content]")); + + const auto uneven_table = parser.parse( + "`t\nA|B|C\n---|:---:|---:\none|two\nx|y|z|ignored\n`t"); + check("uneven rows are padded or truncated to the header width", + uneven_table.tables.size() == 1 && uneven_table.tables[0].row_count == 3 && + uneven_table.tables[0].column_count == 3 && uneven_table.table_cells.size() == 9 && + uneven_table.table_cells[5].run_count == 0); + const auto escaped_table = parser.parse( + "`t\nA\\|B|C\n---|---\nx\\|y|z\n`t"); + check("escaped table separators remain cell text", + escaped_table.tables.size() == 1 && !escaped_table.table_runs.empty() && + escaped_table.table_runs[0].text == "A|B"); + const auto preescaped_row_table = parser.parse( + "`t\nLeft|Right\n---|---\n\\|A|B\n`t"); + check("canonical leading pre-escape is removed before table-row parsing", + preescaped_row_table.tables.size() == 1 && preescaped_row_table.table_runs.size() == 4 && + preescaped_row_table.table_runs[2].text == "A" && + preescaped_row_table.table_runs[3].text == "B"); + const auto section_marker_table = parser.parse( + "`t\n& tables()" in compact_h + assert "const ExternalVector& table_cells()" in compact_h + assert "bool layout_table(" in screen_h + assert "table_fit_width" in screen + assert "layout_table_reflow" in screen + assert "fragment.table_cell" in screen + assert "bool line_started=false" in screen + assert "if(line_started||line_y==top)" in screen + layout = screen[screen.index("bool NomadNetScreen::layout_table("): + screen.index("bool NomadNetScreen::layout_from(")] + assert "lv_obj_create" not in layout + assert "lv_label_create" not in layout + parse_call = manager.index("_nomad_parser.parse(") + parse_guard = manager[parse_call - 300:parse_call + 500] + assert "try {" in parse_guard + assert "catch (const std::bad_alloc&)" in parse_guard + assert "Page is too large for available memory" in parse_guard + assert "_nomad_response.clear()" in parse_guard + + def test_nomadnet_anchor_navigation_stays_local_and_uses_layout_checkpoints(): document_h = (INCLUDE / "NomadNetDocument.h").read_text() document = (INCLUDE / "NomadNetDocument.cpp").read_text() From 64eec3acd75f4b766f9f6411ead6cb93c21aff07 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:48:55 +0000 Subject: [PATCH 2/2] fix: isolate NomadNet table cell styles --- lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp | 2 +- tests/native/test_app_launcher_nomadnet.cpp | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp index b4cd95a7..ff4d53e5 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp @@ -441,7 +441,7 @@ bool append_table(Document& doc, const std::vector& lines, doc.mark_truncated(TruncationReason::TABLE_CELL_BYTES); } parse_inline(doc, parsed, cell_source, style); - if (cell_truncated) style = style_before_cell; + style = style_before_cell; } if (total_runs + parsed.runs.size() > DocumentParser::MAX_TOTAL_RUNS) { parsed.runs.resize(DocumentParser::MAX_TOTAL_RUNS - total_runs); diff --git a/tests/native/test_app_launcher_nomadnet.cpp b/tests/native/test_app_launcher_nomadnet.cpp index 311794ef..d6f7d461 100644 --- a/tests/native/test_app_launcher_nomadnet.cpp +++ b/tests/native/test_app_launcher_nomadnet.cpp @@ -544,6 +544,15 @@ int main(int argc, char** argv) { check("truncated formatting cannot bleed into the following table cell", bounded_style_table.tables.size() == 1 && plain_cell.run_count == 1 && !bounded_style_table.table_runs[plain_cell.first_run].bold); + const auto isolated_style_table = parser.parse( + "`t\nStyled|Plain\n---|---\n`!bold|plain\n`t\nafter"); + const auto& isolated_plain_cell = isolated_style_table.table_cells[3]; + check("non-truncated formatting cannot bleed into the following table cell", + isolated_style_table.tables.size() == 1 && isolated_plain_cell.run_count == 1 && + !isolated_style_table.table_runs[isolated_plain_cell.first_run].bold); + check("table-cell formatting cannot bleed into post-table content", + !isolated_style_table.blocks.back().runs.empty() && + !isolated_style_table.blocks.back().runs.front().bold); std::string wide_header; std::string wide_separator; @@ -609,6 +618,17 @@ int main(int argc, char** argv) { bounded_table_cells.table_cells.size() == DocumentParser::MAX_TABLE_CELLS && bounded_table_cells.has_truncation(TruncationReason::TABLE_ROWS)); + const auto two_full_tables = parser.parse(cell_bounded_source + "\n" + cell_bounded_source); + CompactPage two_full_tables_page; + check("two parser-valid full tables fit the aggregate compact cell budget", + two_full_tables.tables.size() == 2 && + two_full_tables.table_cells.size() == DocumentParser::MAX_TOTAL_TABLE_CELLS && + two_full_tables_page.assign(two_full_tables) && + two_full_tables_page.tables().size() == 2 && + two_full_tables_page.table_cells().size() == CompactPage::MAX_TABLE_CELLS && + two_full_tables_page.tables()[0].row_count > 0 && + two_full_tables_page.tables()[1].row_count > 0); + const auto anchor_doc = parser.parse( "First\n" "`:spot Second\n"