diff --git a/riddle/src/display.rs b/riddle/src/display.rs index 7ca77e5..d19b016 100644 --- a/riddle/src/display.rs +++ b/riddle/src/display.rs @@ -28,6 +28,11 @@ mod quill_ffi { impl Display { pub fn open() -> io::Result<(Self, Surface)> { if let Ok(key) = std::env::var("QTFB_KEY") { + // Windowed AppLoad mode. FBFMT_RMPP_RGB565 is a Paper Pro + // ("ferrari") -specific wire format (see qtfb.rs), so this path + // is Ferrari-only; riddle ships with "qtfb": false in its + // manifest and always runs via takeover (below) instead. A + // Move-native qtfb format would need its own investigation. let key: i32 = key.parse().map_err(io::Error::other)?; let mut client = crate::qtfb::QtfbClient::connect( key, diff --git a/riddle/src/fb.rs b/riddle/src/fb.rs index 0a596af..163e8b2 100644 --- a/riddle/src/fb.rs +++ b/riddle/src/fb.rs @@ -1,7 +1,34 @@ //! Geometry helpers. Drawing lives in surface.rs. -pub const SCREEN_W: usize = 1620; -pub const SCREEN_H: usize = 2160; +use std::sync::OnceLock; + +// Panel size varies by device (Paper Pro: 1620x2160, Paper Pro Move: +// 960x1696) and isn't known until the display backend opens and reports +// the real aux/qtfb framebuffer geometry. `init_screen` sets it once, early +// in `main`; every reader after that goes through `screen_w`/`screen_h`. +static SCREEN_DIMS: OnceLock<(usize, usize)> = OnceLock::new(); + +pub fn init_screen(w: usize, h: usize) { + let _ = SCREEN_DIMS.set((w, h)); +} + +pub fn screen_w() -> usize { + SCREEN_DIMS.get().expect("init_screen not called before screen_w").0 +} + +pub fn screen_h() -> usize { + SCREEN_DIMS.get().expect("init_screen not called before screen_h").1 +} + +/// Tests exercise geometry helpers without ever opening a display, so they +/// need `init_screen` called explicitly. Any dimensions work for a test; +/// this uses the Paper Pro's so tests match the values they were written +/// against. Idempotent (and safe under parallel test execution) since it's +/// the same `OnceLock::set` `init_screen` itself uses. +#[cfg(test)] +pub fn test_init_screen() { + init_screen(1620, 2160); +} /// Grow-only pixel bounding box, used to build update/dissolve regions. #[derive(Clone, Copy, Debug)] @@ -22,8 +49,8 @@ impl BBox { pub fn add(&mut self, x: i32, y: i32, margin: i32) { self.x0 = self.x0.min(x - margin).max(0); self.y0 = self.y0.min(y - margin).max(0); - self.x1 = self.x1.max(x + margin).min(SCREEN_W as i32 - 1); - self.y1 = self.y1.max(y + margin).min(SCREEN_H as i32 - 1); + self.x1 = self.x1.max(x + margin).min(screen_w() as i32 - 1); + self.y1 = self.y1.max(y + margin).min(screen_h() as i32 - 1); } pub fn rect(&self) -> (i32, i32, i32, i32) { (self.x0, self.y0, self.x1 - self.x0 + 1, self.y1 - self.y0 + 1) diff --git a/riddle/src/help.rs b/riddle/src/help.rs index 1cc0daf..0db9367 100644 --- a/riddle/src/help.rs +++ b/riddle/src/help.rs @@ -2,7 +2,7 @@ //! the diary's gestures; touching the pen to the page dismisses it. Detection //! is local geometry — no oracle — so the guide works even with no network. -use crate::fb::{BBox, SCREEN_H, SCREEN_W}; +use crate::fb::{screen_h, screen_w, BBox}; use crate::script; use crate::surface::{Surface, BLACK, WHITE}; use ab_glyph::FontRef; @@ -154,10 +154,10 @@ pub fn show(surf: &mut Surface, font: &FontRef, takeover: bool) -> Help { for l in body { wmax = wmax.max(script::measure(font, l, BODY_PX)); } - let pw = (wmax as usize + 2 * PAD).min(SCREEN_W - 40); + let pw = (wmax as usize + 2 * PAD).min(screen_w() - 40); let ph = PAD + title_h + line_h / 2 + body.len() * line_h + footer_h + PAD; - let px = (SCREEN_W - pw) / 2; - let py = (SCREEN_H.saturating_sub(ph)) / 2; + let px = (screen_w() - pw) / 2; + let py = (screen_h().saturating_sub(ph)) / 2; let saved = surf.copy_rect(px, py, pw, ph); surf.fill_rect(px, py, pw, ph, WHITE); @@ -192,18 +192,18 @@ impl Help { /// Replace the page with the full-screen sleep card; returns the saved page /// pixels so waking can restore them exactly. pub fn show_sleep(surf: &mut Surface, font: &FontRef) -> Vec { - let saved = surf.copy_rect(0, 0, SCREEN_W, SCREEN_H); - surf.fill_rect(0, 0, SCREEN_W, SCREEN_H, WHITE); - frame(surf, 48, 48, SCREEN_W - 96, SCREEN_H - 96, 4); - frame(surf, 66, 66, SCREEN_W - 132, SCREEN_H - 132, 1); - let y = SCREEN_H * 38 / 100; - blit_centered(surf, font, "The diary sleeps.", 116.0, 0, SCREEN_W, y); - blit_centered(surf, font, "Press the button to wake it.", 56.0, 0, SCREEN_W, y + 230); + let saved = surf.copy_rect(0, 0, screen_w(), screen_h()); + surf.fill_rect(0, 0, screen_w(), screen_h(), WHITE); + frame(surf, 48, 48, screen_w() - 96, screen_h() - 96, 4); + frame(surf, 66, 66, screen_w() - 132, screen_h() - 132, 1); + let y = screen_h() * 38 / 100; + blit_centered(surf, font, "The diary sleeps.", 116.0, 0, screen_w(), y); + blit_centered(surf, font, "Press the button to wake it.", 56.0, 0, screen_w(), y + 230); saved } pub fn restore_sleep(surf: &mut Surface, saved: &[u8]) { - surf.paste_rect(0, 0, SCREEN_W, SCREEN_H, saved); + surf.paste_rect(0, 0, screen_w(), screen_h(), saved); } fn frame(surf: &mut Surface, x: usize, y: usize, w: usize, h: usize, t: usize) { @@ -290,7 +290,8 @@ mod tests { #[test] fn modal_renders_and_restores() { - let (w, h) = (SCREEN_W, SCREEN_H); + crate::fb::test_init_screen(); + let (w, h) = (screen_w(), screen_h()); let mut buf = vec![0xFFu8; w * h * 4]; let ptr = buf.as_mut_ptr(); let mut surf = Surface::new(ptr, buf.len(), w, h, w * 4, crate::surface::PixFmt::Rgb32); @@ -336,7 +337,8 @@ mod tests { #[test] fn sleep_page_renders_and_restores() { - let (w, h) = (SCREEN_W, SCREEN_H); + crate::fb::test_init_screen(); + let (w, h) = (screen_w(), screen_h()); let mut buf = vec![0xFFu8; w * h * 4]; let ptr = buf.as_mut_ptr(); let mut surf = Surface::new(ptr, buf.len(), w, h, w * 4, crate::surface::PixFmt::Rgb32); diff --git a/riddle/src/ink.rs b/riddle/src/ink.rs index 1b9a549..2f5bdfe 100644 --- a/riddle/src/ink.rs +++ b/riddle/src/ink.rs @@ -185,6 +185,7 @@ mod tests { #[test] fn erase_forgets_covered_points_and_splits_strokes() { + crate::fb::test_init_screen(); let (_buf, mut s) = surf(); let mut ink = Ink::new(); // A horizontal stroke across the page. @@ -210,6 +211,7 @@ mod tests { #[test] fn erasing_everything_empties_the_ink() { + crate::fb::test_init_screen(); let (_buf, mut s) = surf(); let mut ink = Ink::new(); ink.pen_point(&mut s, 100, 100, 3); diff --git a/riddle/src/main.rs b/riddle/src/main.rs index 24601b3..21be9f7 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -27,7 +27,7 @@ use std::time::{Duration, Instant}; use ab_glyph::FontRef; -use fb::{BBox, SCREEN_H, SCREEN_W}; +use fb::{screen_h, screen_w, BBox}; use oracle::Event; use surface::{Surface, BLACK, FADED, WHITE}; @@ -40,6 +40,8 @@ const IDLE_COMMIT: Duration = Duration::from_millis(2800); const ORACLE_PATIENCE: Duration = Duration::from_secs(120); const REPLY_PX: f32 = 96.0; const MARGIN_X: i32 = 120; +/// Where a fresh reply starts writing from the top of the (now blank) page. +const REPLY_TOP_MARGIN: i32 = 90; const USAGE: &str = "\ riddle — the diary of Tom Riddle @@ -62,7 +64,16 @@ enum State { Listening { last_pen: Option }, Drinking { stage: u32, next: Instant, region: BBox, rx: OracleRx }, Thinking { rx: OracleRx, pulse: Instant, blot_on: bool, since: Instant }, - Replying { plan: WritePlan, next: Instant, rx: Option }, + /// `pending` holds streamed text that arrived after this page filled up; + /// once the current page finishes inking, it parks in `PageFull`. + Replying { plan: WritePlan, next: Instant, rx: Option, pending: String }, + /// A full page, done inking, with more reply text waiting off-page. Sits + /// until the pen touches the page — turning it before it's been read + /// would be pointless — while still absorbing any further streamed text. + PageFull { region: BBox, pending: String, rx: Option }, + /// The page turn itself: dissolve the full page like `Drinking`, then + /// resume `Replying` with `pending` written fresh from the top. + TurningPage { stage: u32, next: Instant, region: BBox, pending: String, rx: Option }, Lingering { until: Instant, region: BBox }, FadingReply { stage: u32, next: Instant, region: BBox }, /// The guide panel. `panel: None` = dismissed, waiting for pen-up so the @@ -182,6 +193,7 @@ fn run() -> std::io::Result<()> { let font = FontRef::try_from_slice(FONT_TTF).map_err(std::io::Error::other)?; let (disp, mut surf) = display::Display::open()?; + fb::init_screen(surf.w, surf.h); let takeover = matches!(disp, display::Display::Quill); eprintln!( "riddle: display {} ({}x{} stride {})", @@ -215,7 +227,7 @@ fn run() -> std::io::Result<()> { signal_hook::flag::register(signal_hook::consts::SIGINT, Arc::clone(&sigterm))?; // Blank page. - surf.fill_rect(0, 0, SCREEN_W, SCREEN_H, WHITE); + surf.fill_rect(0, 0, screen_w(), screen_h(), WHITE); disp.update_all(surf.w, surf.h); // The diary's memory (None = RIDDLE_MEMORY=off or the dir is unusable). @@ -356,6 +368,9 @@ fn run() -> std::io::Result<()> { State::Lingering { region, .. } => { state = State::FadingReply { stage: 0, next: Instant::now(), region }; } + State::PageFull { region, pending, rx } => { + state = State::TurningPage { stage: 0, next: Instant::now(), region, pending, rx }; + } _ => {} } } @@ -385,6 +400,8 @@ fn run() -> std::io::Result<()> { *last_pen = Some(Instant::now()); } else if let State::Lingering { region, .. } = state { state = State::FadingReply { stage: 0, next: Instant::now(), region }; + } else if let State::PageFull { region, pending, rx } = state { + state = State::TurningPage { stage: 0, next: Instant::now(), region, pending, rx }; } } qtfb::INPUT_PEN_RELEASE => { @@ -432,9 +449,9 @@ fn run() -> std::io::Result<()> { } else if oracle.is_none() { // No spirit at all: don't eat ink that nothing will // answer — leave the writing and put the reason below. - let y = (user_ink.bbox.y1 + 90).min(SCREEN_H as i32 - 400); - let plan = plan_reply(&font, &oracle_excuse("no oracle"), Some(y)); - State::Replying { plan, next: Instant::now(), rx: None } + let y = (user_ink.bbox.y1 + 90).min(screen_h() as i32 - 400); + let (plan, pending) = plan_reply(&font, &oracle_excuse("no oracle"), Some(y)); + State::Replying { plan, next: Instant::now(), rx: None, pending } } else { if let Err(e) = user_ink.to_png(&surf, PNG_PATH) { eprintln!("riddle: rasterize failed: {e}"); @@ -486,8 +503,8 @@ fn run() -> std::io::Result<()> { State::Thinking { rx, pulse, blot_on, since } => match rx.try_recv() { Ok(result) => { - surf.fill_rect(SCREEN_W / 2 - 14, SCREEN_H / 2 - 14, 28, 28, WHITE); - disp.update(SCREEN_W as i32 / 2 - 14, SCREEN_H as i32 / 2 - 14, 28, 28, true); + surf.fill_rect(screen_w() / 2 - 14, screen_h() / 2 - 14, 28, 28, WHITE); + disp.update(screen_w() as i32 / 2 - 14, screen_h() as i32 / 2 - 14, 28, 28, true); // First streamed event: start writing now; keep the // receiver so the rest of the reply can append itself. match result { @@ -498,16 +515,16 @@ fn run() -> std::io::Result<()> { Some(st) => st, None => { eprintln!("riddle: memory {id} is missing"); - let plan = plan_reply(&font, &oracle_excuse("lost page"), None); + let (plan, pending) = plan_reply(&font, &oracle_excuse("lost page"), None); turn_failed = true; - State::Replying { plan, next: Instant::now(), rx: None } + State::Replying { plan, next: Instant::now(), rx: None, pending } } } } Ok(Event::Ink(text)) => { turn_reply.push_str(&text); - let plan = plan_reply(&font, &text, None); - State::Replying { plan, next: Instant::now(), rx: Some(rx) } + let (plan, pending) = plan_reply(&font, &text, None); + State::Replying { plan, next: Instant::now(), rx: Some(rx), pending } } Ok(Event::Transcript(t)) => { // Transcript with no prose (model skipped the @@ -518,8 +535,8 @@ fn run() -> std::io::Result<()> { Err(e) => { eprintln!("riddle: oracle failed: {e}"); turn_failed = true; - let plan = plan_reply(&font, &oracle_excuse(&e), None); - State::Replying { plan, next: Instant::now(), rx: None } + let (plan, pending) = plan_reply(&font, &oracle_excuse(&e), None); + State::Replying { plan, next: Instant::now(), rx: None, pending } } } } @@ -528,12 +545,12 @@ fn run() -> std::io::Result<()> { // The oracle never answered (stalled stream, dead pi): // stop pulsing and say so instead of thinking forever. eprintln!("riddle: oracle timed out after {}s", ORACLE_PATIENCE.as_secs()); - surf.fill_rect(SCREEN_W / 2 - 14, SCREEN_H / 2 - 14, 28, 28, WHITE); - disp.update(SCREEN_W as i32 / 2 - 14, SCREEN_H as i32 / 2 - 14, 28, 28, true); - let plan = plan_reply(&font, &oracle_excuse("timed out"), None); - State::Replying { plan, next: Instant::now(), rx: None } + surf.fill_rect(screen_w() / 2 - 14, screen_h() / 2 - 14, 28, 28, WHITE); + disp.update(screen_w() as i32 / 2 - 14, screen_h() as i32 / 2 - 14, 28, 28, true); + let (plan, pending) = plan_reply(&font, &oracle_excuse("timed out"), None); + State::Replying { plan, next: Instant::now(), rx: None, pending } } else if pulse.elapsed() >= Duration::from_millis(600) { - let (cx, cy) = (SCREEN_W as i32 / 2, SCREEN_H as i32 / 2); + let (cx, cy) = (screen_w() as i32 / 2, screen_h() as i32 / 2); if blot_on { surf.fill_rect(cx as usize - 14, cy as usize - 14, 28, 28, WHITE); } else { @@ -548,23 +565,27 @@ fn run() -> std::io::Result<()> { Err(mpsc::TryRecvError::Disconnected) => State::Listening { last_pen: None }, }, - State::Replying { mut plan, next, mut rx } => { + State::Replying { mut plan, next, mut rx, mut pending } => { // More of the reply may still be streaming in: append each // new chunk below what is already planned, mid-animation. + // Once this page is full, further chunks queue in `pending` + // for the next page instead of writing off the bottom. if let Some(ref r) = rx { let drop_rx = match r.try_recv() { Ok(Ok(Event::Ink(more))) => { - if plan.next_y > SCREEN_H as i32 - 200 { - // The page is full: let the rest go unwritten - // rather than inking below the visible page. - eprintln!("riddle: reply reached the page bottom; trailing text dropped"); - true + turn_reply.push_str(" "); + turn_reply.push_str(&more); + if pending.is_empty() { + let leftover = append_reply(&font, &mut plan, &more); + if !leftover.is_empty() { + eprintln!("riddle: page full — buffering rest for a new page"); + pending = leftover; + } } else { - turn_reply.push_str(" "); - turn_reply.push_str(&more); - append_reply(&font, &mut plan, &more); - false + pending.push(' '); + pending.push_str(&more); } + false } Ok(Ok(Event::Transcript(t))) => { turn_transcript = Some(t); @@ -611,7 +632,13 @@ fn run() -> std::io::Result<()> { let (x, y, w, h) = dirty.rect(); disp.update(x, y, w, h, true); } - if plan.stroke_i >= plan.strokes.len() && rx.is_none() { + if plan.stroke_i >= plan.strokes.len() && !pending.is_empty() { + // This page is fully inked and there's more to show: + // park until the pen confirms it's been read, rather + // than dissolving and moving on unread. + let region = plan.region; + State::PageFull { region, pending, rx } + } else if plan.stroke_i >= plan.strokes.len() && rx.is_none() { // The turn is complete: the diary remembers it. if !turn_failed && !turn_reply.is_empty() { if let Some(ref mut s) = store { @@ -629,10 +656,60 @@ fn run() -> std::io::Result<()> { let region = plan.region; State::Lingering { until: Instant::now() + linger.min(Duration::from_secs(20)), region } } else { - State::Replying { plan, next: Instant::now() + Duration::from_millis(14), rx } + State::Replying { plan, next: Instant::now() + Duration::from_millis(14), rx, pending } } } else { - State::Replying { plan, next, rx } + State::Replying { plan, next, rx, pending } + } + } + + State::PageFull { region, mut pending, mut rx } => { + // Parked waiting for a touch (handled in the pen-event loop + // above); keep absorbing any further streamed text so it's + // ready to write as soon as the page turns. + if let Some(ref r) = rx { + match r.try_recv() { + Ok(Ok(Event::Ink(more))) => { + turn_reply.push_str(" "); + turn_reply.push_str(&more); + pending.push(' '); + pending.push_str(&more); + } + Ok(Ok(Event::Transcript(t))) => turn_transcript = Some(t), + Ok(Ok(Event::Show(_))) => { + eprintln!("riddle: conjuring directive mid-reply ignored"); + } + Ok(Err(e)) => { + eprintln!("riddle: oracle failed mid-reply: {e}"); + turn_failed = true; + rx = None; + } + Err(mpsc::TryRecvError::Disconnected) => rx = None, + Err(mpsc::TryRecvError::Empty) => {} + } + } + State::PageFull { region, pending, rx } + } + + State::TurningPage { stage, next, region, pending, rx } => { + const STAGES: u32 = 10; + if Instant::now() >= next { + ink::dissolve_pass(&mut surf, region, stage, STAGES); + let (x, y, w, h) = region.rect(); + disp.update(x, y, w, h, true); + if stage + 1 >= STAGES { + disp.full_refresh(surf.w, surf.h); + // Whatever accumulated while parked might itself be + // more than one page (e.g. the model kept streaming + // while waiting for a touch); `still_pending` carries + // any remainder into another page-full cycle. + let (plan, still_pending) = plan_reply(&font, pending.trim(), None); + State::Replying { plan, next: Instant::now(), rx, pending: still_pending } + } else { + State::TurningPage { stage: stage + 1, next: Instant::now() + Duration::from_millis(80), region, pending, rx } + } + } else { + State::TurningPage { stage, next, region, pending, rx } } } @@ -664,7 +741,7 @@ fn run() -> std::io::Result<()> { State::Conjuring { mut plan, next, saved } => { if stylus_tapped { // The writer interrupts: today's page returns at once. - surf.paste_rect(0, 0, SCREEN_W, SCREEN_H, &saved); + surf.paste_rect(0, 0, screen_w(), screen_h(), &saved); disp.full_refresh(surf.w, surf.h); State::MemoryShown { saved: None, until: Instant::now(), region: plan.region } } else if Instant::now() >= next { @@ -713,7 +790,7 @@ fn run() -> std::io::Result<()> { Some(s) => { if stylus_tapped || Instant::now() >= until { // The paper swallows its memory; today's page returns. - surf.paste_rect(0, 0, SCREEN_W, SCREEN_H, &s); + surf.paste_rect(0, 0, screen_w(), screen_h(), &s); disp.full_refresh(surf.w, surf.h); eprintln!("riddle: memory dismissed"); State::MemoryShown { saved: None, until, region } @@ -804,8 +881,8 @@ fn conjure( let strokes = s.strokes(id).unwrap_or_default(); eprintln!("riddle: conjuring memory {id} ({})", memory::spoken_date(id)); - let saved = surf.copy_rect(0, 0, SCREEN_W, SCREEN_H); - surf.fill_rect(0, 0, SCREEN_W, SCREEN_H, WHITE); + let saved = surf.copy_rect(0, 0, screen_w(), screen_h()); + surf.fill_rect(0, 0, screen_w(), screen_h(), WHITE); disp.update_all(surf.w, surf.h); let mut all: Vec> = Vec::new(); @@ -815,7 +892,7 @@ fn conjure( let date = memory::spoken_date(entry.id); let mut raster = script::rasterize_line(font, &date, 54.0); script::thin(&mut raster); - let x0 = (SCREEN_W as i32 - raster.width as i32) / 2; + let x0 = (screen_w() as i32 - raster.width as i32) / 2; let mut ink_bottom = 64; for stroke in script::trace(&raster) { let mapped: Vec<(i32, i32, i32)> = @@ -838,8 +915,11 @@ fn conjure( // Tom's old reply, below. if !entry.reply.is_empty() { - let y = (ink_bottom + 130).min(SCREEN_H as i32 - 400); - let reply = plan_reply(font, &entry.reply, Some(y)); + let y = (ink_bottom + 130).min(screen_h() as i32 - 400); + // Memory replay draws the whole remembered reply at once (it isn't + // streamed), so there's no `pending`/`rx` to hand overflow to; + // pagination for a long remembered reply is a separate improvement. + let (reply, _overflow) = plan_reply(font, &entry.reply, Some(y)); for stroke in reply.strokes { let mapped: Vec<(i32, i32, i32)> = stroke.iter().map(|&(x, y)| (x, y, 2)).collect(); for &(x, y, r) in &mapped { @@ -856,14 +936,26 @@ fn conjure( }) } -/// Lay out reply text and produce screen-space strokes. `y_start` continues a -/// streamed reply below its previous chunk; None places the first chunk. -fn plan_reply(font: &FontRef, text: &str, y_start: Option) -> WritePlan { - let max_w = (SCREEN_W as i32 - 2 * MARGIN_X) as f32; +/// How far down the page a line may start and still count as "on the page". +/// Below this, `plan_reply` stops and hands the rest back as leftover. +const PAGE_BOTTOM: i32 = 200; + +/// Lay out reply text and produce screen-space strokes, stopping before the +/// page bottom rather than writing off it. `y_start` continues a streamed +/// reply below its previous chunk; None places the first chunk near the top. +/// Returns the plan plus any text that didn't fit and must wait for a new +/// page — empty when everything fit. +fn plan_reply(font: &FontRef, text: &str, y_start: Option) -> (WritePlan, String) { + let max_w = (screen_w() as i32 - 2 * MARGIN_X) as f32; let lines = script::wrap(font, text, REPLY_PX, max_w); let line_h = (REPLY_PX * 1.25) as i32; - let total_h = line_h * lines.len() as i32; - let mut y = y_start.unwrap_or(((SCREEN_H as i32 - total_h) / 3).max(60)); + let mut y = y_start.unwrap_or(REPLY_TOP_MARGIN); + + let limit = screen_h() as i32 - PAGE_BOTTOM; + let fit = if y > limit { 0 } else { ((limit - y) / line_h + 1).max(0) as usize }; + let fit = fit.min(lines.len()); + let leftover = lines[fit..].join(" "); + let mut strokes = Vec::new(); let mut region = BBox::empty(); let mut seed = 0x1234u32; @@ -872,11 +964,11 @@ fn plan_reply(font: &FontRef, text: &str, y_start: Option) -> WritePlan { ((seed >> 16) % 7) as i32 - 3 }; - for line_text in &lines { + for line_text in &lines[..fit] { let mut raster = script::rasterize_line(font, line_text, REPLY_PX); script::thin(&mut raster); let line_strokes = script::trace(&raster); - let x0 = (SCREEN_W as i32 - raster.width as i32) / 2; + let x0 = (screen_w() as i32 - raster.width as i32) / 2; let wobble = jitter(); for s in line_strokes { let mapped: Vec<(i32, i32)> = s.iter().map(|&(sx, sy)| (x0 + sx, y + sy + wobble)).collect(); @@ -888,17 +980,19 @@ fn plan_reply(font: &FontRef, text: &str, y_start: Option) -> WritePlan { y += line_h; } - WritePlan { strokes, stroke_i: 0, point_i: 0, region, next_y: y } + (WritePlan { strokes, stroke_i: 0, point_i: 0, region, next_y: y }, leftover) } /// Splice a streamed continuation chunk into a running write animation. -fn append_reply(font: &FontRef, plan: &mut WritePlan, more: &str) { - let cont = plan_reply(font, more, Some(plan.next_y)); +/// Returns any part of `more` that didn't fit on the current page. +fn append_reply(font: &FontRef, plan: &mut WritePlan, more: &str) -> String { + let (cont, leftover) = plan_reply(font, more, Some(plan.next_y)); if cont.strokes.is_empty() { - return; + return leftover; } plan.region.add(cont.region.x0, cont.region.y0, 0); plan.region.add(cont.region.x1, cont.region.y1, 0); plan.strokes.extend(cont.strokes); plan.next_y = cont.next_y; + leftover } diff --git a/riddle/src/pen.rs b/riddle/src/pen.rs index 91cf6b6..09452b0 100644 --- a/riddle/src/pen.rs +++ b/riddle/src/pen.rs @@ -8,11 +8,13 @@ use std::io; use std::os::fd::RawFd; -use crate::fb::{SCREEN_H, SCREEN_W}; +use crate::fb::{screen_h, screen_w}; -// Digitizer axis ranges on the Paper Pro ("Elan marker input"). -const DIGI_MAX_X: i32 = 11180; -const DIGI_MAX_Y: i32 = 15340; +// Digitizer axis ranges read from the device itself via EVIOCGABS (differs +// by panel: Paper Pro is 11180x15340, Paper Pro Move is 6760x11960). These +// are only a fallback for the rare case the ioctl fails. +const FALLBACK_DIGI_MAX_X: i32 = 11180; +const FALLBACK_DIGI_MAX_Y: i32 = 15340; pub const MAX_PRESSURE: i32 = 4096; const EV_SYN: u16 = 0; @@ -27,6 +29,36 @@ const BTN_TOOL_RUBBER: u16 = 321; const BTN_TOUCH: u16 = 330; const EVIOCGRAB: libc::c_ulong = 0x40044590; +// EVIOCGABS(ABS_X)/EVIOCGABS(ABS_Y): _IOR('E', 0x40 + abs, struct input_absinfo). +const EVIOCGABS_X: libc::c_ulong = 0x80184540; +const EVIOCGABS_Y: libc::c_ulong = 0x80184541; + +#[repr(C)] +#[derive(Default)] +struct InputAbsInfo { + value: i32, + minimum: i32, + maximum: i32, + fuzz: i32, + flat: i32, + resolution: i32, +} + +/// Read the digitizer's real axis maximum via EVIOCGABS, falling back to +/// the Paper Pro's known range if the ioctl fails for some reason. +fn query_abs_max(fd: RawFd, code: libc::c_ulong, fallback: i32) -> i32 { + let mut info = InputAbsInfo::default(); + let r = unsafe { libc::ioctl(fd, code, &mut info as *mut InputAbsInfo) }; + if r != 0 || info.maximum <= 0 { + eprintln!( + "riddle: warning: EVIOCGABS failed ({}), assuming {}", + io::Error::last_os_error(), + fallback + ); + return fallback; + } + info.maximum +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tool { @@ -47,6 +79,8 @@ pub struct PenSample { pub struct PenDevice { fd: RawFd, + digi_max_x: i32, + digi_max_y: i32, // Accumulated state between SYN_REPORTs. raw_x: i32, raw_y: i32, @@ -70,8 +104,13 @@ impl PenDevice { eprintln!("riddle: warning: EVIOCGRAB failed ({}) — xochitl will also see the pen", io::Error::last_os_error()); } eprintln!("riddle: pen device {path} opened (grabbed: {})", grab == 0); + let digi_max_x = query_abs_max(fd, EVIOCGABS_X, FALLBACK_DIGI_MAX_X); + let digi_max_y = query_abs_max(fd, EVIOCGABS_Y, FALLBACK_DIGI_MAX_Y); + eprintln!("riddle: pen digitizer range {digi_max_x}x{digi_max_y}"); Ok(Self { fd, + digi_max_x, + digi_max_y, raw_x: 0, raw_y: 0, pressure: 0, @@ -127,8 +166,8 @@ impl PenDevice { if self.dirty { self.dirty = false; out.push(PenSample { - x: self.raw_x * (SCREEN_W as i32 - 1) / DIGI_MAX_X, - y: self.raw_y * (SCREEN_H as i32 - 1) / DIGI_MAX_Y, + x: self.raw_x * (screen_w() as i32 - 1) / self.digi_max_x, + y: self.raw_y * (screen_h() as i32 - 1) / self.digi_max_y, pressure: self.pressure, tool: self.tool, touching: self.touching,