From 9c69c029589e7691e26d49b0973cbc791b35c0ec Mon Sep 17 00:00:00 2001 From: David Budnick Date: Wed, 17 Jun 2026 09:12:11 -0500 Subject: [PATCH 1/2] fix(gui): fit keyboard image and render glow live --- crates/openlogi-gui/src/app.rs | 165 +++++++------- crates/openlogi-gui/src/asset/glow.rs | 207 +++++++++--------- crates/openlogi-gui/src/asset/mod.rs | 60 ++++- crates/openlogi-gui/src/main.rs | 4 + .../openlogi-gui/src/mouse_model/geometry.rs | 36 ++- crates/openlogi-gui/src/mouse_model/view.rs | 79 +++++-- crates/openlogi-gui/src/state.rs | 31 +-- 7 files changed, 327 insertions(+), 255 deletions(-) diff --git a/crates/openlogi-gui/src/app.rs b/crates/openlogi-gui/src/app.rs index 9f4de247..77727da5 100644 --- a/crates/openlogi-gui/src/app.rs +++ b/crates/openlogi-gui/src/app.rs @@ -1,9 +1,9 @@ -use std::path::PathBuf; +use std::sync::Arc; use gpui::{ - AnyElement, App, AppContext as _, BorrowAppContext as _, BoxShadow, Context, Div, Entity, - FontWeight, InteractiveElement, IntoElement, ParentElement, Render, SharedString, - StatefulInteractiveElement as _, Styled, Subscription, Window, div, img, point, + AnyElement, App, AppContext as _, BorrowAppContext as _, Bounds, BoxShadow, Context, Div, + Entity, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, Render, SharedString, + StatefulInteractiveElement as _, Styled, Subscription, Window, canvas, div, fill, img, point, prelude::FluentBuilder as _, px, relative, rgb, }; use gpui_component::{ @@ -25,7 +25,7 @@ use tracing::info; use openlogi_agent_core::ipc::InventoryHealth; use crate::app_menu::{CloseWindow, Minimize, Zoom}; -use crate::asset::AssetResolver; +use crate::asset::{AssetResolver, GlowGeometry}; use crate::components::carousel::Carousel; use crate::components::dpi_panel::DpiPanel; use crate::components::lighting_panel::LightingPanel; @@ -147,44 +147,6 @@ pub struct AppView { } impl AppView { - /// Generate any missing keyboard glow overlays off the render thread, once - /// each. The gallery only reads the cached PNG ([`lighting_overlay`]); when a - /// worker finishes it refreshes the windows and the next render shows it. - fn ensure_glow(cx: &mut Context) { - let jobs: Vec = { - let Some(state) = cx.try_global::() else { - return; - }; - state - .device_list - .iter() - .filter_map(|record| glow_job(state, record)) - .collect() - }; - for job in jobs { - let first = cx.update_global::(|state, _| { - state.mark_glow_attempted(job.cache.clone()) - }); - if !first { - continue; - } - let GlowJob { cache, depot, hex } = job; - let (tx, rx) = tokio::sync::oneshot::channel(); - std::thread::spawn(move || { - let _ = tx.send(crate::asset::ensure_glow_png(&depot, &hex).is_some()); - }); - cx.spawn(async move |_view, cx| { - if matches!(rx.await, Ok(true)) { - cx.update_global::(|state, cx| { - state.mark_glow_ready(cache); - cx.refresh_windows(); - }); - } - }) - .detach(); - } - } - /// Construct the root view and its child entities. pub fn new(_inventories: &[DeviceInventory], cx: &mut Context) -> Self { let cache = AssetResolver::new(); @@ -404,7 +366,6 @@ impl Render for AppView { .child(Self::accessibility_gate(pal, cx)) .into_any_element(); } - Self::ensure_glow(cx); let has_device = cx .try_global::() @@ -627,7 +588,9 @@ fn device_gallery(cx: &mut Context) -> impl IntoElement { return div().into_any_element(); }; let key = record.config_key.clone(); - let glow = lighting_overlay(&record, cx); + let glow = cx + .try_global::() + .and_then(|s| keyboard_glow(s, &record)); let view = view.clone(); device_card(&record, focused, glow, pal) .id(("device-card", idx)) @@ -645,48 +608,70 @@ fn device_gallery(cx: &mut Context) -> impl IntoElement { ) } -/// Path to the cached inter-key colour overlay for a light-up keyboard, if it -/// has been generated. Generation runs off the render thread in -/// [`AppView::ensure_glow`]; this lookup only stats the cache. `None` unless the -/// device is a keyboard with lighting enabled and the overlay exists yet. -fn lighting_overlay(record: &DeviceRecord, cx: &App) -> Option { +/// Opacity the lighting colour is painted at over the device image, in both the +/// home gallery and the device-detail model. +const GLOW_OPACITY: f32 = 0.6; + +/// The inter-key glow geometry and tinted colour for `record`, or `None` unless +/// it's a keyboard with lighting enabled and a depot that ships a baked mask. +/// The geometry is painted live by [`glow_canvas`] — no pre-rendered PNG, so a +/// colour change costs no new texture. +pub(crate) fn keyboard_glow( + state: &AppState, + record: &DeviceRecord, +) -> Option<(Arc, Hsla)> { if record.kind != DeviceKind::Keyboard { return None; } - let state = cx.try_global::()?; let lighting = state .lighting_for(&record.config_key) .filter(|l| l.enabled)?; - let asset = record.asset.as_ref()?; - asset.hero_image_path.as_ref()?; - let path = crate::asset::glow_path(&asset.depot, &lighting.color)?; - state.glow_is_ready(&path).then_some(path) -} - -/// A pending off-thread glow generation: the cache path to fill plus the inputs -/// [`crate::asset::ensure_glow_png`] needs. -struct GlowJob { - cache: PathBuf, - depot: String, - hex: String, + let geom = record.asset.as_ref()?.glow.clone()?; + let [_, r, g, b] = crate::components::lighting_panel::parse_hex(&lighting.color).to_be_bytes(); + let color = gpui::Rgba { + r: f32::from(r) / 255., + g: f32::from(g) / 255., + b: f32::from(b) / 255., + a: GLOW_OPACITY, + }; + Some((geom, color.into())) } -/// The glow job for `record` when it's a keyboard with lighting enabled and a -/// resolved photo; `None` otherwise. -fn glow_job(state: &AppState, record: &DeviceRecord) -> Option { - if record.kind != DeviceKind::Keyboard { - return None; - } - let lighting = state - .lighting_for(&record.config_key) - .filter(|l| l.enabled)?; - let asset = record.asset.as_ref()?; - asset.hero_image_path.as_ref()?; - Some(GlowJob { - cache: crate::asset::glow_path(&asset.depot, &lighting.color)?, - depot: asset.depot.clone(), - hex: lighting.color, - }) +/// Paint a keyboard's baked inter-key holes in its lighting colour, scaled with +/// a contain-fit so the holes register with the keys at any render size. A +/// `canvas` of tinted quads — no pre-rendered PNG and no per-colour texture, so +/// the runtime footprint is just the depot's small segment list (#272). +pub(crate) fn glow_canvas(geom: Arc, color: Hsla) -> impl IntoElement { + canvas( + move |_, _, _| (geom, color), + move |bounds, (geom, color), window, _| { + let bw = f32::from(bounds.size.width); + let bh = f32::from(bounds.size.height); + if bw <= 0. || bh <= 0. { + return; + } + // Contain-fit a `geom.aspect` box inside the bounds, matching the + // device image's object-fit so the holes line up with the keys. + let (rw, rh) = if bw / bh > geom.aspect { + (bh * geom.aspect, bh) + } else { + (bw, bw / geom.aspect) + }; + let ox = f32::from(bounds.origin.x) + (bw - rw) / 2.; + let oy = f32::from(bounds.origin.y) + (bh - rh) / 2.; + for s in &geom.segments { + let quad = Bounds { + origin: point(px(ox + s.x * rw), px(oy + s.y * rh)), + size: gpui::size(px((s.w * rw).max(1.)), px((s.h * rh).max(1.))), + }; + window.paint_quad(fill(quad, color)); + } + }, + ) + .absolute() + .top_0() + .left_0() + .size_full() } /// A device card in the Home gallery: the device photo floating on the window @@ -695,7 +680,12 @@ fn glow_job(state: &AppState, record: &DeviceRecord) -> Option { /// faint accent ring; inactive cards reserve the same 1px border in a /// transparent colour so selection never nudges the layout. Returns a bare /// [`Div`] so the gallery can wire the click handler. -fn device_card(record: &DeviceRecord, active: bool, glow: Option, pal: Palette) -> Div { +fn device_card( + record: &DeviceRecord, + active: bool, + glow: Option<(Arc, Hsla)>, + pal: Palette, +) -> Div { let ring = if active { rgb(theme::ACCENT_BLUE).into() } else { @@ -718,17 +708,10 @@ fn device_card(record: &DeviceRecord, active: bool, glow: Option, pal: .flex() .items_center() .justify_center() - .child(device_image(record, pal)) - .when_some(glow, |this, path| { - this.child( - img(path) - .absolute() - .top_0() - .left_0() - .size_full() - .opacity(0.6), - ) - }), + .when_some(glow, |this, (geom, color)| { + this.child(glow_canvas(geom, color)) + }) + .child(device_image(record, pal)), ) .child( v_flex() diff --git a/crates/openlogi-gui/src/asset/glow.rs b/crates/openlogi-gui/src/asset/glow.rs index 497db5fa..f48d74b8 100644 --- a/crates/openlogi-gui/src/asset/glow.rs +++ b/crates/openlogi-gui/src/asset/glow.rs @@ -1,4 +1,4 @@ -//! Inter-key "hole glow" overlay for a light-up keyboard. +//! Inter-key "hole glow" for a light-up keyboard, painted live from a baked mask. //! //! A floating-key keyboard render (e.g. the G513) has many small *enclosed* //! transparent gaps between its keys. Painting only those holes in the device's @@ -8,24 +8,22 @@ //! //! Finding the holes is expensive (a full-image flood-fill), so the assets repo //! precomputes them once (`scripts/precompute_glow.py`) into each depot's -//! `metadata.json` as a run-length-encoded mask. At runtime we only recolour -//! that tiny mask per lighting colour — no flood-fill, no full-image decode. -//! A depot without a precomputed mask simply gets no overlay. +//! `metadata.json` as a run-length-encoded mask. At runtime we decode that mask +//! into normalized horizontal segments ([`GlowGeometry`]) once per resolve and +//! paint them as scaled, tinted quads on the fly — no pre-rendered PNG and no +//! per-colour texture, so a depot's whole lighting footprint is the segment list. -use std::path::PathBuf; +use std::path::Path; -use image::{Rgba, RgbaImage}; use serde::Deserialize; -use tracing::{debug, warn}; - -use crate::components::lighting_panel::parse_hex; +use tracing::warn; /// Metadata files to read the precomputed mask from, newest schema first. const META_FILES: [&str; 2] = ["core_metadata.json", "metadata.json"]; /// Sanity bound on a baked mask's stored dimensions. The masks are ~1k px wide; /// anything far larger is a corrupt or hostile `metadata.json`. The cap also -/// keeps `width * height` well inside `u32`, so the run accumulator can't wrap. +/// keeps `width * height` well inside `u64`, so the run accumulator can't wrap. const MAX_MASK_DIM: u32 = 8192; /// Precomputed inter-key hole mask embedded in a depot's `metadata.json`: @@ -44,98 +42,85 @@ struct MetaGlow { glow: Option, } -/// Generate (once, then cache) a `glow-.png` overlay for a keyboard: the -/// precomputed inter-key holes painted `hex`, transparent elsewhere. `None` -/// unless the depot ships a precomputed mask (the feature gate) or the cache -/// can't be written. Cached under the writable user dir keyed by `depot`, so it -/// survives a read-only `.app` bundle. -pub(crate) fn ensure_glow_png(depot: &str, hex: &str) -> Option { - let dir = depot_dir(depot)?; - let out = dir.join(format!("glow-{hex}.png")); - if out.exists() { - return Some(out); - } - let [_, r, g, b] = parse_hex(hex).to_be_bytes(); - let color = Rgba([r, g, b, 255]); - let overlay = render_mask(&read_baked_mask(depot)?, color)?; - - std::fs::create_dir_all(&dir).ok()?; - // Write atomically (temp + rename) so a concurrent render never loads a - // half-written PNG; gpui caches an image-load *failure* permanently. For the - // same reason we keep every colour variant on disk (bounded by the small - // swatch palette) — deleting one would strand the GUI's in-memory "ready" - // set pointing at a file that no longer exists, blanking the card. - let tmp = dir.join(format!("glow-{hex}.png.tmp")); - overlay - .save_with_format(&tmp, image::ImageFormat::Png) - .map_err(|e| warn!(path = %tmp.display(), error = %e, "glow: save failed")) - .ok()?; - std::fs::rename(&tmp, &out).ok()?; - debug!(depot, hex, "glow: cached"); - Some(out) -} - -/// Cache path for a depot's glow overlay at colour `hex` (stat-only — no -/// writes). `None` when the depot name isn't a safe single path component, -/// so read-side lookups stay inside the cache root just like the writers. -pub(crate) fn glow_path(depot: &str, hex: &str) -> Option { - Some(depot_dir(depot)?.join(format!("glow-{hex}.png"))) +/// One horizontal run of inter-key holes, normalized to the mask's `[0, 1]` +/// extent so it scales to whatever size the device image renders at. +#[derive(Debug, Clone, Copy)] +pub(crate) struct GlowSeg { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, } -/// Validated `/` — `None` (with a warn) when the -/// index-supplied depot name isn't a single safe path component, so glow -/// IO can never leave the cache root. -fn depot_dir(depot: &str) -> Option { - openlogi_assets::http::safe_component_path( - &super::paths::user_cache_root(), - depot, - "asset depot", - ) - .map_err(|e| warn!(depot, error = %e, "glow: refusing depot dir")) - .ok() +/// The baked inter-key holes as normalized segments plus the mask's aspect +/// ratio, ready to paint over the device image at any size. Decoded once per +/// asset resolve; the segment list is the entire runtime footprint — there is +/// no recoloured texture, so a session that cycles colours costs nothing extra. +#[derive(Debug, Clone)] +pub(crate) struct GlowGeometry { + pub aspect: f32, + pub segments: Vec, } -/// Read the precomputed `glow` mask from the depot's metadata, ignoring every -/// other field (so the keyboard-schema hotspot data is irrelevant here). -fn read_baked_mask(depot: &str) -> Option { - let dir = depot_dir(depot)?; - META_FILES.iter().find_map(|name| { +/// Load and decode the precomputed glow mask from a depot directory's metadata. +/// `None` when the depot ships no mask (the feature gate) or it's malformed. +pub(crate) fn load_glow_geometry(dir: &Path) -> Option { + let mask = META_FILES.iter().find_map(|name| { let text = std::fs::read_to_string(dir.join(name)).ok()?; serde_json::from_str::(&text).ok()?.glow - }) + })?; + GlowGeometry::from_mask(&mask) } -/// Paint the RLE mask in `color`, then soften. -fn render_mask(mask: &GlowMask, color: Rgba) -> Option { - Some(image::imageops::blur(&paint_mask(mask, color)?, 1.5)) -} - -/// Reconstruct the RLE mask into a transparent image with the on-runs painted -/// `color`. `None` if the runs don't cover exactly `width * height`. -fn paint_mask(mask: &GlowMask, color: Rgba) -> Option { - let (w, h) = (mask.width, mask.height); - if w == 0 || h == 0 || w > MAX_MASK_DIM || h > MAX_MASK_DIM { - warn!(w, h, "glow: precomputed mask dimensions out of range"); - return None; - } - let total = u64::from(w) * u64::from(h); - if mask.runs.iter().map(|&r| u64::from(r)).sum::() != total { - warn!(w, h, "glow: precomputed mask runs don't cover width*height"); - return None; - } - let mut img = RgbaImage::new(w, h); - let mut idx: u32 = 0; - let mut on = false; - for &run in &mask.runs { - if on { - for p in idx..idx + run { - img.put_pixel(p % w, p / w, color); +impl GlowGeometry { + /// Decode the RLE mask into normalized per-row hole segments. A run that + /// crosses a row boundary is split so every segment stays on one row. + /// `None` if the stored dimensions are out of range or the runs don't cover + /// exactly `width * height`. + #[allow( + clippy::cast_precision_loss, + reason = "mask coords are < 8192 px — well within f32 mantissa" + )] + fn from_mask(mask: &GlowMask) -> Option { + let (w, h) = (mask.width, mask.height); + if w == 0 || h == 0 || w > MAX_MASK_DIM || h > MAX_MASK_DIM { + warn!(w, h, "glow: precomputed mask dimensions out of range"); + return None; + } + let total = u64::from(w) * u64::from(h); + if mask.runs.iter().map(|&r| u64::from(r)).sum::() != total { + warn!(w, h, "glow: precomputed mask runs don't cover width*height"); + return None; + } + let (wf, hf) = (w as f32, h as f32); + let mut segments = Vec::new(); + let mut idx: u64 = 0; + let mut on = false; + for &run in &mask.runs { + if on && run > 0 { + let mut start = idx; + let end = idx + u64::from(run); + while start < end { + let row = start / u64::from(w); + let col = start % u64::from(w); + let seg_end = end.min((row + 1) * u64::from(w)); + segments.push(GlowSeg { + x: col as f32 / wf, + y: row as f32 / hf, + w: (seg_end - start) as f32 / wf, + h: 1.0 / hf, + }); + start = seg_end; + } } + idx += u64::from(run); + on = !on; } - idx += run; - on = !on; + Some(Self { + aspect: wf / hf, + segments, + }) } - Some(img) } #[cfg(test)] @@ -144,27 +129,45 @@ mod tests { use super::*; #[test] - fn paint_mask_paints_only_on_runs() { - // 3x2 mask, runs alternate off/on starting off: off2, on1, off2, on1. - // Row-major pixels idx 2 and idx 5 are ON. + fn from_mask_extracts_on_runs_as_normalized_segments() { + // 4x2 mask, runs alternate off/on starting off: off2, on2, off3, on1. + // Row-major idx 2..4 ON (row 0, cols 2-3); idx 7 ON (row 1, col 3). + let mask = GlowMask { + width: 4, + height: 2, + runs: vec![2, 2, 3, 1], + }; + let geom = GlowGeometry::from_mask(&mask).expect("valid mask"); + assert!((geom.aspect - 2.0).abs() < 1e-6); + assert_eq!(geom.segments.len(), 2); + let first = geom.segments[0]; + assert!((first.x - 0.5).abs() < 1e-6); // col 2 / 4 + assert!((first.y - 0.0).abs() < 1e-6); // row 0 + assert!((first.w - 0.5).abs() < 1e-6); // len 2 / 4 + let second = geom.segments[1]; + assert!((second.x - 0.75).abs() < 1e-6); // col 3 / 4 + assert!((second.y - 0.5).abs() < 1e-6); // row 1 / 2 + } + + #[test] + fn from_mask_splits_a_run_across_rows() { + // 2x2, runs off1 on3: idx 1 (row 0 col 1) + idx 2..4 (row 1) → 2 segments. let mask = GlowMask { - width: 3, + width: 2, height: 2, - runs: vec![2, 1, 2, 1], + runs: vec![1, 3], }; - let img = paint_mask(&mask, Rgba([10, 20, 30, 255])).expect("mask paints"); - assert_eq!(img.get_pixel(2, 0).0, [10, 20, 30, 255]); // idx 2, on - assert_eq!(img.get_pixel(2, 1).0, [10, 20, 30, 255]); // idx 5, on - assert_eq!(img.get_pixel(0, 0).0[3], 0); // idx 0, off → transparent + let geom = GlowGeometry::from_mask(&mask).expect("valid mask"); + assert_eq!(geom.segments.len(), 2); } #[test] - fn paint_mask_rejects_bad_run_total() { + fn from_mask_rejects_bad_run_total() { let mask = GlowMask { width: 4, height: 4, runs: vec![3, 2], // sums to 5, not 16 }; - assert!(paint_mask(&mask, Rgba([0, 0, 0, 255])).is_none()); + assert!(GlowGeometry::from_mask(&mask).is_none()); } } diff --git a/crates/openlogi-gui/src/asset/mod.rs b/crates/openlogi-gui/src/asset/mod.rs index 805a5d6f..d5bfc4e8 100644 --- a/crates/openlogi-gui/src/asset/mod.rs +++ b/crates/openlogi-gui/src/asset/mod.rs @@ -18,9 +18,10 @@ mod images; mod paths; pub mod sync; -pub(crate) use self::glow::{ensure_glow_png, glow_path}; +pub(crate) use self::glow::GlowGeometry; use std::path::{Path, PathBuf}; +use std::sync::Arc; use openlogi_assets::{ BUTTONS_RENDER_FILES, DeviceEntry, FRONT_RENDER_FILES, Index, METADATA_FILES, Metadata, @@ -62,6 +63,36 @@ pub fn clear_cache() -> std::io::Result<()> { } } +/// Remove the legacy pre-rendered keyboard glow overlays (`glow-.png`, plus +/// any `.tmp` left by an interrupted write) the old overlay path baked into each +/// depot's user-cache dir. The glow is painted live from the depot's run-mask +/// now, so these are dead bytes; sweep them once at startup. Best-effort — an +/// unreadable dir or undeletable file is skipped silently. +pub fn cleanup_legacy_glow_pngs() { + cleanup_glow_pngs_in(&user_cache_root()); +} + +fn cleanup_glow_pngs_in(root: &Path) { + let Ok(depots) = std::fs::read_dir(root) else { + return; + }; + for depot in depots.flatten() { + if !depot.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let Ok(files) = std::fs::read_dir(depot.path()) else { + continue; + }; + for file in files.flatten() { + let name = file.file_name(); + let name = name.to_string_lossy(); + if name.starts_with("glow-") && (name.ends_with(".png") || name.ends_with(".png.tmp")) { + let _ = std::fs::remove_file(file.path()); + } + } + } +} + /// Reveal the asset cache directory in the OS file manager (Finder on macOS), /// creating it first so there's something to open. pub fn reveal_cache_in_file_manager() { @@ -106,6 +137,10 @@ pub struct ResolvedAsset { /// the side/buttons view the mouse model aligns hotspots against. `None` /// when the depot ships no front render. pub hero_image_path: Option, + /// Precomputed inter-key lighting holes for a light-up keyboard, decoded + /// from the depot's baked RLE mask and painted live over the device image + /// (see [`crate::app::glow_canvas`]). `None` for depots without a mask. + pub glow: Option>, pub metadata: Metadata, /// Actual pixel dimensions of `image_path`. Logi's /// `core_metadata.json` `origin` field tracks the *bbox of the mouse @@ -285,6 +320,7 @@ impl AssetResolver { kind: DeviceKind::from_registry_type(&entry.kind), image_path, hero_image_path, + glow: self::glow::load_glow_geometry(&dir).map(Arc::new), metadata, png_width, png_height, @@ -527,4 +563,26 @@ mod tests { assert_eq!((asset.png_width, asset.png_height), (100, 200)); assert_eq!(asset.metadata.assignments().count(), 1); } + + #[test] + fn cleanup_removes_only_legacy_glow_pngs() { + let root = + std::env::temp_dir().join(format!("openlogi-glow-cleanup-{}", std::process::id())); + let depot = root.join("g513"); + std::fs::create_dir_all(&depot).expect("create depot dir"); + std::fs::write(depot.join("glow-ff9500.png"), b"x").expect("write glow png"); + std::fs::write(depot.join("glow-af52de.png.tmp"), b"x").expect("write glow tmp"); + std::fs::write(depot.join("front.png"), b"x").expect("write front render"); + std::fs::write(depot.join("metadata.json"), b"{}").expect("write metadata"); + + cleanup_glow_pngs_in(&root); + + let kept = depot.join("front.png").exists() && depot.join("metadata.json").exists(); + let swept = !depot.join("glow-ff9500.png").exists() + && !depot.join("glow-af52de.png.tmp").exists(); + std::fs::remove_dir_all(&root).ok(); + + assert!(swept, "legacy glow files must be deleted"); + assert!(kept, "real assets must be left untouched"); + } } diff --git a/crates/openlogi-gui/src/main.rs b/crates/openlogi-gui/src/main.rs index 1a97ee25..1556c645 100644 --- a/crates/openlogi-gui/src/main.rs +++ b/crates/openlogi-gui/src/main.rs @@ -250,6 +250,10 @@ fn main() -> Result<()> { // assets (below). Rebuilding per snapshot was pure waste: the // unchanged-list early-return discarded the fresh records anyway. let mut cache = asset::AssetResolver::new(); + // One-time sweep of the legacy pre-rendered glow PNGs the old overlay + // baked into the user cache; the glow is painted live now, so they're + // dead bytes. Off-thread so it never delays the first paint. + std::thread::spawn(asset::cleanup_legacy_glow_pngs); // Asset sync runs in the background, in two stages: the first // agent snapshot — even a deviceless one — triggers an index // prefetch so the registry is on disk before any device needs diff --git a/crates/openlogi-gui/src/mouse_model/geometry.rs b/crates/openlogi-gui/src/mouse_model/geometry.rs index 0c72e424..ec709edc 100644 --- a/crates/openlogi-gui/src/mouse_model/geometry.rs +++ b/crates/openlogi-gui/src/mouse_model/geometry.rs @@ -16,22 +16,40 @@ const ASSET_HOTSPOT: f32 = 56.; /// separately-clickable dots. const THUMBWHEEL_ROTATION_OFFSET: f32 = 18.; -/// Scale the device image to fit a target height while preserving the -/// **actual PNG's** aspect ratio. The metadata's `origin` reports the -/// silhouette bbox inside the PNG, which is typically narrower than the -/// full image (Logi pads transparent strips on both sides); sizing by -/// origin causes `ObjectFit::Contain` to letterbox vertically and pulls -/// every hotspot off the rendered button. +/// Scale the device image to *fit inside* a `max_w` × `target_h` box while +/// preserving the **actual PNG's** aspect ratio. A tall device (a mouse) is +/// bound by the height; a wide one (a keyboard) is bound by the width — which +/// is what stops a wide keyboard render from overflowing the panel (#272). +/// +/// The metadata's `origin` reports the silhouette bbox inside the PNG, which +/// is typically narrower than the full image (Logi pads transparent strips on +/// both sides); sizing by origin causes `ObjectFit::Contain` to letterbox +/// vertically and pulls every hotspot off the rendered button. #[allow( clippy::cast_precision_loss, reason = "device images are < 4096 px on either axis — well within f32 mantissa" )] -pub fn asset_dimensions_for_png(asset: &ResolvedAsset, target_h: f32) -> (f32, f32) { +pub fn asset_dimensions_for_png(asset: &ResolvedAsset, target_h: f32, max_w: f32) -> (f32, f32) { if asset.png_height == 0 { return MOUSE_MODEL_SIZE; } - let w = target_h * (asset.png_width as f32) / (asset.png_height as f32); - (w, target_h) + let aspect = (asset.png_width as f32) / (asset.png_height as f32); + let w = target_h * aspect; + if w > max_w { + (max_w, max_w / aspect) + } else { + (w, target_h) + } +} + +/// Whether the asset exposes any remappable button markers. Mice do (so the +/// model reserves a side gutter for their leader-line labels); keyboards and +/// other label-less devices don't, so the model can hand them the full width. +pub fn asset_has_button_labels(asset: &ResolvedAsset) -> bool { + asset + .metadata + .assignments() + .any(|a| map_slot_name(&a.slot_name).is_some()) } /// Convert Logitech's percent-based markers into mouse-local pixel rects, diff --git a/crates/openlogi-gui/src/mouse_model/view.rs b/crates/openlogi-gui/src/mouse_model/view.rs index ceb940c4..dffdf2be 100644 --- a/crates/openlogi-gui/src/mouse_model/view.rs +++ b/crates/openlogi-gui/src/mouse_model/view.rs @@ -1,18 +1,22 @@ +use std::sync::Arc; + use gpui::{ - Anchor, AnyElement, App, BorrowAppContext as _, Context, ElementId, Entity, InteractiveElement, - IntoElement, MouseButton, ParentElement, Render, RenderOnce, StatefulInteractiveElement as _, - Styled, Subscription, Window, canvas, div, hsla, img, prelude::FluentBuilder as _, px, rgb, - svg, + Anchor, AnyElement, App, BorrowAppContext as _, Context, ElementId, Entity, Hsla, + InteractiveElement, IntoElement, MouseButton, ParentElement, Render, RenderOnce, + StatefulInteractiveElement as _, Styled, Subscription, Window, canvas, div, hsla, img, + prelude::FluentBuilder as _, px, rgb, svg, }; use gpui_component::{Icon, IconName, Selectable, h_flex, popover::Popover, v_flex}; -use crate::asset::ResolvedAsset; +use crate::app::{glow_canvas, keyboard_glow}; +use crate::asset::{GlowGeometry, ResolvedAsset}; use crate::data::mouse_buttons::{ Action, ButtonId, GestureDirection, Hotspot, MOUSE_MODEL_SIZE, default_binding, default_hotspots, }; use crate::mouse_model::geometry::{ - asset_dimensions_for_png, asset_hotspots_for_png, default_labels, labels_from_hotspots, + asset_dimensions_for_png, asset_has_button_labels, asset_hotspots_for_png, default_labels, + labels_from_hotspots, }; use crate::mouse_model::leader_lines::{ Geometry as LeaderGeometry, Label, Side, paint as paint_leader_lines, @@ -41,6 +45,14 @@ const MODEL_VERTICAL_RESERVE: f32 = 224.; /// keep the viewport above [`MODEL_VERTICAL_RESERVE`] + this. const MODEL_MIN_H: f32 = 448.; +/// Max width the model (side gutter + image) may occupy, matching the +/// `buttons_tab` content cap so a wide keyboard image never overflows the panel. +const MODEL_CONTENT_MAX_W: f32 = 760.; +/// Horizontal chrome the model can't draw into (the buttons-tab padding). +const MODEL_HORIZONTAL_RESERVE: f32 = 48.; +/// Floor for the model's available width on a narrow window. +const MODEL_MIN_CONTENT_W: f32 = 320.; + /// Interactive mouse model with button hotspots. pub struct MouseModelView { hovered: Option, @@ -78,7 +90,7 @@ impl MouseModelView { impl Render for MouseModelView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let (asset, active, bindings, gesture_owner) = cx + let (asset, active, bindings, gesture_owner, glow) = cx .try_global::() .map(|s| { ( @@ -86,22 +98,31 @@ impl Render for MouseModelView { s.active_button, s.button_bindings.clone(), s.current_gesture_owner(), + s.current_record().and_then(|r| keyboard_glow(s, r)), ) }) .unwrap_or_default(); - // Scale the model to the viewport height so it always fits the content - // area. An oversized model would otherwise overflow and compress the - // fixed header/footer. Capped at the design height; floored so the side - // labels stay readable (the window's min height keeps the viewport above - // the floor — see `main`). + // Scale the model to fit the content area in *both* axes. A tall mouse + // is bound by the viewport height (capped at the design height, floored + // so the side labels stay readable — the window's min height keeps the + // viewport above the floor, see `main`). A wide keyboard is bound by the + // available width so it can't overflow the panel (#272), and — having no + // side labels — drops the label gutter to centre at full width. let viewport_h = f32::from(window.viewport_size().height); + let viewport_w = f32::from(window.viewport_size().width); let target_h = (viewport_h - MODEL_VERTICAL_RESERVE).clamp(MODEL_MIN_H, MOUSE_MODEL_SIZE.1); - let (mouse_w, mouse_h, hotspots, labels) = scaled_model(asset.as_ref(), target_h); - - let canvas_w = SIDE_W + SIDE_GAP + mouse_w; + let has_labels = asset.as_ref().is_none_or(asset_has_button_labels); + let gutter = if has_labels { SIDE_W + SIDE_GAP } else { 0. }; + let content_w = + (viewport_w - MODEL_HORIZONTAL_RESERVE).clamp(MODEL_MIN_CONTENT_W, MODEL_CONTENT_MAX_W); + let max_image_w = (content_w - gutter).max(MODEL_MIN_CONTENT_W / 2.); + let (mouse_w, mouse_h, hotspots, labels) = + scaled_model(asset.as_ref(), target_h, max_image_w); + + let canvas_w = gutter + mouse_w; let canvas_h = mouse_h; - let mouse_left = SIDE_W + SIDE_GAP; + let mouse_left = gutter; let highlight = self.hovered.or(active); let view = cx.entity(); @@ -117,7 +138,7 @@ impl Render for MouseModelView { let capable = gesture_capable_buttons(&labels_outer); let gesture_owner = gesture_owner.filter(|id| capable.contains(id)); let leader_canvas = leader_canvas(hotspots, labels, highlight, mouse_left, mouse_w); - let breathing_art = breathing_art(asset.as_ref(), mouse_left, mouse_w, mouse_h, pal); + let breathing_art = breathing_art(asset.as_ref(), mouse_left, mouse_w, mouse_h, pal, glow); let hotspots_layer = hotspots_layer( &hotspots_outer, mouse_left, @@ -186,21 +207,22 @@ impl Render for MouseModelView { } } -/// Model geometry at `target_h`, scaled to fit. With a real asset the hotspots -/// and labels are recomputed from the scaled dimensions; the synthetic +/// Model geometry fit inside a `max_w` × `target_h` box. With a real asset the +/// hotspots and labels are recomputed from the scaled dimensions; the synthetic /// silhouette's authored coordinates are scaled by the same factor. Returns /// `(mouse_w, mouse_h, hotspots, labels)`. fn scaled_model( asset: Option<&ResolvedAsset>, target_h: f32, + max_w: f32, ) -> (f32, f32, Vec, Vec