@@ -17,6 +17,17 @@ static std::string trimLeft(const std::string& s) {
|
|||||||
return s.substr(i);
|
return s.substr(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool isWordChar(char c) { return std::isalnum(static_cast<unsigned char>(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) {
|
static bool isHorizontalRuleLine(const std::string& line) {
|
||||||
// Must be at least 3 chars of the same marker (-, *, _) with optional spaces
|
// Must be at least 3 chars of the same marker (-, *, _) with optional spaces
|
||||||
if (line.size() < 3) return false;
|
if (line.size() < 3) return false;
|
||||||
@@ -65,6 +76,11 @@ std::vector<Span> parseInline(const std::string& text) {
|
|||||||
|
|
||||||
// *** or ___ — toggle both bold and italic
|
// *** or ___ — toggle both bold and italic
|
||||||
if ((c == '*' || c == '_') && i + 2 < text.size() && text[i + 1] == c && text[i + 2] == c) {
|
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();
|
emitSpan();
|
||||||
bold = !bold;
|
bold = !bold;
|
||||||
italic = !italic;
|
italic = !italic;
|
||||||
@@ -74,6 +90,11 @@ std::vector<Span> parseInline(const std::string& text) {
|
|||||||
|
|
||||||
// ** or __ — toggle bold
|
// ** or __ — toggle bold
|
||||||
if ((c == '*' || c == '_') && i + 1 < text.size() && text[i + 1] == c) {
|
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();
|
emitSpan();
|
||||||
bold = !bold;
|
bold = !bold;
|
||||||
i += 2;
|
i += 2;
|
||||||
@@ -82,6 +103,11 @@ std::vector<Span> parseInline(const std::string& text) {
|
|||||||
|
|
||||||
// * or _ — toggle italic
|
// * or _ — toggle italic
|
||||||
if (c == '*' || c == '_') {
|
if (c == '*' || c == '_') {
|
||||||
|
if (c == '_' && !isUnderscoreEmphasis(text, i, 1)) {
|
||||||
|
current.push_back(c);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
emitSpan();
|
emitSpan();
|
||||||
italic = !italic;
|
italic = !italic;
|
||||||
i += 1;
|
i += 1;
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "activities/Activity.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 {
|
class MdReaderActivity final : public Activity {
|
||||||
std::unique_ptr<Txt> txt;
|
std::unique_ptr<Txt> txt;
|
||||||
|
|
||||||
@@ -26,6 +33,11 @@ class MdReaderActivity final : public Activity {
|
|||||||
std::vector<size_t> pageOffsets;
|
std::vector<size_t> pageOffsets;
|
||||||
std::vector<uint8_t> pageCodeBlockState; // 1 if page starts inside a code block
|
std::vector<uint8_t> pageCodeBlockState; // 1 if page starts inside a code block
|
||||||
std::vector<RenderedLine> currentPageLines;
|
std::vector<RenderedLine> currentPageLines;
|
||||||
|
std::vector<uint8_t> pageBuffer;
|
||||||
|
|
||||||
|
std::vector<MdHeading> headings;
|
||||||
|
int currentHeadingIndex = -1;
|
||||||
|
|
||||||
int linesPerPage = 0;
|
int linesPerPage = 0;
|
||||||
int viewportWidth = 0;
|
int viewportWidth = 0;
|
||||||
bool initialized = false;
|
bool initialized = false;
|
||||||
@@ -55,6 +67,13 @@ class MdReaderActivity final : public Activity {
|
|||||||
void savePageIndexCache() const;
|
void savePageIndexCache() const;
|
||||||
void saveProgress() const;
|
void saveProgress() const;
|
||||||
void loadProgress();
|
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.
|
// Word-wrap a parsed markdown line into one or more RenderedLines.
|
||||||
// Returns true if all content was emitted, false if truncated by maxLines.
|
// Returns true if all content was emitted, false if truncated by maxLines.
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
#include <Utf8.h>
|
#include <Utf8.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <numeric>
|
#include <numeric>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "MdReaderTocSelectionActivity.h"
|
||||||
#include "ReaderUtils.h"
|
#include "ReaderUtils.h"
|
||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
@@ -19,8 +21,18 @@
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr size_t CHUNK_SIZE = 8 * 1024;
|
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 uint32_t CACHE_MAGIC = 0x4D4B4449; // "MKDI"
|
||||||
constexpr uint8_t CACHE_VERSION = 3; // Bumped: nested list indent + task checkboxes
|
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
|
} // namespace
|
||||||
|
|
||||||
void MdReaderActivity::onEnter() {
|
void MdReaderActivity::onEnter() {
|
||||||
@@ -38,11 +50,163 @@ void MdReaderActivity::onEnter() {
|
|||||||
auto fileName = filePath.substr(filePath.rfind('/') + 1);
|
auto fileName = filePath.substr(filePath.rfind('/') + 1);
|
||||||
APP_STATE.openEpubPath = filePath;
|
APP_STATE.openEpubPath = filePath;
|
||||||
APP_STATE.saveToFile();
|
APP_STATE.saveToFile();
|
||||||
RECENT_BOOKS.addBook(filePath, fileName, "", "");
|
RECENT_BOOKS.addBook(filePath, fileName, "", "", "");
|
||||||
|
|
||||||
requestUpdate();
|
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<int>((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<int>(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<int>(headings.size()) - 1;
|
||||||
|
} else {
|
||||||
|
headingIndex += next ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headingIndex < 0) {
|
||||||
|
headingIndex = 0;
|
||||||
|
} else if (headingIndex >= static_cast<int>(headings.size())) {
|
||||||
|
headingIndex = static_cast<int>(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<int>(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<char*>(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() {
|
void MdReaderActivity::onExit() {
|
||||||
Activity::onExit();
|
Activity::onExit();
|
||||||
|
|
||||||
@@ -68,17 +232,40 @@ void MdReaderActivity::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && !headings.empty()) {
|
||||||
|
currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]);
|
||||||
|
ReaderUtils::enforceExitFullRefresh(renderer);
|
||||||
|
startActivityForResult(
|
||||||
|
std::make_unique<MdReaderTocSelectionActivity>(renderer, mappedInput, headings, currentHeadingIndex),
|
||||||
|
[this](const ActivityResult& result) {
|
||||||
|
if (!result.isCancelled) {
|
||||||
|
currentPage = std::get<PageResult>(result.data).page;
|
||||||
|
currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]);
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
auto [prevTriggered, nextTriggered] = ReaderUtils::detectPageTurn(mappedInput);
|
auto [prevTriggered, nextTriggered] = ReaderUtils::detectPageTurn(mappedInput);
|
||||||
if (!prevTriggered && !nextTriggered) {
|
if (!prevTriggered && !nextTriggered) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bool headingSkip = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > HEADING_SKIP_MS;
|
||||||
|
if (headingSkip && !headings.empty()) {
|
||||||
|
jumpToHeading(nextTriggered);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (prevTriggered && currentPage > 0) {
|
if (prevTriggered && currentPage > 0) {
|
||||||
currentPage--;
|
currentPage--;
|
||||||
|
currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]);
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
} else if (nextTriggered) {
|
} else if (nextTriggered) {
|
||||||
if (currentPage < totalPages - 1) {
|
if (currentPage < totalPages - 1) {
|
||||||
currentPage++;
|
currentPage++;
|
||||||
|
currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]);
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
} else {
|
} else {
|
||||||
onGoHome();
|
onGoHome();
|
||||||
@@ -107,6 +294,9 @@ void MdReaderActivity::initializeReader() {
|
|||||||
const int viewportHeight = renderer.getScreenHeight() - cachedOrientedMarginTop - cachedOrientedMarginBottom;
|
const int viewportHeight = renderer.getScreenHeight() - cachedOrientedMarginTop - cachedOrientedMarginBottom;
|
||||||
const int lineHeight = renderer.getLineHeight(cachedFontId);
|
const int lineHeight = renderer.getLineHeight(cachedFontId);
|
||||||
|
|
||||||
|
pageBuffer.reserve(CHUNK_SIZE + 1);
|
||||||
|
scanHeadings();
|
||||||
|
|
||||||
linesPerPage = viewportHeight / lineHeight;
|
linesPerPage = viewportHeight / lineHeight;
|
||||||
if (linesPerPage < 1) linesPerPage = 1;
|
if (linesPerPage < 1) linesPerPage = 1;
|
||||||
|
|
||||||
@@ -116,6 +306,7 @@ void MdReaderActivity::initializeReader() {
|
|||||||
buildPageIndex();
|
buildPageIndex();
|
||||||
savePageIndexCache();
|
savePageIndexCache();
|
||||||
}
|
}
|
||||||
|
assignHeadingPageNumbers();
|
||||||
|
|
||||||
loadProgress();
|
loadProgress();
|
||||||
|
|
||||||
@@ -259,42 +450,57 @@ bool MdReaderActivity::loadPageAtOffset(size_t offset, bool startInCodeBlock, st
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset);
|
size_t bufferSize = std::min(CHUNK_SIZE, fileSize - offset);
|
||||||
auto* buffer = static_cast<uint8_t*>(malloc(chunkSize + 1));
|
pageBuffer.resize(bufferSize + 1);
|
||||||
if (!buffer) {
|
if (!txt->readContent(pageBuffer.data(), offset, bufferSize)) {
|
||||||
LOG_ERR("MDR", "Failed to allocate %zu bytes", chunkSize);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
pageBuffer[bufferSize] = '\0';
|
||||||
if (!txt->readContent(buffer, offset, chunkSize)) {
|
|
||||||
free(buffer);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
buffer[chunkSize] = '\0';
|
|
||||||
|
|
||||||
bool inCodeBlock = startInCodeBlock;
|
bool inCodeBlock = startInCodeBlock;
|
||||||
size_t pos = 0;
|
size_t pos = 0;
|
||||||
|
|
||||||
while (pos < chunkSize && static_cast<int>(outLines.size()) < linesPerPage) {
|
while (pos < bufferSize && static_cast<int>(outLines.size()) < linesPerPage) {
|
||||||
// Find end of line
|
// Find end of line and extend the read buffer if we are at chunk boundary.
|
||||||
size_t lineEnd = pos;
|
size_t lineEnd = pos;
|
||||||
while (lineEnd < chunkSize && buffer[lineEnd] != '\n') {
|
while (lineEnd < bufferSize && pageBuffer[lineEnd] != '\n') {
|
||||||
lineEnd++;
|
lineEnd++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have a complete line
|
while (lineEnd == bufferSize && offset + bufferSize < fileSize && bufferSize < MAX_LINE_LENGTH) {
|
||||||
bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize);
|
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()) {
|
if (!lineComplete && !outLines.empty()) {
|
||||||
// Incomplete line at chunk boundary and we already have content — stop here
|
// Incomplete line at chunk boundary and we already have content — stop here
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t lineContentLen = lineEnd - pos;
|
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;
|
size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen;
|
||||||
|
|
||||||
std::string rawLine(reinterpret_cast<char*>(buffer + pos), displayLen);
|
std::string rawLine(reinterpret_cast<char*>(pageBuffer.data() + pos), displayLen);
|
||||||
|
|
||||||
// Check for code fence toggle
|
// Check for code fence toggle
|
||||||
bool wasFence = false;
|
bool wasFence = false;
|
||||||
@@ -363,8 +569,6 @@ bool MdReaderActivity::loadPageAtOffset(size_t offset, bool startInCodeBlock, st
|
|||||||
}
|
}
|
||||||
|
|
||||||
endInCodeBlock = inCodeBlock;
|
endInCodeBlock = inCodeBlock;
|
||||||
free(buffer);
|
|
||||||
|
|
||||||
return !outLines.empty();
|
return !outLines.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#include "MdReaderTocSelectionActivity.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <I18n.h>
|
||||||
|
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
|
||||||
|
int MdReaderTocSelectionActivity::getTotalItems() const { return static_cast<int>(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<uint32_t>(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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "MdReaderActivity.h"
|
||||||
|
#include "../Activity.h"
|
||||||
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
|
class MdReaderTocSelectionActivity final : public Activity {
|
||||||
|
std::vector<MdHeading> headings;
|
||||||
|
ButtonNavigator buttonNavigator;
|
||||||
|
int selectorIndex = 0;
|
||||||
|
|
||||||
|
int getPageItems() const;
|
||||||
|
int getTotalItems() const;
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit MdReaderTocSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
std::vector<MdHeading> headings, int currentHeadingIndex)
|
||||||
|
: Activity("MdReaderTocSelection", renderer, mappedInput), headings(std::move(headings)), selectorIndex(0) {
|
||||||
|
if (currentHeadingIndex >= 0 && currentHeadingIndex < static_cast<int>(headings.size())) {
|
||||||
|
selectorIndex = currentHeadingIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
void render(RenderLock&&) override;
|
||||||
|
};
|
||||||
@@ -167,7 +167,7 @@ void ReaderActivity::onEnter() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onGoToXtcReader(std::move(xtc));
|
onGoToXtcReader(std::move(xtc));
|
||||||
} else if (isMdFile(initialBookPath)) {
|
} else if (isMDFile(initialBookPath)) {
|
||||||
auto txt = loadTxt(initialBookPath);
|
auto txt = loadTxt(initialBookPath);
|
||||||
if (!txt) {
|
if (!txt) {
|
||||||
onGoBack();
|
onGoBack();
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<MdParser::Span>& spans) {
|
||||||
|
std::string result;
|
||||||
|
for (const auto& span : spans) {
|
||||||
|
result += span.text;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool allRegular(const std::vector<MdParser::Span>& 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user