From a90290a59672ac9555d3de74142d24ff47d2fad1 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 23 Apr 2026 21:00:19 +0200 Subject: [PATCH 01/10] add Markdown (.md) file reader with formatting support #1698 by dcherrera --- lib/Md/MdParser.cpp | 306 ++++++++++ lib/Md/MdParser.h | 46 ++ lib/Txt/Txt.cpp | 4 +- open-x4-sdk | 2 +- src/activities/reader/MDReaderActivity.h | 75 +++ src/activities/reader/MdReaderActivity.cpp | 662 +++++++++++++++++++++ src/activities/reader/ReaderActivity.cpp | 21 +- src/activities/reader/ReaderActivity.h | 2 + 8 files changed, 1112 insertions(+), 6 deletions(-) create mode 100644 lib/Md/MdParser.cpp create mode 100644 lib/Md/MdParser.h create mode 100644 src/activities/reader/MDReaderActivity.h create mode 100644 src/activities/reader/MdReaderActivity.cpp diff --git a/lib/Md/MdParser.cpp b/lib/Md/MdParser.cpp new file mode 100644 index 00000000..64bb7bc0 --- /dev/null +++ b/lib/Md/MdParser.cpp @@ -0,0 +1,306 @@ +#include "MdParser.h" + +#include + +namespace MdParser { + +static EpdFontFamily::Style combineFlags(bool bold, bool italic) { + if (bold && italic) return EpdFontFamily::BOLD_ITALIC; + if (bold) return EpdFontFamily::BOLD; + if (italic) return EpdFontFamily::ITALIC; + return EpdFontFamily::REGULAR; +} + +static std::string trimLeft(const std::string& s) { + size_t i = 0; + while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) i++; + return s.substr(i); +} + +static bool isHorizontalRuleLine(const std::string& line) { + // Must be at least 3 chars of the same marker (-, *, _) with optional spaces + if (line.size() < 3) return false; + + char marker = 0; + int count = 0; + for (char c : line) { + if (c == ' ' || c == '\t') continue; + if (c == '-' || c == '*' || c == '_') { + if (marker == 0) marker = c; + if (c != marker) return false; + count++; + } else { + return false; + } + } + return count >= 3; +} + +std::vector parseInline(const std::string& text) { + std::vector spans; + std::string current; + bool bold = false; + bool italic = false; + size_t i = 0; + + auto emitSpan = [&]() { + if (!current.empty()) { + spans.push_back({std::move(current), combineFlags(bold, italic)}); + current.clear(); + } + }; + + while (i < text.size()) { + char c = text[i]; + + // Escaped character + if (c == '\\' && i + 1 < text.size()) { + char next = text[i + 1]; + if (next == '*' || next == '_' || next == '`' || next == '[' || next == '!' || next == '\\') { + current += next; + i += 2; + continue; + } + } + + // *** or ___ — toggle both bold and italic + if ((c == '*' || c == '_') && i + 2 < text.size() && text[i + 1] == c && text[i + 2] == c) { + emitSpan(); + bold = !bold; + italic = !italic; + i += 3; + continue; + } + + // ** or __ — toggle bold + if ((c == '*' || c == '_') && i + 1 < text.size() && text[i + 1] == c) { + emitSpan(); + bold = !bold; + i += 2; + continue; + } + + // * or _ — toggle italic + if (c == '*' || c == '_') { + emitSpan(); + italic = !italic; + i += 1; + continue; + } + + // Backtick code span — strip backticks, render as regular + if (c == '`') { + size_t end = text.find('`', i + 1); + if (end != std::string::npos) { + emitSpan(); + spans.push_back({text.substr(i + 1, end - i - 1), EpdFontFamily::REGULAR}); + i = end + 1; + continue; + } + current += c; + i++; + continue; + } + + // Image ![alt](url) — show [alt] + if (c == '!' && i + 1 < text.size() && text[i + 1] == '[') { + size_t closeBracket = text.find(']', i + 2); + if (closeBracket != std::string::npos && closeBracket + 1 < text.size() && text[closeBracket + 1] == '(') { + size_t closeParen = text.find(')', closeBracket + 2); + if (closeParen != std::string::npos) { + std::string alt = text.substr(i + 2, closeBracket - i - 2); + current += "["; + current += alt; + current += "]"; + i = closeParen + 1; + continue; + } + } + current += c; + i++; + continue; + } + + // Link [text](url) — show text only + if (c == '[') { + size_t closeBracket = text.find(']', i + 1); + if (closeBracket != std::string::npos && closeBracket + 1 < text.size() && text[closeBracket + 1] == '(') { + size_t closeParen = text.find(')', closeBracket + 2); + if (closeParen != std::string::npos) { + current += text.substr(i + 1, closeBracket - i - 1); + i = closeParen + 1; + continue; + } + } + current += c; + i++; + continue; + } + + current += c; + i++; + } + + emitSpan(); + + // If bold/italic were left open, the text had unmatched markers. + // The spans are still usable — the trailing text just keeps the toggled style. + return spans; +} + +bool isCodeFence(const std::string& line) { + auto trimmed = trimLeft(line); + if (trimmed.size() < 3) return false; + // Must start with ``` (with optional language tag after) + if (trimmed[0] == '`' && trimmed[1] == '`' && trimmed[2] == '`') return true; + // Also support ~~~ fences + if (trimmed[0] == '~' && trimmed[1] == '~' && trimmed[2] == '~') return true; + return false; +} + +// Detect task list checkbox at start of list content, update prefix accordingly. +// Returns content with the checkbox marker stripped. +static std::string handleTaskList(const std::string& content, std::string& listPrefix) { + if (content.size() >= 3 && content[0] == '[' && content[2] == ']') { + char mark = content[1]; + if (mark == 'x' || mark == 'X') { + listPrefix = "[x] "; + } else if (mark == ' ') { + listPrefix = "[ ] "; + } else { + return content; // Not a checkbox — keep content as-is + } + size_t skip = 3; + if (skip < content.size() && content[skip] == ' ') skip++; + return content.substr(skip); + } + return content; +} + +ParsedLine parseLine(const std::string& rawLine, bool inCodeBlock) { + ParsedLine result; + + // Inside a code block: either closing fence or verbatim text + if (inCodeBlock) { + if (isCodeFence(rawLine)) { + result.blockType = BlockType::CodeBlock; + return result; + } + result.blockType = BlockType::CodeBlock; + result.spans.push_back({rawLine, EpdFontFamily::REGULAR}); + return result; + } + + // Opening code fence + if (isCodeFence(rawLine)) { + result.blockType = BlockType::CodeBlock; + return result; + } + + // Count leading whitespace for nesting level before trimming + size_t leadingSpaces = 0; + for (size_t i = 0; i < rawLine.size(); i++) { + if (rawLine[i] == ' ') + leadingSpaces++; + else if (rawLine[i] == '\t') + leadingSpaces += 2; // Treat tab as 2 spaces + else + break; + } + result.indentLevel = static_cast(leadingSpaces / 2); + + std::string trimmed = trimLeft(rawLine); + + // Blank line + if (trimmed.empty()) { + result.blockType = BlockType::BlankLine; + return result; + } + + // Horizontal rule (must check BEFORE unordered list since --- and *** overlap) + if (isHorizontalRuleLine(trimmed)) { + result.blockType = BlockType::HorizontalRule; + return result; + } + + // ATX headers: # H1, ## H2, ### H3+ + if (trimmed[0] == '#') { + int level = 0; + size_t pos = 0; + while (pos < trimmed.size() && trimmed[pos] == '#') { + level++; + pos++; + } + if (pos < trimmed.size() && trimmed[pos] == ' ') { + std::string content = trimmed.substr(pos + 1); + // Strip optional trailing # sequence + size_t trail = content.size(); + while (trail > 0 && content[trail - 1] == '#') trail--; + while (trail > 0 && content[trail - 1] == ' ') trail--; + if (trail < content.size()) content = content.substr(0, trail); + + if (level <= 1) + result.blockType = BlockType::Header1; + else if (level == 2) + result.blockType = BlockType::Header2; + else + result.blockType = BlockType::Header3; + + result.spans = parseInline(content); + // Force bold on all header spans + for (auto& span : result.spans) { + if (span.style == EpdFontFamily::REGULAR) + span.style = EpdFontFamily::BOLD; + else if (span.style == EpdFontFamily::ITALIC) + span.style = EpdFontFamily::BOLD_ITALIC; + } + return result; + } + } + + // Unordered list: - , * , + (marker followed by space) + if (trimmed.size() > 1 && trimmed[1] == ' ' && (trimmed[0] == '-' || trimmed[0] == '*' || trimmed[0] == '+')) { + result.blockType = BlockType::UnorderedList; + result.listPrefix = "\xe2\x80\xa2 "; // "• " + std::string content = handleTaskList(trimmed.substr(2), result.listPrefix); + result.spans = parseInline(content); + return result; + } + + // Ordered list: 1. , 2. , etc. (up to 3-digit number) + { + size_t dotPos = trimmed.find(". "); + if (dotPos != std::string::npos && dotPos <= 3 && dotPos > 0) { + bool allDigits = true; + for (size_t j = 0; j < dotPos; j++) { + if (!std::isdigit(static_cast(trimmed[j]))) { + allDigits = false; + break; + } + } + if (allDigits) { + result.blockType = BlockType::OrderedList; + result.listPrefix = trimmed.substr(0, dotPos + 2); // e.g. "1. " + std::string content = handleTaskList(trimmed.substr(dotPos + 2), result.listPrefix); + result.spans = parseInline(content); + return result; + } + } + } + + // Blockquote: > text + if (trimmed[0] == '>') { + result.blockType = BlockType::Blockquote; + std::string content = trimmed.substr(1); + if (!content.empty() && content[0] == ' ') content = content.substr(1); + result.spans = parseInline(content); + return result; + } + + // Default: paragraph + result.blockType = BlockType::Paragraph; + result.spans = parseInline(trimmed); + return result; +} + +} // namespace MdParser \ No newline at end of file diff --git a/lib/Md/MdParser.h b/lib/Md/MdParser.h new file mode 100644 index 00000000..936db7c2 --- /dev/null +++ b/lib/Md/MdParser.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +#include +#include +#include + +namespace MdParser { + +struct Span { + std::string text; + EpdFontFamily::Style style; +}; + +enum class BlockType : uint8_t { + Paragraph, + Header1, + Header2, + Header3, + UnorderedList, + OrderedList, + Blockquote, + CodeBlock, + HorizontalRule, + BlankLine +}; + +struct ParsedLine { + BlockType blockType; + std::vector spans; + std::string listPrefix; // "• " or "1. " etc. + uint8_t indentLevel = 0; // Nesting depth (each 2 spaces = 1 level) +}; + +// Parse a single raw line of markdown into block type and styled spans. +// |inCodeBlock| indicates whether the line is inside a fenced code block. +ParsedLine parseLine(const std::string& rawLine, bool inCodeBlock); + +// Returns true if the line is a code fence (``` with optional language tag). +bool isCodeFence(const std::string& line); + +// Parse inline markdown formatting (bold, italic, code spans, links, images). +std::vector parseInline(const std::string& text); + +} // namespace MdParser \ No newline at end of file diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp index 83ef123c..692e3850 100644 --- a/lib/Txt/Txt.cpp +++ b/lib/Txt/Txt.cpp @@ -40,9 +40,11 @@ std::string Txt::getTitle() const { size_t lastSlash = filepath.find_last_of('/'); std::string filename = (lastSlash != std::string::npos) ? filepath.substr(lastSlash + 1) : filepath; - // Remove .txt extension + // Remove .txt or .mdextension if (FsHelpers::hasTxtExtension(filename)) { filename = filename.substr(0, filename.length() - 4); + } else if (FsHelpers::hasMarkdownExtension(filename)) { + filename = filename.substr(0, filename.length() - 3); } return filename; diff --git a/open-x4-sdk b/open-x4-sdk index a931d452..ed5cb2f9 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit a931d452d4bf9f100683705dbb8da6c29283bee0 +Subproject commit ed5cb2f99dc319deaa3c4f2c41e297929f940386 diff --git a/src/activities/reader/MDReaderActivity.h b/src/activities/reader/MDReaderActivity.h new file mode 100644 index 00000000..f62986b5 --- /dev/null +++ b/src/activities/reader/MDReaderActivity.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "activities/Activity.h" + +class MdReaderActivity final : public Activity { + std::unique_ptr txt; + + int currentPage = 0; + int totalPages = 1; + int pagesUntilFullRefresh = 0; + + // A single rendered line on screen (after word-wrapping) + struct RenderedLine { + std::vector spans; + int indent = 0; // left indent in pixels + bool isHR = false; // draw as horizontal rule + }; + + // Streaming reader state + std::vector pageOffsets; + std::vector pageCodeBlockState; // 1 if page starts inside a code block + std::vector currentPageLines; + int linesPerPage = 0; + int viewportWidth = 0; + bool initialized = false; + + // Cached settings for cache validation + int cachedFontId = 0; + uint8_t cachedScreenMargin = 0; + uint8_t cachedParagraphAlignment = CrossPointSettings::LEFT_ALIGN; + int cachedOrientedMarginTop = 0; + int cachedOrientedMarginRight = 0; + int cachedOrientedMarginBottom = 0; + int cachedOrientedMarginLeft = 0; + + // Indent constants (in pixels) + static constexpr int LIST_INDENT = 20; + static constexpr int BLOCKQUOTE_INDENT = 16; + static constexpr int CODE_INDENT = 8; + + void renderPage(); + void renderStatusBar() const; + + void initializeReader(); + bool loadPageAtOffset(size_t offset, bool startInCodeBlock, std::vector& outLines, size_t& nextOffset, + bool& endInCodeBlock); + void buildPageIndex(); + bool loadPageIndexCache(); + void savePageIndexCache() const; + void saveProgress() const; + void loadProgress(); + + // Word-wrap a parsed markdown line into one or more RenderedLines. + // Returns true if all content was emitted, false if truncated by maxLines. + bool wordWrapParsedLine(const MdParser::ParsedLine& parsed, int indent, std::vector& outLines, + int maxLines); + + // Measure total pixel width of a span list + int measureSpans(const std::vector& spans) const; + + public: + explicit MdReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr txt) + : Activity("MdReader", renderer, mappedInput), txt(std::move(txt)) {} + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + bool isReaderActivity() const override { return true; } +}; \ No newline at end of file diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp new file mode 100644 index 00000000..8b1fdee4 --- /dev/null +++ b/src/activities/reader/MdReaderActivity.cpp @@ -0,0 +1,662 @@ +#include "MdReaderActivity.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "CrossPointState.h" +#include "MappedInputManager.h" +#include "ReaderUtils.h" +#include "RecentBooksStore.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { +constexpr size_t CHUNK_SIZE = 8 * 1024; +constexpr uint32_t CACHE_MAGIC = 0x4D4B4449; // "MKDI" +constexpr uint8_t CACHE_VERSION = 3; // Bumped: nested list indent + task checkboxes +} // namespace + +void MdReaderActivity::onEnter() { + Activity::onEnter(); + + if (!txt) { + return; + } + + ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); + + txt->setupCacheDir(); + + auto filePath = txt->getPath(); + auto fileName = filePath.substr(filePath.rfind('/') + 1); + APP_STATE.openEpubPath = filePath; + APP_STATE.saveToFile(); + RECENT_BOOKS.addBook(filePath, fileName, "", ""); + + requestUpdate(); +} + +void MdReaderActivity::onExit() { + Activity::onExit(); + + renderer.setOrientation(GfxRenderer::Orientation::Portrait); + + pageOffsets.clear(); + pageCodeBlockState.clear(); + currentPageLines.clear(); + APP_STATE.readerActivityLoadCount = 0; + APP_STATE.saveToFile(); + txt.reset(); +} + +void MdReaderActivity::loop() { + if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { + activityManager.goToFileBrowser(txt ? txt->getPath() : ""); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Back) && + mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) { + onGoHome(); + return; + } + + auto [prevTriggered, nextTriggered] = ReaderUtils::detectPageTurn(mappedInput); + if (!prevTriggered && !nextTriggered) { + return; + } + + if (prevTriggered && currentPage > 0) { + currentPage--; + requestUpdate(); + } else if (nextTriggered) { + if (currentPage < totalPages - 1) { + currentPage++; + requestUpdate(); + } else { + onGoHome(); + } + } +} + +void MdReaderActivity::initializeReader() { + if (initialized) { + return; + } + + cachedFontId = SETTINGS.getReaderFontId(); + cachedScreenMargin = SETTINGS.screenMargin; + cachedParagraphAlignment = SETTINGS.paragraphAlignment; + + renderer.getOrientedViewableTRBL(&cachedOrientedMarginTop, &cachedOrientedMarginRight, &cachedOrientedMarginBottom, + &cachedOrientedMarginLeft); + cachedOrientedMarginTop += cachedScreenMargin; + cachedOrientedMarginLeft += cachedScreenMargin; + cachedOrientedMarginRight += cachedScreenMargin; + cachedOrientedMarginBottom += + std::max(cachedScreenMargin, static_cast(UITheme::getInstance().getStatusBarHeight())); + + viewportWidth = renderer.getScreenWidth() - cachedOrientedMarginLeft - cachedOrientedMarginRight; + const int viewportHeight = renderer.getScreenHeight() - cachedOrientedMarginTop - cachedOrientedMarginBottom; + const int lineHeight = renderer.getLineHeight(cachedFontId); + + linesPerPage = viewportHeight / lineHeight; + if (linesPerPage < 1) linesPerPage = 1; + + LOG_DBG("MDR", "Viewport: %dx%d, lines per page: %d", viewportWidth, viewportHeight, linesPerPage); + + if (!loadPageIndexCache()) { + buildPageIndex(); + savePageIndexCache(); + } + + loadProgress(); + + initialized = true; +} + +int MdReaderActivity::measureSpans(const std::vector& spans) const { + return std::accumulate(spans.begin(), spans.end(), 0, [this](int acc, const MdParser::Span& span) { + return acc + (span.text.empty() ? 0 : renderer.getTextAdvanceX(cachedFontId, span.text.c_str(), span.style)); + }); +} + +bool MdReaderActivity::wordWrapParsedLine(const MdParser::ParsedLine& parsed, int indent, + std::vector& outLines, int maxLines) { + const size_t startSize = outLines.size(); + + if (parsed.spans.empty()) { + RenderedLine rl; + rl.indent = indent; + rl.isHR = (parsed.blockType == MdParser::BlockType::HorizontalRule); + outLines.push_back(std::move(rl)); + return true; + } + + const int availableWidth = viewportWidth - indent; + if (availableWidth <= 0) return true; + + // Build a flat list of all spans, prepending the list prefix if present + std::vector allSpans; + if (!parsed.listPrefix.empty()) { + allSpans.push_back({parsed.listPrefix, EpdFontFamily::REGULAR}); + } + allSpans.insert(allSpans.end(), parsed.spans.begin(), parsed.spans.end()); + + // Check if everything fits on one line + int totalWidth = measureSpans(allSpans); + if (totalWidth <= availableWidth) { + RenderedLine rl; + rl.spans = std::move(allSpans); + rl.indent = indent; + outLines.push_back(std::move(rl)); + return true; + } + + // Word-wrap across spans + RenderedLine currentLine; + currentLine.indent = indent; + int currentWidth = 0; + bool fullyConsumed = true; + + for (size_t si = 0; si < allSpans.size(); si++) { + const auto& span = allSpans[si]; + if (span.text.empty()) continue; + + int spanWidth = renderer.getTextAdvanceX(cachedFontId, span.text.c_str(), span.style); + + if (currentWidth + spanWidth <= availableWidth) { + currentLine.spans.push_back(span); + currentWidth += spanWidth; + continue; + } + + // Need to break within this span + std::string remaining = span.text; + auto style = span.style; + + while (!remaining.empty()) { + // Check line limit (using lines added, not total size) + if (static_cast(outLines.size() - startSize) >= maxLines) { + fullyConsumed = false; + goto done; + } + + int remWidth = renderer.getTextAdvanceX(cachedFontId, remaining.c_str(), style); + + if (currentWidth + remWidth <= availableWidth) { + currentLine.spans.push_back({remaining, style}); + currentWidth += remWidth; + remaining.clear(); + break; + } + + size_t breakPos = remaining.size(); + + while (breakPos > 0 && renderer.getTextAdvanceX(cachedFontId, remaining.substr(0, breakPos).c_str(), style) > + availableWidth - currentWidth) { + size_t spacePos = remaining.rfind(' ', breakPos - 1); + if (spacePos != std::string::npos && spacePos > 0) { + breakPos = spacePos; + } else { + breakPos--; + while (breakPos > 0 && (remaining[breakPos] & 0xC0) == 0x80) { + breakPos--; + } + } + } + + if (breakPos == 0) { + if (currentLine.spans.empty()) { + breakPos = 1; + while (breakPos < remaining.size() && (remaining[breakPos] & 0xC0) == 0x80) { + breakPos++; + } + } else { + outLines.push_back(std::move(currentLine)); + currentLine = RenderedLine(); + currentLine.indent = indent; + currentWidth = 0; + continue; + } + } + + currentLine.spans.push_back({remaining.substr(0, breakPos), style}); + outLines.push_back(std::move(currentLine)); + currentLine = RenderedLine(); + currentLine.indent = indent; + currentWidth = 0; + + size_t skip = breakPos; + if (skip < remaining.size() && remaining[skip] == ' ') { + skip++; + } + remaining = remaining.substr(skip); + } + } + +done: + if (!currentLine.spans.empty()) { + outLines.push_back(std::move(currentLine)); + } + return fullyConsumed; +} + +bool MdReaderActivity::loadPageAtOffset(size_t offset, bool startInCodeBlock, std::vector& outLines, + size_t& nextOffset, bool& endInCodeBlock) { + outLines.clear(); + endInCodeBlock = startInCodeBlock; + const size_t fileSize = txt->getFileSize(); + + if (offset >= fileSize) { + return false; + } + + size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset); + auto* buffer = static_cast(malloc(chunkSize + 1)); + if (!buffer) { + LOG_ERR("MDR", "Failed to allocate %zu bytes", chunkSize); + return false; + } + + if (!txt->readContent(buffer, offset, chunkSize)) { + free(buffer); + return false; + } + buffer[chunkSize] = '\0'; + + bool inCodeBlock = startInCodeBlock; + size_t pos = 0; + + while (pos < chunkSize && static_cast(outLines.size()) < linesPerPage) { + // Find end of line + size_t lineEnd = pos; + while (lineEnd < chunkSize && buffer[lineEnd] != '\n') { + lineEnd++; + } + + // Check if we have a complete line + bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize); + + if (!lineComplete && !outLines.empty()) { + // Incomplete line at chunk boundary and we already have content — stop here + break; + } + + size_t lineContentLen = lineEnd - pos; + bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r'); + size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen; + + std::string rawLine(reinterpret_cast(buffer + pos), displayLen); + + // Check for code fence toggle + bool wasFence = false; + if (MdParser::isCodeFence(rawLine)) { + inCodeBlock = !inCodeBlock; + wasFence = true; + } + + // Parse the markdown line + MdParser::ParsedLine parsed; + if (wasFence) { + // Fence lines produce no visible output + parsed.blockType = MdParser::BlockType::CodeBlock; + } else { + parsed = MdParser::parseLine(rawLine, inCodeBlock); + } + + // Determine indent (base + nesting level) + int indent = 0; + switch (parsed.blockType) { + case MdParser::BlockType::UnorderedList: + case MdParser::BlockType::OrderedList: + indent = LIST_INDENT + parsed.indentLevel * LIST_INDENT; + break; + case MdParser::BlockType::Blockquote: + indent = BLOCKQUOTE_INDENT; + break; + case MdParser::BlockType::CodeBlock: + if (!wasFence) indent = CODE_INDENT; + break; + default: + break; + } + + // Word-wrap and add to output (skip fence lines) + if (!wasFence) { + size_t linesBefore = outLines.size(); + int remainingLines = linesPerPage - static_cast(outLines.size()); + bool fullyConsumed = wordWrapParsedLine(parsed, indent, outLines, remainingLines); + + if (!fullyConsumed) { + if (linesBefore > 0) { + // Page was partially filled — rollback this line and save it for next page + outLines.resize(linesBefore); + // Don't advance pos — next page re-processes this source line + } else { + // First line on page is longer than a full page — accept truncation, advance past it + pos = lineComplete ? lineEnd + 1 : lineEnd; + } + break; + } + } + + // Advance past the newline (only if source line was fully consumed) + pos = lineEnd + 1; + } + + // Ensure progress + if (pos == 0 && !outLines.empty()) { + pos = 1; + } + + nextOffset = offset + pos; + if (nextOffset > fileSize) { + nextOffset = fileSize; + } + + endInCodeBlock = inCodeBlock; + free(buffer); + + return !outLines.empty(); +} + +void MdReaderActivity::buildPageIndex() { + pageOffsets.clear(); + pageCodeBlockState.clear(); + pageOffsets.push_back(0); + pageCodeBlockState.push_back(0); + + size_t offset = 0; + const size_t fileSize = txt->getFileSize(); + bool inCodeBlock = false; + + LOG_DBG("MDR", "Building page index for %zu bytes...", fileSize); + + GUI.drawPopup(renderer, tr(STR_INDEXING)); + + while (offset < fileSize) { + std::vector tempLines; + size_t nextOffset = offset; + bool nextCodeBlock = inCodeBlock; + + if (!loadPageAtOffset(offset, inCodeBlock, tempLines, nextOffset, nextCodeBlock)) { + break; + } + + if (nextOffset <= offset) { + break; + } + + offset = nextOffset; + inCodeBlock = nextCodeBlock; + + if (offset < fileSize) { + pageOffsets.push_back(offset); + pageCodeBlockState.push_back(inCodeBlock ? 1 : 0); + } + + if (pageOffsets.size() % 20 == 0) { + vTaskDelay(1); + } + } + + totalPages = pageOffsets.size(); + LOG_DBG("MDR", "Built page index: %d pages", totalPages); +} + +void MdReaderActivity::render(RenderLock&&) { + if (!txt) { + return; + } + + if (!initialized) { + initializeReader(); + } + + if (pageOffsets.empty()) { + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_FILE), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + if (currentPage < 0) currentPage = 0; + if (currentPage >= totalPages) currentPage = totalPages - 1; + + // Load current page + size_t offset = pageOffsets[currentPage]; + bool startCodeBlock = + (currentPage < static_cast(pageCodeBlockState.size())) ? pageCodeBlockState[currentPage] : false; + size_t nextOffset; + bool endCodeBlock; + currentPageLines.clear(); + loadPageAtOffset(offset, startCodeBlock, currentPageLines, nextOffset, endCodeBlock); + + renderer.clearScreen(); + renderPage(); + + saveProgress(); +} + +void MdReaderActivity::renderPage() { + const int lineHeight = renderer.getLineHeight(cachedFontId); + + auto renderLines = [&]() { + int y = cachedOrientedMarginTop; + for (const auto& line : currentPageLines) { + if (line.isHR) { + // Draw horizontal rule as a thin line + int hrY = y + lineHeight / 2; + renderer.drawLine(cachedOrientedMarginLeft + line.indent, hrY, cachedOrientedMarginLeft + viewportWidth, hrY); + } else if (!line.spans.empty()) { + int x = cachedOrientedMarginLeft + line.indent; + + // Apply text alignment for non-indented lines + if (line.indent == 0) { + int contentWidth = viewportWidth; + switch (cachedParagraphAlignment) { + case CrossPointSettings::CENTER_ALIGN: { + x = cachedOrientedMarginLeft + (contentWidth - measureSpans(line.spans)) / 2; + break; + } + case CrossPointSettings::RIGHT_ALIGN: { + x = cachedOrientedMarginLeft + contentWidth - measureSpans(line.spans); + break; + } + default: + break; + } + } + + // Render each span + for (const auto& span : line.spans) { + if (!span.text.empty()) { + renderer.drawText(cachedFontId, x, y, span.text.c_str(), true, span.style); + x += renderer.getTextAdvanceX(cachedFontId, span.text.c_str(), span.style); + } + } + } + y += lineHeight; + } + }; + + // Font prewarm: scan pass accumulates text, then prewarm, then real render + auto* fcm = renderer.getFontCacheManager(); + auto scope = fcm->createPrewarmScope(); + renderLines(); + scope.endScanAndPrewarm(); + + // BW rendering + renderLines(); + renderStatusBar(); + + ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); + + if (SETTINGS.textAntiAliasing) { + ReaderUtils::renderAntiAliased(renderer, [&renderLines]() { renderLines(); }); + } +} + +void MdReaderActivity::renderStatusBar() const { + const float progress = totalPages > 0 ? (currentPage + 1) * 100.0f / totalPages : 0; + std::string title; + if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) { + title = txt->getTitle(); + } + GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title); +} + +void MdReaderActivity::saveProgress() const { + FsFile f; + if (Storage.openFileForWrite("MDR", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + data[0] = currentPage & 0xFF; + data[1] = (currentPage >> 8) & 0xFF; + data[2] = 0; + data[3] = 0; + f.write(data, 4); + } +} + +void MdReaderActivity::loadProgress() { + FsFile f; + if (Storage.openFileForRead("MDR", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + if (f.read(data, 4) == 4) { + currentPage = data[0] + (data[1] << 8); + if (currentPage >= totalPages) { + currentPage = totalPages - 1; + } + if (currentPage < 0) { + currentPage = 0; + } + LOG_DBG("MDR", "Loaded progress: page %d/%d", currentPage, totalPages); + } + } +} + +bool MdReaderActivity::loadPageIndexCache() { + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!Storage.openFileForRead("MDR", cachePath, f)) { + LOG_DBG("MDR", "No page index cache found"); + return false; + } + + uint32_t magic; + serialization::readPod(f, magic); + if (magic != CACHE_MAGIC) { + LOG_DBG("MDR", "Cache magic mismatch, rebuilding"); + return false; + } + + uint8_t version; + serialization::readPod(f, version); + if (version != CACHE_VERSION) { + LOG_DBG("MDR", "Cache version mismatch (%d != %d), rebuilding", version, CACHE_VERSION); + return false; + } + + uint32_t fileSize; + serialization::readPod(f, fileSize); + if (fileSize != txt->getFileSize()) { + LOG_DBG("MDR", "Cache file size mismatch, rebuilding"); + return false; + } + + int32_t cachedWidth; + serialization::readPod(f, cachedWidth); + if (cachedWidth != viewportWidth) { + LOG_DBG("MDR", "Cache viewport width mismatch, rebuilding"); + return false; + } + + int32_t cachedLines; + serialization::readPod(f, cachedLines); + if (cachedLines != linesPerPage) { + LOG_DBG("MDR", "Cache lines per page mismatch, rebuilding"); + return false; + } + + int32_t fontId; + serialization::readPod(f, fontId); + if (fontId != cachedFontId) { + LOG_DBG("MDR", "Cache font ID mismatch, rebuilding"); + return false; + } + + int32_t margin; + serialization::readPod(f, margin); + if (margin != cachedScreenMargin) { + LOG_DBG("MDR", "Cache screen margin mismatch, rebuilding"); + return false; + } + + uint8_t alignment; + serialization::readPod(f, alignment); + if (alignment != cachedParagraphAlignment) { + LOG_DBG("MDR", "Cache paragraph alignment mismatch, rebuilding"); + return false; + } + + uint32_t numPages; + serialization::readPod(f, numPages); + + pageOffsets.clear(); + // Sanity check: reject corrupt cache with absurd page count + if (numPages == 0 || numPages > 100000) { + LOG_DBG("MDR", "Cache page count out of range (%u), rebuilding", numPages); + return false; + } + + pageOffsets.reserve(numPages); + pageCodeBlockState.clear(); + pageCodeBlockState.reserve(numPages); + + for (uint32_t i = 0; i < numPages; i++) { + uint32_t pageOffset; + serialization::readPod(f, pageOffset); + uint8_t codeState; + serialization::readPod(f, codeState); + pageOffsets.push_back(pageOffset); + pageCodeBlockState.push_back(codeState); + } + + totalPages = pageOffsets.size(); + LOG_DBG("MDR", "Loaded page index cache: %d pages", totalPages); + return true; +} + +void MdReaderActivity::savePageIndexCache() const { + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!Storage.openFileForWrite("MDR", cachePath, f)) { + LOG_ERR("MDR", "Failed to save page index cache"); + return; + } + + serialization::writePod(f, CACHE_MAGIC); + serialization::writePod(f, CACHE_VERSION); + serialization::writePod(f, static_cast(txt->getFileSize())); + serialization::writePod(f, static_cast(viewportWidth)); + serialization::writePod(f, static_cast(linesPerPage)); + serialization::writePod(f, static_cast(cachedFontId)); + serialization::writePod(f, static_cast(cachedScreenMargin)); + serialization::writePod(f, cachedParagraphAlignment); + serialization::writePod(f, static_cast(pageOffsets.size())); + + for (size_t i = 0; i < pageOffsets.size(); i++) { + serialization::writePod(f, static_cast(pageOffsets[i])); + serialization::writePod(f, pageCodeBlockState[i]); + } + + LOG_DBG("MDR", "Saved page index cache: %d pages", totalPages); +} \ No newline at end of file diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 69f572a0..fa976a0e 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -11,6 +11,7 @@ #include "CrossPointState.h" #include "Epub.h" #include "EpubReaderActivity.h" +#include "MdReaderActivity.h" #include "Txt.h" #include "TxtReaderActivity.h" #include "Xtc.h" @@ -46,10 +47,9 @@ std::string ReaderActivity::extractFolderPath(const std::string& filePath) { bool ReaderActivity::isXtcFile(const std::string& path) { return FsHelpers::hasXtcExtension(path); } -bool ReaderActivity::isTxtFile(const std::string& path) { - return FsHelpers::hasTxtExtension(path) || - FsHelpers::hasMarkdownExtension(path); // Treat .md as txt files (until we have a markdown reader) -} +bool ReaderActivity::isTxtFile(const std::string& path) { return FsHelpers::hasTxtExtension(path); } + +bool ReaderActivity::isMDFile(const std::string& path) { return FsHelpers::hasMarkdownExtension(path); } bool ReaderActivity::isImageFile(const std::string& path) { return FsHelpers::hasBmpExtension(path) || FsHelpers::hasJpgExtension(path) || FsHelpers::hasPngExtension(path); @@ -130,6 +130,12 @@ void ReaderActivity::onGoToTxtReader(std::unique_ptr txt) { activityManager.replaceActivity(std::make_unique(renderer, mappedInput, std::move(txt))); } +void ReaderActivity::onGoToMdReader(std::unique_ptr txt) { + const auto txtPath = txt->getPath(); + currentBookPath = txtPath; + activityManager.replaceActivity(std::make_unique(renderer, mappedInput, std::move(txt))); +} + void ReaderActivity::onEnter() { Activity::onEnter(); logReaderLaunchMemSnapshot("onEnter_begin"); @@ -161,6 +167,13 @@ void ReaderActivity::onEnter() { return; } onGoToXtcReader(std::move(xtc)); + } else if (isMdFile(initialBookPath)) { + auto txt = loadTxt(initialBookPath); + if (!txt) { + onGoBack(); + return; + } + onGoToMdReader(std::move(txt)); } else if (isTxtFile(initialBookPath)) { auto txt = loadTxt(initialBookPath); if (!txt) { diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index efdfd201..51258ad1 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -16,6 +16,7 @@ class ReaderActivity final : public Activity { static std::unique_ptr loadTxt(const std::string& path); static bool isXtcFile(const std::string& path); static bool isTxtFile(const std::string& path); + static bool isMDFile(const std::string& path); static bool isImageFile(const std::string& path); static std::string extractFolderPath(const std::string& filePath); @@ -23,6 +24,7 @@ class ReaderActivity final : public Activity { void onGoToEpubReader(std::unique_ptr epub); void onGoToXtcReader(std::unique_ptr xtc); void onGoToTxtReader(std::unique_ptr txt); + void onGoToMdReader(std::unique_ptr txt); void onGoToBmpViewer(const std::string& path); void onGoBack(); From 01a9d7cb36d717b5ba9bfed7f1610fe4e2d048fe Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 23 Apr 2026 21:27:14 +0200 Subject: [PATCH 02/10] Add TOC support Co-authored-by: Copilot --- lib/Md/MdParser.cpp | 26 ++ src/activities/reader/MDReaderActivity.h | 19 ++ src/activities/reader/MdReaderActivity.cpp | 244 ++++++++++++++++-- .../reader/MdReaderTocSelectionActivity.cpp | 110 ++++++++ .../reader/MdReaderTocSelectionActivity.h | 30 +++ src/activities/reader/ReaderActivity.cpp | 2 +- test/md_parser/MdParserTest.cpp | 92 +++++++ 7 files changed, 502 insertions(+), 21 deletions(-) create mode 100644 src/activities/reader/MdReaderTocSelectionActivity.cpp create mode 100644 src/activities/reader/MdReaderTocSelectionActivity.h create mode 100644 test/md_parser/MdParserTest.cpp diff --git a/lib/Md/MdParser.cpp b/lib/Md/MdParser.cpp index 64bb7bc0..a1780874 100644 --- a/lib/Md/MdParser.cpp +++ b/lib/Md/MdParser.cpp @@ -17,6 +17,17 @@ static std::string trimLeft(const std::string& s) { return s.substr(i); } +static bool isWordChar(char c) { return std::isalnum(static_cast(c)) || c == '_'; } + +static bool isUnderscoreEmphasis(const std::string& text, size_t pos, size_t count) { + if (pos == 0 || pos + count >= text.size()) { + return true; + } + const char before = text[pos - 1]; + const char after = text[pos + count]; + return !(isWordChar(before) && isWordChar(after)); +} + static bool isHorizontalRuleLine(const std::string& line) { // Must be at least 3 chars of the same marker (-, *, _) with optional spaces if (line.size() < 3) return false; @@ -65,6 +76,11 @@ std::vector parseInline(const std::string& text) { // *** or ___ — toggle both bold and italic if ((c == '*' || c == '_') && i + 2 < text.size() && text[i + 1] == c && text[i + 2] == c) { + if (c == '_' && !isUnderscoreEmphasis(text, i, 3)) { + current.append(3, c); + i += 3; + continue; + } emitSpan(); bold = !bold; italic = !italic; @@ -74,6 +90,11 @@ std::vector parseInline(const std::string& text) { // ** or __ — toggle bold if ((c == '*' || c == '_') && i + 1 < text.size() && text[i + 1] == c) { + if (c == '_' && !isUnderscoreEmphasis(text, i, 2)) { + current.append(2, c); + i += 2; + continue; + } emitSpan(); bold = !bold; i += 2; @@ -82,6 +103,11 @@ std::vector parseInline(const std::string& text) { // * or _ — toggle italic if (c == '*' || c == '_') { + if (c == '_' && !isUnderscoreEmphasis(text, i, 1)) { + current.push_back(c); + i += 1; + continue; + } emitSpan(); italic = !italic; i += 1; diff --git a/src/activities/reader/MDReaderActivity.h b/src/activities/reader/MDReaderActivity.h index f62986b5..7fa9215d 100644 --- a/src/activities/reader/MDReaderActivity.h +++ b/src/activities/reader/MDReaderActivity.h @@ -8,6 +8,13 @@ #include "CrossPointSettings.h" #include "activities/Activity.h" +struct MdHeading { + size_t offset = 0; + int level = 1; + std::string title; + int pageIndex = -1; +}; + class MdReaderActivity final : public Activity { std::unique_ptr txt; @@ -26,6 +33,11 @@ class MdReaderActivity final : public Activity { std::vector pageOffsets; std::vector pageCodeBlockState; // 1 if page starts inside a code block std::vector currentPageLines; + std::vector pageBuffer; + + std::vector headings; + int currentHeadingIndex = -1; + int linesPerPage = 0; int viewportWidth = 0; bool initialized = false; @@ -55,6 +67,13 @@ class MdReaderActivity final : public Activity { void savePageIndexCache() const; void saveProgress() const; void loadProgress(); + void scanHeadings(); + void assignHeadingPageNumbers(); + int getHeadingIndexForOffset(size_t offset) const; + void jumpToHeading(bool next); + void scanHeadings(); + int getHeadingIndexForOffset(size_t offset) const; + void jumpToHeading(bool next); // Word-wrap a parsed markdown line into one or more RenderedLines. // Returns true if all content was emitted, false if truncated by maxLines. diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 8b1fdee4..b4830ebc 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -7,11 +7,13 @@ #include #include +#include #include #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "MdReaderTocSelectionActivity.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "components/UITheme.h" @@ -19,8 +21,18 @@ namespace { constexpr size_t CHUNK_SIZE = 8 * 1024; +constexpr size_t MAX_LINE_LENGTH = 64 * 1024; +constexpr unsigned long HEADING_SKIP_MS = 700; constexpr uint32_t CACHE_MAGIC = 0x4D4B4449; // "MKDI" constexpr uint8_t CACHE_VERSION = 3; // Bumped: nested list indent + task checkboxes + +static std::string flattenHeadingText(const MdParser::ParsedLine& parsed) { + std::string result; + for (const auto& span : parsed.spans) { + result += span.text; + } + return result; +} } // namespace void MdReaderActivity::onEnter() { @@ -38,11 +50,163 @@ void MdReaderActivity::onEnter() { auto fileName = filePath.substr(filePath.rfind('/') + 1); APP_STATE.openEpubPath = filePath; APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(filePath, fileName, "", ""); + RECENT_BOOKS.addBook(filePath, fileName, "", "", ""); requestUpdate(); } +void MdReaderActivity::assignHeadingPageNumbers() { + if (pageOffsets.empty()) { + return; + } + for (auto& heading : headings) { + const auto it = std::upper_bound(pageOffsets.begin(), pageOffsets.end(), heading.offset); + heading.pageIndex = static_cast((it == pageOffsets.begin()) ? 0 : (it - pageOffsets.begin() - 1)); + } +} + +int MdReaderActivity::getHeadingIndexForOffset(size_t offset) const { + if (headings.empty()) { + return -1; + } + int index = -1; + for (int i = 0; i < static_cast(headings.size()); i++) { + if (headings[i].offset <= offset) { + index = i; + } else { + break; + } + } + return index; +} + +void MdReaderActivity::jumpToHeading(bool next) { + if (headings.empty() || pageOffsets.empty()) { + return; + } + + const size_t currentOffset = (currentPage >= 0 && currentPage < totalPages) ? pageOffsets[currentPage] : 0; + int headingIndex = getHeadingIndexForOffset(currentOffset); + + if (headingIndex < 0) { + headingIndex = next ? 0 : static_cast(headings.size()) - 1; + } else { + headingIndex += next ? 1 : -1; + } + + if (headingIndex < 0) { + headingIndex = 0; + } else if (headingIndex >= static_cast(headings.size())) { + headingIndex = static_cast(headings.size()) - 1; + } + + const size_t headingOffset = headings[headingIndex].offset; + const auto it = std::upper_bound(pageOffsets.begin(), pageOffsets.end(), headingOffset); + if (it == pageOffsets.begin()) { + currentPage = 0; + } else { + currentPage = static_cast(it - pageOffsets.begin() - 1); + } + currentHeadingIndex = headingIndex; + requestUpdate(); +} + +void MdReaderActivity::scanHeadings() { + headings.clear(); + if (!txt) { + return; + } + + const size_t fileSize = txt->getFileSize(); + if (fileSize == 0) { + return; + } + + std::string pending; + size_t pendingOffset = 0; + bool hasPending = false; + bool inCodeBlock = false; + + pageBuffer.resize(CHUNK_SIZE + 1); + size_t offset = 0; + + while (offset < fileSize) { + const size_t toRead = std::min(CHUNK_SIZE, fileSize - offset); + if (!txt->readContent(pageBuffer.data(), offset, toRead)) { + return; + } + pageBuffer[toRead] = '\0'; + + size_t pos = 0; + while (pos < toRead) { + size_t lineEnd = pos; + while (lineEnd < toRead && pageBuffer[lineEnd] != '\n') { + lineEnd++; + } + + const bool hasNewline = (lineEnd < toRead && pageBuffer[lineEnd] == '\n'); + const bool fileHasMore = (offset + toRead < fileSize); + const size_t rawLen = lineEnd - pos; + const bool hasCR = (rawLen > 0 && pageBuffer[pos + rawLen - 1] == '\r'); + const size_t displayLen = hasCR ? rawLen - 1 : rawLen; + const size_t lineStartOffset = hasPending ? pendingOffset : (offset + pos); + + std::string rawLine; + if (hasPending) { + rawLine = std::move(pending); + pending.clear(); + hasPending = false; + } + rawLine.append(reinterpret_cast(pageBuffer.data() + pos), displayLen); + + if (!hasNewline && fileHasMore) { + if (!hasPending) { + pendingOffset = lineStartOffset; + } + pending = std::move(rawLine); + hasPending = true; + break; + } + + if (MdParser::isCodeFence(rawLine)) { + inCodeBlock = !inCodeBlock; + } + const MdParser::ParsedLine parsed = MdParser::parseLine(rawLine, inCodeBlock); + if (parsed.blockType == MdParser::BlockType::Header1 || parsed.blockType == MdParser::BlockType::Header2 || + parsed.blockType == MdParser::BlockType::Header3) { + int level = 1; + if (parsed.blockType == MdParser::BlockType::Header2) { + level = 2; + } else if (parsed.blockType == MdParser::BlockType::Header3) { + level = 3; + } + headings.push_back({lineStartOffset, level, flattenHeadingText(parsed)}); + } + + pos = hasNewline ? lineEnd + 1 : lineEnd; + } + + offset += toRead; + } + + if (hasPending) { + if (MdParser::isCodeFence(pending)) { + inCodeBlock = !inCodeBlock; + } + const MdParser::ParsedLine parsed = MdParser::parseLine(pending, inCodeBlock); + if (parsed.blockType == MdParser::BlockType::Header1 || parsed.blockType == MdParser::BlockType::Header2 || + parsed.blockType == MdParser::BlockType::Header3) { + int level = 1; + if (parsed.blockType == MdParser::BlockType::Header2) { + level = 2; + } else if (parsed.blockType == MdParser::BlockType::Header3) { + level = 3; + } + headings.push_back({pendingOffset, level, flattenHeadingText(parsed)}); + } + } +} + void MdReaderActivity::onExit() { Activity::onExit(); @@ -68,17 +232,40 @@ void MdReaderActivity::loop() { return; } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && !headings.empty()) { + currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]); + ReaderUtils::enforceExitFullRefresh(renderer); + startActivityForResult( + std::make_unique(renderer, mappedInput, headings, currentHeadingIndex), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + currentPage = std::get(result.data).page; + currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]); + requestUpdate(); + } + }); + return; + } + auto [prevTriggered, nextTriggered] = ReaderUtils::detectPageTurn(mappedInput); if (!prevTriggered && !nextTriggered) { return; } + const bool headingSkip = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > HEADING_SKIP_MS; + if (headingSkip && !headings.empty()) { + jumpToHeading(nextTriggered); + return; + } + if (prevTriggered && currentPage > 0) { currentPage--; + currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]); requestUpdate(); } else if (nextTriggered) { if (currentPage < totalPages - 1) { currentPage++; + currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]); requestUpdate(); } else { onGoHome(); @@ -107,6 +294,9 @@ void MdReaderActivity::initializeReader() { const int viewportHeight = renderer.getScreenHeight() - cachedOrientedMarginTop - cachedOrientedMarginBottom; const int lineHeight = renderer.getLineHeight(cachedFontId); + pageBuffer.reserve(CHUNK_SIZE + 1); + scanHeadings(); + linesPerPage = viewportHeight / lineHeight; if (linesPerPage < 1) linesPerPage = 1; @@ -116,6 +306,7 @@ void MdReaderActivity::initializeReader() { buildPageIndex(); savePageIndexCache(); } + assignHeadingPageNumbers(); loadProgress(); @@ -259,42 +450,57 @@ bool MdReaderActivity::loadPageAtOffset(size_t offset, bool startInCodeBlock, st return false; } - size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset); - auto* buffer = static_cast(malloc(chunkSize + 1)); - if (!buffer) { - LOG_ERR("MDR", "Failed to allocate %zu bytes", chunkSize); + size_t bufferSize = std::min(CHUNK_SIZE, fileSize - offset); + pageBuffer.resize(bufferSize + 1); + if (!txt->readContent(pageBuffer.data(), offset, bufferSize)) { return false; } - - if (!txt->readContent(buffer, offset, chunkSize)) { - free(buffer); - return false; - } - buffer[chunkSize] = '\0'; + pageBuffer[bufferSize] = '\0'; bool inCodeBlock = startInCodeBlock; size_t pos = 0; - while (pos < chunkSize && static_cast(outLines.size()) < linesPerPage) { - // Find end of line + while (pos < bufferSize && static_cast(outLines.size()) < linesPerPage) { + // Find end of line and extend the read buffer if we are at chunk boundary. size_t lineEnd = pos; - while (lineEnd < chunkSize && buffer[lineEnd] != '\n') { + while (lineEnd < bufferSize && pageBuffer[lineEnd] != '\n') { lineEnd++; } - // Check if we have a complete line - bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize); + while (lineEnd == bufferSize && offset + bufferSize < fileSize && bufferSize < MAX_LINE_LENGTH) { + size_t extra = std::min(CHUNK_SIZE, fileSize - offset - bufferSize); + if (bufferSize + extra > MAX_LINE_LENGTH) { + extra = MAX_LINE_LENGTH - bufferSize; + } + if (extra == 0) { + break; + } + pageBuffer.resize(bufferSize + extra + 1); + if (!txt->readContent(pageBuffer.data() + bufferSize, offset + bufferSize, extra)) { + return false; + } + bufferSize += extra; + pageBuffer[bufferSize] = '\0'; + + while (lineEnd < bufferSize && pageBuffer[lineEnd] != '\n') { + lineEnd++; + } + } + + const bool isAtBufferEnd = (lineEnd == bufferSize); + const bool isEOF = (offset + bufferSize >= fileSize); + const bool lineComplete = (lineEnd < bufferSize) || isEOF || (isAtBufferEnd && bufferSize >= MAX_LINE_LENGTH); if (!lineComplete && !outLines.empty()) { // Incomplete line at chunk boundary and we already have content — stop here break; } size_t lineContentLen = lineEnd - pos; - bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r'); + bool hasCR = (lineContentLen > 0 && pageBuffer[pos + lineContentLen - 1] == '\r'); size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen; - std::string rawLine(reinterpret_cast(buffer + pos), displayLen); + std::string rawLine(reinterpret_cast(pageBuffer.data() + pos), displayLen); // Check for code fence toggle bool wasFence = false; @@ -363,8 +569,6 @@ bool MdReaderActivity::loadPageAtOffset(size_t offset, bool startInCodeBlock, st } endInCodeBlock = inCodeBlock; - free(buffer); - return !outLines.empty(); } diff --git a/src/activities/reader/MdReaderTocSelectionActivity.cpp b/src/activities/reader/MdReaderTocSelectionActivity.cpp new file mode 100644 index 00000000..9a33ac7f --- /dev/null +++ b/src/activities/reader/MdReaderTocSelectionActivity.cpp @@ -0,0 +1,110 @@ +#include "MdReaderTocSelectionActivity.h" + +#include + +#include +#include + +#include "components/UITheme.h" +#include "fontIds.h" + +int MdReaderTocSelectionActivity::getTotalItems() const { return static_cast(headings.size()); } + +int MdReaderTocSelectionActivity::getPageItems() const { + constexpr int lineHeight = 30; + const Rect contentRect = UITheme::getContentRect(renderer, true, false); + const int startY = 60 + contentRect.y; + const int availableHeight = contentRect.y + contentRect.height - startY - lineHeight; + return std::max(1, availableHeight / lineHeight); +} + +void MdReaderTocSelectionActivity::onEnter() { + Activity::onEnter(); + + if (selectorIndex < 0 || selectorIndex >= getTotalItems()) { + selectorIndex = 0; + } + + requestUpdate(); +} + +void MdReaderTocSelectionActivity::onExit() { Activity::onExit(); } + +void MdReaderTocSelectionActivity::loop() { + const int pageItems = getPageItems(); + const int totalItems = getTotalItems(); + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (selectorIndex >= 0 && selectorIndex < totalItems) { + setResult(PageResult{static_cast(headings[selectorIndex].pageIndex)}); + } else { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + } + finish(); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); + return; + } + + buttonNavigator.onNextRelease([this, totalItems] { + selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems); + requestUpdate(); + }); + + buttonNavigator.onPreviousRelease([this, totalItems] { + selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems); + requestUpdate(); + }); + + buttonNavigator.onNextContinuous([this, totalItems, pageItems] { + selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems); + requestUpdate(); + }); + + buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] { + selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems); + requestUpdate(); + }); +} + +void MdReaderTocSelectionActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const Rect contentRect = UITheme::getContentRect(renderer, true, false); + const int pageItems = getPageItems(); + const int totalItems = getTotalItems(); + + const int titleX = + contentRect.x + + (contentRect.width - renderer.getTextWidth(UI_12_FONT_ID, tr(STR_SELECT_CHAPTER), EpdFontFamily::BOLD)) / 2; + renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentRect.y, tr(STR_SELECT_CHAPTER), true, EpdFontFamily::BOLD); + + const int pageStartIndex = selectorIndex / pageItems * pageItems; + renderer.fillRect(contentRect.x, 60 + contentRect.y + (selectorIndex % pageItems) * 30 - 2, contentRect.width - 1, + 30); + + for (int i = 0; i < pageItems; i++) { + int itemIndex = pageStartIndex + i; + if (itemIndex >= totalItems) break; + const int displayY = 60 + contentRect.y + i * 30; + const bool isSelected = (itemIndex == selectorIndex); + + const auto& heading = headings[itemIndex]; + const int indentSize = contentRect.x + 20 + (heading.level - 1) * 10; + const std::string title = renderer.truncatedText(UI_10_FONT_ID, heading.title.c_str(), contentRect.width - 40 - indentSize); + renderer.drawText(UI_10_FONT_ID, indentSize, displayY, title.c_str(), !isSelected); + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/reader/MdReaderTocSelectionActivity.h b/src/activities/reader/MdReaderTocSelectionActivity.h new file mode 100644 index 00000000..101591a9 --- /dev/null +++ b/src/activities/reader/MdReaderTocSelectionActivity.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include "MdReaderActivity.h" +#include "../Activity.h" +#include "util/ButtonNavigator.h" + +class MdReaderTocSelectionActivity final : public Activity { + std::vector headings; + ButtonNavigator buttonNavigator; + int selectorIndex = 0; + + int getPageItems() const; + int getTotalItems() const; + + public: + explicit MdReaderTocSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + std::vector headings, int currentHeadingIndex) + : Activity("MdReaderTocSelection", renderer, mappedInput), headings(std::move(headings)), selectorIndex(0) { + if (currentHeadingIndex >= 0 && currentHeadingIndex < static_cast(headings.size())) { + selectorIndex = currentHeadingIndex; + } + } + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; +}; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index fa976a0e..7e7f9325 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -167,7 +167,7 @@ void ReaderActivity::onEnter() { return; } onGoToXtcReader(std::move(xtc)); - } else if (isMdFile(initialBookPath)) { + } else if (isMDFile(initialBookPath)) { auto txt = loadTxt(initialBookPath); if (!txt) { onGoBack(); diff --git a/test/md_parser/MdParserTest.cpp b/test/md_parser/MdParserTest.cpp new file mode 100644 index 00000000..de1c9f34 --- /dev/null +++ b/test/md_parser/MdParserTest.cpp @@ -0,0 +1,92 @@ +#include +#include +#include + +#include "../../lib/Md/MdParser.h" + +static int testsPassed = 0; +static int testsFailed = 0; + +#define ASSERT_EQ(a, b) \ + do { \ + if ((a) != (b)) { \ + fprintf(stderr, " FAIL: %s:%d: %s != %s\n", __FILE__, __LINE__, #a, #b); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define PASS() testsPassed++ + +static std::string flattenText(const std::vector& spans) { + std::string result; + for (const auto& span : spans) { + result += span.text; + } + return result; +} + +static bool allRegular(const std::vector& spans) { + for (const auto& span : spans) { + if (span.style != EpdFontFamily::REGULAR) { + return false; + } + } + return true; +} + +void testSnakeCaseUnderscoresRemainLiteral() { + printf("testSnakeCaseUnderscoresRemainLiteral...\n"); + auto spans = MdParser::parseInline("foo_bar_baz"); + ASSERT_EQ(flattenText(spans), "foo_bar_baz"); + ASSERT_EQ(allRegular(spans), true); + PASS(); +} + +void testUnderscoreWithinExpressionRemainsLiteral() { + printf("testUnderscoreWithinExpressionRemainsLiteral...\n"); + auto spans = MdParser::parseInline("a_b + c_d"); + ASSERT_EQ(flattenText(spans), "a_b + c_d"); + ASSERT_EQ(allRegular(spans), true); + PASS(); +} + +void testUnderscoreEmphasisStillWorks() { + printf("testUnderscoreEmphasisStillWorks...\n"); + auto spans = MdParser::parseInline("foo _bar_ baz"); + ASSERT_EQ(flattenText(spans), "foo bar baz"); + ASSERT_EQ(spans.size(), 3); + ASSERT_EQ(spans[1].style == EpdFontFamily::ITALIC || spans[1].style == EpdFontFamily::BOLD_ITALIC, true); + PASS(); +} + +void testAsteriskEmphasisStillWorks() { + printf("testAsteriskEmphasisStillWorks...\n"); + auto spans = MdParser::parseInline("foo *bar* baz"); + ASSERT_EQ(flattenText(spans), "foo bar baz"); + ASSERT_EQ(spans.size(), 3); + ASSERT_EQ(spans[1].style == EpdFontFamily::ITALIC || spans[1].style == EpdFontFamily::BOLD_ITALIC, true); + PASS(); +} + +void testUnderscoreBoldWorks() { + printf("testUnderscoreBoldWorks...\n"); + auto spans = MdParser::parseInline("foo __bar__ baz"); + ASSERT_EQ(flattenText(spans), "foo bar baz"); + ASSERT_EQ(spans.size(), 3); + ASSERT_EQ(spans[1].style == EpdFontFamily::BOLD || spans[1].style == EpdFontFamily::BOLD_ITALIC, true); + PASS(); +} + +int main() { + printf("=== Markdown Parser Tests ===\n\n"); + + testSnakeCaseUnderscoresRemainLiteral(); + testUnderscoreWithinExpressionRemainsLiteral(); + testUnderscoreEmphasisStillWorks(); + testAsteriskEmphasisStillWorks(); + testUnderscoreBoldWorks(); + + printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed); + return testsFailed > 0 ? 1 : 0; +} From 35735b5175d3cb770f1801bf6086ac62ec22aa7d Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 23 Apr 2026 21:36:31 +0200 Subject: [PATCH 03/10] Add code style Co-authored-by: Copilot --- lib/Md/MdParser.cpp | 44 +++++++++++++++---- lib/Md/MdParser.h | 4 +- lib/Txt/Txt.cpp | 2 +- src/activities/reader/MDReaderActivity.h | 6 +-- src/activities/reader/MdReaderActivity.cpp | 15 +++++-- .../reader/MdReaderTocSelectionActivity.cpp | 7 +-- .../reader/MdReaderTocSelectionActivity.h | 4 +- src/activities/reader/ReaderActivity.cpp | 4 +- src/activities/reader/ReaderActivity.h | 2 +- 9 files changed, 61 insertions(+), 27 deletions(-) diff --git a/lib/Md/MdParser.cpp b/lib/Md/MdParser.cpp index a1780874..372d5e1f 100644 --- a/lib/Md/MdParser.cpp +++ b/lib/Md/MdParser.cpp @@ -11,6 +11,8 @@ static EpdFontFamily::Style combineFlags(bool bold, bool italic) { return EpdFontFamily::REGULAR; } +static constexpr int TAB_WIDTH = 4; + static std::string trimLeft(const std::string& s) { size_t i = 0; while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) i++; @@ -61,6 +63,9 @@ std::vector parseInline(const std::string& text) { } }; + char boldMarker = 0; + char italicMarker = 0; + while (i < text.size()) { char c = text[i]; @@ -74,7 +79,7 @@ std::vector parseInline(const std::string& text) { } } - // *** or ___ — toggle both bold and italic + // *** or ___ — toggle both bold and italic when marker matches the current open markers if ((c == '*' || c == '_') && i + 2 < text.size() && text[i + 1] == c && text[i + 2] == c) { if (c == '_' && !isUnderscoreEmphasis(text, i, 3)) { current.append(3, c); @@ -82,8 +87,17 @@ std::vector parseInline(const std::string& text) { continue; } emitSpan(); - bold = !bold; - italic = !italic; + if (bold && italic && boldMarker == c && italicMarker == c) { + bold = false; + italic = false; + boldMarker = 0; + italicMarker = 0; + } else { + bold = true; + italic = true; + boldMarker = c; + italicMarker = c; + } i += 3; continue; } @@ -96,7 +110,13 @@ std::vector parseInline(const std::string& text) { continue; } emitSpan(); - bold = !bold; + if (bold && boldMarker == c) { + bold = false; + boldMarker = 0; + } else { + bold = true; + boldMarker = c; + } i += 2; continue; } @@ -109,7 +129,13 @@ std::vector parseInline(const std::string& text) { continue; } emitSpan(); - italic = !italic; + if (italic && italicMarker == c) { + italic = false; + italicMarker = 0; + } else { + italic = true; + italicMarker = c; + } i += 1; continue; } @@ -190,9 +216,9 @@ static std::string handleTaskList(const std::string& content, std::string& listP if (content.size() >= 3 && content[0] == '[' && content[2] == ']') { char mark = content[1]; if (mark == 'x' || mark == 'X') { - listPrefix = "[x] "; + listPrefix = "☑ "; } else if (mark == ' ') { - listPrefix = "[ ] "; + listPrefix = "☐ "; } else { return content; // Not a checkbox — keep content as-is } @@ -229,11 +255,11 @@ ParsedLine parseLine(const std::string& rawLine, bool inCodeBlock) { if (rawLine[i] == ' ') leadingSpaces++; else if (rawLine[i] == '\t') - leadingSpaces += 2; // Treat tab as 2 spaces + leadingSpaces += TAB_WIDTH; // Treat tab as 4 spaces to match CommonMark nesting rules else break; } - result.indentLevel = static_cast(leadingSpaces / 2); + result.indentLevel = static_cast(leadingSpaces / TAB_WIDTH); std::string trimmed = trimLeft(rawLine); diff --git a/lib/Md/MdParser.h b/lib/Md/MdParser.h index 936db7c2..a4ba8ce5 100644 --- a/lib/Md/MdParser.h +++ b/lib/Md/MdParser.h @@ -27,10 +27,10 @@ enum class BlockType : uint8_t { }; struct ParsedLine { - BlockType blockType; + BlockType blockType = BlockType::Paragraph; std::vector spans; std::string listPrefix; // "• " or "1. " etc. - uint8_t indentLevel = 0; // Nesting depth (each 2 spaces = 1 level) + uint8_t indentLevel = 0; // Nesting depth (each 4 spaces = 1 level) }; // Parse a single raw line of markdown into block type and styled spans. diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp index 692e3850..81c4cb3c 100644 --- a/lib/Txt/Txt.cpp +++ b/lib/Txt/Txt.cpp @@ -40,7 +40,7 @@ std::string Txt::getTitle() const { size_t lastSlash = filepath.find_last_of('/'); std::string filename = (lastSlash != std::string::npos) ? filepath.substr(lastSlash + 1) : filepath; - // Remove .txt or .mdextension + // Remove .txt or .md extension if (FsHelpers::hasTxtExtension(filename)) { filename = filename.substr(0, filename.length() - 4); } else if (FsHelpers::hasMarkdownExtension(filename)) { diff --git a/src/activities/reader/MDReaderActivity.h b/src/activities/reader/MDReaderActivity.h index 7fa9215d..604fc532 100644 --- a/src/activities/reader/MDReaderActivity.h +++ b/src/activities/reader/MDReaderActivity.h @@ -27,6 +27,7 @@ class MdReaderActivity final : public Activity { std::vector spans; int indent = 0; // left indent in pixels bool isHR = false; // draw as horizontal rule + bool isCodeBlock = false; }; // Streaming reader state @@ -71,14 +72,11 @@ class MdReaderActivity final : public Activity { void assignHeadingPageNumbers(); int getHeadingIndexForOffset(size_t offset) const; void jumpToHeading(bool next); - void scanHeadings(); - int getHeadingIndexForOffset(size_t offset) const; - void jumpToHeading(bool next); // Word-wrap a parsed markdown line into one or more RenderedLines. // Returns true if all content was emitted, false if truncated by maxLines. bool wordWrapParsedLine(const MdParser::ParsedLine& parsed, int indent, std::vector& outLines, - int maxLines); + int maxLines, bool isCodeBlock = false); // Measure total pixel width of a span list int measureSpans(const std::vector& spans) const; diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index b4830ebc..66b005fb 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -320,7 +320,7 @@ int MdReaderActivity::measureSpans(const std::vector& spans) con } bool MdReaderActivity::wordWrapParsedLine(const MdParser::ParsedLine& parsed, int indent, - std::vector& outLines, int maxLines) { + std::vector& outLines, int maxLines, bool isCodeBlock) { const size_t startSize = outLines.size(); if (parsed.spans.empty()) { @@ -347,6 +347,7 @@ bool MdReaderActivity::wordWrapParsedLine(const MdParser::ParsedLine& parsed, in RenderedLine rl; rl.spans = std::move(allSpans); rl.indent = indent; + rl.isCodeBlock = isCodeBlock; outLines.push_back(std::move(rl)); return true; } @@ -354,6 +355,7 @@ bool MdReaderActivity::wordWrapParsedLine(const MdParser::ParsedLine& parsed, in // Word-wrap across spans RenderedLine currentLine; currentLine.indent = indent; + currentLine.isCodeBlock = isCodeBlock; int currentWidth = 0; bool fullyConsumed = true; @@ -423,6 +425,7 @@ bool MdReaderActivity::wordWrapParsedLine(const MdParser::ParsedLine& parsed, in outLines.push_back(std::move(currentLine)); currentLine = RenderedLine(); currentLine.indent = indent; + currentLine.isCodeBlock = isCodeBlock; currentWidth = 0; size_t skip = breakPos; @@ -539,7 +542,8 @@ bool MdReaderActivity::loadPageAtOffset(size_t offset, bool startInCodeBlock, st if (!wasFence) { size_t linesBefore = outLines.size(); int remainingLines = linesPerPage - static_cast(outLines.size()); - bool fullyConsumed = wordWrapParsedLine(parsed, indent, outLines, remainingLines); + bool fullyConsumed = wordWrapParsedLine(parsed, indent, outLines, remainingLines, + parsed.blockType == MdParser::BlockType::CodeBlock); if (!fullyConsumed) { if (linesBefore > 0) { @@ -660,7 +664,12 @@ void MdReaderActivity::renderPage() { // Draw horizontal rule as a thin line int hrY = y + lineHeight / 2; renderer.drawLine(cachedOrientedMarginLeft + line.indent, hrY, cachedOrientedMarginLeft + viewportWidth, hrY); - } else if (!line.spans.empty()) { + } else { + if (line.isCodeBlock) { + const int barX = cachedOrientedMarginLeft + std::max(line.indent - 6, 0); + renderer.drawLine(barX, y + 2, barX, y + lineHeight - 2); + } + if (!line.spans.empty()) { int x = cachedOrientedMarginLeft + line.indent; // Apply text alignment for non-indented lines diff --git a/src/activities/reader/MdReaderTocSelectionActivity.cpp b/src/activities/reader/MdReaderTocSelectionActivity.cpp index 9a33ac7f..79c2ab59 100644 --- a/src/activities/reader/MdReaderTocSelectionActivity.cpp +++ b/src/activities/reader/MdReaderTocSelectionActivity.cpp @@ -1,10 +1,10 @@ #include "MdReaderTocSelectionActivity.h" -#include - #include #include +#include + #include "components/UITheme.h" #include "fontIds.h" @@ -99,7 +99,8 @@ void MdReaderTocSelectionActivity::render(RenderLock&&) { const auto& heading = headings[itemIndex]; const int indentSize = contentRect.x + 20 + (heading.level - 1) * 10; - const std::string title = renderer.truncatedText(UI_10_FONT_ID, heading.title.c_str(), contentRect.width - 40 - indentSize); + const std::string title = + renderer.truncatedText(UI_10_FONT_ID, heading.title.c_str(), contentRect.width - 40 - indentSize); renderer.drawText(UI_10_FONT_ID, indentSize, displayY, title.c_str(), !isSelected); } diff --git a/src/activities/reader/MdReaderTocSelectionActivity.h b/src/activities/reader/MdReaderTocSelectionActivity.h index 101591a9..0d8bc66c 100644 --- a/src/activities/reader/MdReaderTocSelectionActivity.h +++ b/src/activities/reader/MdReaderTocSelectionActivity.h @@ -2,8 +2,8 @@ #include -#include "MdReaderActivity.h" #include "../Activity.h" +#include "MdReaderActivity.h" #include "util/ButtonNavigator.h" class MdReaderTocSelectionActivity final : public Activity { @@ -16,7 +16,7 @@ class MdReaderTocSelectionActivity final : public Activity { public: explicit MdReaderTocSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - std::vector headings, int currentHeadingIndex) + std::vector headings, int currentHeadingIndex) : Activity("MdReaderTocSelection", renderer, mappedInput), headings(std::move(headings)), selectorIndex(0) { if (currentHeadingIndex >= 0 && currentHeadingIndex < static_cast(headings.size())) { selectorIndex = currentHeadingIndex; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 7e7f9325..06a5c05a 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -49,7 +49,7 @@ bool ReaderActivity::isXtcFile(const std::string& path) { return FsHelpers::hasX bool ReaderActivity::isTxtFile(const std::string& path) { return FsHelpers::hasTxtExtension(path); } -bool ReaderActivity::isMDFile(const std::string& path) { return FsHelpers::hasMarkdownExtension(path); } +bool ReaderActivity::isMdFile(const std::string& path) { return FsHelpers::hasMarkdownExtension(path); } bool ReaderActivity::isImageFile(const std::string& path) { return FsHelpers::hasBmpExtension(path) || FsHelpers::hasJpgExtension(path) || FsHelpers::hasPngExtension(path); @@ -167,7 +167,7 @@ void ReaderActivity::onEnter() { return; } onGoToXtcReader(std::move(xtc)); - } else if (isMDFile(initialBookPath)) { + } else if (isMdFile(initialBookPath)) { auto txt = loadTxt(initialBookPath); if (!txt) { onGoBack(); diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index 51258ad1..0292901f 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -16,7 +16,7 @@ class ReaderActivity final : public Activity { static std::unique_ptr loadTxt(const std::string& path); static bool isXtcFile(const std::string& path); static bool isTxtFile(const std::string& path); - static bool isMDFile(const std::string& path); + static bool isMdFile(const std::string& path); static bool isImageFile(const std::string& path); static std::string extractFolderPath(const std::string& filePath); From cf376df34c2bd30518eb22937eef3717815584b1 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 23 Apr 2026 21:37:52 +0200 Subject: [PATCH 04/10] Rename --- src/activities/reader/{MDReaderActivity.h => MdReaderActivity.h} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/activities/reader/{MDReaderActivity.h => MdReaderActivity.h} (100%) diff --git a/src/activities/reader/MDReaderActivity.h b/src/activities/reader/MdReaderActivity.h similarity index 100% rename from src/activities/reader/MDReaderActivity.h rename to src/activities/reader/MdReaderActivity.h From c2d51d677243355f044f66fb6c7cfdb5ecfc107b Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 23 Apr 2026 21:39:27 +0200 Subject: [PATCH 05/10] clang --- src/activities/reader/MdReaderActivity.cpp | 401 +++++++++++---------- 1 file changed, 202 insertions(+), 199 deletions(-) diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 66b005fb..189589a0 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -543,7 +543,7 @@ bool MdReaderActivity::loadPageAtOffset(size_t offset, bool startInCodeBlock, st size_t linesBefore = outLines.size(); int remainingLines = linesPerPage - static_cast(outLines.size()); bool fullyConsumed = wordWrapParsedLine(parsed, indent, outLines, remainingLines, - parsed.blockType == MdParser::BlockType::CodeBlock); + parsed.blockType == MdParser::BlockType::CodeBlock); if (!fullyConsumed) { if (linesBefore > 0) { @@ -657,219 +657,222 @@ void MdReaderActivity::render(RenderLock&&) { void MdReaderActivity::renderPage() { const int lineHeight = renderer.getLineHeight(cachedFontId); - auto renderLines = [&]() { - int y = cachedOrientedMarginTop; - for (const auto& line : currentPageLines) { - if (line.isHR) { - // Draw horizontal rule as a thin line - int hrY = y + lineHeight / 2; - renderer.drawLine(cachedOrientedMarginLeft + line.indent, hrY, cachedOrientedMarginLeft + viewportWidth, hrY); - } else { - if (line.isCodeBlock) { - const int barX = cachedOrientedMarginLeft + std::max(line.indent - 6, 0); - renderer.drawLine(barX, y + 2, barX, y + lineHeight - 2); - } - if (!line.spans.empty()) { - int x = cachedOrientedMarginLeft + line.indent; - - // Apply text alignment for non-indented lines - if (line.indent == 0) { - int contentWidth = viewportWidth; - switch (cachedParagraphAlignment) { - case CrossPointSettings::CENTER_ALIGN: { - x = cachedOrientedMarginLeft + (contentWidth - measureSpans(line.spans)) / 2; - break; + auto renderLines = + [&]() { + int y = cachedOrientedMarginTop; + for (const auto& line : currentPageLines) { + if (line.isHR) { + // Draw horizontal rule as a thin line + int hrY = y + lineHeight / 2; + renderer.drawLine(cachedOrientedMarginLeft + line.indent, hrY, cachedOrientedMarginLeft + viewportWidth, + hrY); + } else { + if (line.isCodeBlock) { + const int barX = cachedOrientedMarginLeft + std::max(line.indent - 6, 0); + renderer.drawLine(barX, y + 2, barX, y + lineHeight - 2); } - case CrossPointSettings::RIGHT_ALIGN: { - x = cachedOrientedMarginLeft + contentWidth - measureSpans(line.spans); - break; - } - default: - break; - } - } + if (!line.spans.empty()) { + int x = cachedOrientedMarginLeft + line.indent; - // Render each span - for (const auto& span : line.spans) { - if (!span.text.empty()) { - renderer.drawText(cachedFontId, x, y, span.text.c_str(), true, span.style); - x += renderer.getTextAdvanceX(cachedFontId, span.text.c_str(), span.style); + // Apply text alignment for non-indented lines + if (line.indent == 0) { + int contentWidth = viewportWidth; + switch (cachedParagraphAlignment) { + case CrossPointSettings::CENTER_ALIGN: { + x = cachedOrientedMarginLeft + (contentWidth - measureSpans(line.spans)) / 2; + break; + } + case CrossPointSettings::RIGHT_ALIGN: { + x = cachedOrientedMarginLeft + contentWidth - measureSpans(line.spans); + break; + } + default: + break; + } + } + + // Render each span + for (const auto& span : line.spans) { + if (!span.text.empty()) { + renderer.drawText(cachedFontId, x, y, span.text.c_str(), true, span.style); + x += renderer.getTextAdvanceX(cachedFontId, span.text.c_str(), span.style); + } + } + } + y += lineHeight; } + }; + + // Font prewarm: scan pass accumulates text, then prewarm, then real render + auto* fcm = renderer.getFontCacheManager(); + auto scope = fcm->createPrewarmScope(); + renderLines(); + scope.endScanAndPrewarm(); + + // BW rendering + renderLines(); + renderStatusBar(); + + ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); + + if (SETTINGS.textAntiAliasing) { + ReaderUtils::renderAntiAliased(renderer, [&renderLines]() { renderLines(); }); } } - y += lineHeight; + + void + MdReaderActivity::renderStatusBar() const { + const float progress = totalPages > 0 ? (currentPage + 1) * 100.0f / totalPages : 0; + std::string title; + if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) { + title = txt->getTitle(); } - }; - - // Font prewarm: scan pass accumulates text, then prewarm, then real render - auto* fcm = renderer.getFontCacheManager(); - auto scope = fcm->createPrewarmScope(); - renderLines(); - scope.endScanAndPrewarm(); - - // BW rendering - renderLines(); - renderStatusBar(); - - ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); - - if (SETTINGS.textAntiAliasing) { - ReaderUtils::renderAntiAliased(renderer, [&renderLines]() { renderLines(); }); + GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title); } -} -void MdReaderActivity::renderStatusBar() const { - const float progress = totalPages > 0 ? (currentPage + 1) * 100.0f / totalPages : 0; - std::string title; - if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) { - title = txt->getTitle(); - } - GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title); -} - -void MdReaderActivity::saveProgress() const { - FsFile f; - if (Storage.openFileForWrite("MDR", txt->getCachePath() + "/progress.bin", f)) { - uint8_t data[4]; - data[0] = currentPage & 0xFF; - data[1] = (currentPage >> 8) & 0xFF; - data[2] = 0; - data[3] = 0; - f.write(data, 4); - } -} - -void MdReaderActivity::loadProgress() { - FsFile f; - if (Storage.openFileForRead("MDR", txt->getCachePath() + "/progress.bin", f)) { - uint8_t data[4]; - if (f.read(data, 4) == 4) { - currentPage = data[0] + (data[1] << 8); - if (currentPage >= totalPages) { - currentPage = totalPages - 1; - } - if (currentPage < 0) { - currentPage = 0; - } - LOG_DBG("MDR", "Loaded progress: page %d/%d", currentPage, totalPages); + void MdReaderActivity::saveProgress() const { + FsFile f; + if (Storage.openFileForWrite("MDR", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + data[0] = currentPage & 0xFF; + data[1] = (currentPage >> 8) & 0xFF; + data[2] = 0; + data[3] = 0; + f.write(data, 4); } } -} -bool MdReaderActivity::loadPageIndexCache() { - std::string cachePath = txt->getCachePath() + "/index.bin"; - FsFile f; - if (!Storage.openFileForRead("MDR", cachePath, f)) { - LOG_DBG("MDR", "No page index cache found"); - return false; + void MdReaderActivity::loadProgress() { + FsFile f; + if (Storage.openFileForRead("MDR", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + if (f.read(data, 4) == 4) { + currentPage = data[0] + (data[1] << 8); + if (currentPage >= totalPages) { + currentPage = totalPages - 1; + } + if (currentPage < 0) { + currentPage = 0; + } + LOG_DBG("MDR", "Loaded progress: page %d/%d", currentPage, totalPages); + } + } } - uint32_t magic; - serialization::readPod(f, magic); - if (magic != CACHE_MAGIC) { - LOG_DBG("MDR", "Cache magic mismatch, rebuilding"); - return false; + bool MdReaderActivity::loadPageIndexCache() { + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!Storage.openFileForRead("MDR", cachePath, f)) { + LOG_DBG("MDR", "No page index cache found"); + return false; + } + + uint32_t magic; + serialization::readPod(f, magic); + if (magic != CACHE_MAGIC) { + LOG_DBG("MDR", "Cache magic mismatch, rebuilding"); + return false; + } + + uint8_t version; + serialization::readPod(f, version); + if (version != CACHE_VERSION) { + LOG_DBG("MDR", "Cache version mismatch (%d != %d), rebuilding", version, CACHE_VERSION); + return false; + } + + uint32_t fileSize; + serialization::readPod(f, fileSize); + if (fileSize != txt->getFileSize()) { + LOG_DBG("MDR", "Cache file size mismatch, rebuilding"); + return false; + } + + int32_t cachedWidth; + serialization::readPod(f, cachedWidth); + if (cachedWidth != viewportWidth) { + LOG_DBG("MDR", "Cache viewport width mismatch, rebuilding"); + return false; + } + + int32_t cachedLines; + serialization::readPod(f, cachedLines); + if (cachedLines != linesPerPage) { + LOG_DBG("MDR", "Cache lines per page mismatch, rebuilding"); + return false; + } + + int32_t fontId; + serialization::readPod(f, fontId); + if (fontId != cachedFontId) { + LOG_DBG("MDR", "Cache font ID mismatch, rebuilding"); + return false; + } + + int32_t margin; + serialization::readPod(f, margin); + if (margin != cachedScreenMargin) { + LOG_DBG("MDR", "Cache screen margin mismatch, rebuilding"); + return false; + } + + uint8_t alignment; + serialization::readPod(f, alignment); + if (alignment != cachedParagraphAlignment) { + LOG_DBG("MDR", "Cache paragraph alignment mismatch, rebuilding"); + return false; + } + + uint32_t numPages; + serialization::readPod(f, numPages); + + pageOffsets.clear(); + // Sanity check: reject corrupt cache with absurd page count + if (numPages == 0 || numPages > 100000) { + LOG_DBG("MDR", "Cache page count out of range (%u), rebuilding", numPages); + return false; + } + + pageOffsets.reserve(numPages); + pageCodeBlockState.clear(); + pageCodeBlockState.reserve(numPages); + + for (uint32_t i = 0; i < numPages; i++) { + uint32_t pageOffset; + serialization::readPod(f, pageOffset); + uint8_t codeState; + serialization::readPod(f, codeState); + pageOffsets.push_back(pageOffset); + pageCodeBlockState.push_back(codeState); + } + + totalPages = pageOffsets.size(); + LOG_DBG("MDR", "Loaded page index cache: %d pages", totalPages); + return true; } - uint8_t version; - serialization::readPod(f, version); - if (version != CACHE_VERSION) { - LOG_DBG("MDR", "Cache version mismatch (%d != %d), rebuilding", version, CACHE_VERSION); - return false; - } + void MdReaderActivity::savePageIndexCache() const { + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!Storage.openFileForWrite("MDR", cachePath, f)) { + LOG_ERR("MDR", "Failed to save page index cache"); + return; + } - uint32_t fileSize; - serialization::readPod(f, fileSize); - if (fileSize != txt->getFileSize()) { - LOG_DBG("MDR", "Cache file size mismatch, rebuilding"); - return false; - } + serialization::writePod(f, CACHE_MAGIC); + serialization::writePod(f, CACHE_VERSION); + serialization::writePod(f, static_cast(txt->getFileSize())); + serialization::writePod(f, static_cast(viewportWidth)); + serialization::writePod(f, static_cast(linesPerPage)); + serialization::writePod(f, static_cast(cachedFontId)); + serialization::writePod(f, static_cast(cachedScreenMargin)); + serialization::writePod(f, cachedParagraphAlignment); + serialization::writePod(f, static_cast(pageOffsets.size())); - int32_t cachedWidth; - serialization::readPod(f, cachedWidth); - if (cachedWidth != viewportWidth) { - LOG_DBG("MDR", "Cache viewport width mismatch, rebuilding"); - return false; - } + for (size_t i = 0; i < pageOffsets.size(); i++) { + serialization::writePod(f, static_cast(pageOffsets[i])); + serialization::writePod(f, pageCodeBlockState[i]); + } - int32_t cachedLines; - serialization::readPod(f, cachedLines); - if (cachedLines != linesPerPage) { - LOG_DBG("MDR", "Cache lines per page mismatch, rebuilding"); - return false; - } - - int32_t fontId; - serialization::readPod(f, fontId); - if (fontId != cachedFontId) { - LOG_DBG("MDR", "Cache font ID mismatch, rebuilding"); - return false; - } - - int32_t margin; - serialization::readPod(f, margin); - if (margin != cachedScreenMargin) { - LOG_DBG("MDR", "Cache screen margin mismatch, rebuilding"); - return false; - } - - uint8_t alignment; - serialization::readPod(f, alignment); - if (alignment != cachedParagraphAlignment) { - LOG_DBG("MDR", "Cache paragraph alignment mismatch, rebuilding"); - return false; - } - - uint32_t numPages; - serialization::readPod(f, numPages); - - pageOffsets.clear(); - // Sanity check: reject corrupt cache with absurd page count - if (numPages == 0 || numPages > 100000) { - LOG_DBG("MDR", "Cache page count out of range (%u), rebuilding", numPages); - return false; - } - - pageOffsets.reserve(numPages); - pageCodeBlockState.clear(); - pageCodeBlockState.reserve(numPages); - - for (uint32_t i = 0; i < numPages; i++) { - uint32_t pageOffset; - serialization::readPod(f, pageOffset); - uint8_t codeState; - serialization::readPod(f, codeState); - pageOffsets.push_back(pageOffset); - pageCodeBlockState.push_back(codeState); - } - - totalPages = pageOffsets.size(); - LOG_DBG("MDR", "Loaded page index cache: %d pages", totalPages); - return true; -} - -void MdReaderActivity::savePageIndexCache() const { - std::string cachePath = txt->getCachePath() + "/index.bin"; - FsFile f; - if (!Storage.openFileForWrite("MDR", cachePath, f)) { - LOG_ERR("MDR", "Failed to save page index cache"); - return; - } - - serialization::writePod(f, CACHE_MAGIC); - serialization::writePod(f, CACHE_VERSION); - serialization::writePod(f, static_cast(txt->getFileSize())); - serialization::writePod(f, static_cast(viewportWidth)); - serialization::writePod(f, static_cast(linesPerPage)); - serialization::writePod(f, static_cast(cachedFontId)); - serialization::writePod(f, static_cast(cachedScreenMargin)); - serialization::writePod(f, cachedParagraphAlignment); - serialization::writePod(f, static_cast(pageOffsets.size())); - - for (size_t i = 0; i < pageOffsets.size(); i++) { - serialization::writePod(f, static_cast(pageOffsets[i])); - serialization::writePod(f, pageCodeBlockState[i]); - } - - LOG_DBG("MDR", "Saved page index cache: %d pages", totalPages); -} \ No newline at end of file + LOG_DBG("MDR", "Saved page index cache: %d pages", totalPages); + } \ No newline at end of file From e0c42278ad54dc32bc9c981ce6aa3e95fd79bfdb Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 23 Apr 2026 21:51:22 +0200 Subject: [PATCH 06/10] Stupid fix Co-authored-by: Copilot --- src/activities/reader/MdReaderActivity.cpp | 405 ++++++++++----------- 1 file changed, 202 insertions(+), 203 deletions(-) diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 189589a0..1e846371 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include "CrossPointSettings.h" @@ -657,222 +658,220 @@ void MdReaderActivity::render(RenderLock&&) { void MdReaderActivity::renderPage() { const int lineHeight = renderer.getLineHeight(cachedFontId); - auto renderLines = - [&]() { - int y = cachedOrientedMarginTop; - for (const auto& line : currentPageLines) { - if (line.isHR) { - // Draw horizontal rule as a thin line - int hrY = y + lineHeight / 2; - renderer.drawLine(cachedOrientedMarginLeft + line.indent, hrY, cachedOrientedMarginLeft + viewportWidth, - hrY); - } else { - if (line.isCodeBlock) { - const int barX = cachedOrientedMarginLeft + std::max(line.indent - 6, 0); - renderer.drawLine(barX, y + 2, barX, y + lineHeight - 2); - } - if (!line.spans.empty()) { - int x = cachedOrientedMarginLeft + line.indent; + std::function renderLines = [&]() { + int y = cachedOrientedMarginTop; + for (const auto& line : currentPageLines) { + if (line.isHR) { + // Draw horizontal rule as a thin line + int hrY = y + lineHeight / 2; + renderer.drawLine(cachedOrientedMarginLeft + line.indent, hrY, cachedOrientedMarginLeft + viewportWidth, hrY); + } else { + if (line.isCodeBlock) { + const int barX = cachedOrientedMarginLeft + std::max(line.indent - 6, 0); + renderer.drawLine(barX, y + 2, barX, y + lineHeight - 2); + } + if (!line.spans.empty()) { + int x = cachedOrientedMarginLeft + line.indent; - // Apply text alignment for non-indented lines - if (line.indent == 0) { - int contentWidth = viewportWidth; - switch (cachedParagraphAlignment) { - case CrossPointSettings::CENTER_ALIGN: { - x = cachedOrientedMarginLeft + (contentWidth - measureSpans(line.spans)) / 2; - break; - } - case CrossPointSettings::RIGHT_ALIGN: { - x = cachedOrientedMarginLeft + contentWidth - measureSpans(line.spans); - break; - } - default: - break; - } + // Apply text alignment for non-indented lines + if (line.indent == 0) { + int contentWidth = viewportWidth; + switch (cachedParagraphAlignment) { + case CrossPointSettings::CENTER_ALIGN: { + x = cachedOrientedMarginLeft + (contentWidth - measureSpans(line.spans)) / 2; + break; } - - // Render each span - for (const auto& span : line.spans) { - if (!span.text.empty()) { - renderer.drawText(cachedFontId, x, y, span.text.c_str(), true, span.style); - x += renderer.getTextAdvanceX(cachedFontId, span.text.c_str(), span.style); - } + case CrossPointSettings::RIGHT_ALIGN: { + x = cachedOrientedMarginLeft + contentWidth - measureSpans(line.spans); + break; } + default: + break; } - y += lineHeight; } - }; - // Font prewarm: scan pass accumulates text, then prewarm, then real render - auto* fcm = renderer.getFontCacheManager(); - auto scope = fcm->createPrewarmScope(); - renderLines(); - scope.endScanAndPrewarm(); - - // BW rendering - renderLines(); - renderStatusBar(); - - ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); - - if (SETTINGS.textAntiAliasing) { - ReaderUtils::renderAntiAliased(renderer, [&renderLines]() { renderLines(); }); + // Render each span + for (const auto& span : line.spans) { + if (!span.text.empty()) { + renderer.drawText(cachedFontId, x, y, span.text.c_str(), true, span.style); + x += renderer.getTextAdvanceX(cachedFontId, span.text.c_str(), span.style); + } + } } - } - - void - MdReaderActivity::renderStatusBar() const { - const float progress = totalPages > 0 ? (currentPage + 1) * 100.0f / totalPages : 0; - std::string title; - if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) { - title = txt->getTitle(); - } - GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title); - } - - void MdReaderActivity::saveProgress() const { - FsFile f; - if (Storage.openFileForWrite("MDR", txt->getCachePath() + "/progress.bin", f)) { - uint8_t data[4]; - data[0] = currentPage & 0xFF; - data[1] = (currentPage >> 8) & 0xFF; - data[2] = 0; - data[3] = 0; - f.write(data, 4); - } - } - - void MdReaderActivity::loadProgress() { - FsFile f; - if (Storage.openFileForRead("MDR", txt->getCachePath() + "/progress.bin", f)) { - uint8_t data[4]; - if (f.read(data, 4) == 4) { - currentPage = data[0] + (data[1] << 8); - if (currentPage >= totalPages) { - currentPage = totalPages - 1; - } - if (currentPage < 0) { - currentPage = 0; - } - LOG_DBG("MDR", "Loaded progress: page %d/%d", currentPage, totalPages); + y += lineHeight; } } + }; + + // Font prewarm: scan pass accumulates text, then prewarm, then real render + auto* fcm = renderer.getFontCacheManager(); + auto scope = fcm->createPrewarmScope(); + renderLines(); + scope.endScanAndPrewarm(); + + // BW rendering + renderLines(); + renderStatusBar(); + + ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); + + if (SETTINGS.textAntiAliasing) { + ReaderUtils::renderAntiAliased(renderer, [&]() { renderLines(); }); + } +} + +void MdReaderActivity::renderStatusBar() const { + const float progress = totalPages > 0 ? (currentPage + 1) * 100.0f / totalPages : 0; + std::string title; + if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) { + title = txt->getTitle(); + } + GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title); +} + +void MdReaderActivity::saveProgress() const { + FsFile f; + if (Storage.openFileForWrite("MDR", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + data[0] = currentPage & 0xFF; + data[1] = (currentPage >> 8) & 0xFF; + data[2] = 0; + data[3] = 0; + f.write(data, 4); + } +} + +void MdReaderActivity::loadProgress() { + FsFile f; + if (Storage.openFileForRead("MDR", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + if (f.read(data, 4) == 4) { + currentPage = data[0] + (data[1] << 8); + if (currentPage >= totalPages) { + currentPage = totalPages - 1; + } + if (currentPage < 0) { + currentPage = 0; + } + LOG_DBG("MDR", "Loaded progress: page %d/%d", currentPage, totalPages); + } + } +} + +bool MdReaderActivity::loadPageIndexCache() { + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!Storage.openFileForRead("MDR", cachePath, f)) { + LOG_DBG("MDR", "No page index cache found"); + return false; } - bool MdReaderActivity::loadPageIndexCache() { - std::string cachePath = txt->getCachePath() + "/index.bin"; - FsFile f; - if (!Storage.openFileForRead("MDR", cachePath, f)) { - LOG_DBG("MDR", "No page index cache found"); - return false; - } - - uint32_t magic; - serialization::readPod(f, magic); - if (magic != CACHE_MAGIC) { - LOG_DBG("MDR", "Cache magic mismatch, rebuilding"); - return false; - } - - uint8_t version; - serialization::readPod(f, version); - if (version != CACHE_VERSION) { - LOG_DBG("MDR", "Cache version mismatch (%d != %d), rebuilding", version, CACHE_VERSION); - return false; - } - - uint32_t fileSize; - serialization::readPod(f, fileSize); - if (fileSize != txt->getFileSize()) { - LOG_DBG("MDR", "Cache file size mismatch, rebuilding"); - return false; - } - - int32_t cachedWidth; - serialization::readPod(f, cachedWidth); - if (cachedWidth != viewportWidth) { - LOG_DBG("MDR", "Cache viewport width mismatch, rebuilding"); - return false; - } - - int32_t cachedLines; - serialization::readPod(f, cachedLines); - if (cachedLines != linesPerPage) { - LOG_DBG("MDR", "Cache lines per page mismatch, rebuilding"); - return false; - } - - int32_t fontId; - serialization::readPod(f, fontId); - if (fontId != cachedFontId) { - LOG_DBG("MDR", "Cache font ID mismatch, rebuilding"); - return false; - } - - int32_t margin; - serialization::readPod(f, margin); - if (margin != cachedScreenMargin) { - LOG_DBG("MDR", "Cache screen margin mismatch, rebuilding"); - return false; - } - - uint8_t alignment; - serialization::readPod(f, alignment); - if (alignment != cachedParagraphAlignment) { - LOG_DBG("MDR", "Cache paragraph alignment mismatch, rebuilding"); - return false; - } - - uint32_t numPages; - serialization::readPod(f, numPages); - - pageOffsets.clear(); - // Sanity check: reject corrupt cache with absurd page count - if (numPages == 0 || numPages > 100000) { - LOG_DBG("MDR", "Cache page count out of range (%u), rebuilding", numPages); - return false; - } - - pageOffsets.reserve(numPages); - pageCodeBlockState.clear(); - pageCodeBlockState.reserve(numPages); - - for (uint32_t i = 0; i < numPages; i++) { - uint32_t pageOffset; - serialization::readPod(f, pageOffset); - uint8_t codeState; - serialization::readPod(f, codeState); - pageOffsets.push_back(pageOffset); - pageCodeBlockState.push_back(codeState); - } - - totalPages = pageOffsets.size(); - LOG_DBG("MDR", "Loaded page index cache: %d pages", totalPages); - return true; + uint32_t magic; + serialization::readPod(f, magic); + if (magic != CACHE_MAGIC) { + LOG_DBG("MDR", "Cache magic mismatch, rebuilding"); + return false; } - void MdReaderActivity::savePageIndexCache() const { - std::string cachePath = txt->getCachePath() + "/index.bin"; - FsFile f; - if (!Storage.openFileForWrite("MDR", cachePath, f)) { - LOG_ERR("MDR", "Failed to save page index cache"); - return; - } + uint8_t version; + serialization::readPod(f, version); + if (version != CACHE_VERSION) { + LOG_DBG("MDR", "Cache version mismatch (%d != %d), rebuilding", version, CACHE_VERSION); + return false; + } - serialization::writePod(f, CACHE_MAGIC); - serialization::writePod(f, CACHE_VERSION); - serialization::writePod(f, static_cast(txt->getFileSize())); - serialization::writePod(f, static_cast(viewportWidth)); - serialization::writePod(f, static_cast(linesPerPage)); - serialization::writePod(f, static_cast(cachedFontId)); - serialization::writePod(f, static_cast(cachedScreenMargin)); - serialization::writePod(f, cachedParagraphAlignment); - serialization::writePod(f, static_cast(pageOffsets.size())); + uint32_t fileSize; + serialization::readPod(f, fileSize); + if (fileSize != txt->getFileSize()) { + LOG_DBG("MDR", "Cache file size mismatch, rebuilding"); + return false; + } - for (size_t i = 0; i < pageOffsets.size(); i++) { - serialization::writePod(f, static_cast(pageOffsets[i])); - serialization::writePod(f, pageCodeBlockState[i]); - } + int32_t cachedWidth; + serialization::readPod(f, cachedWidth); + if (cachedWidth != viewportWidth) { + LOG_DBG("MDR", "Cache viewport width mismatch, rebuilding"); + return false; + } - LOG_DBG("MDR", "Saved page index cache: %d pages", totalPages); - } \ No newline at end of file + int32_t cachedLines; + serialization::readPod(f, cachedLines); + if (cachedLines != linesPerPage) { + LOG_DBG("MDR", "Cache lines per page mismatch, rebuilding"); + return false; + } + + int32_t fontId; + serialization::readPod(f, fontId); + if (fontId != cachedFontId) { + LOG_DBG("MDR", "Cache font ID mismatch, rebuilding"); + return false; + } + + int32_t margin; + serialization::readPod(f, margin); + if (margin != cachedScreenMargin) { + LOG_DBG("MDR", "Cache screen margin mismatch, rebuilding"); + return false; + } + + uint8_t alignment; + serialization::readPod(f, alignment); + if (alignment != cachedParagraphAlignment) { + LOG_DBG("MDR", "Cache paragraph alignment mismatch, rebuilding"); + return false; + } + + uint32_t numPages; + serialization::readPod(f, numPages); + + pageOffsets.clear(); + // Sanity check: reject corrupt cache with absurd page count + if (numPages == 0 || numPages > 100000) { + LOG_DBG("MDR", "Cache page count out of range (%u), rebuilding", numPages); + return false; + } + + pageOffsets.reserve(numPages); + pageCodeBlockState.clear(); + pageCodeBlockState.reserve(numPages); + + for (uint32_t i = 0; i < numPages; i++) { + uint32_t pageOffset; + serialization::readPod(f, pageOffset); + uint8_t codeState; + serialization::readPod(f, codeState); + pageOffsets.push_back(pageOffset); + pageCodeBlockState.push_back(codeState); + } + + totalPages = pageOffsets.size(); + LOG_DBG("MDR", "Loaded page index cache: %d pages", totalPages); + return true; +} + +void MdReaderActivity::savePageIndexCache() const { + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!Storage.openFileForWrite("MDR", cachePath, f)) { + LOG_ERR("MDR", "Failed to save page index cache"); + return; + } + + serialization::writePod(f, CACHE_MAGIC); + serialization::writePod(f, CACHE_VERSION); + serialization::writePod(f, static_cast(txt->getFileSize())); + serialization::writePod(f, static_cast(viewportWidth)); + serialization::writePod(f, static_cast(linesPerPage)); + serialization::writePod(f, static_cast(cachedFontId)); + serialization::writePod(f, static_cast(cachedScreenMargin)); + serialization::writePod(f, cachedParagraphAlignment); + serialization::writePod(f, static_cast(pageOffsets.size())); + + for (size_t i = 0; i < pageOffsets.size(); i++) { + serialization::writePod(f, static_cast(pageOffsets[i])); + serialization::writePod(f, pageCodeBlockState[i]); + } + + LOG_DBG("MDR", "Saved page index cache: %d pages", totalPages); +} \ No newline at end of file From e968a54e77c708d46e3eec6c5536b10fd9ce020a Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 23 Apr 2026 22:04:19 +0200 Subject: [PATCH 07/10] Improve nested lists Co-authored-by: Copilot --- lib/Md/MdParser.cpp | 14 ++++++++++++-- src/activities/reader/MdReaderActivity.cpp | 15 ++++++++++++--- test/md_parser/MdParserTest.cpp | 22 ++++++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/lib/Md/MdParser.cpp b/lib/Md/MdParser.cpp index 372d5e1f..ea4bb3c7 100644 --- a/lib/Md/MdParser.cpp +++ b/lib/Md/MdParser.cpp @@ -19,6 +19,15 @@ static std::string trimLeft(const std::string& s) { return s.substr(i); } +static uint8_t parseListIndentLevel(size_t leadingSpaces) { + // Top-level list markers may be preceded by up to 3 spaces. + // Nested list items require at least 4 spaces before the marker. + if (leadingSpaces < TAB_WIDTH) { + return 0; + } + return static_cast((leadingSpaces - TAB_WIDTH) / TAB_WIDTH + 1); +} + static bool isWordChar(char c) { return std::isalnum(static_cast(c)) || c == '_'; } static bool isUnderscoreEmphasis(const std::string& text, size_t pos, size_t count) { @@ -249,7 +258,8 @@ ParsedLine parseLine(const std::string& rawLine, bool inCodeBlock) { return result; } - // Count leading whitespace for nesting level before trimming + // Count leading whitespace for nesting level before trimming. + // Up to 3 spaces before a list marker are still top-level in CommonMark. size_t leadingSpaces = 0; for (size_t i = 0; i < rawLine.size(); i++) { if (rawLine[i] == ' ') @@ -259,7 +269,7 @@ ParsedLine parseLine(const std::string& rawLine, bool inCodeBlock) { else break; } - result.indentLevel = static_cast(leadingSpaces / TAB_WIDTH); + result.indentLevel = parseListIndentLevel(leadingSpaces); std::string trimmed = trimLeft(rawLine); diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 1e846371..5ccf5bab 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -335,13 +335,18 @@ bool MdReaderActivity::wordWrapParsedLine(const MdParser::ParsedLine& parsed, in const int availableWidth = viewportWidth - indent; if (availableWidth <= 0) return true; - // Build a flat list of all spans, prepending the list prefix if present + // Build a flat list of all spans, prepending the list prefix if present. std::vector allSpans; if (!parsed.listPrefix.empty()) { allSpans.push_back({parsed.listPrefix, EpdFontFamily::REGULAR}); } allSpans.insert(allSpans.end(), parsed.spans.begin(), parsed.spans.end()); + const int listPrefixIndent = + !parsed.listPrefix.empty() + ? renderer.getTextAdvanceX(cachedFontId, parsed.listPrefix.c_str(), EpdFontFamily::REGULAR) + : 0; + // Check if everything fits on one line int totalWidth = measureSpans(allSpans); if (totalWidth <= availableWidth) { @@ -359,6 +364,7 @@ bool MdReaderActivity::wordWrapParsedLine(const MdParser::ParsedLine& parsed, in currentLine.isCodeBlock = isCodeBlock; int currentWidth = 0; bool fullyConsumed = true; + bool firstLine = true; for (size_t si = 0; si < allSpans.size(); si++) { const auto& span = allSpans[si]; @@ -415,8 +421,10 @@ bool MdReaderActivity::wordWrapParsedLine(const MdParser::ParsedLine& parsed, in } } else { outLines.push_back(std::move(currentLine)); + firstLine = false; currentLine = RenderedLine(); - currentLine.indent = indent; + currentLine.indent = indent + (firstLine ? 0 : listPrefixIndent); + currentLine.isCodeBlock = isCodeBlock; currentWidth = 0; continue; } @@ -424,8 +432,9 @@ bool MdReaderActivity::wordWrapParsedLine(const MdParser::ParsedLine& parsed, in currentLine.spans.push_back({remaining.substr(0, breakPos), style}); outLines.push_back(std::move(currentLine)); + firstLine = false; currentLine = RenderedLine(); - currentLine.indent = indent; + currentLine.indent = indent + (firstLine ? 0 : listPrefixIndent); currentLine.isCodeBlock = isCodeBlock; currentWidth = 0; diff --git a/test/md_parser/MdParserTest.cpp b/test/md_parser/MdParserTest.cpp index de1c9f34..57492dc3 100644 --- a/test/md_parser/MdParserTest.cpp +++ b/test/md_parser/MdParserTest.cpp @@ -78,6 +78,26 @@ void testUnderscoreBoldWorks() { PASS(); } +void testNestedUnorderedListIndentLevel() { + printf("testNestedUnorderedListIndentLevel...\n"); + auto parsed = MdParser::parseLine(" - nested item", false); + ASSERT_EQ(parsed.blockType, MdParser::BlockType::UnorderedList); + ASSERT_EQ(parsed.listPrefix, "\xe2\x80\xa2 "); + ASSERT_EQ(parsed.indentLevel, 1); + ASSERT_EQ(flattenText(parsed.spans), "nested item"); + PASS(); +} + +void testNestedOrderedListIndentLevel() { + printf("testNestedOrderedListIndentLevel...\n"); + auto parsed = MdParser::parseLine(" 1. nested ordered", false); + ASSERT_EQ(parsed.blockType, MdParser::BlockType::OrderedList); + ASSERT_EQ(parsed.listPrefix, "1. "); + ASSERT_EQ(parsed.indentLevel, 2); + ASSERT_EQ(flattenText(parsed.spans), "nested ordered"); + PASS(); +} + int main() { printf("=== Markdown Parser Tests ===\n\n"); @@ -86,6 +106,8 @@ int main() { testUnderscoreEmphasisStillWorks(); testAsteriskEmphasisStillWorks(); testUnderscoreBoldWorks(); + testNestedUnorderedListIndentLevel(); + testNestedOrderedListIndentLevel(); printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed); return testsFailed > 0 ? 1 : 0; From c04153ec5a39c5af8e45764f2067119eeb94730b Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 23 Apr 2026 22:14:41 +0200 Subject: [PATCH 08/10] Address cppcheck Co-authored-by: Copilot --- src/activities/reader/MdReaderActivity.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 5ccf5bab..3368637e 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -359,12 +359,12 @@ bool MdReaderActivity::wordWrapParsedLine(const MdParser::ParsedLine& parsed, in } // Word-wrap across spans + const int continuationIndent = indent + listPrefixIndent; RenderedLine currentLine; currentLine.indent = indent; currentLine.isCodeBlock = isCodeBlock; int currentWidth = 0; bool fullyConsumed = true; - bool firstLine = true; for (size_t si = 0; si < allSpans.size(); si++) { const auto& span = allSpans[si]; @@ -421,9 +421,8 @@ bool MdReaderActivity::wordWrapParsedLine(const MdParser::ParsedLine& parsed, in } } else { outLines.push_back(std::move(currentLine)); - firstLine = false; currentLine = RenderedLine(); - currentLine.indent = indent + (firstLine ? 0 : listPrefixIndent); + currentLine.indent = continuationIndent; currentLine.isCodeBlock = isCodeBlock; currentWidth = 0; continue; @@ -432,9 +431,8 @@ bool MdReaderActivity::wordWrapParsedLine(const MdParser::ParsedLine& parsed, in currentLine.spans.push_back({remaining.substr(0, breakPos), style}); outLines.push_back(std::move(currentLine)); - firstLine = false; currentLine = RenderedLine(); - currentLine.indent = indent + (firstLine ? 0 : listPrefixIndent); + currentLine.indent = continuationIndent; currentLine.isCodeBlock = isCodeBlock; currentWidth = 0; From 43462923408a9cd7643c10a40a0ac01bd4ae47a5 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 23 Apr 2026 22:21:43 +0200 Subject: [PATCH 09/10] Add additional file indicators --- src/network/html/FilesPage.html | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 4d7db447..83b5da0b 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -2278,9 +2278,10 @@ if (file.isDirectory) return 0; const ext = file.name.includes('.') ? file.name.split('.').pop().toLowerCase() : ''; if (ext === 'epub') return 1; - if (ext === 'xtc') return 2; + if (ext === 'xtc' || ext === 'xtch') return 2; if (ext === 'txt') return 3; - return 4; + if (ext === 'md') return 4; + return 5; } function sortFiles(files) { @@ -2355,11 +2356,18 @@ if (!filePath.endsWith("/")) filePath += "/"; filePath += file.name; - fileTableContent += ``; + const ext = file.name.includes('.') ? file.name.split('.').pop().toLowerCase() : ''; + const isXtc = ext === 'xtc' || ext === 'xtch'; + const isMd = ext === 'md'; + const fileIcon = file.isEpub ? '📗' : isXtc ? '📘' : isMd ? '📝' : '📄'; + const fileBadge = file.isEpub ? 'EPUB' : isXtc ? 'XTC' : ext === 'txt' ? 'TXT' : isMd ? 'MD' : ''; + const rowClass = file.isEpub ? 'epub-file' : isXtc ? 'xtc-file' : ''; + + fileTableContent += ``; fileTableContent += ``; - fileTableContent += `${file.isEpub ? '📗' : '📄'}`; + fileTableContent += `${fileIcon}`; fileTableContent += `${escapeHtml(displayFileName(file.name))}`; - if (file.isEpub) fileTableContent += 'EPUB'; + if (fileBadge) fileTableContent += `${fileBadge}`; fileTableContent += ''; fileTableContent += `${file.name.includes('.') ? file.name.split('.').pop().toUpperCase() : '-'}`; fileTableContent += `${formatFileSize(file.size)}`; From 2faeb550fade89ba8c92483da55813567f4ae4b4 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Thu, 23 Apr 2026 22:35:07 +0200 Subject: [PATCH 10/10] More fixes --- src/activities/reader/MdReaderActivity.cpp | 31 +++++++++++++------ .../reader/MdReaderTocSelectionActivity.cpp | 7 +++-- test/md_parser/MdParserTest.cpp | 6 ++-- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 3368637e..9773a950 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -587,11 +587,18 @@ bool MdReaderActivity::loadPageAtOffset(size_t offset, bool startInCodeBlock, st void MdReaderActivity::buildPageIndex() { pageOffsets.clear(); pageCodeBlockState.clear(); + + const size_t fileSize = txt->getFileSize(); + if (fileSize == 0) { + totalPages = 0; + LOG_DBG("MDR", "Empty markdown file, no pages"); + return; + } + pageOffsets.push_back(0); pageCodeBlockState.push_back(0); size_t offset = 0; - const size_t fileSize = txt->getFileSize(); bool inCodeBlock = false; LOG_DBG("MDR", "Building page index for %zu bytes...", fileSize); @@ -672,6 +679,7 @@ void MdReaderActivity::renderPage() { // Draw horizontal rule as a thin line int hrY = y + lineHeight / 2; renderer.drawLine(cachedOrientedMarginLeft + line.indent, hrY, cachedOrientedMarginLeft + viewportWidth, hrY); + y += lineHeight; } else { if (line.isCodeBlock) { const int barX = cachedOrientedMarginLeft + std::max(line.indent - 6, 0); @@ -739,11 +747,12 @@ void MdReaderActivity::renderStatusBar() const { void MdReaderActivity::saveProgress() const { FsFile f; if (Storage.openFileForWrite("MDR", txt->getCachePath() + "/progress.bin", f)) { + uint32_t page = static_cast(currentPage < 0 ? 0 : currentPage); uint8_t data[4]; - data[0] = currentPage & 0xFF; - data[1] = (currentPage >> 8) & 0xFF; - data[2] = 0; - data[3] = 0; + data[0] = page & 0xFF; + data[1] = (page >> 8) & 0xFF; + data[2] = (page >> 16) & 0xFF; + data[3] = (page >> 24) & 0xFF; f.write(data, 4); } } @@ -753,12 +762,14 @@ void MdReaderActivity::loadProgress() { if (Storage.openFileForRead("MDR", txt->getCachePath() + "/progress.bin", f)) { uint8_t data[4]; if (f.read(data, 4) == 4) { - currentPage = data[0] + (data[1] << 8); - if (currentPage >= totalPages) { - currentPage = totalPages - 1; - } - if (currentPage < 0) { + uint32_t loadedPage = static_cast(data[0]) | (static_cast(data[1]) << 8) | + (static_cast(data[2]) << 16) | (static_cast(data[3]) << 24); + if (totalPages == 0) { currentPage = 0; + } else if (loadedPage >= static_cast(totalPages)) { + currentPage = totalPages - 1; + } else { + currentPage = static_cast(loadedPage); } LOG_DBG("MDR", "Loaded progress: page %d/%d", currentPage, totalPages); } diff --git a/src/activities/reader/MdReaderTocSelectionActivity.cpp b/src/activities/reader/MdReaderTocSelectionActivity.cpp index 79c2ab59..89054c7d 100644 --- a/src/activities/reader/MdReaderTocSelectionActivity.cpp +++ b/src/activities/reader/MdReaderTocSelectionActivity.cpp @@ -98,10 +98,11 @@ void MdReaderTocSelectionActivity::render(RenderLock&&) { const bool isSelected = (itemIndex == selectorIndex); const auto& heading = headings[itemIndex]; - const int indentSize = contentRect.x + 20 + (heading.level - 1) * 10; + const int indentRelative = 20 + (heading.level - 1) * 10; + const int drawX = contentRect.x + indentRelative; const std::string title = - renderer.truncatedText(UI_10_FONT_ID, heading.title.c_str(), contentRect.width - 40 - indentSize); - renderer.drawText(UI_10_FONT_ID, indentSize, displayY, title.c_str(), !isSelected); + renderer.truncatedText(UI_10_FONT_ID, heading.title.c_str(), contentRect.width - 40 - indentRelative); + renderer.drawText(UI_10_FONT_ID, drawX, displayY, title.c_str(), !isSelected); } const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); diff --git a/test/md_parser/MdParserTest.cpp b/test/md_parser/MdParserTest.cpp index 57492dc3..d1bc4ff7 100644 --- a/test/md_parser/MdParserTest.cpp +++ b/test/md_parser/MdParserTest.cpp @@ -56,7 +56,7 @@ void testUnderscoreEmphasisStillWorks() { auto spans = MdParser::parseInline("foo _bar_ baz"); ASSERT_EQ(flattenText(spans), "foo bar baz"); ASSERT_EQ(spans.size(), 3); - ASSERT_EQ(spans[1].style == EpdFontFamily::ITALIC || spans[1].style == EpdFontFamily::BOLD_ITALIC, true); + ASSERT_EQ(spans[1].style, EpdFontFamily::ITALIC); PASS(); } @@ -65,7 +65,7 @@ void testAsteriskEmphasisStillWorks() { auto spans = MdParser::parseInline("foo *bar* baz"); ASSERT_EQ(flattenText(spans), "foo bar baz"); ASSERT_EQ(spans.size(), 3); - ASSERT_EQ(spans[1].style == EpdFontFamily::ITALIC || spans[1].style == EpdFontFamily::BOLD_ITALIC, true); + ASSERT_EQ(spans[1].style, EpdFontFamily::ITALIC); PASS(); } @@ -74,7 +74,7 @@ void testUnderscoreBoldWorks() { auto spans = MdParser::parseInline("foo __bar__ baz"); ASSERT_EQ(flattenText(spans), "foo bar baz"); ASSERT_EQ(spans.size(), 3); - ASSERT_EQ(spans[1].style == EpdFontFamily::BOLD || spans[1].style == EpdFontFamily::BOLD_ITALIC, true); + ASSERT_EQ(spans[1].style, EpdFontFamily::BOLD); PASS(); }