Compare commits

..
Author SHA1 Message Date
Uri Tauber d9a207f9a9 refactor: unify control settings models 2026-08-09 12:24:05 +03:00
Uri TauberandJulia Nguyen 77b58e431f feat: group Settings -> Controls into submenus
The Controls category had grown to a flat list of a dozen unrelated
entries. Split it into Power Button, Front Buttons and Side Buttons
groups, reached through a new SUBMENU row type that swaps the visible
list in place rather than pushing another activity.

- Front Buttons only exists on devices that have them. On touch devices
  the entries that still apply (long-press behaviour, which also covers
  the touch page-turn zones) stay at the top level.
- Tilt Page Turn and Tilt Direction sit at the top level, and only on
  IMU-capable boards.
- Quick-return from footnotes now appears whenever any shortcut targets
  footnotes, not only the short power press.

Back leaves a group before it leaves Settings, and the header names the
open group. Choosing which list is on screen was duplicated between the
rebuild and the input handler; both now call one method.

Ported from CrossInk.

Co-Authored-By: Julia Nguyen <julia@uxj.io>
2026-08-07 19:09:51 +03:00
Uri TauberandJulia Nguyen 167663cb6e feat: run configurable Controls shortcuts in the reader
Adds a single ShortcutAction space that both persisted shortcut enums
translate into, so each dispatcher switches over one type instead of
repeating a near-identical switch per binding. Global actions (sleep,
refresh, screenshot, file browser, file transfer, tilt toggle) run from
one executor; reader-only ones (footnotes, bookmark, sync, look up
word) are handled by the reader and fall through to it.

Wires it up:

- Long power hold runs longPwrBtn, latched so a held button fires once
  and the release that ends the hold does not also run the short-press
  action. Sleep is the default, matching what a hold has always done.
- Short power release runs shortPwrBtn, no longer only Refresh.
- Long Back runs longPressBackAction; the default keeps the existing
  file-browser navigation untouched.
- Long Confirm runs longPressMenuAction over the whole action set. An
  action that declines (sync with no credentials, a page with no
  footnotes) leaves the release alone, so Confirm still opens the menu.
- detectPageTurn reports whether a turn came from the side buttons, so
  the side and front pairs honour their own long-press settings and
  their own press-vs-release timing.

Font-size change is deliberately absent from the long-press options:
it needs per-reader re-layout plumbing that does not exist yet, and the
rule for this port is to offer no action we cannot execute cleanly.

Ported from CrossInk.

Co-Authored-By: Julia Nguyen <julia@uxj.io>
2026-08-07 19:04:19 +03:00
Uri TauberandJulia Nguyen 2aa0f7be17 feat: apply Controls button mapping at input layer
Routes the new Controls settings through MappedInputManager:

- Next/Next side layout, via a table of side-button bindings that also
  replaces the two parallel switch statements over sideButtonLayout.
- sideButtonOrientationAware, swapping the side pair (and reader menu
  Up/Down along with it) whenever the screen renders non-portrait.
- The ALL_BUTTONS front orientation mode, which mirrors Back/Confirm
  across the front row when the screen renders inverted. The navigation
  pair is deliberately left alone: isNavDirectionSwapped() already flips
  it, so mirroring both would cancel out.
- readerFrontButtons*, a reader-only front mapping, gated on a
  readerMode flag main.cpp keeps in sync with the activity stack. The
  renderer cannot supply this — a portrait reader renders exactly like
  portrait UI.

Button hints now resolve through the same mapping as the button reads,
so a reader override or an inverted mirror moves the labels with the
buttons instead of leaving them behind.

ButtonRemapActivity takes a target so it can edit either mapping.

Ported from CrossInk.

Co-Authored-By: Julia Nguyen <julia@uxj.io>
2026-08-07 18:55:39 +03:00
Uri TauberandJulia Nguyen a1c0bdfa05 feat: add Controls settings schema and migrations
Adds the persisted fields the CrossInk Controls screen is built on, and
migrates the existing CrossPoint settings onto them so no stored value
changes meaning:

- longPwrBtn, sharing the power-button action enum with shortPwrBtn.
  Values 0..4 keep their CrossPoint meanings — notably 4 stays
  Footnotes, where CrossInk's numbering diverges — and the new actions
  are appended, with the menu order restored via enumRawValues.
- frontButtonOrientationAware, a three-state setting migrated from the
  frontButtonFollowOrientation toggle (on -> nav buttons only).
- sideButtonOrientationAware and sideButtonLongPress. The side pair used
  to share longPressButtonBehavior with the front pair, so the old value
  is carried across on load.
- readerFrontButtons*, a reader-only front-button remap, inert until
  readerFrontButtonsEnabled is turned on.
- longPressMenuAction and longPressBackAction on a shared quick-action
  enum, migrated from longPressMenuFunction.
- tiltPageTurnDirection, splitting the axis out of the combined
  tiltPageTurn enum. Legacy "normal" is the non-inverted left/right
  gesture and "inverted" its negation, so the HAL reproduces both.

Only actions the reader can already execute are offered; the enums carry
no option that has no implementation behind it.

Ported from CrossInk.

Co-Authored-By: Julia Nguyen <julia@uxj.io>
2026-08-07 18:50:11 +03:00
Uri TauberandJulia Nguyen 4e8aea95a0 feat: support raw enum values in settings schema
An ENUM setting's option order and its persisted values were the same
number, so reordering or gapping an option list silently reinterpreted
existing settings.json files. Add an optional parallel enumRawValues
array and route every reader/writer of an ENUM — device settings UI,
web settings API, and load-time validation — through three shared
helpers that translate between display index and raw value.

Behaviour is unchanged for every setting that does not populate
enumRawValues: the helpers fall through to the identity mapping.

Ported from CrossInk.

Co-Authored-By: Julia Nguyen <julia@uxj.io>
2026-08-07 18:39:57 +03:00
92 changed files with 2011 additions and 1979 deletions

No files matched your search

+8 -52
View File
@@ -63,8 +63,7 @@ jobs:
- name: Run cppcheck
run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high
build-default:
name: Build default
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -91,10 +90,14 @@ jobs:
key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }}
restore-keys: pio-${{ runner.os }}-
- name: Build default firmware
- name: Build CrossPoint
# Both envs in ONE pio invocation: a second `pio run` can wipe the whole
# .pio/build tree (project-checksum mismatch after the S3 toolchain
# installs into the restored cache), deleting the default firmware.bin
# before the artifact upload.
run: |
set -euo pipefail
pio run -e default | tee pio.log
pio run -e default -e sticky | tee pio.log
- name: Extract firmware stats
@@ -117,52 +120,6 @@ jobs:
path: .pio/build/default/firmware.bin
if-no-files-found: error
build-sticky:
name: Build sticky
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Install PlatformIO Core
run: uv pip install --system -U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip
- name: Cache PlatformIO packages
uses: actions/cache@v4
with:
path: ~/.platformio
key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }}
restore-keys: pio-${{ runner.os }}-
- name: Build sticky firmware
run: |
set -euo pipefail
pio run -e sticky | tee pio.log
- name: Extract firmware stats
run: |
set -euo pipefail
ram_line="$(grep -E "RAM:\\s" -m1 pio.log || true)"
flash_line="$(grep -E "Flash:\\s" -m1 pio.log || true)"
echo "ram_line=${ram_line}" >> "$GITHUB_OUTPUT"
echo "flash_line=${flash_line}" >> "$GITHUB_OUTPUT"
{
echo "## Sticky firmware build stats"
if [ -n "$ram_line" ]; then echo "- ${ram_line}"; else echo "- RAM: not found"; fi
if [ -n "$flash_line" ]; then echo "- ${flash_line}"; else echo "- Flash: not found"; fi
} >> "$GITHUB_STEP_SUMMARY"
unit-tests:
runs-on: ubuntu-latest
steps:
@@ -195,8 +152,7 @@ jobs:
test-status:
name: Test Status
needs:
- build-default
- build-sticky
- build
- clang-format
- cppcheck
- unit-tests
+47
View File
@@ -0,0 +1,47 @@
name: Compile Release
on:
push:
tags:
- '*'
jobs:
build-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: false
- name: Install PlatformIO Core
run: uv pip install --system -U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip
- name: Cache PlatformIO packages
uses: actions/cache@v4
with:
path: ~/.platformio
key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }}
restore-keys: pio-${{ runner.os }}-
- name: Build CrossPoint
run: pio run -e gh_release
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: CrossPoint-${{ github.ref_name }}
path: |
.pio/build/gh_release/bootloader.bin
.pio/build/gh_release/firmware.bin
.pio/build/gh_release/firmware.elf
.pio/build/gh_release/firmware.map
.pio/build/gh_release/partitions.bin
-1
View File
@@ -31,4 +31,3 @@ lib/EpdFont/scripts/output/
/dependencies.lock
/sdkconfig.*
sdkconfig.sticky
flake.*
+68 -179
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
/home/urit/Documents/CrossPoint/crosspoint-reader/.skills/SKILL.md
+4 -6
View File
@@ -10,10 +10,9 @@ A dictionary folder must contain:
- `.idx` — word index (required, **must be uncompressed** — a `.idx.gz` will not work; decompress it on your computer with `gzip -d` first)
- `.dict` or `.dict.dz` — definition data (`.dict.dz` is supported as-is; entries are decompressed on the fly during lookup)
- `.syn` — synonym index (optional; maps alternate spellings and irregular forms to their headword)
- `.ifo` — metadata (optional)
Not supported: dictionaries with 64-bit index offsets (`idxoffsetbits=64` in the `.ifo` — rare, and rejected with an error), and HTML-formatted definitions render as raw markup rather than styled text.
Not supported: `.syn` synonym files (ignored), dictionaries with 64-bit index offsets (`idxoffsetbits=64` in the `.ifo` — rare, and rejected with an error), and HTML-formatted definitions render as raw markup rather than styled text.
## Setting Up a Dictionary
@@ -36,14 +35,13 @@ One word on the page becomes highlighted:
2. Press **Confirm** to look up the highlighted word.
3. Press **Back** to return to the reader.
On the very first lookup with a dictionary (and again whenever the `.idx` or `.syn` source file changes), the reader shows *"Indexing dictionary…"* while it builds small sidecar files next to them — a `.qidx` for the word index, and a `.sidx` when a `.syn` synonym file is present. Each sidecar is rebuilt independently, only when its own source changes. This takes a few seconds for large dictionaries and makes all subsequent lookups fast. The sidecars can be deleted safely at any time — they will simply be rebuilt.
On the very first lookup with a dictionary (and again if the dictionary file changes), the reader shows *"Indexing dictionary…"* while it builds a small `.qidx` sidecar file next to the `.idx`. This takes a few seconds for large dictionaries and makes all subsequent lookups fast. The sidecar can be deleted safely at any time — it will simply be rebuilt.
### How Lookup Works
1. **Direct match** — the word is found as-is (case-insensitive) in the dictionary index. Surrounding punctuation is ignored.
2. **Synonyms** — on a miss, if the dictionary ships a `.syn` file, alternate spellings and irregular forms recorded there are resolved to their headword (e.g. `oxen` → `ox`, `colour` → `color`). This step is skipped if the `.sidx` sidecar could not be built (e.g. transient low memory during indexing); the dictionary otherwise stays usable, and the build is retried the next time it is opened.
3. **Stemming** — still no match: common English word forms are retried automatically: possessives and plurals (`dogs` → `dog`, `stories` → `story`) and verb endings (`walked` → `walk`, `running` → `run`, `making` → `make`).
4. **Not found** — a short popup appears and you return to word selection.
2. **Stemming** — on a miss, common English word forms are retried automatically: possessives and plurals (`dogs` → `dog`, `stories` → `story`) and verb endings (`walked` → `walk`, `running` → `run`, `making` → `make`).
3. **Not found** — a short popup appears and you return to word selection.
## The Definition Screen
+3 -10
View File
@@ -90,19 +90,12 @@ if (parsedSize != fileSize) {
## `section.bin`
### Version 37
### Version 35
Each file in `sections/*.bin` stores one laid-out spine section. The header is
also the cache-busting key: if any layout-affecting setting differs from the
current reader settings, the section is discarded and rebuilt.
Version 37 increases the fixed-size footnote href field from 96 to 256 bytes.
This changes each serialized footnote record from 128 to 288 bytes, so older
section caches must be discarded and rebuilt.
Version 36 invalidates cached word positions after ruby and CJK justification
layout changes.
Version 35 adds a header offset and a `uint32_t` entry per page for the
visible-text offset LUT. The other section LUTs remain unchanged.
@@ -142,10 +135,10 @@ import std.mem;
import std.string;
import std.core;
#define EXPECTED_VERSION 37
#define EXPECTED_VERSION 35
#define MAX_STRING_LENGTH 65535
#define FOOTNOTE_NUMBER_LEN 32
#define FOOTNOTE_HREF_LEN 256
#define FOOTNOTE_HREF_LEN 96
struct String {
u32 length [[hidden, comment("String byte length")]];
+1 -6
View File
@@ -802,12 +802,7 @@ int Epub::getSpineItemsCount() const {
return bookMetadataCache->getSpineCount();
}
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return 0;
}
return bookMetadataCache->getCumulativeSize(spineIndex);
}
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return getSpineItem(spineIndex).cumulativeSize; }
BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
-19
View File
@@ -481,30 +481,11 @@ bool BookMetadataCache::load() {
serialization::readString(bookFile, coreMetadata.coverItemHref);
serialization::readString(bookFile, coreMetadata.textReferenceHref);
// Cache cumulative spine sizes in RAM. The progress bar (every render) and percent
// jumps otherwise pay 2 seeks + a heap-allocating SpineEntry read per access. Spine
// entries are stored contiguously in index order immediately after the LUTs, so read
// them in a single sequential pass.
cumulativeSizes.clear();
cumulativeSizes.reserve(spineCount);
const uint32_t lutSize = (static_cast<uint32_t>(spineCount) + tocCount) * sizeof(uint32_t);
bookFile.seek(lutOffset + lutSize);
for (uint16_t i = 0; i < spineCount; i++) {
cumulativeSizes.push_back(readSpineEntry(bookFile).cumulativeSize);
}
loaded = true;
LOG_DBG("BMC", "Loaded cache data: %d spine, %d TOC entries", spineCount, tocCount);
return true;
}
uint32_t BookMetadataCache::getCumulativeSize(const int index) const {
if (index < 0 || index >= static_cast<int>(cumulativeSizes.size())) {
return 0;
}
return cumulativeSizes[index];
}
BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) {
if (!loaded) {
LOG_ERR("BMC", "getSpineEntry called but cache not loaded");
-9
View File
@@ -7,7 +7,6 @@
#include <deque>
#include <memory>
#include <string>
#include <vector>
class BookMetadataCache {
public:
@@ -63,11 +62,6 @@ class BookMetadataCache {
// wrapper serves whichever pass is active (spine, then toc).
std::unique_ptr<serialization::BufferedFileWriter> passOut;
// Cumulative spine sizes, cached in RAM at load() so progress/percent lookups are
// O(1) instead of 2 seeks + a heap-allocating SpineEntry read per access (4 bytes
// per spine item; <1KB for typical books).
std::vector<uint32_t> cumulativeSizes;
// Index for fast href→spineIndex lookup (used only for large EPUBs)
struct SpineHrefIndexEntry {
uint64_t hrefHash; // FNV-1a 64-bit hash
@@ -119,9 +113,6 @@ class BookMetadataCache {
bool load();
SpineEntry getSpineEntry(int index);
TocEntry getTocEntry(int index);
// Cumulative byte size up to and including the given spine item (0 if out of range
// or not loaded). Backed by the in-RAM cumulativeSizes cache populated in load().
uint32_t getCumulativeSize(int index) const;
int getSpineCount() const { return spineCount; }
int getTocCount() const { return tocCount; }
bool isLoaded() const { return loaded; }
+1 -4
View File
@@ -3,10 +3,7 @@
#include <cstring>
#define FOOTNOTE_NUMBER_LEN 32
#define FOOTNOTE_HREF_LEN 256
// ponytail: bumped from 96 to 256; calibre-generated EPUBs with long filenames
// and URL-encoded characters routinely exceed 96 chars (e.g.
// "Author-Title_split_NNN.html#_ftnN" encoded is ~150 chars).
#define FOOTNOTE_HREF_LEN 96
struct FootnoteEntry {
char number[FOOTNOTE_NUMBER_LEN];
+1 -6
View File
@@ -31,12 +31,7 @@ namespace {
// match. Keeps <br>-per-paragraph books (common CJK formatting) from
// re-adding container spacing at every paragraph.
// v35: Persist a uint32_t visible-text start offset for every page.
// v36: Ruby and CJK justification layout changes invalidate cached word positions.
// v37: Footnote href records grew from 96 to 256 bytes.
// v38: Image top margin is clamped so a full-viewport-height image cannot
// overflow the page bottom; older caches can hold placements that panels
// with no bottom inset refuse to draw.
constexpr uint8_t SECTION_FILE_VERSION = 38;
constexpr uint8_t SECTION_FILE_VERSION = 36;
// Written into the version field while a build is in progress; patched to
// SECTION_FILE_VERSION only when the build is finalized. An abandoned /
// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects
-2
View File
@@ -357,7 +357,6 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
// Try to render from cache first
std::string cachePath = getCachePath(imagePath);
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
renderer.preserveImagePolarity(x, y, width, height);
return; // Successfully rendered from cache
}
@@ -420,7 +419,6 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
return;
}
renderer.preserveImagePolarity(x, y, width, height);
LOG_DBG("IMG", "Decode successful");
}
@@ -1,28 +1,16 @@
#include "ImageToFramebufferDecoder.h"
#include <Arduino.h>
#include <Logging.h>
bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) {
// 64-bit multiply: a malformed header (e.g. 40000x60000) overflows a plain
// int product before the comparison can reject it.
const int64_t pixels = static_cast<int64_t>(width) * static_cast<int64_t>(height);
if (pixels > MAX_SOURCE_PIXELS) {
LOG_ERR("IMG", "Image too large (%dx%d = %lld pixels %s), max supported: %lld pixels", width, height,
static_cast<long long>(pixels), format.c_str(), static_cast<long long>(MAX_SOURCE_PIXELS));
if (width * height > MAX_SOURCE_PIXELS) {
LOG_ERR("IMG", "Image too large (%dx%d = %d pixels %s), max supported: %d pixels", width, height, width * height,
format.c_str(), MAX_SOURCE_PIXELS);
return false;
}
return true;
}
void ImageToFramebufferDecoder::yieldDuringDecode(uint32_t& lastYieldMs) {
const uint32_t now = millis();
if (now - lastYieldMs >= 250) {
lastYieldMs = now;
vTaskDelay(1);
}
}
void ImageToFramebufferDecoder::warnUnsupportedFeature(const std::string& feature, const std::string& imagePath) {
LOG_ERR("IMG", "Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.", feature.c_str(),
imagePath.c_str());
@@ -31,21 +31,9 @@ class ImageToFramebufferDecoder {
virtual const char* getFormatName() const = 0;
// Call from per-row/per-MCU decode callbacks (free functions, hence public):
// yields one tick at most every 250 ms so multi-second decodes keep the idle
// task (and its watchdog) fed. `lastYieldMs` is caller-held state,
// initialized to the decode start time.
static void yieldDuringDecode(uint32_t& lastYieldMs);
protected:
// Size validation helpers. The cap bounds decode TIME, not memory: both decoders
// stream (JPEG in MCU bands at 1/2..1/8 coarse scale, PNG scanline-by-scanline
// with its own width-based row-buffer guard), so RAM never scales with source
// area. 8 MP admits real-world ebook covers (KDP recommends 1600x2560 and
// 2000x3000) while keeping a worst-case single decode in single-digit seconds;
// the row callbacks yield periodically so a long decode cannot starve the idle
// task's watchdog.
static constexpr int64_t MAX_SOURCE_PIXELS = 8388608; // 8 MP (e.g. 2048 * 4096)
// Size validation helpers
static constexpr int MAX_SOURCE_PIXELS = 3145728; // 2048 * 1536
bool validateImageDimensions(int width, int height, const std::string& format);
void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath);
@@ -46,8 +46,6 @@ struct JpegContext {
PixelCache cache;
bool caching{false};
uint32_t lastYieldMs{0}; // throttle state for yieldDuringDecode()
};
// File I/O callbacks use pFile->fHandle to access the HalFile*,
@@ -124,8 +122,6 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
JpegContext* ctx = reinterpret_cast<JpegContext*>(pDraw->pUser);
if (!ctx || !ctx->config || !ctx->renderer) return 0;
ImageToFramebufferDecoder::yieldDuringDecode(ctx->lastYieldMs);
// In EIGHT_BIT_GRAYSCALE mode, pPixels contains 8-bit grayscale values
// Buffer is densely packed: stride = pDraw->iWidth, valid columns = pDraw->iWidthUsed
uint8_t* pixels = reinterpret_cast<uint8_t*>(pDraw->pPixels);
@@ -498,7 +494,6 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
}
unsigned long decodeStart = millis();
ctx.lastYieldMs = decodeStart;
rc = jpeg->decode(0, 0, jpegScaleOption);
unsigned long decodeTime = millis() - decodeStart;
@@ -38,8 +38,6 @@ struct PngContext {
bool caching{false};
uint8_t* grayLineBuffer{nullptr};
uint32_t lastYieldMs{0}; // throttle state for yieldDuringDecode()
};
// File I/O callbacks use pFile->fHandle to access the HalFile*,
@@ -208,8 +206,6 @@ int pngDrawCallback(PNGDRAW* pDraw) {
PngContext* ctx = reinterpret_cast<PngContext*>(pDraw->pUser);
if (!ctx || !ctx->config || !ctx->renderer || !ctx->grayLineBuffer) return 0;
ImageToFramebufferDecoder::yieldDuringDecode(ctx->lastYieldMs);
int srcY = pDraw->y;
int srcWidth = ctx->srcWidth;
@@ -436,7 +432,6 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
}
unsigned long decodeStart = millis();
ctx.lastYieldMs = decodeStart;
rc = png->decode(&ctx, 0);
unsigned long decodeTime = millis() - decodeStart;
@@ -793,18 +793,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->currentPageVisibleOffsetSet = false;
}
// Apply top margin from container block. Clamp it so the image never
// overflows the page bottom: a full-viewport-height image leaves no room
// for the margin, and the break above only fires on non-empty pages, so a
// fresh page would otherwise place the image at y=marginTop and run
// marginTop pixels past viewportHeight. A large bottom reserve (status
// bar / big screen margin) absorbs that overflow silently, but with a
// thin reserve it crosses the physical screen edge and fails
// ImageBlock::render's bounds check, dropping the image entirely.
if (self->currentPageNextY + imageMarginTop + displayHeight > self->viewportHeight) {
const int room = self->viewportHeight - displayHeight - self->currentPageNextY;
imageMarginTop = static_cast<int16_t>(room > 0 ? room : 0);
}
// Apply top margin from container block
self->currentPageNextY += imageMarginTop;
// Create ImageBlock and add to page
+16 -65
View File
@@ -1,7 +1,6 @@
#include "GfxRenderer.h"
#include <BidiUtils.h>
#include <BoardConfig.h>
#include <BuildScratch.h>
#include <FontDecompressor.h>
#include <HalGPIO.h>
@@ -1355,12 +1354,6 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
free(outputRow);
free(rowBytes);
const int sourceWidth = bitmap.getWidth() - cropPixX * 2;
const int sourceHeight = bitmap.getHeight() - cropPixY * 2;
const int renderedWidth = isScaled ? static_cast<int>(std::floor((sourceWidth - 1) * scale)) + 1 : sourceWidth;
const int renderedHeight = isScaled ? static_cast<int>(std::floor((sourceHeight - 1) * scale)) + 1 : sourceHeight;
preserveImagePolarity(x, y, renderedWidth, renderedHeight);
}
void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth,
@@ -1430,45 +1423,6 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
free(outputRow);
free(rowBytes);
const int renderedWidth =
isScaled ? static_cast<int>(std::floor((bitmap.getWidth() - 1) * scale)) + 1 : bitmap.getWidth();
const int renderedHeight =
isScaled ? static_cast<int>(std::floor((bitmap.getHeight() - 1) * scale)) + 1 : bitmap.getHeight();
preserveImagePolarity(x, y, renderedWidth, renderedHeight);
}
void GfxRenderer::preserveImagePolarity(const int x, const int y, const int width, const int height) const {
if (renderMode != BW || !display.isInverted() || _stripActive || !frameBuffer || width <= 0 || height <= 0) {
return;
}
int ax, ay, bx, by;
rotateCoordinates(orientation, x, y, &ax, &ay, panelWidth, panelHeight);
rotateCoordinates(orientation, x + width - 1, y + height - 1, &bx, &by, panelWidth, panelHeight);
int left = std::max(0, std::min(ax, bx));
int right = std::min(static_cast<int>(panelWidth) - 1, std::max(ax, bx));
int top = std::max(0, std::min(ay, by));
int bottom = std::min(static_cast<int>(panelHeight) - 1, std::max(ay, by));
if (left > right || top > bottom) return;
for (int row = top; row <= bottom; row++) {
uint8_t* rowData = frameBuffer + static_cast<uint32_t>(row) * panelWidthBytes;
int col = left;
while (col <= right && (col & 7) != 0) {
rowData[col >> 3] ^= static_cast<uint8_t>(0x80U >> (col & 7));
col++;
}
while (col + 7 <= right) {
rowData[col >> 3] ^= 0xFF;
col += 8;
}
while (col <= right) {
rowData[col >> 3] ^= static_cast<uint8_t>(0x80U >> (col & 7));
col++;
}
}
}
void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state) const {
@@ -2221,33 +2175,30 @@ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
}
void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const {
// Board truth: the bezel insets live in the active profile (panel-native
// portrait frame); this only rotates them into the current orientation.
const BoardConfig::ViewableInsets& vi = BoardConfig::ACTIVE.viewableInsets;
switch (orientation) {
case Portrait:
*outTop = vi.top;
*outRight = vi.right;
*outBottom = vi.bottom;
*outLeft = vi.left;
*outTop = VIEWABLE_MARGIN_TOP;
*outRight = VIEWABLE_MARGIN_RIGHT;
*outBottom = VIEWABLE_MARGIN_BOTTOM;
*outLeft = VIEWABLE_MARGIN_LEFT;
break;
case LandscapeClockwise:
*outTop = vi.left;
*outRight = vi.top;
*outBottom = vi.right;
*outLeft = vi.bottom;
*outTop = VIEWABLE_MARGIN_LEFT;
*outRight = VIEWABLE_MARGIN_TOP;
*outBottom = VIEWABLE_MARGIN_RIGHT;
*outLeft = VIEWABLE_MARGIN_BOTTOM;
break;
case PortraitInverted:
*outTop = vi.bottom;
*outRight = vi.left;
*outBottom = vi.top;
*outLeft = vi.right;
*outTop = VIEWABLE_MARGIN_BOTTOM;
*outRight = VIEWABLE_MARGIN_LEFT;
*outBottom = VIEWABLE_MARGIN_TOP;
*outLeft = VIEWABLE_MARGIN_RIGHT;
break;
case LandscapeCounterClockwise:
*outTop = vi.right;
*outRight = vi.bottom;
*outBottom = vi.left;
*outLeft = vi.top;
*outTop = VIEWABLE_MARGIN_RIGHT;
*outRight = VIEWABLE_MARGIN_BOTTOM;
*outBottom = VIEWABLE_MARGIN_LEFT;
*outLeft = VIEWABLE_MARGIN_TOP;
break;
}
}
+5 -3
View File
@@ -109,6 +109,11 @@ class GfxRenderer {
: display(halDisplay), renderMode(BW), orientation(Portrait), fadingFix(false) {}
~GfxRenderer() { freeBwBufferChunks(); }
static constexpr int VIEWABLE_MARGIN_TOP = 9;
static constexpr int VIEWABLE_MARGIN_RIGHT = 3;
static constexpr int VIEWABLE_MARGIN_BOTTOM = 3;
static constexpr int VIEWABLE_MARGIN_LEFT = 3;
// Setup
void begin(); // must be called right after display.begin()
void insertFont(int fontId, EpdFontFamily font);
@@ -224,9 +229,6 @@ class GfxRenderer {
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
float cropY = 0) const;
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
// Counter-invert content images in the logical framebuffer so output-level
// dark mode leaves their original polarity unchanged.
void preserveImagePolarity(int x, int y, int width, int height) const;
void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const;
// Snapshot / restore a screen-coordinate framebuffer region (byte-aligned in
Loaded 20 of 92 files, more files were not shown because too many files have changed in this diff. Show more