mirror of
https://github.com/crosspoint-reader/crosspoint-reader.git
synced 2026-08-13 14:21:57 -07:00
Compare commits
32
Commits
No files matched your search
@@ -63,7 +63,8 @@ jobs:
|
||||
- name: Run cppcheck
|
||||
run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high
|
||||
|
||||
build:
|
||||
build-default:
|
||||
name: Build default
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -90,14 +91,10 @@ jobs:
|
||||
key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }}
|
||||
restore-keys: pio-${{ runner.os }}-
|
||||
|
||||
- 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.
|
||||
- name: Build default firmware
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pio run -e default -e sticky | tee pio.log
|
||||
pio run -e default | tee pio.log
|
||||
|
||||
- name: Extract firmware stats
|
||||
|
||||
@@ -120,6 +117,52 @@ 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:
|
||||
@@ -152,7 +195,8 @@ jobs:
|
||||
test-status:
|
||||
name: Test Status
|
||||
needs:
|
||||
- build
|
||||
- build-default
|
||||
- build-sticky
|
||||
- clang-format
|
||||
- cppcheck
|
||||
- unit-tests
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
name: "PR Formatting"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
@@ -9,7 +8,7 @@ on:
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
statuses: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
title-check:
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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
|
||||
+2
-2
@@ -29,6 +29,6 @@ lib/EpdFont/scripts/output/
|
||||
/.dummy
|
||||
/CMakeLists.txt
|
||||
/dependencies.lock
|
||||
/sdkconfig.default
|
||||
/sdkconfig.defaults
|
||||
/sdkconfig.*
|
||||
sdkconfig.sticky
|
||||
flake.*
|
||||
+179
-68
File diff suppressed because it is too large.
Load diff
@@ -36,7 +36,7 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f
|
||||
- OPDS browser with saved servers (up to 8), search, pagination, and direct download
|
||||
- OTA update checks and installs from GitHub releases
|
||||
|
||||
- **Customization**: multiple themes (Classic, Lyra, Lyra Extended, RoundedRaff), sleep screen modes, front/side button remapping, status bar controls, power-button behavior, refresh cadence, and more.
|
||||
- **Customization**: multiple themes (Classic, Lyra, Lyra Extended, RoundedRaff), sleep screen modes including transparent overlays, front/side button remapping, status bar controls, power-button behavior, refresh cadence, and more.
|
||||
|
||||
- **Localization**: 24 UI languages and counting. RTL support.
|
||||
|
||||
@@ -136,7 +136,7 @@ Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py`
|
||||
- [Web server endpoints](./docs/webserver-endpoints.md)
|
||||
- [Project scope](./SCOPE.md)
|
||||
- [Contributing docs](./docs/contributing/README.md)
|
||||
- [Touch and UI development](./docs/contributing/touch-and-ui.md) - FreeInkUI components for new screens, the touch bridge for existing ones, and build envs for the non-Xteink touch devices
|
||||
- [Touch and UI development](./docs/contributing/touch-and-ui.md) - how to build new screens on the FreeInkUI activity bases (UiListActivity and friends), plus build envs for the non-Xteink touch devices
|
||||
|
||||
---
|
||||
|
||||
|
||||
+14
-4
@@ -186,7 +186,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
||||
- "None" - A blank screen
|
||||
- "Cover + Custom" - The book cover image while actively reading, falls back to "Custom" behavior otherwise
|
||||
- "Quick resume" - The text of the last page read will be displayed on the sleep screen and a moon icon is shown on the edge of the screen. Waking up the device will return to the same page of the opened book. This is useful for quickly resuming reading without waiting for the device to fully wake up and load the book.
|
||||
|
||||
- "Transparent" - A transparent overlay image drawn over the current screen; see [Sleep Screen](#37-sleep-screen) below for more information
|
||||
- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected:
|
||||
|
||||
- "Fit" (default) - Scale the image down to fit centered on the screen, padding with white borders as necessary
|
||||
@@ -501,6 +501,7 @@ The **Sleep Screen** setting controls what is displayed when the device goes to
|
||||
| **Custom** | A custom image from the SD card (see below). Falls back to **Dark** if no custom image is found. |
|
||||
| **Cover** | The cover of the currently open book. Falls back to **Dark** if no book is open. |
|
||||
| **Cover + Custom** | The cover of the currently open book, shown only while actively reading. Falls back to **Custom** behavior when not reading. |
|
||||
| **Transparent** | A BMP overlay drawn over the current screen. Supports alpha transparency in 32-bit BGRA images and treats white as transparent in regular BMPs. Falls back to **Dark** if no valid overlay image is found. |
|
||||
| **None** | A blank screen. |
|
||||
|
||||
#### Cover settings
|
||||
@@ -515,12 +516,21 @@ When using **Cover** or **Cover + Custom**, two additional settings apply:
|
||||
To use custom sleep images, set the sleep screen mode to **Custom** or **Cover + Custom**, then place images on the SD card:
|
||||
|
||||
- **Multiple Images (recommended):** Create a `.sleep` directory in the root of the SD card and place any number of `.bmp` images inside. One will be randomly selected each time the device sleeps. (A directory named `sleep` is also accepted as a fallback.)
|
||||
- **Single Image:** Place a file named `sleep.bmp` in the root directory. This is used as a fallback if no valid images are found in the `.sleep`/`sleep` directory.
|
||||
- **Single Image:** Place a file named `sleep.bmp` in the root directory. This takes priority over the `.sleep`/`sleep` directories.
|
||||
|
||||
#### Transparent overlay images
|
||||
|
||||
To use transparent sleep overlays, set the sleep screen mode to **Transparent**, then place BMP files on the SD card:
|
||||
|
||||
- **Multiple Images (recommended):** Create a `.sleep-overlay` directory in the root of the SD card and place any number of valid overlay `.bmp` images inside. One will be randomly selected each time the device sleeps. A directory named `sleep-overlay` is also accepted as a fallback.
|
||||
- **Single Image:** Place a file named `sleep-overlay.bmp` in the root directory. This takes priority over the `.sleep-overlay`/`sleep-overlay` directories.
|
||||
|
||||
Transparent overlay files are intentionally separate from normal sleep images. Regular BMP formats supported by CrossPoint are accepted; white pixels leave the existing screen unchanged. For per-pixel alpha transparency, use a 32-bit BGRA BMP with both visible and non-opaque pixels. Opaque white pixels in these alpha images erase the content behind them.
|
||||
|
||||
> [!TIP]
|
||||
> For best results:
|
||||
>
|
||||
> - Use uncompressed BMP files with 24-bit color depth
|
||||
> - For non-transparent **Custom** mode, use uncompressed BMP files with 24-bit color depth.
|
||||
> - For **Transparent** mode, use a regular BMP for white-as-transparent artwork or an uncompressed 32-bit BGRA BMP for per-pixel alpha.
|
||||
> - X4: Use a resolution of 480x800 pixels to match the device's screen resolution.
|
||||
> - X3: Use a resolution of 528x792 pixels to match the device's screen resolution.
|
||||
|
||||
|
||||
@@ -1,23 +1,88 @@
|
||||
# Touch and UI Development
|
||||
|
||||
CrossPoint now runs on touch devices (Seeed Sticky, M5Paper, LilyGo T5) alongside the button-only Xteink X3/X4. Every screen must work with both input styles. There are two supported ways to get there:
|
||||
CrossPoint runs on touch devices (Seeed Sticky, M5Paper, LilyGo T5) alongside the button-only Xteink X3/X4. Every screen must work with both input styles.
|
||||
|
||||
1. **New screens: build them with FreeInkUI components.** Touch hit-testing, tap highlighting, long-press, and button focus navigation come with the component; you never hand-roll coordinate math.
|
||||
2. **Existing screens and in-flight features: use the MappedInputManager touch bridge.** A small set of helpers adds tap/hold/swipe support to hand-rolled rendering without restructuring the activity.
|
||||
**There is one supported way to build a new screen: FreeInkUI, hosted through the firmware base classes below.** Touch hit-testing, tap highlighting, long-press, swipe scrolling, and button focus navigation all come from the shared stack; you never hand-roll coordinate math.
|
||||
|
||||
If you are starting a new activity, use FreeInkUI. If you have a feature branch with a hand-rolled screen already working on buttons, use the bridge; do not rewrite mid-flight.
|
||||
The old bridge helpers (`rowTouch`, `colTouch`, `wasTapInRect`, manual rect `contains()` checks) are legacy. They survive only for the two remaining hand-rolled surfaces (the theme-driven home screen and the reader page) and must not appear in new code. PRs that add new uses will be asked to convert.
|
||||
|
||||
---
|
||||
|
||||
## Path 1: New screens with FreeInkUI
|
||||
## Picking the right host
|
||||
|
||||
FreeInkUI (`freeink-sdk/libs/ui/FreeInkUI`, namespace `freeink::ui`) is an immediate-mode component library. The core idea:
|
||||
| Your screen is... | Use | In-tree reference |
|
||||
|---|---|---|
|
||||
| A single list of rows | subclass `UiListActivity` | [`LanguageSelectActivity`](../../src/activities/settings/LanguageSelectActivity.cpp) (minimal), [`RecentBooksActivity`](../../src/activities/home/RecentBooksActivity.cpp) (long-press) |
|
||||
| Tabbed lists | subclass `UiTabListActivity` | [`SettingsActivity`](../../src/activities/settings/SettingsActivity.cpp) |
|
||||
| Custom FUI layout (sliders, prompts, state machines) | inherit `UiAppHost` directly | [`EpubReaderPercentSelectionActivity`](../../src/activities/reader/EpubReaderPercentSelectionActivity.cpp), [`WifiSelectionActivity`](../../src/activities/network/WifiSelectionActivity.cpp) |
|
||||
| A modal picker or confirm inside a legacy activity | `OptionPopup` (or push `ConfirmationActivity`) | [`OtaUpdateActivity`](../../src/activities/settings/OtaUpdateActivity.cpp) |
|
||||
| A Yes/No prompt inside a FUI screen | build `fui::optionDialog` into the screen | `WifiSelectionActivity::buildPromptDialog` |
|
||||
|
||||
- While a component renders, it registers its tappable areas ("hit rects") into the frame's interaction buffer via `Frame::hit(rect, action, value, inputMask, state)`.
|
||||
- Each loop you build an `InputSnapshot` from the input manager and route it against that buffer. If a tap (or mapped button press) lands in a registered rect, the routed `ActionEvent` tells you which action fired and with what value.
|
||||
- Components query `frame.stateFor(action, value)` while painting, so touch-down highlight and focus states render correctly without any per-activity code.
|
||||
All four paths route input through the same SDK interaction table, so touch and physical buttons fire the same actions with no per-screen coordinate code.
|
||||
|
||||
You get touch, hold highlighting, long-press, minimum touch-target sizing, and orientation-aware coordinates for free. The same action IDs fire from physical buttons, so one code path serves both input styles.
|
||||
## The hosting stack
|
||||
|
||||
`UiAppHost` ([src/components/UiAppHost.h](../../src/components/UiAppHost.h)) owns what every FUI screen shares: the font-bound render target, the `FreeInkApp`, and the `uiReady` handshake that lets the loop task route touch snapshots against the interaction table the render task rebuilds. Never re-implement that handshake; it is a cross-task protocol and lives in exactly one place.
|
||||
|
||||
`UiListActivity` ([src/activities/UiListActivity.h](../../src/activities/UiListActivity.h)) layers the list protocol on top: swipes scroll the viewport without moving the selection, buttons move the selection and pull the viewport along (`fui::ListNav`), plus the render skeleton (header chrome, app, footer hints). `UiTabListActivity` is the tab-bar sibling; its selection ring puts the tab bar at position 0 and the rows after it.
|
||||
|
||||
### New list screen skeleton
|
||||
|
||||
Subclasses supply the data; the base owns the loop. The whole contract:
|
||||
|
||||
```cpp
|
||||
class MyListActivity final : public UiListActivity {
|
||||
public:
|
||||
MyListActivity(GfxRenderer& r, MappedInputManager& in) : UiListActivity("MyList", r, in) {}
|
||||
|
||||
private:
|
||||
std::vector<Entry> entries; // the activity's data, loaded in onEnter
|
||||
std::vector<fui::ListItem> rows; // activity-owned row cache: rebuilt only when entries changes, reused every render
|
||||
|
||||
int listCount() const override { return entries.size(); }
|
||||
const char* headerTitle() const override { return tr(STR_MY_TITLE); }
|
||||
|
||||
void onEnter() override {
|
||||
UiListActivity::onEnter();
|
||||
entries = /* ... load from wherever ... */;
|
||||
rebuildRows();
|
||||
}
|
||||
|
||||
// Called whenever entries changes (here, only onEnter; a mutable list would
|
||||
// also call this after any add/remove). NOT called from buildScreen().
|
||||
void rebuildRows() {
|
||||
rows.clear();
|
||||
rows.reserve(entries.size());
|
||||
// ... one fui::ListItem per entry (label, actionValue = index) ...
|
||||
}
|
||||
|
||||
void buildScreen(UiScreen& screen) override {
|
||||
// set content margin from the theme safe area, then:
|
||||
fui::ListProps props;
|
||||
props.items = rows.data();
|
||||
props.count = rows.size();
|
||||
props.action = ACTION_ROW;
|
||||
props.inputMask = fui::InputTouch; // physical buttons stay in the base loop
|
||||
syncListViewport(screen, props); // selection/viewport handoff, always right before list()
|
||||
screen.list(props);
|
||||
}
|
||||
|
||||
void activateIndex(int index) override {
|
||||
app.clearTapFlash(); // leaving the screen: a lingering flash would gray the next render
|
||||
// ... open the thing ...
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
See [`FileBrowserActivity`](../../src/activities/home/FileBrowserActivity.cpp)'s `rebuildRowItems()` for this pattern applied to a directory listing that can run into the hundreds of entries, and `LanguageSelectActivity.cpp` for the rest of the skeleton (content-margin math, footer, etc.) — note that file still builds its row vector locally inside `buildScreen()`; match `FileBrowserActivity`, not that file, for the row cache. Optional overrides: `onRowLongPress(index)`, `drawFooter()`, `handleButtons()` for extra physical-button handling, and `ACTION_USER`-and-up action ids for non-row elements (register handlers in `onEnter` after the base's).
|
||||
|
||||
### Rules that apply to every FUI screen
|
||||
|
||||
- Register actions with `app.on(...)` in `onEnter` (after `resetUi()` for direct `UiAppHost` users). Handlers receive a `void* user` you cast back to the activity.
|
||||
- Handlers that leave the current screen call `app.clearTapFlash()` first.
|
||||
- Theme tokens are shared and bound by `resetUi()`; never call `app.setTheme` yourself. Metrics flow from the active UITheme through [`UIThemeTokens.h`](../../src/components/UIThemeTokens.h), including the per-board bezel insets that keep scrollbars visible.
|
||||
- `TextStyle.maxLines` defaults to 1 and truncates with an ellipsis. Set `maxLines` explicitly on any dialog headline or message that can wrap.
|
||||
- Everything stays allocation-free in steady state. A local `std::vector` inside `buildScreen()` is **not** allocation-free even with `reserve()` first: it starts at zero capacity on every call, `reserve()` allocates, and the destructor frees that storage before the call returns — real allocator work and fragmentation risk on every repaint (cursor move, tap flash, ...), not just on data changes. Build `ListItem` rows into activity-owned storage instead, reserved once when the underlying data loads (`onEnter()`/a `load*()` — see the skeleton above and `FileBrowserActivity::rebuildRowItems()`), and reused unchanged by every `buildScreen()` call. Use a fixed-capacity array (e.g. `ListItem rows[MAX]`, as `OptionPopup` and `KOReaderSyncActivity`'s action rows do) when the count is small and bounded. Do not hold FUI `props` across renders — only the row storage they point into.
|
||||
|
||||
### Component inventory
|
||||
|
||||
@@ -33,106 +98,11 @@ All under `freeink-sdk/libs/ui/FreeInkUI/include/components/`:
|
||||
| Media | `book-card`, `cover-grid`, `cover-carousel`, `metric-card` |
|
||||
| Text | `text-field`, `text-area` |
|
||||
|
||||
### Integration skeleton
|
||||
|
||||
The in-tree reference is [`src/activities/util/KeyboardEntryActivity.cpp`](../../src/activities/util/KeyboardEntryActivity.cpp); it drives the FreeInkUI keyboard component inside a normal Activity. The shape:
|
||||
|
||||
```cpp
|
||||
#include <FreeInkUIGfxRenderer.h>
|
||||
namespace fui = freeink::ui;
|
||||
|
||||
// Render step: draw the component and (re)register its hit rects.
|
||||
fui::GfxRendererTarget target(renderer); // adapts GfxRenderer to FreeInkUI's DrawTarget
|
||||
target.setFont(fui::GfxRendererTarget::FONT_BODY, UI_12_FONT_ID);
|
||||
const fui::DeviceContext device = target.deviceContext(); // orientation, safe area, touch sizing
|
||||
|
||||
fui::Frame<48> frame(target, device, fui::InputSnapshot{}, interactions); // 48 = max hit rects
|
||||
fui::KeyboardProps props;
|
||||
props.layout = ¤tLayout();
|
||||
props.keyAction = ACTION_KEY; // one action id; the key is the event value
|
||||
props.inputMask = fui::InputTouch | fui::InputLongPress;
|
||||
fui::keyboard(frame, kbRect, props); // draws AND registers hit rects
|
||||
```
|
||||
|
||||
```cpp
|
||||
// Input step (each loop): feed touch state, act on routed events.
|
||||
int tx, ty, tapX, tapY, hx, hy;
|
||||
const bool pressedDown = mappedInput.wasScreenTouchDown(tx, ty);
|
||||
const bool tapped = mappedInput.wasScreenTapped(tapX, tapY);
|
||||
const bool inContact = mappedInput.isScreenTouchHeld(hx, hy);
|
||||
|
||||
const auto result = touchRouter.update(interactions, pressedDown, tx, ty,
|
||||
tapped, tapX, tapY, inContact, millis());
|
||||
if (result.event) {
|
||||
activateValue(result.event.value, result.event.longPress);
|
||||
requestUpdate();
|
||||
} else if (result.activeChanged) {
|
||||
requestUpdate(); // repaint touch-down highlight
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `Frame<N>` is templated on the max number of hit rects; the buffer is stack/static, no heap.
|
||||
- `fui::TouchHoldRouter` (see `components/keyboard/keyboard.h`) synthesizes long-press while the finger is still down and swallows the eventual release. Use it whenever a component distinguishes tap from long-press.
|
||||
- For simpler screens without long-press, `FreeInkApp` / `Screen<N>` (`FreeInkApp.h`) is the ergonomic layer: it owns the interaction buffer, offers a top-to-bottom builder (`takeTop`, footer helpers, etc.), and its `render(input)` call routes the snapshot and dispatches registered action callbacks in one step. `snapshotFrom(input, device)` in `FreeInkUIInputManager.h` builds the `InputSnapshot` with orientation-aware touch mapping.
|
||||
- Everything stays allocation-free and works on the button-only devices unchanged: physical buttons route through the same interaction table via each hit rect's `inputMask`.
|
||||
One exception to "always go through a host": `KeyboardEntryActivity` drives the keyboard component with a raw `fui::Frame` and `TouchHoldRouter` because per-key hold repeat needs its own routing. If you think your screen needs that, raise it in the PR first.
|
||||
|
||||
---
|
||||
|
||||
## Path 2: Adding touch to existing hand-rolled screens
|
||||
|
||||
For activities that draw their own rows, menus, and buttons, `MappedInputManager` ([src/MappedInputManager.h](../../src/MappedInputManager.h)) exposes bridge helpers. They all return **logical screen coordinates** (orientation already applied via `GfxRenderer::tapToLogical`); never touch raw normalized panel coordinates or the SDK `InputManager` directly.
|
||||
|
||||
| Helper | Use for |
|
||||
|---|---|
|
||||
| `wasScreenTapped(x, y)` | A completed tap (press + release), with logical coords |
|
||||
| `wasScreenTouchDown(x, y)` | Touch-down (held > 90 ms, not yet released): draw selection highlight |
|
||||
| `isScreenTouchHeld(x, y)` | Live contact position while the finger is down (drag tracking) |
|
||||
| `wasTapInRect(x, y, w, h)` | One-off hit test on a rectangle (a single button, a banner) |
|
||||
| `wasListItemTapped(index, count, selected, listTop, listHeight, hasSubtitle)` | Taps on a standard UITheme list; does the row/paging math for you |
|
||||
| `wasListItemTouchedDown(...)` | Same geometry, touch-down phase (highlight before activate) |
|
||||
| `rowTouch(row, top, rowStep, rowCount, xStart, xEnd, rowHeight)` | Any custom band of equal-height rows; returns `RowTouch::None/Down/Tap` |
|
||||
| `colTouch(col, left, colStep, colCount, yStart, yEnd, colWidth)` | Horizontal button bands (dialogs, prompts) |
|
||||
| `wasSwipe()` | Raw swipe direction if you need one beyond the global gestures |
|
||||
| `hasTouch()` | True when the device has a touch panel (rarely needed; helpers simply never fire without one) |
|
||||
|
||||
### Pattern A: standard themed list
|
||||
|
||||
One call per loop; UITheme owns the row geometry ([EpubReaderBookmarksActivity.cpp:123](../../src/activities/reader/EpubReaderBookmarksActivity.cpp)):
|
||||
|
||||
```cpp
|
||||
int tapped = -1;
|
||||
if (mappedInput.wasListItemTapped(tapped, bookmarks.size(), selectorIndex, listY, listHeight, true)) {
|
||||
selectorIndex = tapped;
|
||||
openBookmark();
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern B: custom rows with hold highlight
|
||||
|
||||
For non-theme row layouts, use `rowTouch` and distinguish `Down` (highlight) from `Tap` (activate), as in [EpubReaderFootnotesActivity.cpp](../../src/activities/reader/EpubReaderFootnotesActivity.cpp):
|
||||
|
||||
```cpp
|
||||
int row = -1;
|
||||
const auto touch = mappedInput.rowTouch(row, listTop, lineHeight, visibleCount,
|
||||
contentX, contentX + contentWidth);
|
||||
if (touch != MappedInputManager::RowTouch::None) {
|
||||
const int touched = scrollOffset + row;
|
||||
if (touch == MappedInputManager::RowTouch::Down) {
|
||||
if (selectedIndex != touched) { selectedIndex = touched; requestUpdate(); }
|
||||
} else { // RowTouch::Tap
|
||||
selectedIndex = touched;
|
||||
activateSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
The `Down` state exists because e-ink repaints are slow: highlight on touch-down gives immediate feedback, activation happens on release.
|
||||
|
||||
### Global gestures: do not reimplement these
|
||||
## Global gestures: do not reimplement these
|
||||
|
||||
Three gestures are handled once, for every screen. Activities must not add their own edge-swipe handling:
|
||||
|
||||
@@ -142,14 +112,25 @@ Three gestures are handled once, for every screen. Activities must not add their
|
||||
| Home | Up-swipe starting in the bottom 14% | `ActivityManager::loop()`; pops to Home (activities can override via `handleHomeGesture()`) |
|
||||
| Menu | Down-swipe starting in the top 14% | Activities that have a menu check `wasMenuGesture()` themselves (the reader does this) |
|
||||
|
||||
Because the back gesture arrives as `Button::Back`, most button-era activities gain back-swipe support with zero changes. That is the bar to aim for: bridge helpers should make touch an additive layer over the button flow, not a second input state machine.
|
||||
Because the back gesture arrives as `Button::Back`, most button-era activities gain back-swipe support with zero changes.
|
||||
|
||||
### Bridge rules
|
||||
---
|
||||
|
||||
- Handle touch in `loop()` next to the existing button handling, one helper call per interaction zone, and `return` after consuming an event (mirrors the button pattern).
|
||||
- Never call the SDK `InputManager` or read GPIO directly; the HAL rule from the main guide applies to touch too.
|
||||
- Coordinates from the helpers are logical and orientation-correct on all four rotations; test at least Portrait and one Landscape mode before PR.
|
||||
- Nothing to clean up in `onExit()`; the helpers are stateless from the activity's point of view.
|
||||
## Legacy bridge helpers (do not use in new code)
|
||||
|
||||
`MappedInputManager` still exposes raw touch accessors. The FUI stack consumes them internally (via `touchSnapshotFrom` in [`UiAppHelpers.h`](../../src/components/UiAppHelpers.h)); activities should not.
|
||||
|
||||
| Helper | Status |
|
||||
|---|---|
|
||||
| `wasScreenTapped` / `wasScreenTouchDown` / `isScreenTouchHeld` | Consumed by the FUI snapshot builder. Direct use only in the two legacy surfaces |
|
||||
| `wasTapInRect(x, y, w, h)` | Legacy one-off hit test |
|
||||
| `rowTouch` / `colTouch` | Legacy row/column band math. Sole remaining user: `HomeActivity` (theme-driven layout) |
|
||||
| `wasSwipe()` | Raw swipe direction, for behavior beyond the global gestures (reader page turns) |
|
||||
| `hasTouch()` | Still fine anywhere: gate touch-only chrome (on-screen Cancel/OK pairs) on it |
|
||||
|
||||
The `wasListItemTapped` / `wasListItemTouchedDown` helpers from the button-era bridge have been removed; every themed list is a `UiListActivity` now. If you are porting a branch that still calls them, convert the screen to `UiListActivity` rather than resurrecting the helpers.
|
||||
|
||||
As with all input: never call the SDK `InputManager` or read GPIO directly. The HAL rule from the main guide applies to touch too.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-4
@@ -10,9 +10,10 @@ 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: `.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.
|
||||
Not supported: dictionaries with 64-bit index offsets (`idxoffsetbits=64` in the `.ifo` — rare, and rejected with an error).
|
||||
|
||||
## Setting Up a Dictionary
|
||||
|
||||
@@ -35,18 +36,21 @@ 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 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.
|
||||
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.
|
||||
|
||||
### How Lookup Works
|
||||
|
||||
1. **Direct match** — the word is found as-is (case-insensitive) in the dictionary index. Surrounding punctuation is ignored.
|
||||
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.
|
||||
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.
|
||||
|
||||
## The Definition Screen
|
||||
|
||||
When a word is found, the definition screen shows the matched headword at the top and the definition text below, with a page counter for long definitions.
|
||||
|
||||
HTML dictionaries that declare `sametypesequence=h` use the EPUB text-layout engine for semantic formatting such as headings, bold, italics, lists, and line breaks. Images and CSS styling are ignored. Definitions that are too large or cannot be laid out within the available memory fall back to plain text.
|
||||
|
||||
- **Left/Right** or side **Up/Down** — previous / next page
|
||||
- **Back** — return to word selection
|
||||
|
||||
|
||||
+10
-3
@@ -90,12 +90,19 @@ if (parsedSize != fileSize) {
|
||||
|
||||
## `section.bin`
|
||||
|
||||
### Version 35
|
||||
### Version 37
|
||||
|
||||
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.
|
||||
|
||||
@@ -135,10 +142,10 @@ import std.mem;
|
||||
import std.string;
|
||||
import std.core;
|
||||
|
||||
#define EXPECTED_VERSION 35
|
||||
#define EXPECTED_VERSION 37
|
||||
#define MAX_STRING_LENGTH 65535
|
||||
#define FOOTNOTE_NUMBER_LEN 32
|
||||
#define FOOTNOTE_HREF_LEN 96
|
||||
#define FOOTNOTE_HREF_LEN 256
|
||||
|
||||
struct String {
|
||||
u32 length [[hidden, comment("String byte length")]];
|
||||
|
||||
@@ -153,6 +153,7 @@ To convert your own TrueType/OpenType fonts:
|
||||
| `armenian` | Armenian |
|
||||
| `ethiopic` | Ethiopic + Extended |
|
||||
| `vietnamese` | Vietnamese subset (ơ/ư and combining marks) |
|
||||
| `ipa-chars` | IPA Extensions + Spacing Modifier Letters (phonetic transcription) |
|
||||
| `punctuation` | General punctuation (U+2000–U+206F) |
|
||||
| `cjk` | CJK Unified Ideographs + Hiragana + Katakana + Fullwidth |
|
||||
| `hangul` | Korean Hangul syllables + Jamo + Compatibility Jamo |
|
||||
|
||||
+1
-1
Submodule freeink-sdk updated: a485dc46ef...57f05e80c0.
@@ -49,6 +49,7 @@ INTERVAL_PRESETS = {
|
||||
"armenian": [(0x0530, 0x058F)],
|
||||
"ethiopic": [(0x1200, 0x137F), (0x1380, 0x139F), (0x2D80, 0x2DDF)],
|
||||
"vietnamese": [(0x01A0, 0x01B0), (0x1EA0, 0x1EF9)],
|
||||
"ipa-chars": [(0x0250, 0x02AF), (0x02B0, 0x02FF)],
|
||||
"punctuation": [(0x2000, 0x206F)],
|
||||
"cjk": [(0x3000, 0x303F), (0x3040, 0x309F), (0x30A0, 0x30FF),
|
||||
(0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0xFF00, 0xFFEF)],
|
||||
|
||||
@@ -35,7 +35,7 @@ families:
|
||||
|
||||
- name: Literata
|
||||
description: "Screen-optimized serif (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
intervals: latin-ext,greek,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Regular.ttf"}
|
||||
@@ -45,7 +45,7 @@ families:
|
||||
|
||||
- name: SourceSerif4
|
||||
description: "Adobe transitional serif (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
intervals: latin-ext,greek,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Regular.ttf"}
|
||||
@@ -55,7 +55,7 @@ families:
|
||||
|
||||
- name: NotoSerifExtended
|
||||
description: "Serif (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
intervals: latin-ext,greek,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Regular.ttf"}
|
||||
@@ -65,7 +65,7 @@ families:
|
||||
|
||||
- name: Merriweather
|
||||
description: "Warm serif for long-form reading (Latin, Cyrillic)"
|
||||
intervals: latin-ext,cyrillic
|
||||
intervals: latin-ext,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Regular.ttf"}
|
||||
@@ -75,7 +75,7 @@ families:
|
||||
|
||||
- name: Lora
|
||||
description: "Calligraphic serif for literary reading (Latin, Cyrillic)"
|
||||
intervals: latin-ext,cyrillic
|
||||
intervals: latin-ext,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Regular.ttf"}
|
||||
@@ -85,7 +85,7 @@ families:
|
||||
|
||||
- name: GentiumBookPlus
|
||||
description: "Scholarly serif with wide Unicode coverage (Latin, Greek, Cyrillic, IPA)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
intervals: latin-ext,greek,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Regular.ttf"}
|
||||
@@ -95,7 +95,7 @@ families:
|
||||
|
||||
- name: IBMPlexSerif
|
||||
description: "Professional serif (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
intervals: latin-ext,greek,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Regular.ttf"}
|
||||
@@ -105,7 +105,7 @@ families:
|
||||
|
||||
- name: Bitter
|
||||
description: "Slab serif designed for e-ink (Latin, Cyrillic)"
|
||||
intervals: latin-ext,cyrillic
|
||||
intervals: latin-ext,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 500}}
|
||||
@@ -115,7 +115,7 @@ families:
|
||||
|
||||
- name: Domitian
|
||||
description: "A humanist serif for literary reading (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
intervals: latin-ext,greek,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://mirrors.ctan.org/fonts/domitian/opentype/Domitian-Roman.otf"}
|
||||
@@ -125,7 +125,7 @@ families:
|
||||
|
||||
- name: LibreBaskerville
|
||||
description: "A serif reimplementing the classic Baskerville (Latin)"
|
||||
intervals: latin-ext
|
||||
intervals: latin-ext,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Regular.ttf"}
|
||||
@@ -135,7 +135,7 @@ families:
|
||||
|
||||
- name: Vollkorn
|
||||
description: "A serif for bread and butter use by Friedrich Althausen (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
intervals: latin-ext,greek,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn%5Bwght%5D.ttf", variable: {wght: 400}}
|
||||
@@ -147,7 +147,7 @@ families:
|
||||
|
||||
- name: NotoSansExtended
|
||||
description: "Sans-serif (Latin, Greek, Cyrillic, Georgian, Armenian, Ethiopic)"
|
||||
intervals: latin-ext,greek,cyrillic,georgian,armenian,ethiopic
|
||||
intervals: latin-ext,greek,cyrillic,georgian,armenian,ethiopic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {path: "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"}
|
||||
@@ -157,7 +157,7 @@ families:
|
||||
|
||||
- name: Inter
|
||||
description: "Modern sans-serif (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
intervals: latin-ext,greek,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", variable: {wght: 400, opsz: 14}}
|
||||
@@ -167,7 +167,7 @@ families:
|
||||
|
||||
- name: SourceSans3
|
||||
description: "Adobe humanist sans-serif (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
intervals: latin-ext,greek,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Regular.ttf"}
|
||||
@@ -177,7 +177,7 @@ families:
|
||||
|
||||
- name: IBMPlexSans
|
||||
description: "IBM corporate sans-serif (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
intervals: latin-ext,greek,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Regular.ttf"}
|
||||
@@ -187,7 +187,7 @@ families:
|
||||
|
||||
- name: Alegreya
|
||||
description: "Calligraphic serif/display (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
intervals: latin-ext,greek,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", variable: {wght: 400}}
|
||||
@@ -199,7 +199,7 @@ families:
|
||||
|
||||
- name: IBMPlexMono
|
||||
description: "Monospace for code and technical reading (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
intervals: latin-ext,greek,cyrillic,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Regular.ttf"}
|
||||
@@ -209,7 +209,7 @@ families:
|
||||
|
||||
- name: SourceCodePro
|
||||
description: "Adobe monospace with excellent hinting (Latin)"
|
||||
intervals: latin-ext
|
||||
intervals: latin-ext,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Regular.ttf"}
|
||||
@@ -221,7 +221,7 @@ families:
|
||||
|
||||
- name: OpenDyslexic
|
||||
description: "Dyslexia-friendly font (Latin)"
|
||||
intervals: latin-ext
|
||||
intervals: latin-ext,ipa-chars
|
||||
sizes: [8, 10, 12, 14]
|
||||
styles:
|
||||
regular: {path: "builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf"}
|
||||
@@ -233,7 +233,7 @@ families:
|
||||
|
||||
- name: AtkinsonHyperlegibleNext
|
||||
description: "Accessibility font for low vision (Latin)"
|
||||
intervals: latin-ext
|
||||
intervals: latin-ext,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Regular.ttf"}
|
||||
@@ -243,7 +243,7 @@ families:
|
||||
|
||||
- name: LexicaUltralegible
|
||||
description: "Accessibility font for low vision / dyslexia (Latin)"
|
||||
intervals: latin-ext
|
||||
intervals: latin-ext,ipa-chars
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Regular.ttf"}
|
||||
|
||||
+6
-1
@@ -802,7 +802,12 @@ int Epub::getSpineItemsCount() const {
|
||||
return bookMetadataCache->getSpineCount();
|
||||
}
|
||||
|
||||
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return getSpineItem(spineIndex).cumulativeSize; }
|
||||
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const {
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
return 0;
|
||||
}
|
||||
return bookMetadataCache->getCumulativeSize(spineIndex);
|
||||
}
|
||||
|
||||
BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
|
||||
@@ -481,11 +481,30 @@ 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");
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class BookMetadataCache {
|
||||
public:
|
||||
@@ -62,6 +63,11 @@ 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
|
||||
@@ -113,6 +119,9 @@ 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; }
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
#include <cstring>
|
||||
|
||||
#define FOOTNOTE_NUMBER_LEN 32
|
||||
#define FOOTNOTE_HREF_LEN 96
|
||||
#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).
|
||||
|
||||
struct FootnoteEntry {
|
||||
char number[FOOTNOTE_NUMBER_LEN];
|
||||
|
||||
@@ -681,6 +681,76 @@ static inline bool isCjkIdeograph(uint32_t cp) {
|
||||
(cp >= 0x20000 && cp <= 0x3FFFF);
|
||||
}
|
||||
|
||||
// The first word of a line may have its ruby characters wider than the word (the base text). In that case, we need to
|
||||
// move the base text to the right a bit so that ruby text doesn't overflow the left border, and it is still centered
|
||||
// over the base text. This function calculates how much we need to move the base text to the right.
|
||||
int ParsedText::calculateRubyExtraStartOffset(const size_t wordIdx, const size_t maxWordIdx,
|
||||
const GfxRenderer& renderer, const int fontId) const {
|
||||
if (rubyTexts.empty() || wordIdx >= rubyTexts.size() || rubyTexts[wordIdx].empty() ||
|
||||
(wordStyles[wordIdx] & EpdFontFamily::RUBY_CONTINUE) != 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t groupWordCount = 1;
|
||||
while (wordIdx + groupWordCount < maxWordIdx &&
|
||||
(wordStyles[wordIdx + groupWordCount] & EpdFontFamily::RUBY_CONTINUE) != 0) {
|
||||
groupWordCount++;
|
||||
}
|
||||
int groupActualWidth = 0;
|
||||
for (size_t k = 0; k < groupWordCount; ++k) {
|
||||
groupActualWidth += measureWordWidth(renderer, fontId, words[wordIdx + k], wordStyles[wordIdx + k]);
|
||||
}
|
||||
const int rubyWidth = renderer.getTextAdvanceX(fontId, rubyTexts[wordIdx].c_str(), EpdFontFamily::SUP);
|
||||
if (rubyWidth <= groupActualWidth) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const int leftOverlap = (rubyWidth - groupActualWidth) / 2;
|
||||
|
||||
// This function is only ever called for the first word of a line.
|
||||
// words[wordIdx - 1], if it exists, is always the last word of the *prior* line
|
||||
// and cannot absorb any left overhang on the current line.
|
||||
// The full leftOverlap must therefore be reserved as a visual indent so the
|
||||
// ruby text does not overflow the left margin.
|
||||
return leftOverlap;
|
||||
}
|
||||
|
||||
// The last ruby group on a line may have its ruby characters wider than the group's base text.
|
||||
// The right half of that overhang protrudes past the last base character. This function returns
|
||||
// the amount of right-margin space that must be reserved so the ruby does not overflow the right
|
||||
// border. It mirrors calculateRubyExtraStartOffset: words[lineBreak] is on the *next* line and
|
||||
// cannot absorb any of the right overhang on the current line, so the full rightOverlap is returned.
|
||||
int ParsedText::calculateRubyExtraEndOffset(const size_t lineStartIdx, const size_t lineBreakIdx,
|
||||
const GfxRenderer& renderer, const int fontId) const {
|
||||
if (rubyTexts.empty() || lineBreakIdx == 0 || lineStartIdx >= lineBreakIdx) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Walk backwards from the last word to find the leader of the last ruby group on the line.
|
||||
size_t leaderIdx = lineBreakIdx - 1;
|
||||
while (leaderIdx > lineStartIdx && (wordStyles[leaderIdx] & EpdFontFamily::RUBY_CONTINUE) != 0) {
|
||||
leaderIdx--;
|
||||
}
|
||||
|
||||
// leaderIdx must be a ruby group leader (non-empty ruby, no RUBY_CONTINUE flag).
|
||||
if (leaderIdx >= rubyTexts.size() || rubyTexts[leaderIdx].empty() ||
|
||||
(wordStyles[leaderIdx] & EpdFontFamily::RUBY_CONTINUE) != 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Measure the group.
|
||||
int groupActualWidth = 0;
|
||||
for (size_t k = leaderIdx; k < lineBreakIdx; ++k) {
|
||||
groupActualWidth += measureWordWidth(renderer, fontId, words[k], wordStyles[k]);
|
||||
}
|
||||
const int rubyWidth = renderer.getTextAdvanceX(fontId, rubyTexts[leaderIdx].c_str(), EpdFontFamily::SUP);
|
||||
if (rubyWidth <= groupActualWidth) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (rubyWidth - groupActualWidth) / 2;
|
||||
}
|
||||
|
||||
std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) {
|
||||
std::vector<uint16_t> wordWidths;
|
||||
wordWidths.reserve(words.size());
|
||||
@@ -691,9 +761,6 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
|
||||
|
||||
// Adjust widths for ruby groups to comply with JLReq standards
|
||||
if (!rubyTexts.empty()) {
|
||||
const int ascender = renderer.getFontAscenderSize(fontId);
|
||||
const int maxOverhang = ascender / 2; // Maximum overhang over non-ideographic characters (Kana, punctuation, etc.)
|
||||
|
||||
struct RubyGroupInfo {
|
||||
size_t start;
|
||||
size_t count;
|
||||
@@ -730,9 +797,10 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
|
||||
if (g.start > 0) {
|
||||
const uint32_t cpPrev = lastCodepoint(words[g.start - 1]);
|
||||
if (isCjkIdeograph(cpPrev)) {
|
||||
wordWidths[g.start] += g.leftOverlap;
|
||||
wordWidths[g.start - 1] += g.leftOverlap;
|
||||
} else {
|
||||
wordWidths[g.start] += std::max(0, g.leftOverlap - maxOverhang);
|
||||
const int maxLeftOverhang = wordWidths[g.start - 1] / 2;
|
||||
wordWidths[g.start - 1] += std::max(0, g.leftOverlap - maxLeftOverhang);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,7 +820,8 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
|
||||
if (isCjkIdeograph(cpNext)) {
|
||||
wordWidths[g.start + g.count - 1] += g.rightOverlap;
|
||||
} else {
|
||||
wordWidths[g.start + g.count - 1] += std::max(0, g.rightOverlap - maxOverhang);
|
||||
const int maxRightOverhang = wordWidths[nextIdx] / 2;
|
||||
wordWidths[g.start + g.count - 1] += std::max(0, g.rightOverlap - maxRightOverhang);
|
||||
}
|
||||
|
||||
// Check if there is another ruby group further ahead separated only by non-ideographs
|
||||
@@ -769,8 +838,10 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
|
||||
gapWidth += wordWidths[k];
|
||||
}
|
||||
if (onlyNonIdeographsInBetween) {
|
||||
const int allowedRight = std::min(g.rightOverlap, maxOverhang);
|
||||
const int allowedLeft = std::min(nextG.leftOverlap, maxOverhang);
|
||||
const int maxRightOverhang = wordWidths[g.start + g.count - 1] / 2;
|
||||
const int maxLeftOverhang = wordWidths[nextG.start - 1] / 2;
|
||||
const int allowedRight = std::min(g.rightOverlap, maxRightOverhang);
|
||||
const int allowedLeft = std::min(nextG.leftOverlap, maxLeftOverhang);
|
||||
const int touchOverlap = allowedRight + allowedLeft - gapWidth;
|
||||
if (touchOverlap > 0) {
|
||||
wordWidths[g.start + g.count - 1] += touchOverlap;
|
||||
@@ -837,36 +908,7 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
||||
}
|
||||
|
||||
// Calculate extraStartOffset for the first word on the line (i) (protect left margin)
|
||||
int extraStartOffset = 0;
|
||||
if (j == i && !rubyTexts.empty() && i < rubyTexts.size() && !rubyTexts[i].empty() &&
|
||||
(wordStyles[i] & EpdFontFamily::RUBY_CONTINUE) == 0) {
|
||||
int groupWordCount = 1;
|
||||
while (i + groupWordCount < totalWordCount &&
|
||||
(wordStyles[i + groupWordCount] & EpdFontFamily::RUBY_CONTINUE) != 0) {
|
||||
groupWordCount++;
|
||||
}
|
||||
int groupActualWidth = 0;
|
||||
for (int k = 0; k < groupWordCount; ++k) {
|
||||
groupActualWidth += measureWordWidth(renderer, fontId, words[i + k], wordStyles[i + k]);
|
||||
}
|
||||
const int rubyWidth = renderer.getTextAdvanceX(fontId, rubyTexts[i].c_str(), EpdFontFamily::SUP);
|
||||
if (rubyWidth > groupActualWidth) {
|
||||
const int leftOverlap = (rubyWidth - groupActualWidth) / 2;
|
||||
const int ascender = renderer.getFontAscenderSize(fontId);
|
||||
const int maxOverhang = ascender / 2;
|
||||
|
||||
int expansion_added = 0;
|
||||
if (i > 0) {
|
||||
const uint32_t cpPrev = lastCodepoint(words[i - 1]);
|
||||
if (isCjkIdeograph(cpPrev)) {
|
||||
expansion_added = leftOverlap;
|
||||
} else {
|
||||
expansion_added = std::max(0, leftOverlap - maxOverhang);
|
||||
}
|
||||
}
|
||||
extraStartOffset = leftOverlap - expansion_added;
|
||||
}
|
||||
}
|
||||
const int extraStartOffset = (j == i) ? calculateRubyExtraStartOffset(i, totalWordCount, renderer, fontId) : 0;
|
||||
|
||||
currlen += wordWidths[j] + gap + (j == i ? extraStartOffset : 0);
|
||||
|
||||
@@ -879,46 +921,7 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate extraEndOffset if we break after word j (protect right margin)
|
||||
int extraEndOffset = 0;
|
||||
if (!rubyTexts.empty() && j < rubyTexts.size()) {
|
||||
bool isLastOfGroup = false;
|
||||
size_t leaderIdx = j;
|
||||
while (true) {
|
||||
if (!rubyTexts[leaderIdx].empty() && (wordStyles[leaderIdx] & EpdFontFamily::RUBY_CONTINUE) == 0) {
|
||||
isLastOfGroup = true;
|
||||
break;
|
||||
}
|
||||
if (leaderIdx == 0 || (wordStyles[leaderIdx] & EpdFontFamily::RUBY_CONTINUE) == 0) {
|
||||
break;
|
||||
}
|
||||
leaderIdx--;
|
||||
}
|
||||
|
||||
if (isLastOfGroup && (j + 1 == totalWordCount || (wordStyles[j + 1] & EpdFontFamily::RUBY_CONTINUE) == 0)) {
|
||||
int groupActualWidth = 0;
|
||||
for (size_t k = leaderIdx; k <= j; ++k) {
|
||||
groupActualWidth += measureWordWidth(renderer, fontId, words[k], wordStyles[k]);
|
||||
}
|
||||
const int rubyWidth = renderer.getTextAdvanceX(fontId, rubyTexts[leaderIdx].c_str(), EpdFontFamily::SUP);
|
||||
if (rubyWidth > groupActualWidth) {
|
||||
const int rightOverlap = (rubyWidth - groupActualWidth) / 2;
|
||||
const int ascender = renderer.getFontAscenderSize(fontId);
|
||||
const int maxOverhang = ascender / 2;
|
||||
|
||||
int expansion_added = 0;
|
||||
if (j + 1 < totalWordCount) {
|
||||
const uint32_t cpNext = firstCodepoint(words[j + 1]);
|
||||
if (isCjkIdeograph(cpNext)) {
|
||||
expansion_added = rightOverlap;
|
||||
} else {
|
||||
expansion_added = std::max(0, rightOverlap - maxOverhang);
|
||||
}
|
||||
}
|
||||
extraEndOffset = rightOverlap - expansion_added;
|
||||
}
|
||||
}
|
||||
}
|
||||
const int extraEndOffset = calculateRubyExtraEndOffset(i, j + 1, renderer, fontId);
|
||||
|
||||
if (currlen + extraEndOffset > effectivePageWidth) {
|
||||
continue; // Cannot split here as it would overflow the right margin
|
||||
@@ -939,7 +942,9 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
||||
}
|
||||
}
|
||||
|
||||
if (cost < dp[i]) {
|
||||
// Favor longer lines when line-breaking costs are equal, to avoid unnecessary short lines in Chinese and Japanese
|
||||
// text.
|
||||
if (cost <= dp[i]) {
|
||||
dp[i] = cost;
|
||||
ans[i] = j; // j is the index of the last word in this optimal line
|
||||
}
|
||||
@@ -1171,6 +1176,9 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
std::copy(rubyTexts.begin() + lastBreakAt, rubyTexts.begin() + lastBreakAt + copyCount, lineRubyTexts.begin());
|
||||
}
|
||||
|
||||
const int extraStartOffset = calculateRubyExtraStartOffset(lastBreakAt, lineBreak, renderer, fontId);
|
||||
const int extraEndOffset = calculateRubyExtraEndOffset(lastBreakAt, lineBreak, renderer, fontId);
|
||||
|
||||
std::vector<std::string> lineWords;
|
||||
lineWords.reserve(lineWordCount);
|
||||
std::vector<EpdFontFamily::Style> lineWordStyles;
|
||||
@@ -1225,8 +1233,9 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
? CssTextAlign::Right
|
||||
: blockStyle.alignment;
|
||||
|
||||
// For justified text, compute per-gap extra to distribute remaining space evenly
|
||||
const int spareSpace = effectivePageWidth - lineWordWidthSum - totalNaturalGaps;
|
||||
// For justified text, compute per-gap extra to distribute remaining space evenly.
|
||||
// extraEndOffset reserves space for any ruby group at the right edge of the line.
|
||||
const int spareSpace = effectivePageWidth - extraStartOffset - extraEndOffset - lineWordWidthSum - totalNaturalGaps;
|
||||
const int justifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
|
||||
? computeJustifyExtra(spareSpace, actualGapCount)
|
||||
: 0;
|
||||
@@ -1307,7 +1316,8 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
}
|
||||
}
|
||||
|
||||
const int reorderedSpare = effectivePageWidth - reorderedWordWidthSum - reorderedNaturalGaps;
|
||||
const int reorderedSpare =
|
||||
effectivePageWidth - extraStartOffset - extraEndOffset - reorderedWordWidthSum - reorderedNaturalGaps;
|
||||
const int reorderedJustifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
|
||||
? computeJustifyExtra(reorderedSpare, reorderedGapCount)
|
||||
: 0;
|
||||
@@ -1412,7 +1422,7 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
}
|
||||
} else {
|
||||
// LTR: position words from left to right
|
||||
int xpos = firstLineIndent;
|
||||
int xpos = firstLineIndent + extraStartOffset;
|
||||
if (effectiveAlignment == CssTextAlign::Right) {
|
||||
xpos = effectivePageWidth - lineWordWidthSum - totalNaturalGaps;
|
||||
} else if (effectiveAlignment == CssTextAlign::Center) {
|
||||
|
||||
Loaded 20 of 195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user