Skip to content
Merged
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",
};
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