Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions crates/compositor/src/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
Comment on lines +139 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Log the required cursor compositor smoke test

Because this changes cursor-state selection used by native compositor preview/export, the repository requires a cursor-focused manual E2E slice and a results-log row identifying the build, platform, and skipped coverage; I checked technical-documentation/testing/manual-e2e-checklist.md, and there is no entry for this commit. Run the relevant native cursor preview/export checks on an available real platform and append the result, or log the checks as skipped with the environment reason, before treating the cross-platform behavior as verified.

AGENTS.md reference: AGENTS.md:L86-L90

Useful? React with 👍 / 👎.

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());
Expand Down Expand Up @@ -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"
);
}
}
58 changes: 58 additions & 0 deletions src/components/ai-edition/CursorPane.preview.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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(
<I18nProvider>
<CursorPane />
</I18nProvider>,
);
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(
<I18nProvider>
<CursorPane />
</I18nProvider>,
);
const cell = screen.getByRole("button", { name: "Default" });
expect(cell.querySelectorAll("img")).toHaveLength(1);
});
});
6 changes: 6 additions & 0 deletions src/components/ai-edition/NewEditorShell.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
42 changes: 27 additions & 15 deletions src/components/ai-edition/RightPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
}),
],
Expand Down Expand Up @@ -2385,14 +2392,19 @@ export function CursorPane() {
disabled={!hasDocument}
onClick={() => void set({ cursor: { theme: option.id } })}
>
<img
src={option.previewUrl}
alt=""
width={20}
height={20}
draggable={false}
style={{ objectFit: "contain", pointerEvents: "none" }}
/>
<span className={styles.cursorCellPreviews}>
{option.previewUrls.map((url) => (
<img
key={url}
src={url}
alt=""
width={option.previewUrls.length > 1 ? 14 : 20}
height={option.previewUrls.length > 1 ? 14 : 20}
draggable={false}
style={{ objectFit: "contain", pointerEvents: "none" }}
/>
))}
</span>
</button>
);
})}
Expand Down
49 changes: 49 additions & 0 deletions src/lib/cursor/cursorThemes.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
19 changes: 19 additions & 0 deletions src/lib/cursor/cursorThemes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,25 @@ export const CURSOR_THEME_IDS: ReadonlySet<string> = 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) {
Expand Down
Loading