diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index 621309e6..de80c043 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -130,13 +130,18 @@ impl CursorTrack { if s["interactionType"].as_str() == Some("click") { clicks.push(t); } - // Seules les TRANSITIONS sont retenues — voir `types`. Les échantillons sans - // `cursorType` (macOS ne le tague pas toujours) n'interrompent pas l'état courant : - // c'est une absence d'information, pas un retour à la flèche. - if let Some(ct) = s["cursorType"].as_str() { - if types.last().map(|(_, prev)| prev.as_str()) != Some(ct) { - types.push((t, ct.to_string())); - } + // Seules les TRANSITIONS sont retenues — voir `types`. Le helper + // macOS rend nil hors texte/pointeur pour que le rendu retombe sur + // la flèche. Le sidecar stocke ça en JSON null ou omet la clé. + // Ignorer ces échantillons gardait le dernier type sémantique + // (`pointer`/`text`) : un thème restait collé après le retour à la + // flèche. Null / absence = reset vers `arrow`. + let ct = match s.get("cursorType") { + Some(v) => v.as_str().filter(|label| !label.is_empty()).unwrap_or("arrow"), + None => "arrow", + }; + if types.last().map(|(_, prev)| prev.as_str()) != Some(ct) { + types.push((t, ct.to_string())); } } samples.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); @@ -291,4 +296,49 @@ mod tests { assert_eq!(smoothed.type_at(0.1), Some("arrow")); assert_eq!(smoothed.type_at(0.7), Some("text")); } + + /// JSON null et une clé `cursorType` absente resetent vers la flèche, + /// au lieu de garder le dernier `pointer`/`text`. + #[test] + fn null_cursor_type_resets_to_arrow() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let path = std::env::temp_dir().join(format!( + "openscreen-cursor-null-reset-{}-{}.json", + std::process::id(), + unique + )); + std::fs::write( + &path, + r#"{"samples":[ + {"timeMs":0,"cx":0.1,"cy":0.1,"cursorType":"pointer"}, + {"timeMs":100,"cx":0.2,"cy":0.2,"cursorType":null}, + {"timeMs":200,"cx":0.3,"cy":0.3,"cursorType":"pointer"}, + {"timeMs":300,"cx":0.4,"cy":0.4} + ]}"#, + ) + .expect("write temp sidecar"); + let path_str = path.to_str().expect("utf-8 temp path"); + let track = CursorTrack::load(path_str, 0.0, 1.0).expect("load sidecar"); + let _ = std::fs::remove_file(&path); + + assert_eq!(track.type_at(0.00), Some("pointer")); + assert_eq!( + track.type_at(0.10), + Some("arrow"), + "JSON null must reset to arrow" + ); + assert_eq!( + track.type_at(0.20), + Some("pointer"), + "pointer after null must hold until the omitted-key sample" + ); + assert_eq!( + track.type_at(0.30), + Some("arrow"), + "omitted cursorType after pointer must reset to arrow independently" + ); + } } diff --git a/src/components/ai-edition/CursorPane.preview.test.tsx b/src/components/ai-edition/CursorPane.preview.test.tsx new file mode 100644 index 00000000..e3336b96 --- /dev/null +++ b/src/components/ai-edition/CursorPane.preview.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import { LOCALE_STORAGE_KEY } from "@/i18n/config"; +import { CursorPane } from "./RightPanes"; + +function stubStorage() { + const store = new Map(); + const localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => { + store.clear(); + }, + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }; + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: localStorage }); +} + +beforeEach(() => { + stubStorage(); + window.localStorage.setItem(LOCALE_STORAGE_KEY, "en"); +}); + +afterEach(() => { + cleanup(); +}); + +describe("CursorPane theme previews", () => { + it("full: hello-kitty-watermelon theme cell shows arrow and pointer", () => { + render( + + + , + ); + const cell = screen.getByRole("button", { name: "Hello Kitty & Watermelon" }); + expect(cell.querySelectorAll("img")).toHaveLength(2); + }); + + it("empty: default theme cell shows a single preview img", () => { + render( + + + , + ); + const cell = screen.getByRole("button", { name: "Default" }); + expect(cell.querySelectorAll("img")).toHaveLength(1); + }); +}); diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index 8078ca27..814f0ea9 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -1159,6 +1159,12 @@ transition: border-color 150ms ease, box-shadow 150ms ease; color: var(--muted); } +.cursorCellPreviews { + display: flex; + align-items: center; + justify-content: center; + gap: 2px; +} .cursorCell:hover { border-color: var(--border-hi); color: var(--fg-2); } .cursorCell:disabled { opacity: 0.5; cursor: not-allowed; } .cursorCell:disabled:hover { border-color: var(--border); color: var(--muted); } diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 2e15f7b1..a5886da3 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -67,7 +67,11 @@ import type { TranscriptGateReason } from "@/lib/ai-edition/transcription/status import { getAssetPath } from "@/lib/assetPath"; import { resolveWebcamLayoutPreset, supportsWebcamReactiveZoom } from "@/lib/compositeLayout"; import { supportsCursorClickEffects } from "@/lib/cursor/cursorCapabilities"; -import { CURSOR_THEMES, DEFAULT_CURSOR_THEME_ID } from "@/lib/cursor/cursorThemes"; +import { + CURSOR_THEMES, + DEFAULT_CURSOR_THEME_ID, + themePickerPreviewAssets, +} from "@/lib/cursor/cursorThemes"; import { buildGradientFromEditor } from "@/lib/gradientBuilder"; import { classifyWallpaper, @@ -2321,22 +2325,25 @@ export function CursorPane() { // handlers below push diffs live. Sizes are sent as direct scales (1 = fixture default). // Synchro initiale : cf. NativeCompositorOverlay (`pushAllNativeParams`). - // Built-in "Default" plus each bundled theme. Thumbnails use the theme's - // arrow asset; the persisted value is the theme id. Same shape as the - // legacy SettingsPanel picker. + // Built-in "Default" plus each bundled theme. When arrow and pointer art + // differ, both sprites are shown so a pack is not previewed as arrow-only. const cursorThemeOptions = useMemo( () => [ { id: DEFAULT_CURSOR_THEME_ID, name: ts("cursor.themeDefault"), - previewUrl: defaultCursorPreviewUrl, + previewUrls: [defaultCursorPreviewUrl], }, ...CURSOR_THEMES.map((theme) => { - const previewPath = (theme.assets.arrow ?? theme.assets.pointer)?.assetPath; + const preview = themePickerPreviewAssets(theme); + const urls = [ + preview.arrow ? safeAssetUrl(preview.arrow) : defaultCursorPreviewUrl, + ...(preview.pointer ? [safeAssetUrl(preview.pointer)] : []), + ]; return { id: theme.id, name: theme.name, - previewUrl: previewPath ? safeAssetUrl(previewPath) : defaultCursorPreviewUrl, + previewUrls: urls, }; }), ], @@ -2385,14 +2392,19 @@ export function CursorPane() { disabled={!hasDocument} onClick={() => void set({ cursor: { theme: option.id } })} > - + + {option.previewUrls.map((url) => ( + 1 ? 14 : 20} + height={option.previewUrls.length > 1 ? 14 : 20} + draggable={false} + style={{ objectFit: "contain", pointerEvents: "none" }} + /> + ))} + ); })} diff --git a/src/lib/cursor/cursorThemes.test.ts b/src/lib/cursor/cursorThemes.test.ts new file mode 100644 index 00000000..00d5933e --- /dev/null +++ b/src/lib/cursor/cursorThemes.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + CURSOR_THEMES, + type CursorTheme, + DEFAULT_CURSOR_SPRITES, + themePickerPreviewAssets, +} from "./cursorThemes"; + +describe("themePickerPreviewAssets", () => { + it("hello-kitty-watermelon exposes distinct arrow and pointer", () => { + const theme = CURSOR_THEMES.find((t) => t.id === "hello-kitty-watermelon"); + if (!theme) { + throw new Error("hello-kitty-watermelon theme is missing from CURSOR_THEMES"); + } + const preview = themePickerPreviewAssets(theme); + expect(preview.arrow.length).toBeGreaterThan(0); + expect(preview.pointer).toBeTruthy(); + expect(preview.pointer).not.toBe(preview.arrow); + }); + + it("arrow-only theme has no pointer preview", () => { + const theme: CursorTheme = { + id: "arrow-only", + name: "Arrow only", + assets: { + arrow: { + assetPath: "cursors/fake/arrow.png", + width: 32, + height: 32, + hotspotX: 0, + hotspotY: 0, + }, + }, + }; + const preview = themePickerPreviewAssets(theme); + expect(preview.arrow).toBe("cursors/fake/arrow.png"); + expect(preview.pointer).toBeNull(); + }); + + it("default theme uses built-in arrow and pointer when they differ", () => { + const preview = themePickerPreviewAssets(null); + expect(preview.arrow).toBe(DEFAULT_CURSOR_SPRITES.arrow.assetPath); + if (DEFAULT_CURSOR_SPRITES.pointer.assetPath !== DEFAULT_CURSOR_SPRITES.arrow.assetPath) { + expect(preview.pointer).toBe(DEFAULT_CURSOR_SPRITES.pointer.assetPath); + } else { + expect(preview.pointer).toBeNull(); + } + }); +}); diff --git a/src/lib/cursor/cursorThemes.ts b/src/lib/cursor/cursorThemes.ts index e4674845..85e50f04 100644 --- a/src/lib/cursor/cursorThemes.ts +++ b/src/lib/cursor/cursorThemes.ts @@ -460,6 +460,25 @@ export const CURSOR_THEME_IDS: ReadonlySet = new Set([ ...CURSOR_THEMES.map((theme) => theme.id), ]); +/** + * Paths the theme picker should show. `pointer` is set only when that artwork + * differs from `arrow`, so a pack like Hello Kitty / Watermelon is not previewed + * as arrow-only. + */ +export function themePickerPreviewAssets(theme: CursorTheme | null): { + arrow: string; + pointer: string | null; +} { + if (!theme) { + const arrow = DEFAULT_CURSOR_SPRITES.arrow.assetPath; + const pointer = DEFAULT_CURSOR_SPRITES.pointer.assetPath; + return { arrow, pointer: pointer !== arrow ? pointer : null }; + } + const arrow = theme.assets.arrow?.assetPath ?? theme.assets.pointer?.assetPath ?? ""; + const pointer = theme.assets.pointer?.assetPath ?? null; + return { arrow, pointer: pointer && pointer !== arrow ? pointer : null }; +} + /** Returns the theme for `id`, or null for the default / unknown ids. */ export function getCursorTheme(id: string | null | undefined): CursorTheme | null { if (!id || id === DEFAULT_CURSOR_THEME_ID) {