Initial commit: Chatter — assistive-writing app for reMarkable Paper Pro Move
Direct-framebuffer ink pipeline (stock-quality strokes), finger-wipe erase, growable scrolling canvas with color-ghost cleanup, bidirectional toggle with a persistent 4-finger return launcher, instant button feedback. Includes prebuilt aarch64 binaries (dist/), build/deploy/install scripts, a user guide, and a complete technical reference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
86
tools/chatter_launcher.c
Normal file
86
tools/chatter_launcher.c
Normal file
@@ -0,0 +1,86 @@
|
||||
// Chatter launcher daemon. Runs always (systemd service), reads the touch device
|
||||
// (event3) WITHOUT grabbing it, and when it sees a multi-finger hold gesture
|
||||
// (>= FINGERS contacts held >= HOLD_MS) while xochitl is the active app, it
|
||||
// switches to Chatter. This is the "return to Chatter from the standard
|
||||
// interface" trigger (xochitl has no plugin API to add a real button).
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/input.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define DEV "/dev/input/event3"
|
||||
#define MAX_SLOTS 16
|
||||
#define FINGERS 4 // contacts required
|
||||
#define HOLD_MS 700 // how long they must be held
|
||||
#define COOLDOWN_MS 4000 // ignore re-triggers for this long
|
||||
|
||||
static long now_ms(void)
|
||||
{
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return ts.tv_sec * 1000L + ts.tv_nsec / 1000000L;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
long lastTrigger = 0;
|
||||
fprintf(stderr, "chatter-launcher: started (fingers=%d hold=%dms)\n", FINGERS, HOLD_MS);
|
||||
|
||||
// Outer loop: (re)open the device forever, so a read interruption (e.g. the
|
||||
// device sleeping/waking) never kills the daemon.
|
||||
for (;;) {
|
||||
int fd = open(DEV, O_RDONLY);
|
||||
if (fd < 0) { sleep(2); continue; }
|
||||
|
||||
int slot = 0;
|
||||
int tid[MAX_SLOTS];
|
||||
for (int i = 0; i < MAX_SLOTS; i++)
|
||||
tid[i] = -1;
|
||||
long holdStart = 0;
|
||||
int triggered = 0;
|
||||
|
||||
struct input_event ev;
|
||||
while (read(fd, &ev, sizeof(ev)) == (ssize_t)sizeof(ev)) {
|
||||
if (ev.type == EV_ABS) {
|
||||
if (ev.code == ABS_MT_SLOT) {
|
||||
slot = ev.value;
|
||||
if (slot < 0) slot = 0;
|
||||
if (slot >= MAX_SLOTS) slot = MAX_SLOTS - 1;
|
||||
} else if (ev.code == ABS_MT_TRACKING_ID) {
|
||||
tid[slot] = ev.value; // >=0 active, -1 released
|
||||
}
|
||||
} else if (ev.type == EV_SYN && ev.code == SYN_REPORT) {
|
||||
int active = 0;
|
||||
for (int i = 0; i < MAX_SLOTS; i++)
|
||||
if (tid[i] >= 0) active++;
|
||||
|
||||
const long t = now_ms();
|
||||
if (active >= FINGERS) {
|
||||
if (holdStart == 0)
|
||||
holdStart = t;
|
||||
else if (!triggered && (t - holdStart) >= HOLD_MS &&
|
||||
(t - lastTrigger) >= COOLDOWN_MS) {
|
||||
triggered = 1;
|
||||
lastTrigger = t;
|
||||
if (system("systemctl is-active --quiet xochitl") == 0) {
|
||||
fprintf(stderr, "chatter-launcher: gesture -> switching to Chatter\n");
|
||||
system("/home/root/chatter/to-chatter.sh >/dev/null 2>&1 &");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
holdStart = 0;
|
||||
triggered = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(fd);
|
||||
fprintf(stderr, "chatter-launcher: input read ended, reopening\n");
|
||||
sleep(1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
56
tools/fbdump.cpp
Normal file
56
tools/fbdump.cpp
Normal file
@@ -0,0 +1,56 @@
|
||||
// Framebuffer snapshot shim: preloaded into xochitl, it captures the e-paper
|
||||
// framebuffer (via setBuffers) and, when the trigger file /tmp/fbdump appears,
|
||||
// saves the current framebuffer to /home/root/fbdump.png. Used to recover the
|
||||
// real Calligraphy nib angle by measuring xochitl's actual rendered strokes.
|
||||
|
||||
#define _GNU_SOURCE 1
|
||||
#include <QImage>
|
||||
#include <tuple>
|
||||
#include <dlfcn.h>
|
||||
#include <pthread.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <cstdio>
|
||||
|
||||
namespace {
|
||||
uchar *g_bits = nullptr;
|
||||
int g_w = 0, g_h = 0, g_bpl = 0, g_fmt = 0;
|
||||
bool g_started = false;
|
||||
|
||||
void *dumper(void *)
|
||||
{
|
||||
fprintf(stderr, "FBSHIM dumper thread started\n");
|
||||
for (;;) {
|
||||
usleep(400000);
|
||||
struct stat st;
|
||||
if (g_bits && stat("/tmp/fbdump", &st) == 0) {
|
||||
QImage snap = QImage(g_bits, g_w, g_h, g_bpl, QImage::Format(g_fmt)).copy();
|
||||
bool ok = snap.save("/home/root/fbdump.png");
|
||||
if (!ok) ok = snap.save("/home/root/fbdump.bmp", "BMP");
|
||||
::unlink("/tmp/fbdump");
|
||||
fprintf(stderr, "FBDUMP %dx%d saved ok=%d\n", g_w, g_h, int(ok));
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" void _ZN13EPFramebuffer10setBuffersESt5tupleIJ6QImageS1_EEPS1_(
|
||||
void *self, void *tuplePtr, void *imgPtr)
|
||||
{
|
||||
static void (*real)(void *, void *, void *) = nullptr;
|
||||
if (!real)
|
||||
real = (void (*)(void *, void *, void *))dlsym(
|
||||
RTLD_NEXT, "_ZN13EPFramebuffer10setBuffersESt5tupleIJ6QImageS1_EEPS1_");
|
||||
auto *t = reinterpret_cast<std::tuple<QImage, QImage> *>(tuplePtr);
|
||||
const QImage &a = std::get<0>(*t);
|
||||
g_bits = const_cast<uchar *>(a.constBits());
|
||||
g_w = a.width(); g_h = a.height(); g_bpl = a.bytesPerLine(); g_fmt = int(a.format());
|
||||
fprintf(stderr, "FBSHIM setBuffers %dx%d fmt=%d\n", g_w, g_h, g_fmt);
|
||||
if (!g_started) {
|
||||
g_started = true;
|
||||
pthread_t th;
|
||||
pthread_create(&th, nullptr, dumper, nullptr);
|
||||
}
|
||||
if (real) real(self, tuplePtr, imgPtr);
|
||||
}
|
||||
26
tools/grabtest.c
Normal file
26
tools/grabtest.c
Normal file
@@ -0,0 +1,26 @@
|
||||
// Probe whether an input device is exclusively grabbed (EVIOCGRAB) by another
|
||||
// process (e.g. xochitl). If we can grab it, it was free -> a background watcher
|
||||
// can read it alongside xochitl. If EBUSY, xochitl holds it exclusively.
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/input.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/ioctl.h>
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
const char *dev = argc > 1 ? argv[1] : "/dev/input/event3";
|
||||
int fd = open(dev, O_RDONLY);
|
||||
if (fd < 0) { printf("%s: open failed: %s\n", dev, strerror(errno)); return 1; }
|
||||
int r = ioctl(fd, EVIOCGRAB, (void *)1);
|
||||
if (r == 0) {
|
||||
ioctl(fd, EVIOCGRAB, (void *)0); // release immediately
|
||||
printf("%s: NOT exclusively grabbed -> a watcher can read it\n", dev);
|
||||
} else {
|
||||
printf("%s: GRABBED by another process (%s)\n", dev, strerror(errno));
|
||||
}
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
35
tools/setbufshim.cpp
Normal file
35
tools/setbufshim.cpp
Normal file
@@ -0,0 +1,35 @@
|
||||
// Feasibility check: can we intercept EPFramebuffer::setBuffers to capture the
|
||||
// framebuffer's backing QImages? setBuffers is called from the epaper platform
|
||||
// plugin (libepaper) into libqsgepaper — a cross-DSO call, so unlike swapBuffers
|
||||
// it should be interposable via LD_PRELOAD. If this fires and reports real image
|
||||
// dimensions + a pixel pointer, the direct-framebuffer ink pipeline is viable.
|
||||
//
|
||||
// ABI: std::tuple<QImage,QImage> is non-trivially-copyable, so it's passed by
|
||||
// reference (a pointer); QImage* is a pointer. So three pointer args after this.
|
||||
|
||||
#include <QImage>
|
||||
#include <tuple>
|
||||
#include <dlfcn.h>
|
||||
#include <cstdio>
|
||||
|
||||
extern "C" void _ZN13EPFramebuffer10setBuffersESt5tupleIJ6QImageS1_EEPS1_(
|
||||
void *self, void *tuplePtr, void *imgPtr)
|
||||
{
|
||||
static void (*real)(void *, void *, void *) = nullptr;
|
||||
if (!real)
|
||||
real = (void (*)(void *, void *, void *))dlsym(
|
||||
RTLD_NEXT, "_ZN13EPFramebuffer10setBuffersESt5tupleIJ6QImageS1_EEPS1_");
|
||||
|
||||
auto *t = reinterpret_cast<std::tuple<QImage, QImage> *>(tuplePtr);
|
||||
QImage *c = reinterpret_cast<QImage *>(imgPtr);
|
||||
const QImage &a = std::get<0>(*t);
|
||||
const QImage &b = std::get<1>(*t);
|
||||
fprintf(stderr,
|
||||
"SETBUFFERS self=%p | A=%dx%d fmt=%d bytesPerLine=%d cbits=%p | "
|
||||
"B=%dx%d fmt=%d | C=%p %dx%d\n",
|
||||
self, a.width(), a.height(), int(a.format()), a.bytesPerLine(),
|
||||
(const void *)a.constBits(), b.width(), b.height(), int(b.format()),
|
||||
(void *)c, c ? c->width() : -1, c ? c->height() : -1);
|
||||
|
||||
if (real) real(self, tuplePtr, imgPtr);
|
||||
}
|
||||
BIN
tools/setbufshim.so
Executable file
BIN
tools/setbufshim.so
Executable file
Binary file not shown.
46
tools/swapshim.cpp
Normal file
46
tools/swapshim.cpp
Normal file
@@ -0,0 +1,46 @@
|
||||
// LD_PRELOAD trace shim: intercept EPFramebuffer::swapBuffers in libqsgepaper to
|
||||
// learn the exact (contentType, screenMode, flags) the stock app uses for pen
|
||||
// strokes. We define functions with the real mangled names so the dynamic loader
|
||||
// interposes them, log the args, then chain to the real implementation.
|
||||
//
|
||||
// QRect is passed by value; its memory layout is {int x1,y1,x2,y2} (l,t,r,b), so
|
||||
// we model it as a 16-byte POD to match the ABI without linking Qt. The enums and
|
||||
// QFlags are all 4-byte int-sized.
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <cstdio>
|
||||
|
||||
struct RawRect { int x1, y1, x2, y2; };
|
||||
|
||||
extern "C" {
|
||||
|
||||
// swapBuffers(QRect, EPContentType, EPScreenMode, QFlags<UpdateFlag>)
|
||||
void _ZN13EPFramebuffer11swapBuffersE5QRect13EPContentType12EPScreenMode6QFlagsINS_10UpdateFlagEE(
|
||||
void *self, RawRect r, int contentType, int screenMode, int flags)
|
||||
{
|
||||
static void (*real)(void *, RawRect, int, int, int) = nullptr;
|
||||
if (!real)
|
||||
real = (void (*)(void *, RawRect, int, int, int))dlsym(
|
||||
RTLD_NEXT,
|
||||
"_ZN13EPFramebuffer11swapBuffersE5QRect13EPContentType12EPScreenMode6QFlagsINS_10UpdateFlagEE");
|
||||
fprintf(stderr, "SWAPTRACE1 rect=(%d,%d)-(%d,%d) %dx%d content=%d screen=%d flags=%d\n",
|
||||
r.x1, r.y1, r.x2, r.y2, r.x2 - r.x1 + 1, r.y2 - r.y1 + 1,
|
||||
contentType, screenMode, flags);
|
||||
if (real) real(self, r, contentType, screenMode, flags);
|
||||
}
|
||||
|
||||
// swapBuffers(const QRegion&, const EPContentMap&, const EPScreenModeMap&, QFlags<UpdateFlag>)
|
||||
void _ZN13EPFramebuffer11swapBuffersERK7QRegionRK12EPContentMapRK15EPScreenModeMap6QFlagsINS_10UpdateFlagEE(
|
||||
void *self, const void *region, const void *contentMap, const void *modeMap, int flags)
|
||||
{
|
||||
static void (*real)(void *, const void *, const void *, const void *, int) = nullptr;
|
||||
if (!real)
|
||||
real = (void (*)(void *, const void *, const void *, const void *, int))dlsym(
|
||||
RTLD_NEXT,
|
||||
"_ZN13EPFramebuffer11swapBuffersERK7QRegionRK12EPContentMapRK15EPScreenModeMap6QFlagsINS_10UpdateFlagEE");
|
||||
fprintf(stderr, "SWAPTRACE2 region=%p contentMap=%p modeMap=%p flags=%d\n",
|
||||
region, contentMap, modeMap, flags);
|
||||
if (real) real(self, region, contentMap, modeMap, flags);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
BIN
tools/swapshim.so
Executable file
BIN
tools/swapshim.so
Executable file
Binary file not shown.
Reference in New Issue
Block a user