diff --git a/src/apps/desktop/src/computer_use/macos_capture.m b/src/apps/desktop/src/computer_use/macos_capture.m index 59f5a72831..b9cccbfa12 100644 --- a/src/apps/desktop/src/computer_use/macos_capture.m +++ b/src/apps/desktop/src/computer_use/macos_capture.m @@ -463,14 +463,29 @@ - (void)drawRect:(NSRect)rect { [arrow lineToPoint:NSMakePoint(15, 8)]; [arrow curveToPoint:NSMakePoint(8, 8) controlPoint1:NSMakePoint(12, 7) controlPoint2:NSMakePoint(10, 6)]; [arrow closePath]; + // Shrink around the hotspot, leaving room for the fixed 3pt white outline. + NSAffineTransform *size = [NSAffineTransform transform]; + [size translateXBy:8 yBy:8]; + [size scaleBy:0.7]; + [size translateXBy:-8 yBy:-8]; + [arrow transformUsingAffineTransform:size]; + arrow.lineWidth = 3; + arrow.lineJoinStyle = NSLineJoinStyleRound; [NSGraphicsContext saveGraphicsState]; NSShadow *shadow = [[NSShadow alloc] init]; shadow.shadowColor = [NSColor colorWithWhite:0 alpha:0.45]; shadow.shadowBlurRadius = 3; - shadow.shadowOffset = NSMakeSize(0, -2); + // This view is flipped, so positive Y places the shadow below the cursor. + shadow.shadowOffset = NSMakeSize(0, 2); [shadow set]; - [[NSColor colorWithSRGBRed:166.0/255 green:166.0/255 blue:166.0/255 alpha:1] setFill]; + // Cast one shadow from the complete silhouette, including its white edge. + CGContextRef context = NSGraphicsContext.currentContext.CGContext; + CGContextBeginTransparencyLayer(context, NULL); + [[NSColor colorWithSRGBRed:64.0/255 green:64.0/255 blue:64.0/255 alpha:1] setFill]; [arrow fill]; + [NSColor.whiteColor setStroke]; + [arrow stroke]; + CGContextEndTransparencyLayer(context); [NSGraphicsContext restoreGraphicsState]; } @end diff --git a/src/apps/desktop/src/computer_use/windows_pointer_feedback.rs b/src/apps/desktop/src/computer_use/windows_pointer_feedback.rs index 75c665c711..eeaaff5fd1 100644 --- a/src/apps/desktop/src/computer_use/windows_pointer_feedback.rs +++ b/src/apps/desktop/src/computer_use/windows_pointer_feedback.rs @@ -119,7 +119,8 @@ unsafe extern "system" { // Rasterize the shared 40x40 cubic contour at four samples per axis. Alpha is // premultiplied for AC_SRC_ALPHA: black shadow pixels retain alpha, unlike a -// color-key surface. The blur is independent of the solid, unstroked arrow. +// color-key surface. The contour shrinks around its hotspot before adding a +// fixed 3px white stroke; the complete silhouette casts the shadow. fn pointer_pixels(clicked: bool) -> Vec { type P = (f64, f64); fn cubic(points: &mut Vec

, a: P, b: P, c: P, d: P) { @@ -172,29 +173,49 @@ fn pointer_pixels(clicked: bool) -> Vec { (10.0, 6.0), (8.0, 8.0), ); + for point in &mut outline { + point.0 = 8.0 + (point.0 - 8.0) * 0.7; + point.1 = 8.0 + (point.1 - 8.0) * 0.7; + } let mut mask = vec![0.0; 1600]; + let mut stroke_mask = vec![0.0; 1600]; for y in 0..40 { for x in 0..40 { let mut coverage = 0; + let mut stroke_coverage = 0; for sy in 0..4 { for sx in 0..4 { let px = x as f64 + (f64::from(sx) + 0.5) / 4.0; let py = y as f64 + (f64::from(sy) + 0.5) / 4.0; let mut inside = false; + let mut on_stroke = false; for edge in outline.windows(2) { let (a, b) = (edge[0], edge[1]); + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let length_squared = dx * dx + dy * dy; + let t = if length_squared > 0.0 { + (((px - a.0) * dx + (py - a.1) * dy) / length_squared).clamp(0.0, 1.0) + } else { + 0.0 + }; + on_stroke |= (px - a.0 - t * dx).powi(2) + (py - a.1 - t * dy).powi(2) + <= 1.5_f64.powi(2); if (a.1 > py) != (b.1 > py) && px < (b.0 - a.0) * (py - a.1) / (b.1 - a.1) + a.0 { inside = !inside; } } - if inside { + if inside || on_stroke { coverage += 1; } + if on_stroke { + stroke_coverage += 1; + } } } mask[y * 40 + x] = f64::from(coverage) / 16.0; + stroke_mask[y * 40 + x] = f64::from(stroke_coverage) / 16.0; } } let weights: Vec = (-5..=5) @@ -216,7 +237,9 @@ fn pointer_pixels(clicked: bool) -> Vec { } } } - let fill = mask[(y * 40 + x) as usize]; + let silhouette = mask[(y * 40 + x) as usize]; + let stroke = stroke_mask[(y * 40 + x) as usize]; + let fill = silhouette - stroke; let radius = ((f64::from(x) + 0.5 - 8.0).powi(2) + (f64::from(y) + 0.5 - 8.0).powi(2)).sqrt(); let ring = if clicked { @@ -225,8 +248,9 @@ fn pointer_pixels(clicked: bool) -> Vec { 0.0 }; let behind = ring + shadow * 0.45 * (1.0 - ring); - let alpha = fill + behind * (1.0 - fill); - let channel = (166.0 * fill + 166.0 * ring * (1.0 - fill)).round() as u32; + let alpha = silhouette + behind * (1.0 - silhouette); + let channel = + (64.0 * fill + 255.0 * stroke + 166.0 * ring * (1.0 - silhouette)).round() as u32; pixels[(y * 40 + x) as usize] = ((alpha * 255.0).round() as u32) << 24 | channel << 16 | channel << 8 | channel; } @@ -398,10 +422,11 @@ pub(super) fn show_pointer(target: usize, x: i32, y: i32, clicked: bool) { mod tests { use super::pointer_pixels; #[test] - fn rounded_cursor_has_grey_fill_and_translucent_black_shadow() { + fn rounded_cursor_has_dark_fill_white_edge_and_translucent_black_shadow() { let pixels = pointer_pixels(false); assert_eq!(pixels.len(), 1600); - assert_eq!(pixels[16 * 40 + 16], 0xffa6a6a6); + assert_eq!(pixels[16 * 40 + 16], 0xff404040); + assert!(pixels.contains(&0xffffffff)); assert!(pixels .iter() .any(|p| p & 0xffffff == 0 && p >> 24 > 0 && p >> 24 < 115)); diff --git a/src/web-ui/src/app/components/ComputerUseControlCard.scss b/src/web-ui/src/app/components/ComputerUseControlCard.scss index 1b463a4d4e..42aa155d04 100644 --- a/src/web-ui/src/app/components/ComputerUseControlCard.scss +++ b/src/web-ui/src/app/components/ComputerUseControlCard.scss @@ -43,7 +43,10 @@ inline-size: 100%; block-size: 100%; // Fixed neutral feedback over captured content, independent of accent color. - fill: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 65%, var(--openbitfun-color-content-on-light)); + fill: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 25%, var(--openbitfun-color-content-on-light)); + stroke: var(--openbitfun-color-content-on-dark); + stroke-width: 3px; + stroke-linejoin: round; filter: drop-shadow(0 2px 3px color-mix(in srgb, var(--openbitfun-color-content-on-light) 45%, transparent)); } diff --git a/src/web-ui/src/app/components/computer-use/PreviewCursor.tsx b/src/web-ui/src/app/components/computer-use/PreviewCursor.tsx index 45307365b1..758ada43a0 100644 --- a/src/web-ui/src/app/components/computer-use/PreviewCursor.tsx +++ b/src/web-ui/src/app/components/computer-use/PreviewCursor.tsx @@ -44,6 +44,6 @@ export function PreviewCursor({ x, y, immediate = false }: { x: number; y: numbe useEffect(() => { update.current?.(); }, [x, y, immediate]); return ; }