From e818734cafabb1a0edd278d8133cdb0876bf5474 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:27:23 +0100 Subject: [PATCH] fix: restore recorded cursor behavior in both editors --- apps/desktop-gpui/Cargo.lock | 1 + .../desktop-gpui/src/editor_sidebar/cursor.rs | 143 ++++++++++------ .../src/routes/editor/CursorStylePicker.tsx | 80 +++++---- .../src/routes/editor/cursor-style.test.ts | 22 +++ .../desktop/src/routes/editor/cursor-style.ts | 29 ++++ crates/cursor-info/Cargo.toml | 2 +- .../assets/mac/recorded/closed-hand.png | Bin 0 -> 1638 bytes .../assets/mac/recorded/open-hand.png | Bin 0 -> 2061 bytes .../assets/mac/recorded/pointing-hand.png | Bin 0 -> 2042 bytes crates/cursor-info/src/macos.rs | 71 +++++++- crates/rendering/src/layers/cursor.rs | 158 +++++++++++++++--- 11 files changed, 401 insertions(+), 105 deletions(-) create mode 100644 apps/desktop/src/routes/editor/cursor-style.test.ts create mode 100644 apps/desktop/src/routes/editor/cursor-style.ts create mode 100644 crates/cursor-info/assets/mac/recorded/closed-hand.png create mode 100644 crates/cursor-info/assets/mac/recorded/open-hand.png create mode 100644 crates/cursor-info/assets/mac/recorded/pointing-hand.png diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 686399628c5..12d9e3decb1 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -1517,6 +1517,7 @@ name = "cap-cursor-info" version = "0.0.0" dependencies = [ "serde", + "sha2", "specta", "strum 0.27.2", "windows 0.60.0", diff --git a/apps/desktop-gpui/src/editor_sidebar/cursor.rs b/apps/desktop-gpui/src/editor_sidebar/cursor.rs index 45ed9769b75..beac97dc78d 100644 --- a/apps/desktop-gpui/src/editor_sidebar/cursor.rs +++ b/apps/desktop-gpui/src/editor_sidebar/cursor.rs @@ -13,6 +13,7 @@ const CARD_GROUP: &str = "cursor-style-card"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CursorCard { + Default, Family(CursorFamily), Circle, } @@ -20,6 +21,7 @@ enum CursorCard { impl CursorCard { fn label(self) -> &'static str { match self { + Self::Default => "Default", Self::Family(CursorFamily::MacOS) => "macOS", Self::Family(CursorFamily::MacOSTahoe) => "macOS Tahoe", Self::Family(CursorFamily::Windows) => "Windows", @@ -29,6 +31,7 @@ impl CursorCard { fn key(self) -> &'static str { match self { + Self::Default => "auto", Self::Family(CursorFamily::MacOS) => "macos", Self::Family(CursorFamily::MacOSTahoe) => "tahoe", Self::Family(CursorFamily::Windows) => "windows", @@ -38,6 +41,7 @@ impl CursorCard { fn cursor_type(self) -> CursorType { match self { + Self::Default => CursorType::Auto, Self::Family(CursorFamily::MacOS) => CursorType::MacOS, Self::Family(CursorFamily::MacOSTahoe) => CursorType::MacOSTahoe, Self::Family(CursorFamily::Windows) => CursorType::Windows, @@ -46,9 +50,10 @@ impl CursorCard { } } -fn cursor_cards() -> [CursorCard; 4] { +fn cursor_cards() -> [CursorCard; 5] { if cfg!(target_os = "windows") { [ + CursorCard::Default, CursorCard::Family(CursorFamily::Windows), CursorCard::Family(CursorFamily::MacOS), CursorCard::Family(CursorFamily::MacOSTahoe), @@ -56,6 +61,7 @@ fn cursor_cards() -> [CursorCard; 4] { ] } else { [ + CursorCard::Default, CursorCard::Family(CursorFamily::MacOS), CursorCard::Family(CursorFamily::MacOSTahoe), CursorCard::Family(CursorFamily::Windows), @@ -64,13 +70,13 @@ fn cursor_cards() -> [CursorCard; 4] { } } -fn selected_card(cursor_type: &CursorType, recorded: Option) -> CursorCard { +fn selected_card(cursor_type: &CursorType) -> CursorCard { if *cursor_type == CursorType::Circle { return CursorCard::Circle; } match cursor_type.family() { Some(family) => CursorCard::Family(family), - None => CursorCard::Family(recorded.unwrap_or(host_cursor_family())), + None => CursorCard::Default, } } @@ -149,10 +155,7 @@ impl EditorWindow { } fn selected_cursor_card(&self) -> CursorCard { - selected_card( - self.style_control_project().cursor.cursor_type(), - self.recorded_cursor_family, - ) + selected_card(self.style_control_project().cursor.cursor_type()) } fn cursor_preview(&self, shape: CursorShape, size: f32) -> Option> { @@ -186,6 +189,26 @@ impl EditorWindow { fn render_cursor_tile(&self, card: CursorCard, selected: bool, recorded: bool) -> AnyElement { let theme = self.theme; let art = match card { + CursorCard::Default => div() + .flex() + .items_center() + .gap(px(12.)) + .child( + self.cursor_art( + self.recorded_cursor_family + .unwrap_or(host_cursor_family()) + .arrow(), + ARROW_BOX, + ), + ) + .child(div().text_size(px(12.)).child("Default")) + .child( + div() + .text_size(px(11.)) + .text_color(Hsla::from(theme.gray_11)) + .child("Recorded cursors"), + ) + .into_any_element(), CursorCard::Family(family) => self.cursor_art(family.arrow(), ARROW_BOX), CursorCard::Circle => circle_art(), }; @@ -243,27 +266,29 @@ impl EditorWindow { .gap(px(6.)) .cursor_pointer() .child(self.render_cursor_tile(card, selected, is_recorded)) - .child( - div() - .max_w_full() - .whitespace_nowrap() - .overflow_hidden() - .text_ellipsis() - .text_size(px(11.)) - .line_height(px(11.)) - .font_weight(FontWeight::MEDIUM) - .text_color(Hsla::from(if selected { - theme.gray_12 - } else { - theme.gray_11 - })) - .when(!selected, |this| { - this.group_hover(CARD_GROUP, |this| { - this.text_color(Hsla::from(theme.gray_12)) + .when(card != CursorCard::Default, |this| { + this.child( + div() + .max_w_full() + .whitespace_nowrap() + .overflow_hidden() + .text_ellipsis() + .text_size(px(11.)) + .line_height(px(11.)) + .font_weight(FontWeight::MEDIUM) + .text_color(Hsla::from(if selected { + theme.gray_12 + } else { + theme.gray_11 + })) + .when(!selected, |this| { + this.group_hover(CARD_GROUP, |this| { + this.text_color(Hsla::from(theme.gray_12)) + }) }) - }) - .child(card.label()), - ) + .child(card.label()), + ) + }) .on_click(cx.listener(move |this, _, window, cx| { let cursor_type = cursor_type.clone(); this.edit_project("cursor-type", window, cx, move |project| { @@ -281,14 +306,42 @@ impl EditorWindow { let selected = self.selected_cursor_card(); let recorded = self.recorded_cursor_family; + let description = match selected { + CursorCard::Default => "Keeps the cursor shapes and appearance from your recording.", + CursorCard::Family(CursorFamily::MacOS) => { + "Classic macOS appearance. Custom cursors keep their recorded shape." + } + CursorCard::Family(CursorFamily::MacOSTahoe) => { + "macOS Tahoe appearance. Custom cursors keep their recorded shape." + } + CursorCard::Family(CursorFamily::Windows) => { + "Windows appearance. Custom cursors keep their recorded shape." + } + CursorCard::Circle => "Replaces all cursor shapes with a circle.", + }; div() .flex() - .flex_row() + .flex_col() .gap(px(CARD_GAP)) - .children( - cursor_cards() - .into_iter() - .map(|card| self.render_cursor_card(card, selected == card, recorded, cx)), + .child(self.render_cursor_card( + CursorCard::Default, + selected == CursorCard::Default, + recorded, + cx, + )) + .child( + div().flex().flex_row().gap(px(CARD_GAP)).children( + cursor_cards() + .into_iter() + .filter(|card| *card != CursorCard::Default) + .map(|card| self.render_cursor_card(card, selected == card, recorded, cx)), + ), + ) + .child( + div() + .text_size(px(11.)) + .text_color(Hsla::from(self.theme.gray_11)) + .child(description), ) .into_any_element() } @@ -362,10 +415,11 @@ mod tests { use super::*; #[test] - fn cards_lead_with_the_host_family() { + fn cards_offer_default_before_the_host_family() { let cards = cursor_cards(); - assert_eq!(cards[0], CursorCard::Family(host_cursor_family())); - assert_eq!(cards[3], CursorCard::Circle); + assert_eq!(cards[0], CursorCard::Default); + assert_eq!(cards[1], CursorCard::Family(host_cursor_family())); + assert_eq!(cards[4], CursorCard::Circle); for family in [ CursorFamily::MacOS, CursorFamily::MacOSTahoe, @@ -379,25 +433,14 @@ mod tests { fn every_card_round_trips_through_its_type() { for card in cursor_cards() { let written = card.cursor_type(); - assert_ne!(written, CursorType::Auto); - assert_eq!(selected_card(&written, None), card, "{:?}", card.label()); + assert_eq!(selected_card(&written), card, "{:?}", card.label()); } } #[test] - fn auto_follows_the_recording_then_the_host() { - assert_eq!( - selected_card(&CursorType::Auto, Some(CursorFamily::MacOSTahoe)), - CursorCard::Family(CursorFamily::MacOSTahoe) - ); - assert_eq!( - selected_card(&CursorType::Pointer, Some(CursorFamily::Windows)), - CursorCard::Family(CursorFamily::Windows) - ); - assert_eq!( - selected_card(&CursorType::Auto, None), - CursorCard::Family(host_cursor_family()) - ); + fn auto_and_legacy_pointer_select_default() { + assert_eq!(selected_card(&CursorType::Auto), CursorCard::Default); + assert_eq!(selected_card(&CursorType::Pointer), CursorCard::Default); } #[test] diff --git a/apps/desktop/src/routes/editor/CursorStylePicker.tsx b/apps/desktop/src/routes/editor/CursorStylePicker.tsx index b1d09286ddb..5d18b16ace6 100644 --- a/apps/desktop/src/routes/editor/CursorStylePicker.tsx +++ b/apps/desktop/src/routes/editor/CursorStylePicker.tsx @@ -10,10 +10,16 @@ import tahoeArrow from "../../../../../crates/cursor-info/assets/mac/tahoe/defau import windowsArrow from "../../../../../crates/cursor-info/assets/windows/arrow.svg?raw"; import { RgbInput } from "./color-utils"; import { type TransformedMeta, useEditorContext } from "./context"; +import { + type CursorFamily, + type CursorStyle, + cursorStyleDescription, + cursorStyleOrder, + selectedCursorStyle, +} from "./cursor-style"; import { Field, Section, Slider } from "./ui"; -export type CursorFamily = "macos" | "tahoe" | "windows"; -type CursorStyle = CursorFamily | "circle"; +export type { CursorFamily } from "./cursor-style"; const CURSOR_FAMILIES = { macos: { label: "macOS", arrow: macArrow }, @@ -60,12 +66,6 @@ function hostCursorFamily(): CursorFamily { return ostype() === "windows" ? "windows" : "macos"; } -function cursorStyleOrder(): CursorStyle[] { - return ostype() === "windows" - ? ["windows", "macos", "tahoe", "circle"] - : ["macos", "tahoe", "windows", "circle"]; -} - function CursorArrow(props: { svg: string }) { return (
@@ -78,31 +78,58 @@ function CircleCursor() { ); } -function CursorStyleCard(props: { style: CursorStyle; recorded: boolean }) { +function CursorStyleCard(props: { + style: CursorStyle; + recorded: CursorFamily | undefined; +}) { const label = () => - props.style === "circle" ? "Circle" : CURSOR_FAMILIES[props.style].label; + props.style === "auto" + ? "Default" + : props.style === "circle" + ? "Circle" + : CURSOR_FAMILIES[props.style].label; const tile = () => ( -
+
} > {(family) => } + + Default + Recorded cursors +
); return ( - + - - - {tile()} - - - + + {tile()} + + {label()} @@ -115,11 +142,7 @@ export function CursorStylePicker() { const recorded = createMemo(() => recordedCursorFamily(meta())); - const selected = createMemo(() => { - const type = project.cursor.type; - if (type === "circle" || isExplicitCursorFamily(type)) return type; - return recorded() ?? hostCursorFamily(); - }); + const selected = createMemo(() => selectedCursorStyle(project.cursor.type)); return (
@@ -128,12 +151,13 @@ export function CursorStylePicker() { value={selected()} onChange={(value) => setProject("cursor", "type", value as CursorType)} > - - {(style) => ( - - )} + + {(style) => } +

+ {cursorStyleDescription(selected())} +

); } diff --git a/apps/desktop/src/routes/editor/cursor-style.test.ts b/apps/desktop/src/routes/editor/cursor-style.test.ts new file mode 100644 index 00000000000..98913f58d29 --- /dev/null +++ b/apps/desktop/src/routes/editor/cursor-style.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { cursorStyleOrder, selectedCursorStyle } from "./cursor-style"; + +describe("cursor style selection", () => { + it("keeps Default distinct from an explicit OS appearance", () => { + expect(selectedCursorStyle("auto")).toBe("auto"); + expect(selectedCursorStyle("pointer")).toBe("auto"); + for (const style of ["macos", "tahoe", "windows", "circle"] as const) { + expect(selectedCursorStyle(style)).toBe(style); + } + }); + + it("offers Default first and every appearance on each platform", () => { + for (const platform of ["macos", "windows", "linux"]) { + const styles = cursorStyleOrder(platform); + expect(styles[0]).toBe("auto"); + expect(new Set(styles)).toEqual( + new Set(["auto", "macos", "tahoe", "windows", "circle"]), + ); + } + }); +}); diff --git a/apps/desktop/src/routes/editor/cursor-style.ts b/apps/desktop/src/routes/editor/cursor-style.ts new file mode 100644 index 00000000000..63f4b4d041b --- /dev/null +++ b/apps/desktop/src/routes/editor/cursor-style.ts @@ -0,0 +1,29 @@ +import type { CursorType } from "~/utils/tauri"; + +export type CursorFamily = "macos" | "tahoe" | "windows"; +export type CursorStyle = CursorFamily | "auto" | "circle"; + +export function selectedCursorStyle(type: CursorType): CursorStyle { + return type === "pointer" ? "auto" : type; +} + +export function cursorStyleOrder(platform: string): CursorStyle[] { + return platform === "windows" + ? ["auto", "windows", "macos", "tahoe", "circle"] + : ["auto", "macos", "tahoe", "windows", "circle"]; +} + +export function cursorStyleDescription(style: CursorStyle): string { + switch (style) { + case "auto": + return "Keeps the cursor shapes and appearance from your recording."; + case "macos": + return "Classic macOS appearance. Custom cursors keep their recorded shape."; + case "tahoe": + return "macOS Tahoe appearance. Custom cursors keep their recorded shape."; + case "windows": + return "Windows appearance. Custom cursors keep their recorded shape."; + case "circle": + return "Replaces all cursor shapes with a circle."; + } +} diff --git a/crates/cursor-info/Cargo.toml b/crates/cursor-info/Cargo.toml index e86871e9ecb..423bd0dca46 100644 --- a/crates/cursor-info/Cargo.toml +++ b/crates/cursor-info/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" publish = false [dependencies] +sha2 = "0.10" serde = { version = "1.0.219", features = ["derive"] } specta = { workspace = true, features = ["derive"] } strum = { version = "0.27.2", features = ["derive"] } @@ -18,7 +19,6 @@ windows = { workspace = true, features = [ [dev-dependencies] hex = "0.4" -sha2 = "0.10" [target.'cfg(target_os = "macos")'.dev-dependencies] objc2 = "0.6" diff --git a/crates/cursor-info/assets/mac/recorded/closed-hand.png b/crates/cursor-info/assets/mac/recorded/closed-hand.png new file mode 100644 index 0000000000000000000000000000000000000000..d9b730ea74c2331d68ba1a9591216702cb83ad65 GIT binary patch literal 1638 zcmV-s2ATPZP)m0TRaCjg!R@EU;k zxLqCqR|bnYurL5PdEi9=dH`%FME?XZPWO$CjpEw1Yoev4MSivpU`qM!DuB=UJ%>gJ z2M@LwK$j#_UtceLK3{6aJsyt;g+fB&{Q2{#&#J4d#hEi_RO|QjQh+}I3<3BVKsSJ+EQIr*iD2-+ zO4zxX1OkCTis<h(I7%3DW{d90~f)+S=NVPB%6vvNjR7 zkT)K;1sIA29JTKAKA+Fr?y9S+(;CScj>TfTE(5xfP-FpCkT8sOyS%?EO965AW$%CFIKc6!4HXdhx4d0jx{l&K-)cOpMV=v1E`Qy8uEVPlNe^Dz9I^ zjz}c(K!+Z=46HsNlLb)S&=8%9P5i=rCUSWWaPVGCuD=FABUvd8rn9s2zTs>%8Wjr* z3nGz7n9s3TEG((tvbe{XF;l5=&kh23&35TU80enmo>vp@v@bIv0 z@jnSxQ&S@a_!_`V0G{E#-MKsrIXMGF$;{_Dq4^CB)a&&Mzu#|*0IjX9lKc$k176_h zv(U!ld{#NYQl61|&H(>N11>Bq6gO_%$cj8=iI_Y&j87@Z1n?{eUpX^>YaWn}!ezWJ zDckWb=kd(JH#Iehv9Ym-BEG!5ESj5}h2m_#hi zfT83D^4U2Rvk+dzI^N{05oL|HGob097@xw)p8)*EKaddVzxh7QRv{xnTLy0jtO=3= zMTjE4FJPyc$AXlxVDgut1)gP^-3ZsMG4XY-+u$I`wgAYm<0TnRh>~c5Qu0|GHw&ev zwPjQpXOdB#?JyIM@@KZ40n|a;37Mpje!;E$P%1@{sq;(%Y_bJxaEWo*x6s$H^JN*V zK`sj^h`hJv^8^!=BB(_kdXoD!(EWmZw-iDZ#hTBP`@Oa7`+o$C{d(~({aF7nAt4DK k+CN5UXlQ6?XyEYSUqsMOPRvPaCjbBd07*qoM6N<$g0O1$761SM literal 0 HcmV?d00001 diff --git a/crates/cursor-info/assets/mac/recorded/open-hand.png b/crates/cursor-info/assets/mac/recorded/open-hand.png new file mode 100644 index 0000000000000000000000000000000000000000..4142671a1ecbb5d02edd3c082f9a8ec715844b0d GIT binary patch literal 2061 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 Y1>W}}Y7^Go Option { + use sha2::{Digest, Sha256}; + + if (width, height) != (64, 64) || rgba.len() != 64 * 64 * 4 { + return None; + } + + // Older recordings saved these Tahoe images without shape metadata. + // Match decoded pixels so PNG encoding differences do not prevent SVG recovery. + match format!("{:x}", Sha256::digest(rgba)).as_str() { + "396c2b7efc0851133f7803d78a155bf667cc3b74a848470ed16d3f3a34092c04" => { + Some(Self::TahoePointingHand) + } + "e0f0be82a751b8fe54402e760e53c304e77f34f29854ca305a599d0852ab041a" => { + Some(Self::TahoeOpenHand) + } + "02eb891a826aaf55e4d52d6b9359d3c38e0bdbe8884a9a4169134afa54ec0def" => { + Some(Self::TahoeClosedHand) + } + _ => None, + } + } + /// macOS doesn't allow comparing `NSCursor` instances directly so we hash the image data. /// macOS cursor are also resolution-independent so this works. pub fn from_hash(hash: &str) -> Option { @@ -268,7 +290,8 @@ impl CursorShapeMacOS { "24ae740b1b618e08ccf3f54375e6f072da5eb47048426460d0500e21a8be0963" => { Self::ContextualMenu } - "e8dcb6cb19ebfa9336297a61950674a365e19ff01b8bf1a327a2f83851f3bc6c" => { + "e8dcb6cb19ebfa9336297a61950674a365e19ff01b8bf1a327a2f83851f3bc6c" + | "96c9bc41e07a6aa881f8026505e04ae5aaa3ae2699cbb4ddd3590ec416feef65" => { Self::TahoeClosedHand } "c5bc204d864e56fce70bca01f309b6cf21e1c77b4389c32883c1c140621bc024" => { @@ -284,13 +307,15 @@ impl CursorShapeMacOS { Self::TahoeDragLink } "3de4a52b22f76f28db5206dc4c2219dff28a6ee5abfb9c5656a469f2140f7eaa" => Self::TahoeIBeam, - "e335333967dc50a93683f85da145e3e4858f0618a81e5d2ca93d496d9159fbf1" => { + "e335333967dc50a93683f85da145e3e4858f0618a81e5d2ca93d496d9159fbf1" + | "472712b01b8a560f40e726a2360e06b395238fa4a289787c7ff8935ba90f5151" => { Self::TahoeOpenHand } "57f34c3b50a051f7504b165226f552d009378f1cd20f16ba6568216f3982fd59" => { Self::TahoeOperationNotAllowed } - "65d626a50079c3111f3c3da9ad8a98220331a592332e00afcf61c0c9c77402f2" => { + "65d626a50079c3111f3c3da9ad8a98220331a592332e00afcf61c0c9c77402f2" + | "533657ebca7c00fa1b6bde9b53a62a64eba792e9889f37d1313395c135af93bb" => { Self::TahoePointingHand } // As calculated from `NSCursor` directly @@ -413,3 +438,41 @@ impl CursorShapeMacOS { } } } + +#[cfg(test)] +mod detection_tests { + use super::*; + + #[test] + fn current_tahoe_hand_images_are_recognized() { + for (hash, expected) in [ + ( + "533657ebca7c00fa1b6bde9b53a62a64eba792e9889f37d1313395c135af93bb", + CursorShapeMacOS::TahoePointingHand, + ), + ( + "472712b01b8a560f40e726a2360e06b395238fa4a289787c7ff8935ba90f5151", + CursorShapeMacOS::TahoeOpenHand, + ), + ( + "96c9bc41e07a6aa881f8026505e04ae5aaa3ae2699cbb4ddd3590ec416feef65", + CursorShapeMacOS::TahoeClosedHand, + ), + ] { + assert_eq!(CursorShapeMacOS::from_hash(hash), Some(expected)); + } + } + + #[test] + fn custom_pixels_and_invalid_dimensions_are_not_classified() { + assert_eq!( + CursorShapeMacOS::from_rgba(64, 64, &vec![0; 64 * 64 * 4]), + None + ); + assert_eq!( + CursorShapeMacOS::from_rgba(32, 128, &vec![0; 64 * 64 * 4]), + None + ); + assert_eq!(CursorShapeMacOS::from_rgba(64, 64, &[0]), None); + } +} diff --git a/crates/rendering/src/layers/cursor.rs b/crates/rendering/src/layers/cursor.rs index fc4513358df..5e7ebe6ccdf 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -35,6 +35,24 @@ static SVG_CURSOR_RASTERIZED_HEIGHT: u32 = 200; const CIRCLE_CURSOR_SIZE: u32 = 256; +fn cursor_asset_shape( + recorded: Option, + use_svg: bool, + cursor_type: &CursorType, +) -> Option { + let shape = recorded?; + match cursor_type.family() { + Some(family) => Some(shape.in_family(family)), + None if use_svg => Some(shape), + None => None, + } +} + +fn recorded_image_shape(image: &image::RgbaImage) -> Option { + cap_cursor_info::CursorShapeMacOS::from_rgba(image.width(), image.height(), image.as_raw()) + .map(Into::into) +} + pub struct CursorLayer { statics: Statics, bind_group: Option, @@ -300,22 +318,7 @@ impl CursorLayer { } }; - // An explicit family cross-maps the recorded shape into it (and - // stands in with its arrow for recordings that carry no shape info at - // all, e.g. Linux PNG-only captures), so SVG assets are the only - // possible source and `use_svg` no longer applies. - let (cursor_shape, use_svg) = match cursor_type.family() { - Some(family) => ( - Some( - recorded_shape.map_or_else(|| family.arrow(), |shape| shape.in_family(family)), - ), - true, - ), - None => (recorded_shape, use_svg), - }; - - if let Some(cursor_shape) = cursor_shape - && use_svg + if let Some(cursor_shape) = cursor_asset_shape(recorded_shape, use_svg, cursor_type) && let Some(info) = cursor_shape.resolve() { loaded_cursor = CursorTexture::prepare_svg(constants, info.raw, info.hotspot.into()) @@ -346,12 +349,29 @@ impl CursorLayer { ); match cursor { Ok(Some(cursor)) => { - loaded_cursor = Some(CursorTexture::prepare( - constants, - &cursor.image.to_rgba8(), - cursor.image.dimensions(), - cursor.hotspot, - )); + let rgba = cursor.image.to_rgba8(); + if recorded_shape.is_none() + && let Some(shape) = + cursor_asset_shape(recorded_image_shape(&rgba), use_svg, cursor_type) + && let Some(info) = shape.resolve() + { + loaded_cursor = + CursorTexture::prepare_svg(constants, info.raw, info.hotspot.into()) + .map_err(|err| { + error!( + "Error loading recovered SVG cursor {cursor_id:?}: {err}" + ) + }) + .ok(); + } + if loaded_cursor.is_none() { + loaded_cursor = Some(CursorTexture::prepare( + constants, + &rgba, + cursor.image.dimensions(), + cursor.hotspot, + )); + } } Err(error) => error!("{error}"), Ok(None) => {} @@ -1096,6 +1116,100 @@ mod tests { } } + #[test] + fn appearance_selection_preserves_unknown_recorded_shapes() { + for cursor_type in [ + CursorType::Auto, + CursorType::Pointer, + CursorType::MacOS, + CursorType::MacOSTahoe, + CursorType::Windows, + ] { + for use_svg in [false, true] { + assert_eq!(cursor_asset_shape(None, use_svg, &cursor_type), None); + } + } + } + + #[test] + fn appearance_selection_keeps_pointer_and_text_transitions() { + use cap_cursor_info::{CursorShape, CursorShapeMacOS, CursorShapeWindows}; + for (recorded, macos, windows) in [ + ( + CursorShapeMacOS::TahoeArrow, + CursorShapeMacOS::Arrow, + CursorShapeWindows::Arrow, + ), + ( + CursorShapeMacOS::TahoePointingHand, + CursorShapeMacOS::PointingHand, + CursorShapeWindows::Hand, + ), + ( + CursorShapeMacOS::TahoeIBeam, + CursorShapeMacOS::IBeam, + CursorShapeWindows::IBeam, + ), + ] { + let shape = CursorShape::MacOS(recorded); + assert_eq!( + cursor_asset_shape(Some(shape), true, &CursorType::Auto), + Some(shape) + ); + assert_eq!( + cursor_asset_shape(Some(shape), false, &CursorType::Auto), + None + ); + assert_eq!( + cursor_asset_shape(Some(shape), false, &CursorType::MacOS), + Some(CursorShape::MacOS(macos)) + ); + assert_eq!( + cursor_asset_shape(Some(shape), false, &CursorType::Windows), + Some(CursorShape::Windows(windows)) + ); + } + } + + #[test] + fn unclassified_recorded_hands_recover_scalable_assets() { + use cap_cursor_info::{CursorShape, CursorShapeMacOS}; + let fixtures: &[(&[u8], CursorShapeMacOS)] = &[ + ( + include_bytes!("../../../cursor-info/assets/mac/recorded/pointing-hand.png"), + CursorShapeMacOS::TahoePointingHand, + ), + ( + include_bytes!("../../../cursor-info/assets/mac/recorded/open-hand.png"), + CursorShapeMacOS::TahoeOpenHand, + ), + ( + include_bytes!("../../../cursor-info/assets/mac/recorded/closed-hand.png"), + CursorShapeMacOS::TahoeClosedHand, + ), + ]; + for (bytes, expected) in fixtures { + let mut image = image::load_from_memory(bytes).unwrap().to_rgba8(); + let shape = recorded_image_shape(&image); + assert_eq!(shape, Some(CursorShape::MacOS(*expected))); + for cursor_type in [ + CursorType::Auto, + CursorType::MacOS, + CursorType::MacOSTahoe, + CursorType::Windows, + ] { + let selected = cursor_asset_shape(shape, true, &cursor_type).unwrap(); + let svg = selected.resolve().unwrap(); + let raster = rasterize_svg_cursor(svg.raw).unwrap(); + assert_eq!(raster.dimensions.1, SVG_CURSOR_RASTERIZED_HEIGHT); + assert!(raster.rgba.chunks_exact(4).any(|pixel| pixel[3] > 200)); + } + assert_eq!(cursor_asset_shape(shape, false, &CursorType::Auto), None); + image.get_pixel_mut(0, 0).0 = [255; 4]; + assert_eq!(recorded_image_shape(&image), None); + } + } + #[test] fn cursor_height_uses_source_relative_default() { let height = cursor_height_px(1964.0, 1964.0, 864.0, 100.0, 1.0);