diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index ce99f3d0504..900791a5cb8 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -1184,7 +1184,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1541,7 +1541,7 @@ dependencies = [ "anyhow", "ashpd 0.11.1", "base64 0.22.1", - "block2 0.5.1", + "block2 0.6.2", "bytes", "cap-audio", "cap-camera", @@ -1585,9 +1585,11 @@ dependencies = [ "libc", "lyon_tessellation", "minisign-verify", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "parakeet-rs", "raw-window-handle", "reqwest 0.12.28", @@ -1914,7 +1916,7 @@ dependencies = [ "khronos-egl", "libc", "objc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "parking_lot", "pipewire", @@ -1972,7 +1974,7 @@ dependencies = [ "libloading 0.8.9", "log", "metal 0.31.0", - "objc2 0.6.4", + "objc2 0.6.5", "rayon", "reactive_graph", "resvg 0.45.1", @@ -2327,7 +2329,7 @@ checksum = "afede46921767868c5c7f8f55202bdd8bec0bab6bc9605174200f45924f93c62" dependencies = [ "clipboard-win", "image 0.25.10", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "windows 0.59.0", @@ -3396,7 +3398,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -4450,7 +4452,7 @@ checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" dependencies = [ "crossbeam-channel", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "once_cell", "thiserror 2.0.20", @@ -4750,7 +4752,7 @@ dependencies = [ "media", "metal 0.33.0", "objc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-user-notifications", @@ -6458,7 +6460,7 @@ checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" dependencies = [ "cc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", "time", "uuid", @@ -6779,7 +6781,7 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -7231,9 +7233,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -7264,7 +7266,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-cloud-kit", "objc2-core-data 0.3.2", "objc2-core-foundation", @@ -7285,7 +7287,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-avf-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -7305,7 +7307,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -7316,7 +7318,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -7327,7 +7329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", ] @@ -7339,7 +7341,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -7361,7 +7363,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -7373,7 +7375,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.1", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -7383,10 +7385,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", "dispatch2", - "objc2 0.6.4", + "libc", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", + "objc2-metal 0.3.2", ] [[package]] @@ -7407,7 +7412,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -7417,7 +7422,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -7429,7 +7434,7 @@ checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" dependencies = [ "bitflags 2.13.1", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -7443,7 +7448,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", ] @@ -7455,7 +7460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", @@ -7497,7 +7502,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -7507,7 +7512,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", ] @@ -7529,7 +7534,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -7539,7 +7544,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", "objc2-core-media", @@ -7565,7 +7570,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.1", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -7589,8 +7594,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.1", - "objc2 0.6.4", + "block2 0.6.2", + "libc", + "objc2 0.6.5", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", ] @@ -7603,7 +7612,7 @@ checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.1", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-cloud-kit", "objc2-core-data 0.3.2", "objc2-core-foundation", @@ -7624,7 +7633,7 @@ checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ "bitflags 2.13.1", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-location", "objc2-foundation 0.3.2", ] @@ -7903,7 +7912,7 @@ dependencies = [ "android_system_properties", "log", "nix 0.31.3", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", "objc2-ui-kit", "serde", @@ -8994,7 +9003,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -9379,7 +9388,7 @@ dependencies = [ "dispatch2", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -9672,7 +9681,7 @@ version = "0.1.0" dependencies = [ "cidre", "futures", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "tracing", @@ -11691,7 +11700,7 @@ dependencies = [ "dirs 6.0.0", "libappindicator", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", @@ -12746,7 +12755,7 @@ dependencies = [ "log", "naga 29.0.4", "ndk-sys 0.6.0+11769913", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", @@ -13787,7 +13796,7 @@ dependencies = [ "num-complex", "num-integer", "num-rational", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "open", diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 7c0a9e79b82..8b9c003a1b4 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -62,7 +62,10 @@ cap-project = { path = "../../crates/project" } # a `RenderImage` (`src/editor_sidebar/cursor.rs`). cap-cursor-info = { path = "../../crates/cursor-info" } resvg = "0.45" -rodio = { version = "0.19.0", default-features = false, features = ["mp3", "vorbis"] } +rodio = { version = "0.19.0", default-features = false, features = [ + "mp3", + "vorbis", +] } # The editor stack. `cap-rendering` is not a new subtree -- cap-recording # already depends on it, so wgpu 25 and its vendored wgpu-hal have been built # by this workspace since the recording unit; naming it here only makes the @@ -100,7 +103,11 @@ ffmpeg = { package = "ffmpeg-next", git = "https://github.com/CapSoftware/rust-f # `webp` is the config sidebar's four background-source illustrations, which # the app ships as webp; features are additive, so enabling it here does not # move gpui's own pin. -image = { version = "0.25.1", default-features = false, features = ["jpeg", "png", "webp"] } +image = { version = "0.25.1", default-features = false, features = [ + "jpeg", + "png", + "webp", +] } jpeg-decoder = "0.3" smallvec = "1" # The OS language list the UI language follows when set to System default. @@ -131,7 +138,10 @@ sanitize-filename = "0.6.0" semver = "1" base64 = "0.22" minisign-verify = "0.2.4" -rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] } +rfd = { version = "0.15", default-features = false, features = [ + "xdg-portal", + "tokio", +] } # The screenshot export compositor (`src/screenshot_export.rs`): the canvas-2d # work `renderScreenshotExportCanvas` does in the webview -- shape rasterizing, # text, and the share fingerprint. All three are already in this lockfile @@ -179,7 +189,14 @@ gpui = { git = "https://github.com/wingleeio/zed", rev = "5d1f83d9f27a19bec1fb24 # decoders, renderer and preview tasks are all tokio-spawned -- a current-thread # runtime would deadlock the moment the test thread blocks on a frame. Kept in # dev-dependencies so the shipped binary's tokio features are unchanged. -tokio = { version = "1", features = ["rt-multi-thread", "time", "test-util", "net", "io-util", "macros"] } +tokio = { version = "1", features = [ + "rt-multi-thread", + "time", + "test-util", + "net", + "io-util", + "macros", +] } # Caption oracles (`src/transcription/verify_tests.rs`): fixture projects and # audio markers for the fake transcriber. cap-verify = { path = "../../crates/verify" } @@ -220,15 +237,37 @@ x11rb = "0.13" [target.'cfg(target_os = "macos")'.dependencies] # Native window treatment (panel levels, Spaces) gpui does not expose. Same -# versions gpui's own tree compiles, so no second objc2 universe gets built. -objc2 = "0.5.2" +# direct 0.6 line used by this app's class definitions and AppKit bindings. +objc2 = { version = "0.6.5", features = ["exception"] } # `NSVisualEffectView` is the pre-macOS-26 window-material fallback. Features # only -- the versions stay pinned, or a second objc2 universe gets built. -objc2-app-kit = { version = "0.2.2", features = ["NSAppearance", "NSWindow", "NSView", "NSResponder", "NSGraphics", "NSPasteboard", "NSVisualEffectView", "NSWorkspace", "NSAccessibility"] } +objc2-app-kit = { version = "0.3.2", features = [ + "NSAppearance", + "NSWindow", + "NSView", + "NSResponder", + "NSGraphics", + "NSPasteboard", + "NSMenuItemBadge", + "NSVisualEffectView", + "NSWorkspace", + "NSAccessibility", +] } +objc2-quartz-core = { version = "0.3.2", features = [ + "CALayer", + "CATransaction", + "objc2-core-foundation", +] } +objc2-core-graphics = { version = "0.3.2", features = ["CGColor"] } # NSRect/NSPoint/NSSize for `setFrame:display:`. Already in the tree as # objc2-app-kit's own dependency, so this adds nothing to the build. -objc2-foundation = { version = "0.2.2", features = ["NSNotification", "NSOperation", "NSString", "block2"] } -block2 = "0.5.1" +objc2-foundation = { version = "0.3.2", features = [ + "NSNotification", + "NSOperation", + "NSString", + "block2", +] } +block2 = "0.6" raw-window-handle = "0.6" core-foundation = "0.10" core-video = { version = "0.5.2", features = ["metal"] } diff --git a/apps/desktop-gpui/README.md b/apps/desktop-gpui/README.md index fc9fadcad4f..6830cfa41ff 100644 --- a/apps/desktop-gpui/README.md +++ b/apps/desktop-gpui/README.md @@ -636,8 +636,8 @@ Sizes are the Tauri app's, from `apps/desktop/src-tauri/src/windows.rs`. | Teleprompter | 560×320 | **Done, with deviations** — resizable to the 420×220 floor, level 101 on all Spaces, the `"teleprompter"` material at radius 22, traffic lights at (14, 11) — the Tauri `(14, 14)` is a `position_window_controls` inset, not a top-left, auto-scroll from the ported `teleprompter-utils` maths, the full footer and settings popover, native window opacity, the `teleprompter` store section, capture exclusion + content protection. The script editor is a real multi-line field and Mirror is inert — see below | | Editor | 1275×800 | **E1–E5 done — window, shell, playback, timeline, editing, config sidebar.** The real 1275×800 window with the traffic lights at (20, 20) — the Tauri `(20, 32)` is a `position_window_controls` inset, not a top-left, the header, the letterboxed player, the 260px timeline strip and the 416px config sidebar with its six-tab rail; a real project through `EditorInstance`; play/pause (button + Space), a 60fps live playhead and clock, real audio, click/drag seeking, end-of-media stop; the timeline at 1:1 — all nine track types from the project's own config, waveforms, the ruler's resolution ladder, minimap, edge fade, hover ghost, zoom (keys, buttons, slider, wheel, pinch) and pan; and **timeline editing**: selection (single, ⌘-multi, ⇧-range, ⌘A), trim, move, split (S + C, with snapping), zoom-segment create/resize/move, delete, undo/redo and the debounced write back to `project-config.json`. and **the config sidebar in full**: the six-tab rail, the scroll body, selection routing, all six tabs at 1:1, the shared colour-correction section on both its targets, and the eight per-segment panels with multi-select where the source has it. Crop and export are the remaining units | | Screenshot editor | 1240×800 (min 800×600) | **Done, with deviations** — the real window (native traffic lights, resizable to the 800×600 floor), one editor per bundle keyed like the video editor's registry, opened from capture, Recents, the settings Screenshots page, the tray's Previous and image import. The still renders through the same GPU path the Tauri instance uses (`FrameRenderer::render_immediate`, `preserve_screen_alpha`, no camera) on a config watch, painted with `paint_image` instead of shipped over the frame websocket; edits publish to the renderer live, ride the 1000ms debounced `project-config.json` write, and walk a real undo history (⌘Z / ⌘⇧Z / ⌘Y, a drag's sixty intermediate values collapsed into one entry). Styling: background source tabs (color swatches, gradient presets, wallpaper grid with theme tabs and cached thumbnails, image picker), hex fields, blur/padding/rounding/shadow sliders at the popovers' ranges plus the advanced-shadow collapsible, squircle/rounded corner style, border toggle + width/color/opacity, aspect-ratio menu. Annotations: the full engine — all seven tools with their single-key shortcuts, the frame-space geometry of `layout.ts`/`arrow.ts` verbatim, selection handles, the per-type config bar (stroke/fill swatches with the colour popover, width/opacity/mask-level/text-size sliders), the layers panel with drag reorder, blur/pixelate masks resampled live off the rendered frame, inline text editing, ⌘C/⌘V duplicate, delete. Crop dialog: the `editor_crop` engine wholesale over `original.png` (`screenshot_crop.rs`) — Size/Position number boxes, the round ratio button and its options menu (right-click opens the same one at the cursor) with the snap-to-ratio toggle persisted in the shared store, Full/Reset, arrow-key nudges, one history entry on Save, Escape/backdrop cancel. Export: Copy/Save/Share all bake the annotations in (`screenshot_export.rs` — the `renderScreenshotExportCanvas` pass: masks re-filtered at export scale from an unmodified copy, shapes via tiny-skia, text via cosmic-text at the preview's Helvetica baseline, the output expanded to the union of image and annotation boxes with the source's white-fill rules); Copy composites over white and lands PNG on the clipboard, Save writes PNG, Share fingerprints the config (`sha256` over recursively key-sorted JSON), short-circuits to the stored link on a hash match, and otherwise uploads JPEG-0.9 (PNG when transparency is actually needed) through create-or-get + presigned PUT — reusing `sharing.id` so the link survives edits — then persists `SharingMeta { id, link, content_hash }`, with Tauri's exact auth gates and toast ladder; reveal, delete behind a native confirm. Deviations: no OCR text-selection overlay (Vision OCR feeding an invisible DOM-style selectable text layer has no gpui equivalent yet; the layer is invisible in the Tauri editor too, so zero visual impact); the colour swatches are inert — no native colour panel is wired to this window, the hex fields are the input; the skeleton's Cap logo is drawn still (the app ships no bare glyph to spin, only the full lockup); the crop options menu has no separator row (`ui::Menu` has none, so its two groups sit adjacent); no `PendingScreenshots` in-memory hand-off (the PNG is on disk before the editor opens) | -| Tray (status item) | menu bar | **Done, with deviations** — a native `NSStatusItem` (no gpui API exists for one; built on AppKit with the color-panel channel discipline), menu byte-identical to `build_tray_menu`: Open Main Window, Record Display/Window/Area (screenshot-mode relabels), Select Mode ▸ with the ✓, Previous ▸ with 32px cover-cropped thumbnails and the 🎬⚡📷 prefixes, View all recordings / screenshots, Settings, version row, Quit Cap. The icon follows the mode and becomes the stop icon while recording, when clicking the item stops the capture (the menu is detached so the button's action can fire). Select Mode writes the same `recording_settings.mode` store key the Tauri app reads. Take a Screenshot captures the display under the cursor and Import Media… routes video/image imports; a Previous screenshot opens the screenshot editor. Deviations: Upload Logs renders disabled (no log-upload infrastructure yet) | -| App menu + shortcuts | menu bar | **Done** — the `build_macos_app_menu` structure (Cap / File / Edit / View / Window / Help) via `cx.set_menus`, with ⌘W closing the key window through each window's own close path (main hides, Tauri's `CloseRequested` arm transcribed: camera bubble closed, overlays closed, feeds released when idle), ⌘M / Zoom / ⌃⌘F on the key window, ⌘H / ⌥⌘H, ⌘Q, and Edit items dispatching the text-input actions. Dock-icon policy (Regular ↔ Accessory per `hideDockIcon` + visible windows) synced on every window show/hide seam | +| Tray (status item) | menu bar | **Done, with deviations** — a native `NSStatusItem` (no gpui API exists for one; built on AppKit with the color-panel channel discipline), menu in `build_menu`'s own grouping: Open Main Window; the current mode as the group label (an `NSMenu` section header reading e.g. `Studio` plus a palette item with the mode icons on macOS 14+ (the header follows the palette's highlight in italic), a ✓-ticked `Recording Mode ▸` submenu on older macOS and on Windows); Record Display/Window/Area + Take a Screenshot (screenshot-mode relabels); Previous ▸ (clock glyph) with 32px cover-cropped thumbnails and the 🎬⚡📷 prefixes, View all recordings / screenshots; Import Media…, Settings (⌘,), Upload Logs; Quit Cap with the version as a trailing section header on macOS and as a disabled row above Quit elsewhere. The four record rows, Previous ▸, Import Media… and Settings each carry an SF Symbol (`display`, `interface.window` (`macwindow` below macOS 26), `rectangle.dashed`, `camera.on.rectangle`, `clock.arrow.circlepath`, `square.and.arrow.down`, `gear`) drawn straight from the system catalog on macOS 11+ with no fallback — an unresolvable name leaves the row bare — while Windows draws the cached 32×32 raster of the same SVG their in-app buttons use (`ROW_ICON_SOURCES` / `svg_icon_png`); the macOS glyphs are template images the menu tints, and Previous's own rows keep their photo thumbnails; the chrome rows (Open Main Window, View all …, Upload Logs, Quit) stay bare. The icon follows the mode and becomes the stop icon while recording, when clicking the item stops the capture (the menu is detached so the button's action can fire). The mode picker writes the same `recording_settings.mode` store key the Tauri app reads. Take a Screenshot captures the display under the cursor and Import Media… routes video/image imports; a Previous screenshot opens the screenshot editor. Deviations: Upload Logs renders disabled (no log-upload infrastructure yet) | +| App menu + shortcuts | menu bar | **Done** — the `build_macos_app_menu` structure (Cap / File / Edit / View / Window / Help) via `cx.set_menus`, with ⌘W closing the key window through each window's own close path (main hides, Tauri's `CloseRequested` arm transcribed: camera bubble closed, overlays closed, feeds released when idle), ⌘M / Zoom / ⌃⌘F on the key window, ⌘H / ⌥⌘H, ⌘Q, and Edit items dispatching the text-input actions. The Cap menu carries About (AppKit's own `orderFrontStandardAboutPanel:`, patched in by row position, so there is no gpui About action), Check for Update and Preferences… with ⌘,. The update row resolves to three states — `Check for Update`, `Download update…` and `Restart to update…`, with `Checking for Update…` greyed out while a check runs — and is rewritten in place in the live `NSMenu` (`platform::set_update_menu_item`, by row position, `setBadge:` on macOS 14+) so an open menu survives a status change; the version, and the download percentage on macOS, ride the badge, and the menu bar is only reinstalled on the other platforms when the state itself changes. File carries Open File… (an existing recording or screenshot, routed through the tray's `open_previous_item`), Import Media… (the same picker the tray runs), the AppKit-managed Open Recent list — every editor open registers its bundle with `add_recent_document`, and a click comes back through `application:openURLs:` → `deeplink` → `open_editor` — and Close Window. Help carries the Changelog and the cap.so/help links. Dock-icon policy (Regular ↔ Accessory per `hideDockIcon` + visible windows) synced on every window show/hide seam | ## Recording diff --git a/apps/desktop-gpui/locales/de.toml b/apps/desktop-gpui/locales/de.toml index 6a03591f376..0838f104b93 100644 --- a/apps/desktop-gpui/locales/de.toml +++ b/apps/desktop-gpui/locales/de.toml @@ -24,9 +24,17 @@ quit = "Cap beenden" services = "Dienste" settings = "Einstellungen…" +[menu.about] +licensed = "Lizenziert unter AGPL-3.0" +website = "Website" +commit = "Commit" + [menu.file] close_window = "Fenster schließen" title = "Ablage" +open_file = "Datei öffnen…" +open_recent = "Zuletzt geöffnet" +import_media = "Medien importieren…" [menu.edit] copy = "Kopieren" @@ -49,12 +57,22 @@ zoom = "Zoomen" [menu.help] title = "Hilfe" +feedback = "Feedback geben…" +changelog = "Änderungsprotokoll" +dashboard = "Dashboard" +documentation = "Dokumentation" +faq = "FAQ" +self_hosting = "Selbsthosting" +help_center = "Hilfe-Center" +system_status = "Systemstatus" +discord = "Discord-Community beitreten" [tray] import_media = "Medien importieren..." no_recent_items = "Noch keine Einträge" open_main_window = "Hauptfenster öffnen" previous = "Zuletzt erstellt" +recording_mode = "Aufnahmemodus" quit = "Cap beenden" record_area = "Bereich aufnehmen" record_display = "Bildschirm aufnehmen" @@ -586,3 +604,19 @@ click_or_press = "Hier klicken oder drücken:" get_started = "Los geht’s" space_key = "Leertaste" tagline = "Die Open-Source-App zum Teilen des Bildschirms für alle." + +[menu.update] +badge_progress = "v{version} · {percent} %" +badge_version = "v{version}" +check = "Nach Updates suchen" +download = "Update herunterladen…" +checking = "Suche nach Updates…" +downloading = "Update wird geladen…" +restart = "Zum Aktualisieren neu starten…" + +[settings.general.updates.download_policy] +auto = "Automatisch" +description = "Wähle, ob Cap Updates automatisch herunterlädt." +label = "Update-Downloads" +manual = "Vorher fragen" +never = "Nie" diff --git a/apps/desktop-gpui/locales/en.toml b/apps/desktop-gpui/locales/en.toml index c4415d07108..07745b284cc 100644 --- a/apps/desktop-gpui/locales/en.toml +++ b/apps/desktop-gpui/locales/en.toml @@ -24,9 +24,17 @@ quit = "Quit Cap" services = "Services" settings = "Settings…" +[menu.about] +licensed = "Licensed under AGPL-3.0" +website = "Website" +commit = "Commit" + [menu.file] close_window = "Close Window" title = "File" +open_file = "Open File…" +open_recent = "Open Recent" +import_media = "Import Media…" [menu.edit] copy = "Copy" @@ -49,12 +57,22 @@ zoom = "Zoom" [menu.help] title = "Help" +feedback = "Give Feedback…" +changelog = "Changelog" +dashboard = "Dashboard" +documentation = "Documentation" +faq = "FAQ" +self_hosting = "Self-hosting" +help_center = "Help Center" +system_status = "System Status" +discord = "Join Discord Community" [tray] import_media = "Import Media..." no_recent_items = "No recent items" open_main_window = "Open Main Window" previous = { text = "Previous", context = "Submenu listing the most recent recordings and screenshots." } +recording_mode = "Recording Mode" quit = "Quit Cap" record_area = "Record Area" record_display = "Record Display" @@ -588,3 +606,19 @@ click_or_press = "Click here, or press" get_started = "Get Started" space_key = "Space" tagline = "The open source screen sharing app for everyone." + +[menu.update] +badge_progress = "v{version} · {percent}%" +badge_version = "v{version}" +check = "Check for Update" +download = "Download update…" +checking = "Checking for Update…" +downloading = "Downloading update…" +restart = "Restart to update…" + +[settings.general.updates.download_policy] +auto = "Auto" +description = "Choose whether Cap downloads updates automatically." +label = "Update downloads" +manual = "Ask first" +never = "Never" diff --git a/apps/desktop-gpui/locales/es.toml b/apps/desktop-gpui/locales/es.toml index 00c4c9b74f7..4fb4a66bb9c 100644 --- a/apps/desktop-gpui/locales/es.toml +++ b/apps/desktop-gpui/locales/es.toml @@ -24,9 +24,17 @@ quit = "Salir de Cap" services = "Servicios" settings = "Ajustes…" +[menu.about] +licensed = "Licenciado bajo AGPL-3.0" +website = "Sitio web" +commit = "Commit" + [menu.file] close_window = "Cerrar ventana" title = "Archivo" +open_file = "Abrir archivo…" +open_recent = "Abrir reciente" +import_media = "Importar contenido…" [menu.edit] copy = "Copiar" @@ -49,12 +57,22 @@ zoom = "Zoom" [menu.help] title = "Ayuda" +feedback = "Dar comentarios…" +changelog = "Registro de cambios" +dashboard = "Panel" +documentation = "Documentación" +faq = "Preguntas frecuentes" +self_hosting = "Autoalojamiento" +help_center = "Centro de ayuda" +system_status = "Estado del sistema" +discord = "Unirse a la comunidad de Discord" [tray] import_media = "Importar contenido..." no_recent_items = "No hay elementos recientes" open_main_window = "Abrir ventana principal" previous = "Recientes" +recording_mode = "Modo de grabación" quit = "Salir de Cap" record_area = "Grabar área" record_display = "Grabar pantalla" @@ -586,3 +604,19 @@ click_or_press = "Haz clic aquí o pulsa" get_started = "Empezar" space_key = "Espacio" tagline = "La app de código abierto para compartir pantalla, para todo el mundo." + +[menu.update] +badge_progress = "v{version} · {percent} %" +badge_version = "v{version}" +check = "Buscar actualizaciones" +download = "Descargar actualización…" +checking = "Buscando actualizaciones…" +downloading = "Descargando actualización…" +restart = "Reiniciar para actualizar…" + +[settings.general.updates.download_policy] +auto = "Automático" +description = "Elige si Cap descarga las actualizaciones automáticamente." +label = "Descargas de actualizaciones" +manual = "Preguntar antes" +never = "Nunca" diff --git a/apps/desktop-gpui/locales/fr.toml b/apps/desktop-gpui/locales/fr.toml index e70141f4c4e..a40ca2c63a3 100644 --- a/apps/desktop-gpui/locales/fr.toml +++ b/apps/desktop-gpui/locales/fr.toml @@ -24,9 +24,17 @@ quit = "Quitter Cap" services = "Services" settings = "Paramètres…" +[menu.about] +licensed = "Sous licence AGPL-3.0" +website = "Site web" +commit = "Commit" + [menu.file] close_window = "Fermer la fenêtre" title = "Fichier" +open_file = "Ouvrir un fichier…" +open_recent = "Ouvrir récent" +import_media = "Importer des médias…" [menu.edit] copy = "Copier" @@ -49,12 +57,22 @@ zoom = "Réduire/agrandir" [menu.help] title = "Aide" +feedback = "Donner son avis…" +changelog = "Journal des modifications" +dashboard = "Tableau de bord" +documentation = "Documentation" +faq = "FAQ" +self_hosting = "Auto-hébergement" +help_center = "Centre d'aide" +system_status = "État du système" +discord = "Rejoindre la communauté Discord" [tray] import_media = "Importer des médias..." no_recent_items = "Aucun élément récent" open_main_window = "Ouvrir la fenêtre principale" previous = "Récents" +recording_mode = "Mode d'enregistrement" quit = "Quitter Cap" record_area = "Enregistrer une zone" record_display = "Enregistrer l'écran" @@ -586,3 +604,19 @@ click_or_press = "Cliquez ici ou appuyez sur" get_started = "Commencer" space_key = "Espace" tagline = "L'app open source de partage d'écran pour tout le monde." + +[menu.update] +badge_progress = "v{version} · {percent} %" +badge_version = "v{version}" +check = "Rechercher des mises à jour" +download = "Télécharger la mise à jour…" +checking = "Recherche de mises à jour…" +downloading = "Téléchargement de la mise à jour…" +restart = "Redémarrer pour mettre à jour…" + +[settings.general.updates.download_policy] +auto = "Automatique" +description = "Choisissez si Cap télécharge les mises à jour automatiquement." +label = "Téléchargements de mises à jour" +manual = "Demander d'abord" +never = "Jamais" diff --git a/apps/desktop-gpui/locales/hi.toml b/apps/desktop-gpui/locales/hi.toml index 0ee072f4c59..a49b07b4110 100644 --- a/apps/desktop-gpui/locales/hi.toml +++ b/apps/desktop-gpui/locales/hi.toml @@ -24,9 +24,17 @@ quit = "Cap से बाहर निकलें" services = "सेवाएँ" settings = "सेटिंग्स…" +[menu.about] +licensed = "AGPL-3.0 के अंतर्गत लाइसेंस प्राप्त" +website = "वेबसाइट" +commit = "कमिट" + [menu.file] close_window = "विंडो बंद करें" title = "फ़ाइल" +open_file = "फ़ाइल खोलें…" +open_recent = "हाल ही में खोलें" +import_media = "मीडिया इम्पोर्ट करें…" [menu.edit] copy = "कॉपी करें" @@ -49,12 +57,22 @@ zoom = "ज़ूम करें" [menu.help] title = "सहायता" +feedback = "प्रतिक्रिया दें…" +changelog = "चेंजलॉग" +dashboard = "डैशबोर्ड" +documentation = "दस्तावेज़ीकरण" +faq = "सामान्य प्रश्न" +self_hosting = "सेल्फ-होस्टिंग" +help_center = "सहायता केंद्र" +system_status = "सिस्टम स्थिति" +discord = "Discord समुदाय से जुड़ें" [tray] import_media = "मीडिया इम्पोर्ट करें..." no_recent_items = "कोई हालिया आइटम नहीं" open_main_window = "मुख्य विंडो खोलें" previous = "हाल के आइटम" +recording_mode = "रिकॉर्डिंग मोड" quit = "Cap से बाहर निकलें" record_area = "क्षेत्र रिकॉर्ड करें" record_display = "डिस्प्ले रिकॉर्ड करें" @@ -586,3 +604,19 @@ click_or_press = "यहाँ क्लिक करें, या दबाए get_started = "शुरू करें" space_key = "स्पेस" tagline = "सबके लिए ओपन सोर्स स्क्रीन शेयरिंग ऐप।" + +[menu.update] +badge_progress = "v{version} · {percent}%" +badge_version = "v{version}" +check = "अपडेट जाँचें" +download = "अपडेट डाउनलोड करें…" +checking = "अपडेट जाँच रहे हैं…" +downloading = "अपडेट डाउनलोड हो रहा है…" +restart = "अपडेट करने के लिए फिर से शुरू करें…" + +[settings.general.updates.download_policy] +auto = "स्वचालित" +description = "चुनें कि Cap अपडेट स्वचालित रूप से डाउनलोड करे या नहीं।" +label = "अपडेट डाउनलोड" +manual = "पहले पूछें" +never = "कभी नहीं" diff --git a/apps/desktop-gpui/locales/ja.toml b/apps/desktop-gpui/locales/ja.toml index 44dd3f99308..5fe9cf767a8 100644 --- a/apps/desktop-gpui/locales/ja.toml +++ b/apps/desktop-gpui/locales/ja.toml @@ -24,9 +24,17 @@ quit = "Capを終了" services = "サービス" settings = "設定…" +[menu.about] +licensed = "AGPL-3.0ライセンス" +website = "ウェブサイト" +commit = "コミット" + [menu.file] close_window = "ウィンドウを閉じる" title = "ファイル" +open_file = "ファイルを開く…" +open_recent = "最近使った項目を開く" +import_media = "メディアを読み込む…" [menu.edit] copy = "コピー" @@ -49,12 +57,22 @@ zoom = "拡大/縮小" [menu.help] title = "ヘルプ" +feedback = "フィードバックを送る…" +changelog = "変更履歴" +dashboard = "ダッシュボード" +documentation = "ドキュメント" +faq = "FAQ" +self_hosting = "セルフホスティング" +help_center = "ヘルプセンター" +system_status = "システムステータス" +discord = "Discordコミュニティに参加" [tray] import_media = "メディアを読み込む..." no_recent_items = "最近の項目はありません" open_main_window = "メインウィンドウを開く" previous = "最近の項目" +recording_mode = "録画モード" quit = "Capを終了" record_area = "範囲を録画" record_display = "ディスプレイを録画" @@ -586,3 +604,19 @@ click_or_press = "ここをクリックするか、次のキーを押す:" get_started = "はじめる" space_key = "スペース" tagline = "誰もが使えるオープンソースの画面共有アプリ。" + +[menu.update] +badge_progress = "v{version} · {percent}%" +badge_version = "v{version}" +check = "アップデートを確認" +download = "アップデートをダウンロード…" +checking = "アップデートを確認中…" +downloading = "アップデートをダウンロード中…" +restart = "アップデートのために再起動…" + +[settings.general.updates.download_policy] +auto = "自動" +description = "Cap がアップデートを自動でダウンロードするか選択します。" +label = "アップデートのダウンロード" +manual = "確認してから" +never = "しない" diff --git a/apps/desktop-gpui/locales/zh-CN.toml b/apps/desktop-gpui/locales/zh-CN.toml index faaf888950e..a37123809c9 100644 --- a/apps/desktop-gpui/locales/zh-CN.toml +++ b/apps/desktop-gpui/locales/zh-CN.toml @@ -24,9 +24,17 @@ quit = "退出 Cap" services = "服务" settings = "设置…" +[menu.about] +licensed = "基于 AGPL-3.0 许可" +website = "网站" +commit = "提交" + [menu.file] close_window = "关闭窗口" title = "文件" +open_file = "打开文件…" +open_recent = "打开最近使用" +import_media = "导入媒体…" [menu.edit] copy = "拷贝" @@ -49,12 +57,22 @@ zoom = "缩放" [menu.help] title = "帮助" +feedback = "提供反馈…" +changelog = "更新日志" +dashboard = "仪表盘" +documentation = "文档" +faq = "常见问题" +self_hosting = "自托管" +help_center = "帮助中心" +system_status = "系统状态" +discord = "加入 Discord 社区" [tray] import_media = "导入媒体..." no_recent_items = "没有最近的项目" open_main_window = "打开主窗口" previous = "最近项目" +recording_mode = "录制模式" quit = "退出 Cap" record_area = "录制区域" record_display = "录制屏幕" @@ -586,3 +604,19 @@ click_or_press = "点击此处,或按下" get_started = "开始使用" space_key = "空格" tagline = "人人可用的开源屏幕分享应用。" + +[menu.update] +badge_progress = "v{version} · {percent}%" +badge_version = "v{version}" +check = "检查更新" +download = "下载更新…" +checking = "正在检查更新…" +downloading = "正在下载更新…" +restart = "重新启动以更新…" + +[settings.general.updates.download_policy] +auto = "自动" +description = "选择 Cap 是否自动下载更新。" +label = "更新下载" +manual = "先询问" +never = "从不" diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index aafc67eb919..93b540b0bcc 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -582,6 +582,7 @@ pub fn init(main: WindowHandle, session: Entity, c cx.spawn(async move |cx| { while thumbnails.recv_async().await.is_ok() { cx.update(|cx| { + crate::tray::refresh_previous(cx); refresh_library_after_delete(cx); crate::menus::recheck_pending_quit(cx); }); @@ -1460,6 +1461,7 @@ pub fn open_onboarding(cx: &mut App) { .detach(); hide_main_and_park_camera_preview(cx); crate::tray::refresh_menu(cx); + crate::menus::refresh(cx); } pub fn onboarding_finished(cx: &mut App) { @@ -1470,6 +1472,7 @@ pub fn onboarding_finished(cx: &mut App) { .ok(); } crate::tray::refresh_menu(cx); + crate::menus::refresh(cx); show_main_window(cx); } @@ -1486,6 +1489,7 @@ pub fn onboarding_closed(cx: &mut App) { crate::permission_guide::stop(cx); cx.global_mut::().onboarding.take(); crate::tray::refresh_menu(cx); + crate::menus::refresh(cx); if !crate::store::should_show_onboarding() { show_main_window(cx); } @@ -4888,6 +4892,8 @@ fn open_editor_window( return None; } + cx.add_recent_document(&project_path); + if let Some(window_id) = cx .global::() .editors @@ -6865,6 +6871,8 @@ pub fn open_screenshot_editor(path: PathBuf, cx: &mut App) { }; let key = editor_key(&bundle); + cx.add_recent_document(&bundle); + if let Some(handle) = cx .global::() .screenshot_editors diff --git a/apps/desktop-gpui/src/deeplink.rs b/apps/desktop-gpui/src/deeplink.rs index 8a1523477da..cd4ee07a9bb 100644 --- a/apps/desktop-gpui/src/deeplink.rs +++ b/apps/desktop-gpui/src/deeplink.rs @@ -540,6 +540,11 @@ impl DeepLinkAction { DeepLinkAction::OpenEditor { project_path } => { use cap_project::{RecordingMeta, RecordingMetaInner, StudioRecordingStatus}; + if crate::library::is_screenshot_bundle(&project_path) { + cx.defer(move |cx| app_windows::open_screenshot_editor(project_path, cx)); + return Ok(()); + } + let meta = RecordingMeta::load_for_project(&project_path).map_err(|v| v.to_string())?; match &meta.inner { diff --git a/apps/desktop-gpui/src/feeds.rs b/apps/desktop-gpui/src/feeds.rs index 4b02665fb31..14ce0bd60b0 100644 --- a/apps/desktop-gpui/src/feeds.rs +++ b/apps/desktop-gpui/src/feeds.rs @@ -341,9 +341,9 @@ impl RecordingCameraWorker { } self.publisher.publish( cap_recording::instant_recording::LinuxProcessedCameraFrame { - bgra: cap_recording::instant_recording::LinuxCameraPixels::new( - RenderImagePixels(image.clone()), - ), + bgra: cap_recording::instant_recording::LinuxCameraPixels::new(RenderImagePixels( + image.clone(), + )), dimensions: (dims.0 as u32, dims.1 as u32), stride: dims.0 * 4, timestamp, diff --git a/apps/desktop-gpui/src/hotkeys.rs b/apps/desktop-gpui/src/hotkeys.rs index 54331af3a3d..d2fa8cc522c 100644 --- a/apps/desktop-gpui/src/hotkeys.rs +++ b/apps/desktop-gpui/src/hotkeys.rs @@ -213,6 +213,7 @@ pub fn pause(cx: &mut App) -> bool { hotkeys.paused = true; hotkeys.release_idle_manager(); tracing::info!("global hotkeys paused while a shortcut is recorded"); + crate::tray::refresh_menu(cx); true } @@ -245,6 +246,16 @@ pub fn resume(cx: &mut App) -> bool { true } +/// The registered combo for an action, or `None` when nothing is bound. The +/// tray menu renders it as the row's accelerator. +pub(crate) fn accelerator(action: HotkeyAction, cx: &App) -> Option { + cx.try_global::()? + .bindings + .iter() + .find(|(_, bound)| *bound == action) + .map(|(hotkey, _)| *hotkey) +} + pub fn unavailable_actions(cx: &App) -> Vec<&'static str> { cx.try_global::() .map(|hotkeys| { @@ -801,6 +812,7 @@ fn register_from_store(cx: &mut App) { } hotkeys.release_idle_manager(); tracing::info!(count = hotkeys.bindings.len(), "global hotkeys registered"); + crate::tray::refresh_menu(cx); } /// `CAP_GPUI_AUTO_HOTKEY=`: run an action's dispatch arm without diff --git a/apps/desktop-gpui/src/i18n/tests.rs b/apps/desktop-gpui/src/i18n/tests.rs index c3b43e28840..bd33dd14ef3 100644 --- a/apps/desktop-gpui/src/i18n/tests.rs +++ b/apps/desktop-gpui/src/i18n/tests.rs @@ -24,6 +24,9 @@ fn english_matches_the_literals_it_replaced() { (tr::menu::app::services(), "Services".into()), (tr::menu::app::settings(), "Settings…".into()), (tr::menu::file::close_window(), "Close Window".into()), + (tr::menu::file::open_file(), "Open File…".into()), + (tr::menu::file::open_recent(), "Open Recent".into()), + (tr::menu::file::import_media(), "Import Media…".into()), (tr::menu::file::title(), "File".into()), (tr::menu::edit::copy(), "Copy".into()), (tr::menu::edit::cut(), "Cut".into()), @@ -42,8 +45,32 @@ fn english_matches_the_literals_it_replaced() { (tr::menu::window::title(), "Window".into()), (tr::menu::window::zoom(), "Zoom".into()), (tr::menu::help::title(), "Help".into()), + (tr::menu::help::feedback(), "Give Feedback…".into()), + (tr::menu::help::changelog(), "Changelog".into()), + ( + tr::menu::about::licensed(), + "Licensed under AGPL-3.0".into(), + ), + (tr::menu::about::website(), "Website".into()), + (tr::menu::about::commit(), "Commit".into()), + (tr::menu::help::dashboard(), "Dashboard".into()), + (tr::menu::help::documentation(), "Documentation".into()), + (tr::menu::help::faq(), "FAQ".into()), + (tr::menu::help::self_hosting(), "Self-hosting".into()), + (tr::menu::help::help_center(), "Help Center".into()), + (tr::menu::help::system_status(), "System Status".into()), + (tr::menu::help::discord(), "Join Discord Community".into()), + (tr::menu::update::check(), "Check for Update".into()), + (tr::menu::update::download(), "Download update…".into()), + (tr::menu::update::checking(), "Checking for Update…".into()), + ( + tr::menu::update::downloading(), + "Downloading update…".into(), + ), + (tr::menu::update::restart(), "Restart to update…".into()), (tr::tray::import_media(), "Import Media...".into()), (tr::tray::no_recent_items(), "No recent items".into()), + (tr::tray::recording_mode(), "Recording Mode".into()), (tr::tray::open_main_window(), "Open Main Window".into()), (tr::tray::previous(), "Previous".into()), (tr::tray::quit(), "Quit Cap".into()), @@ -398,6 +425,26 @@ fn english_matches_the_literals_it_replaced() { "Versioned releases (recommended)".into(), ), (tr::settings::general::updates::title(), "Updates".into()), + ( + tr::settings::general::updates::download_policy::label(), + "Update downloads".into(), + ), + ( + tr::settings::general::updates::download_policy::description(), + "Choose whether Cap downloads updates automatically.".into(), + ), + ( + tr::settings::general::updates::download_policy::manual(), + "Ask first".into(), + ), + ( + tr::settings::general::updates::download_policy::auto(), + "Auto".into(), + ), + ( + tr::settings::general::updates::download_policy::never(), + "Never".into(), + ), ( tr::settings::general::updates::channel::description(), "Which release channel Cap updates from.".into(), @@ -442,7 +489,9 @@ fn english_matches_the_literals_it_replaced() { && !super::surface_tests::is_main_surface_key(key) }) .count() - - 3; + // `tray.version`, `excluded_windows.missing_body`, `self_host.confirm_change`, + // `menu.update.badge_progress` and `menu.update.badge_version` take arguments. + - 5; assert_eq!( pairs.len(), plain_keys, @@ -770,6 +819,12 @@ fn english_messages_format_like_the_literals_they_replaced() { to sign in again." ) ); + + assert_eq!( + tr::menu::update::badge_progress("0.4.0", 42), + "v0.4.0 · 42%" + ); + assert_eq!(tr::menu::update::badge_version("0.4.0"), "v0.4.0"); } #[test] diff --git a/apps/desktop-gpui/src/library.rs b/apps/desktop-gpui/src/library.rs index 5e3f9c31f47..8c4906d58bf 100644 --- a/apps/desktop-gpui/src/library.rs +++ b/apps/desktop-gpui/src/library.rs @@ -754,6 +754,14 @@ fn screenshot_items_in(dir: &Path) -> impl Iterator { }) } +/// Whether a `.cap` directory is a screenshot rather than a recording. The +/// same rule `list_screenshots` uses, so the two never disagree on a bundle. +pub fn is_screenshot_bundle(bundle: &Path) -> bool { + bundle.extension().and_then(|ext| ext.to_str()) == Some("cap") + && bundle.is_dir() + && screenshot_png(bundle).is_some() +} + fn screenshot_png(bundle: &Path) -> Option<(PathBuf, f64)> { let original = bundle.join("original.png"); if let Ok(metadata) = original.metadata() { diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index e2edff04f36..db32a0fa617 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -47,6 +47,7 @@ mod i18n; mod import; mod installer; mod jpeg_thumbnail; +mod library; #[cfg(not(any(target_os = "macos", target_os = "windows")))] mod linux_dialog; #[cfg(target_os = "linux")] @@ -55,7 +56,6 @@ mod linux_display; mod linux_portal_id; #[cfg(target_os = "linux")] mod linux_wallpaper; -mod library; mod main_window; mod media_access; mod menus; @@ -893,8 +893,8 @@ mod runtime_tests { #[cfg(test)] mod logging_tests { use super::create_log_appender; - use tracing_subscriber::Layer as _; use std::{io::Write, path::PathBuf}; + use tracing_subscriber::{Layer as _, layer::SubscriberExt as _}; struct LogDirectory(PathBuf); @@ -920,7 +920,6 @@ mod logging_tests { #[test] fn the_default_filter_drops_zbus_property_cache_warnings_only() { use std::sync::{Arc, Mutex}; - use tracing_subscriber::layer::SubscriberExt as _; #[derive(Clone, Default)] struct Lines(Arc>>); diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index 48989b3b09f..82377d3bf1f 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -1112,7 +1112,7 @@ impl MainWindow { Self { theme, // `rawOptions.mode` -- the recording mode is a persisted setting, - // and the tray's Select Mode submenu writes the same key, so the + // and the tray's mode picker writes the same key, so the // window has to start where the store left it rather than at a // hardcoded Instant. mode: Mode::from_store(), @@ -1916,7 +1916,7 @@ impl MainWindow { // `RecordingSettingsStore::set_mode` plus the two tray refreshes it // triggers (`handle_mode_selection`). Every mode affordance -- the // pill, the info panel, the mode select window and the tray's own - // Select Mode submenu -- reaches this method, so this is the one place + // mode picker -- reaches this method, so this is the one place // the setting is written. // Written inline, like every other store write in this app (and like // `RecordingSettingsStore::set_mode` itself): a background write would diff --git a/apps/desktop-gpui/src/menus.rs b/apps/desktop-gpui/src/menus.rs index 118f858230b..675c96b63cb 100644 --- a/apps/desktop-gpui/src/menus.rs +++ b/apps/desktop-gpui/src/menus.rs @@ -7,8 +7,9 @@ //! Tauri app decides between Regular and Accessory from exactly the set of //! windows ⌘W changes. //! -//! The menu itself is [`build_menus`], a byte-for-byte transcription of -//! `build_macos_app_menu` (`apps/desktop/src-tauri/src/lib.rs:479-566`). gpui +//! The menu itself is [`build_menus`], the GPUI model for the native menu bar. +//! macOS standard menu items are upgraded in `platform` after GPUI installs it. +//! gpui //! builds the real `NSMenu` from it and takes each item's key equivalent from //! the *keymap*, so an action with no `KeyBinding` renders without a shortcut //! and an action with no live handler renders disabled @@ -36,12 +37,83 @@ use crate::{ settings_window::Page, store, ui::text_input, + updates, }; /// `productName` in `tauri.conf.json`, which is what macOS renames the first /// submenu to. pub const APP_NAME: &str = "Cap"; +struct MenuRegistration(UpdateRow); + +impl Global for MenuRegistration {} + +/// What the Check for Update row currently says. Download progress only moves +/// the badge, and only macOS applies it without rebuilding the menu bar, so a +/// percentage tick never touches the menu on the other platforms. +#[derive(Clone, PartialEq)] +struct UpdateRow { + title: &'static str, + enabled: bool, + badge: Option, +} + +impl UpdateRow { + fn of(status: &updates::UpdateStatus) -> Self { + use updates::UpdateStatus; + + let title = match status { + UpdateStatus::Idle => tr::menu::update::check(), + UpdateStatus::Checking => tr::menu::update::checking(), + UpdateStatus::Downloading { fraction: None, .. } => tr::menu::update::download(), + UpdateStatus::Downloading { .. } | UpdateStatus::Installing { .. } => { + tr::menu::update::downloading() + } + UpdateStatus::Ready { .. } | UpdateStatus::Installed { .. } => { + tr::menu::update::restart() + } + }; + Self { + title, + enabled: !matches!( + status, + UpdateStatus::Checking + | UpdateStatus::Downloading { + fraction: Some(_), + .. + } + | UpdateStatus::Installing { .. } + ), + badge: update_menu_badge(status), + } + } +} + +#[cfg(target_os = "macos")] +fn update_menu_badge(status: &updates::UpdateStatus) -> Option { + use updates::UpdateStatus; + + match status { + UpdateStatus::Downloading { + version, + fraction: Some(fraction), + } => Some(tr::menu::update::badge_progress( + version, + (fraction * 100.0).round() as u32, + )), + UpdateStatus::Downloading { version, .. } + | UpdateStatus::Ready { version } + | UpdateStatus::Installing { version } + | UpdateStatus::Installed { version, .. } => Some(tr::menu::update::badge_version(version)), + UpdateStatus::Idle | UpdateStatus::Checking => None, + } +} + +#[cfg(not(target_os = "macos"))] +fn update_menu_badge(_: &updates::UpdateStatus) -> Option { + None +} + /// `env!("CARGO_PKG_VERSION")`, exactly as `build_tray_menu` spells it -- this /// crate's version, which is the gpui app's own (0.1.x) rather than the /// shipping desktop app's. Noted as a deviation in the report: there is no @@ -55,12 +127,26 @@ actions!( [ /// `PredefinedMenuItem::about` -- `orderFrontStandardAboutPanel`. About, + CheckForUpdate, /// `PredefinedMenuItem::hide` -- ⌘H. HideSelf, /// `PredefinedMenuItem::hide_others` -- ⌥⌘H. HideOthers, /// The Cap menu's explicit Quit item -- ⌘Q. Quit, + /// Settings… -- ⌘, on macOS, Ctrl+, on Windows and Linux. + OpenSettings, + OpenFile, + ImportMedia, + Changelog, + HelpDashboard, + HelpDocs, + HelpFaq, + HelpSelfHosting, + HelpCenter, + HelpStatus, + HelpDiscord, + HelpFeedback, /// `PredefinedMenuItem::close_window` -- ⌘W. Present twice in the /// Tauri menu (File and Window), one action either way. CloseWindow, @@ -71,8 +157,6 @@ actions!( Zoom, /// `PredefinedMenuItem::fullscreen` -- ⌃⌘F. ToggleFullscreen, - /// Settings… -- ⌘, on macOS, Ctrl+, on Windows and Linux. - OpenSettings, ] ); @@ -87,15 +171,52 @@ pub fn init(cx: &mut App) { cx.bind_keys(bindings()); cx.on_action(|_: &About, cx: &mut App| { - // Ordering a window front re-enters gpui's window callbacks, so it runs - // from a task with no borrow held (`platform::place_overlay_panel`'s - // rule). cx.spawn(async move |_| platform::show_about_panel(APP_NAME, app_version())) .detach(); }); + cx.on_action(|_: &CheckForUpdate, cx: &mut App| { + updates::check_manually(cx); + }); cx.on_action(|_: &HideSelf, cx: &mut App| cx.hide()); cx.on_action(|_: &HideOthers, cx: &mut App| cx.hide_other_apps()); cx.on_action(|_: &Quit, cx: &mut App| quit(cx)); + cx.on_action(|_: &OpenFile, cx: &mut App| { + let (files, directories) = if cfg!(target_os = "macos") { + (true, false) + } else { + (false, true) + }; + let paths = cx.prompt_for_paths(gpui::PathPromptOptions { + files, + directories, + multiple: false, + prompt: Some("Open Recording".into()), + }); + cx.spawn(async move |cx| { + if let Ok(Ok(Some(paths))) = paths.await + && let Some(path) = paths.into_iter().next() + { + cx.update(|cx| crate::tray::open_previous_item(path, cx)); + } + }) + .detach(); + }); + cx.on_action(|_: &ImportMedia, cx: &mut App| { + crate::import::pick_and_import_media(cx); + }); + cx.on_action(|_: &Changelog, cx: &mut App| { + app_windows::open_settings(crate::settings_window::Page::Changelog, cx) + }); + cx.on_action(|_: &HelpDashboard, cx: &mut App| cx.open_url("https://cap.so/dashboard")); + cx.on_action(|_: &HelpDocs, cx: &mut App| cx.open_url("https://cap.so/docs")); + cx.on_action(|_: &HelpFaq, cx: &mut App| cx.open_url("https://cap.so/faq")); + cx.on_action(|_: &HelpSelfHosting, cx: &mut App| cx.open_url("https://cap.so/self-hosting")); + cx.on_action(|_: &HelpCenter, cx: &mut App| cx.open_url("https://help.cap.so/")); + cx.on_action(|_: &HelpStatus, cx: &mut App| cx.open_url("https://cap.openstatus.dev/")); + cx.on_action(|_: &HelpDiscord, cx: &mut App| cx.open_url("https://cap.link/discord")); + cx.on_action(|_: &HelpFeedback, cx: &mut App| { + app_windows::open_settings(crate::settings_window::Page::Feedback, cx) + }); cx.on_action(|_: &CloseWindow, cx: &mut App| close_key_window(cx)); cx.on_action(|_: &Minimize, cx: &mut App| with_key_native(cx, platform::minimize_native)); cx.on_action(|_: &Zoom, cx: &mut App| with_key_native(cx, platform::zoom_native)); @@ -133,7 +254,8 @@ pub fn init(cx: &mut App) { with_key_native(cx, platform::toggle_fullscreen_native) }); - cx.set_menus(build_menus()); + cx.set_global(MenuRegistration(UpdateRow::of(&updates::status(cx)))); + install_menus(cx); MENUS_INSTALLED.store(true, Ordering::Relaxed); #[cfg(target_os = "macos")] if crate::i18n::current() != crate::i18n::Locale::En { @@ -148,7 +270,7 @@ pub fn refresh(cx: &mut App) { if !MENUS_INSTALLED.load(Ordering::Relaxed) { return; } - cx.set_menus(build_menus()); + install_menus(cx); #[cfg(target_os = "macos")] register_system_menus(); } @@ -164,6 +286,51 @@ fn register_system_menus() { ); } +pub(crate) fn refresh_update_menu(cx: &mut App) { + if !cx.has_global::() { + return; + } + + let row = UpdateRow::of(&updates::status(cx)); + if cx.global::().0 == row { + return; + } + cx.global_mut::().0 = row.clone(); + + #[cfg(target_os = "macos")] + platform::set_update_menu_item(row.title, row.badge.as_deref(), row.enabled); + #[cfg(not(target_os = "macos"))] + install_menus(cx); +} + +pub(crate) fn reapply_update_row(cx: &mut App) { + if !cx.has_global::() { + return; + } + let row = UpdateRow::of(&updates::status(cx)); + cx.global_mut::().0 = row.clone(); + #[cfg(target_os = "macos")] + platform::set_update_menu_item(row.title, row.badge.as_deref(), row.enabled); + #[cfg(not(target_os = "macos"))] + install_menus(cx); +} + +fn install_menus(cx: &mut App) { + let update_status = updates::status(cx); + let settings_enabled = !crate::app_windows::onboarding_is_open(cx); + cx.set_menus(build_menus(&update_status, settings_enabled)); + #[cfg(target_os = "macos")] + { + platform::patch_standard_application_menu(); + // The badge lives only on the native item, so a rebuilt menu bar + // (a language change, onboarding finishing) needs it put back. + let row = UpdateRow::of(&update_status); + if row.badge.is_some() { + platform::set_update_menu_item(row.title, row.badge.as_deref(), row.enabled); + } + } +} + pub(crate) fn bindings() -> Vec { vec![ KeyBinding::new("cmd-q", Quit, None), @@ -184,15 +351,19 @@ fn settings_shortcut_allowed(idle: bool, onboarding_open: bool, hotkeys_paused: /// /// One-to-one with `build_macos_app_menu`, including the empty Help submenu /// (`Submenu::with_id_and_items(.., HELP_SUBMENU_ID, "Help", true, &[])`). -pub fn build_menus() -> Vec { +pub fn build_menus(update_status: &updates::UpdateStatus, settings_enabled: bool) -> Vec { + let update_row = UpdateRow::of(update_status); + vec![ Menu { name: APP_NAME.into(), disabled: false, items: vec![ MenuItem::action(tr::menu::app::about(), About), + MenuItem::action(update_row.title, CheckForUpdate).disabled(!update_row.enabled), MenuItem::separator(), - MenuItem::action(tr::menu::app::settings(), OpenSettings), + MenuItem::action(tr::menu::app::settings(), OpenSettings) + .disabled(!settings_enabled), MenuItem::separator(), MenuItem::os_submenu(tr::menu::app::services(), SystemMenuType::Services), MenuItem::separator(), @@ -205,10 +376,12 @@ pub fn build_menus() -> Vec { Menu { name: tr::menu::file::title().into(), disabled: false, - items: vec![MenuItem::action( - tr::menu::file::close_window(), - CloseWindow, - )], + items: vec![ + MenuItem::action(tr::menu::file::open_file(), OpenFile), + MenuItem::action(tr::menu::file::import_media(), ImportMedia), + MenuItem::separator(), + MenuItem::action(tr::menu::file::close_window(), CloseWindow), + ], }, Menu { name: tr::menu::edit::title().into(), @@ -262,7 +435,19 @@ pub fn build_menus() -> Vec { Menu { name: tr::menu::help::title().into(), disabled: false, - items: vec![], + items: vec![ + MenuItem::action(tr::menu::help::feedback(), HelpFeedback), + MenuItem::separator(), + MenuItem::action(tr::menu::help::changelog(), Changelog), + MenuItem::separator(), + MenuItem::action(tr::menu::help::dashboard(), HelpDashboard), + MenuItem::action(tr::menu::help::documentation(), HelpDocs), + MenuItem::action(tr::menu::help::faq(), HelpFaq), + MenuItem::action(tr::menu::help::self_hosting(), HelpSelfHosting), + MenuItem::action(tr::menu::help::help_center(), HelpCenter), + MenuItem::action(tr::menu::help::system_status(), HelpStatus), + MenuItem::action(tr::menu::help::discord(), HelpDiscord), + ], }, ] } @@ -1046,8 +1231,17 @@ mod tests { } #[test] - fn menu_bar_matches_the_tauri_structure() { - let menus: Vec<_> = build_menus().iter().map(describe).collect(); + fn menu_bar_includes_native_and_cap_destinations() { + let menus: Vec<_> = build_menus(&updates::UpdateStatus::Idle, true) + .iter() + .map(describe) + .collect(); + let expected_file_items = vec![ + "Open File…".into(), + "Import Media…".into(), + "-".into(), + "Close Window".into(), + ]; assert_eq!( menus, vec![ @@ -1055,8 +1249,9 @@ mod tests { "Cap".to_string(), vec![ "About Cap".to_string(), + "Check for Update".into(), "-".into(), - "Settings…".into(), + tr::menu::app::settings().into(), "-".into(), "Services".into(), "-".into(), @@ -1066,7 +1261,7 @@ mod tests { "Quit Cap".into(), ] ), - ("File".into(), vec!["Close Window".into()]), + ("File".into(), expected_file_items,), ( "Edit".into(), vec![ @@ -1089,11 +1284,134 @@ mod tests { "Close Window".into(), ] ), - ("Help".into(), vec![]), + ( + "Help".into(), + vec![ + "Give Feedback…".into(), + "-".into(), + "Changelog".into(), + "-".into(), + "Dashboard".into(), + "Documentation".into(), + "FAQ".into(), + "Self-hosting".into(), + "Help Center".into(), + "System Status".into(), + "Join Discord Community".into(), + ], + ), ] ); } + /// The row is the second item of the App menu (`menu_bar_...` pins that + /// order); it greys out while a check or download is running so a second + /// click cannot fire another one. + #[test] + fn the_update_row_greys_out_while_busy() { + fn row(status: &updates::UpdateStatus) -> (String, bool) { + let menus = build_menus(status, true); + match &menus[0].items[1] { + MenuItem::Action { name, disabled, .. } => (name.to_string(), *disabled), + _ => panic!("the update row is an action"), + } + } + + assert_eq!( + row(&updates::UpdateStatus::Idle), + ("Check for Update".to_string(), false) + ); + assert_eq!( + row(&updates::UpdateStatus::Checking), + ("Checking for Update…".to_string(), true) + ); + assert_eq!( + row(&updates::UpdateStatus::Downloading { + version: semver::Version::parse("0.4.0").unwrap(), + fraction: Some(0.42), + }), + ("Downloading update…".to_string(), true) + ); + assert_eq!( + row(&updates::UpdateStatus::Installing { + version: semver::Version::parse("0.4.0").unwrap(), + }), + ("Downloading update…".to_string(), true) + ); + } + + /// The version lives only in the badge, and a percentage tick changes + /// nothing else -- that is what keeps a download from reinstalling the + /// menu bar on every progress event. + #[test] + fn the_update_row_keeps_the_version_out_of_the_title() { + use semver::Version; + use updates::UpdateStatus; + + let version = Version::parse("0.4.0").unwrap(); + let downloading = |fraction| UpdateStatus::Downloading { + version: version.clone(), + fraction, + }; + + let cold = UpdateRow::of(&downloading(None)); + let warm = UpdateRow::of(&downloading(Some(0.42))); + assert_eq!(cold.title, "Download update…"); + assert!(cold.enabled); + assert_eq!(warm.title, "Downloading update…"); + assert!(!warm.enabled); + + let ready = UpdateRow::of(&UpdateStatus::Ready { + version: version.clone(), + }); + let installed = UpdateRow::of(&UpdateStatus::Installed { + version: version.clone(), + executable: std::path::PathBuf::from("/Applications/Cap.app"), + }); + assert_eq!(ready.title, "Restart to update…"); + assert_eq!(installed.title, ready.title); + assert_eq!(installed.badge, ready.badge); + + let installing = UpdateRow::of(&UpdateStatus::Installing { + version: version.clone(), + }); + assert_eq!(installing.title, "Downloading update…"); + assert!(!installing.enabled); + } + + #[cfg(target_os = "macos")] + #[test] + fn the_update_badge_shows_the_version_and_live_progress() { + use semver::Version; + use updates::UpdateStatus; + + let version = Version::parse("0.4.0").unwrap(); + let badge = |status| UpdateRow::of(&status).badge; + + assert_eq!( + badge(UpdateStatus::Downloading { + version: version.clone(), + fraction: None, + }), + Some("v0.4.0".to_string()) + ); + assert_eq!( + badge(UpdateStatus::Downloading { + version: version.clone(), + fraction: Some(0.42), + }), + Some("v0.4.0 · 42%".to_string()) + ); + assert_eq!( + badge(UpdateStatus::Ready { + version: version.clone(), + }), + Some("v0.4.0".to_string()) + ); + assert_eq!(badge(UpdateStatus::Idle), None); + assert_eq!(badge(UpdateStatus::Checking), None); + } + /// `sync_macos_dock_visibility`'s three branches. #[test] fn dock_policy_matches_the_tauri_rule() { diff --git a/apps/desktop-gpui/src/onboarding_window.rs b/apps/desktop-gpui/src/onboarding_window.rs index a19f48ab12b..3703af15c26 100644 --- a/apps/desktop-gpui/src/onboarding_window.rs +++ b/apps/desktop-gpui/src/onboarding_window.rs @@ -1246,9 +1246,13 @@ impl OnboardingWindow { .justify_center() .gap(px(4.)) .child( - copy(tr::onboarding::welcome::click_or_press(), 11., ink.opacity(0.55)) - .font_weight(FontWeight::NORMAL) - .line_height(px(14.)), + copy( + tr::onboarding::welcome::click_or_press(), + 11., + ink.opacity(0.55), + ) + .font_weight(FontWeight::NORMAL) + .line_height(px(14.)), ) .child( copy(tr::onboarding::welcome::space_key(), 10., ink.opacity(0.7)) @@ -1290,18 +1294,14 @@ impl OnboardingWindow { .child(welcome_script(written, title_opacity)), ) .child( - copy( - tr::onboarding::welcome::tagline(), - 22., - ink.opacity(0.75), - ) - .relative() - .top(px(subtitle_y)) - .opacity(subtitle_opacity) - .font_family("Source Serif 4") - .font_weight(FontWeight::LIGHT) - .line_height(px(32.)) - .whitespace_nowrap(), + copy(tr::onboarding::welcome::tagline(), 22., ink.opacity(0.75)) + .relative() + .top(px(subtitle_y)) + .opacity(subtitle_opacity) + .font_family("Source Serif 4") + .font_weight(FontWeight::LIGHT) + .line_height(px(32.)) + .whitespace_nowrap(), ) .child(button) .when_some(self.error.clone(), |el, error| { @@ -1342,18 +1342,64 @@ impl OnboardingWindow { fn render_overview(&self) -> AnyElement { let theme = self.theme; - div().flex().flex_col().items_center().justify_center().min_h_full().px(px(40.)).gap(px(32.)) - .child(self.heading(tr::onboarding::overview::title(), tr::onboarding::overview::subtitle())) - .child(div().flex().gap(px(16.)).w_full().max_w(px(540.)).children(MODES.iter().enumerate().map(|(index, mode)| { - let paper = stage::Paper::new(&theme); - let card = self.paper_card().flex_1().min_w_0().flex().flex_col().items_center().gap(px(10.)).px(px(20.)).pt(px(22.)).pb(px(24.)) - .child(crate::doodle::doodle(mode_doodle(mode.mode), paper.palette(), self.step_at + Duration::from_millis(index as u64 * 180), !self.animated, 96., 84.)) - .child(copy((mode.title)(), 15., paper.ink).font_weight(FontWeight::MEDIUM).text_center().mt(px(4.))) - .child(copy((mode.tagline)(), 12., paper.soft).font_weight(FontWeight::NORMAL).line_height(px(16.)).text_center()); - if !self.animated { return card.into_any_element(); } - card.with_animation(("overview-card", index), Animation::new(Duration::from_millis(700 + index as u64 * 100)), - |el, t| el.relative().top(px(16. * (1. - ease(t)))).opacity(ease(t))).into_any_element() - }))).into_any_element() + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .min_h_full() + .px(px(40.)) + .gap(px(32.)) + .child(self.heading( + tr::onboarding::overview::title(), + tr::onboarding::overview::subtitle(), + )) + .child(div().flex().gap(px(16.)).w_full().max_w(px(540.)).children( + MODES.iter().enumerate().map(|(index, mode)| { + let paper = stage::Paper::new(&theme); + let card = self + .paper_card() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .items_center() + .gap(px(10.)) + .px(px(20.)) + .pt(px(22.)) + .pb(px(24.)) + .child(crate::doodle::doodle( + mode_doodle(mode.mode), + paper.palette(), + self.step_at + Duration::from_millis(index as u64 * 180), + !self.animated, + 96., + 84., + )) + .child( + copy((mode.title)(), 15., paper.ink) + .font_weight(FontWeight::MEDIUM) + .text_center() + .mt(px(4.)), + ) + .child( + copy((mode.tagline)(), 12., paper.soft) + .font_weight(FontWeight::NORMAL) + .line_height(px(16.)) + .text_center(), + ); + if !self.animated { + return card.into_any_element(); + } + card.with_animation( + ("overview-card", index), + Animation::new(Duration::from_millis(700 + index as u64 * 100)), + |el, t| el.relative().top(px(16. * (1. - ease(t)))).opacity(ease(t)), + ) + .into_any_element() + }), + )) + .into_any_element() } fn render_detail(&self, index: usize) -> AnyElement { @@ -1540,18 +1586,60 @@ impl OnboardingWindow { fn render_shortcuts(&self) -> AnyElement { let theme = self.theme; - div().flex().flex_col().items_center().justify_center().min_h_full().px(px(48.)).gap(px(24.)) - .child(self.heading(tr::onboarding::customize::title(), tr::onboarding::customize::subtitle())) - .child(div().flex().flex_col().w_full().max_w(px(420.)).gap(px(8.)).children([ - (tr::onboarding::customize::shortcuts::title(), tr::onboarding::customize::shortcuts::description()), - (tr::onboarding::customize::s3::title(), tr::onboarding::customize::s3::description()), - (tr::onboarding::customize::domain::title(), tr::onboarding::customize::domain::description()), - (tr::onboarding::customize::preferences::title(), tr::onboarding::customize::preferences::description()), - ].into_iter().map(|(title, description)| { - self.paper_card().rounded(px(16.)).px(px(18.)).py(px(13.)).flex().flex_col().gap(px(4.)) - .child(copy(title, 13., theme.gray_12).font_weight(FontWeight::MEDIUM)) - .child(copy(description, 11., theme.gray_10).line_height(px(15.))) - }))) + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .min_h_full() + .px(px(48.)) + .gap(px(24.)) + .child(self.heading( + tr::onboarding::customize::title(), + tr::onboarding::customize::subtitle(), + )) + .child( + div() + .flex() + .flex_col() + .w_full() + .max_w(px(420.)) + .gap(px(8.)) + .children( + [ + ( + tr::onboarding::customize::shortcuts::title(), + tr::onboarding::customize::shortcuts::description(), + ), + ( + tr::onboarding::customize::s3::title(), + tr::onboarding::customize::s3::description(), + ), + ( + tr::onboarding::customize::domain::title(), + tr::onboarding::customize::domain::description(), + ), + ( + tr::onboarding::customize::preferences::title(), + tr::onboarding::customize::preferences::description(), + ), + ] + .into_iter() + .map(|(title, description)| { + self.paper_card() + .rounded(px(16.)) + .px(px(18.)) + .py(px(13.)) + .flex() + .flex_col() + .gap(px(4.)) + .child( + copy(title, 13., theme.gray_12).font_weight(FontWeight::MEDIUM), + ) + .child(copy(description, 11., theme.gray_10).line_height(px(15.))) + }), + ), + ) .child(copy(tr::onboarding::customize::footer(), 12., theme.gray_9)) .into_any_element() } @@ -1621,13 +1709,17 @@ impl OnboardingWindow { ) .when(index == 0, |el| { el.child( - copy(tr::onboarding::faq::pricing(), 13., theme.blue_10) - .id("onboarding-faq-pricing") - .cursor_pointer() - .mt(px(8.)) - .on_click(|_, _, cx| { - cx.open_url(crate::auth::PRICING_URL) - }), + copy( + tr::onboarding::faq::pricing(), + 13., + theme.blue_10, + ) + .id("onboarding-faq-pricing") + .cursor_pointer() + .mt(px(8.)) + .on_click(|_, _, cx| { + cx.open_url(crate::auth::PRICING_URL) + }), ) }), ) @@ -1912,12 +2004,17 @@ impl OnboardingWindow { .flex() .flex_col() .child( - copy(tr::onboarding::demo::start_recording(), 15., gpui::white()) - .font_weight(FontWeight::MEDIUM) - .whitespace_nowrap(), + copy( + tr::onboarding::demo::start_recording(), + 15., + gpui::white(), + ) + .font_weight(FontWeight::MEDIUM) + .whitespace_nowrap(), ) .child( - copy((MODES[index].title)(), 11., gpui::white()).opacity(0.9), + copy((MODES[index].title)(), 11., gpui::white()) + .opacity(0.9), ), ), ) @@ -2265,7 +2362,10 @@ impl OnboardingWindow { .border_color(theme.gray_3) .bg(theme.body_fill(2)), ) - .child(copy(tr::onboarding::demo::background(), 8., theme.gray_9).mt(px(4.))) + .child( + copy(tr::onboarding::demo::background(), 8., theme.gray_9) + .mt(px(4.)), + ) .child(div().flex().gap(px(4.)).children( [0x667eea, 0xf78c88, 0xcccccc].map(|color| { div() @@ -2557,7 +2657,11 @@ impl OnboardingWindow { 12., self.success_palette().2, )) - .child(copy(tr::onboarding::demo::copied_to_clipboard(), 11., theme.gray_12)), + .child(copy( + tr::onboarding::demo::copied_to_clipboard(), + 11., + theme.gray_12, + )), ), ) }), diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index 27df7a67dc5..6549f0b5225 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -655,8 +655,7 @@ mod mac { use objc2_foundation::{NSNotification, NSOperationQueue}; let reduce_motion = || { - let reduce = - unsafe { NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion() }; + let reduce = NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion(); tracing::debug!(reduce, "system reduce motion"); reduce }; @@ -685,9 +684,13 @@ mod mac { } use gpui::Window; - use objc2::rc::Id; + use objc2::rc::Retained; use objc2::runtime::{AnyObject, Sel}; - use objc2_app_kit::{NSView, NSWindow, NSWindowCollectionBehavior}; + use objc2_app_kit::{ + NSApplication, NSDocumentController, NSMenu, NSMenuItem, NSView, NSWindow, + NSWindowCollectionBehavior, + }; + use objc2_foundation::{NSObjectNSKeyValueCoding, NSString}; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use super::{ForcedAppearance, MaterialKind, PanelBehavior}; @@ -695,6 +698,296 @@ mod mac { static NATIVE_QUIT_REQUESTS: std::sync::OnceLock = std::sync::OnceLock::new(); + /// Rows of `menus::build_menus`' Cap and File menus. Both menus are built + /// from that model in this order, so the positions are fixed. + const APP_MENU: isize = 0; + const ABOUT_ROW: isize = 0; + const UPDATE_ROW: isize = 1; + const SETTINGS_ROW: isize = 3; + const FILE_MENU: isize = 1; + const OPEN_RECENT_ROW: isize = 1; + const HELP_MENU: isize = 5; + const FEEDBACK_ROW: isize = 0; + + /// `PredefinedMenuItem::about`, which on macOS is `orderFrontStandardAboutPanelWithOptions:`. + /// Ordering a window front re-enters gpui's window callbacks, so call it + /// from a task, never from inside an update. + pub fn show_about_panel(name: &str, version: &str) { + use objc2::ClassType; + use objc2_app_kit::{ + NSAboutPanelOptionApplicationName, NSAboutPanelOptionApplicationVersion, + NSAboutPanelOptionCredits, NSApplication, + }; + use objc2_foundation::{MainThreadMarker, NSDictionary, NSString}; + + let Some(main_thread) = MainThreadMarker::new() else { + tracing::error!("The About panel must be shown from the main thread"); + return; + }; + let name_value = NSString::from_str(name); + let version_value = NSString::from_str(version); + let credits = about_credits(); + + let keys = unsafe { + [ + NSAboutPanelOptionApplicationName, + NSAboutPanelOptionApplicationVersion, + NSAboutPanelOptionCredits, + ] + }; + let values: [&AnyObject; 3] = [ + name_value.as_super(), + version_value.as_super(), + credits.as_super(), + ]; + let options = NSDictionary::from_slices(&keys, &values); + + unsafe { + NSApplication::sharedApplication(main_thread) + .orderFrontStandardAboutPanelWithOptions(&options); + } + } + + /// Each entry is one centred line. A `Some` URL makes the line a link. + /// Kept short: a long credits block makes AppKit add a scroll view. + fn about_credit_lines() -> Vec<(String, Option<&'static str>)> { + use crate::i18n::tr; + vec![ + ( + "Copyright \u{a9} 2023-present Cap Software, Inc.".into(), + None, + ), + ( + tr::menu::about::licensed().into(), + Some("https://github.com/CapSoftware/Cap/blob/main/LICENSE"), + ), + (tr::menu::about::website().into(), Some("https://cap.so")), + ] + } + + const COMMIT_FONT_SIZE: f64 = 10.0; + + /// The build's commit, when the build recorded one. + fn commit_line() -> Option { + option_env!("CAP_BUILD_REVISION").map(|revision| { + let short = &revision[..revision.len().min(7)]; + format!("{}: {short}", crate::i18n::tr::menu::about::commit()) + }) + } + + fn about_credits() -> Retained { + use objc2::{AnyThread, ClassType}; + use objc2_app_kit::{ + NSColor, NSFont, NSFontAttributeName, NSForegroundColorAttributeName, + NSLinkAttributeName, NSMutableParagraphStyle, NSParagraphStyleAttributeName, + NSTextAlignment, + }; + use objc2_foundation::{ + NSAttributedString, NSDictionary, NSMutableAttributedString, NSString, NSURL, + }; + + let style = NSMutableParagraphStyle::new(); + style.setAlignment(NSTextAlignment::Center); + let centred: &AnyObject = style.as_super(); + + let spaced = NSMutableParagraphStyle::new(); + spaced.setAlignment(NSTextAlignment::Center); + spaced.setParagraphSpacing(12.0); + let spaced: &AnyObject = spaced.as_super(); + + let commit_style = NSMutableParagraphStyle::new(); + commit_style.setAlignment(NSTextAlignment::Center); + commit_style.setParagraphSpacingBefore(12.0); + let commit_style: &AnyObject = commit_style.as_super(); + + let credits = NSMutableAttributedString::from_nsstring(&NSString::from_str("")); + let append = |text: &str, link: Option<&str>, paragraph: &AnyObject, subdued: bool| { + let line_text = NSString::from_str(&format!("{text}\n")); + let link = link.and_then(|url| NSURL::URLWithString(&NSString::from_str(url))); + + let mut keys: Vec<&NSString> = vec![unsafe { NSParagraphStyleAttributeName }]; + let mut values: Vec<&AnyObject> = vec![paragraph]; + if let Some(link) = link.as_deref() { + keys.push(unsafe { NSLinkAttributeName }); + values.push(link.as_super()); + } + let color; + let font; + if subdued { + color = NSColor::tertiaryLabelColor(); + font = NSFont::systemFontOfSize(COMMIT_FONT_SIZE); + keys.push(unsafe { NSForegroundColorAttributeName }); + values.push(color.as_super()); + keys.push(unsafe { NSFontAttributeName }); + values.push(font.as_super()); + } + let attributes = NSDictionary::from_slices(&keys, &values); + let line = unsafe { + NSAttributedString::initWithString_attributes( + NSAttributedString::alloc(), + &line_text, + Some(&attributes), + ) + }; + credits.appendAttributedString(&line); + }; + + let lines = about_credit_lines(); + for (index, (text, link)) in lines.iter().enumerate() { + let paragraph = if index == 0 { spaced } else { centred }; + append(text, *link, paragraph, false); + } + if let Some(commit) = commit_line() { + append(&commit, None, commit_style, true); + } + credits + } + + pub fn patch_standard_application_menu() { + use objc2::{exception, msg_send, runtime::AnyObject, sel}; + + let Some(main_thread) = objc2_foundation::MainThreadMarker::new() else { + tracing::error!("Cannot patch the application menu off the main thread"); + return; + }; + let app = NSApplication::sharedApplication(main_thread); + let Some(main_menu) = app.mainMenu() else { + tracing::warn!("GPUI did not install an application menu"); + return; + }; + let Some(app_menu) = main_menu + .itemAtIndex(APP_MENU) + .and_then(|item| item.submenu()) + else { + return; + }; + + // gpui's `validateMenuItem:` answers only "is this action available?", + // which is always yes for Cap's global listeners -- so AppKit's + // auto-enable pass overwrites `MenuItem::Action`'s `disabled` every + // time the menu opens. The model's enabled state has to be + // authoritative instead, as it already is for the tray menu and for + // muda. + app_menu.setAutoenablesItems(false); + + if objc2::available!(macos = 15.0) { + if let Some(about) = app_menu.itemAtIndex(ABOUT_ROW) { + set_nsmenu_system_symbol(&about, "info.icon"); + } + if let Some(settings) = app_menu.itemAtIndex(SETTINGS_ROW) { + set_nsmenu_system_symbol(&settings, "gear"); + set_nsmenu_item_prefers_visible_image(&settings); + } + if let Some(feedback) = main_menu + .itemAtIndex(HELP_MENU) + .and_then(|item| item.submenu()) + .and_then(|menu| menu.itemAtIndex(FEEDBACK_ROW)) + { + set_nsmenu_system_symbol(&feedback, "exclamationmark.bubble"); + set_nsmenu_item_prefers_visible_image(&feedback); + } + if let Some(quit) = &app_menu + .numberOfItems() + .checked_sub(1) + .and_then(|index| app_menu.itemAtIndex(index)) + { + set_nsmenu_system_symbol(quit, "xmark.rectangle"); + } + } + + let Some(file_menu) = main_menu + .itemAtIndex(FILE_MENU) + .and_then(|item| item.submenu()) + else { + return; + }; + + let has_recent_menu = file_menu.itemAtIndex(OPEN_RECENT_ROW).is_some_and(|item| { + item.title().isEqualToString(&NSString::from_str( + crate::i18n::tr::menu::file::open_recent(), + )) + }); + if has_recent_menu { + return; + } + + let recent_menu = NSMenu::new(main_thread); + recent_menu.setTitle(&NSString::from_str( + crate::i18n::tr::menu::file::open_recent(), + )); + let name = NSString::from_str("NSRecentDocumentsMenu"); + let key = NSString::from_str("menuName"); + let registered = exception::catch(std::panic::AssertUnwindSafe(|| { + unsafe { recent_menu.setValue_forKey(Some(&*name as &AnyObject), &key) }; + })); + if let Err(error) = registered { + tracing::warn!("Could not register Open Recent menu name: {error:?}"); + } + + let recent_item = NSMenuItem::new(main_thread); + recent_item.setTitle(&NSString::from_str( + crate::i18n::tr::menu::file::open_recent(), + )); + recent_item.setSubmenu(Some(&recent_menu)); + recent_item.setAllowsAutomaticKeyEquivalentLocalization(true); + file_menu.insertItem_atIndex(&recent_item, OPEN_RECENT_ROW); + + // NSDocumentController owns the recent items and the standard + // "Clear Menu" validation; it only needs the empty, named submenu. + let controller = NSDocumentController::sharedDocumentController(main_thread); + let can_install_menu: bool = + unsafe { msg_send![&*controller, respondsToSelector: sel!(_installOpenRecentMenus)] }; + // Each menu-bar rebuild creates a fresh named submenu, so bind the + // controller to this instance. Recent-document updates afterwards are + // handled by NSDocumentController, not by another install call. + if can_install_menu { + unsafe { + let _: () = msg_send![&*controller, _installOpenRecentMenus]; + } + } else { + tracing::warn!("NSDocumentController cannot install the Open Recent menu"); + } + } + + /// Rewrites the Check for Update row in place. A menu item edited this way + /// keeps whatever menu the user has open on screen; reinstalling the menu + /// bar for every download-percentage tick would close it. + pub fn set_update_menu_item(title: &str, badge: Option<&str>, enabled: bool) { + use objc2::runtime::AnyClass; + use objc2_app_kit::NSMenuItemBadge; + use objc2_foundation::MainThreadMarker; + + let Some(main_thread) = MainThreadMarker::new() else { + return; + }; + let Some(item) = update_menu_item(main_thread) else { + tracing::warn!("The update row is missing from the application menu"); + return; + }; + item.setTitle(&NSString::from_str(title)); + item.setEnabled(enabled); + + // `NSMenuItemBadge` and `setBadge:` are macOS 14+, and objc2's class + // lookup panics when the class is absent. + if AnyClass::get(c"NSMenuItemBadge").is_none() { + return; + } + let badge = badge.map(|text| { + NSMenuItemBadge::initWithString(main_thread.alloc(), &NSString::from_str(text)) + }); + item.setBadge(badge.as_deref()); + } + + fn update_menu_item( + main_thread: objc2_foundation::MainThreadMarker, + ) -> Option> { + NSApplication::sharedApplication(main_thread) + .mainMenu()? + .itemAtIndex(APP_MENU)? + .submenu()? + .itemAtIndex(UPDATE_ROW) + } + unsafe extern "C-unwind" fn application_should_terminate( _: &AnyObject, _: Sel, @@ -726,7 +1019,7 @@ mod mac { .as_ref() .ok_or("Application delegate is unavailable")?; let delegate_class = delegate.class(); - if delegate_class.name() != "GPUIApplicationDelegate" { + if delegate_class.name() != c"GPUIApplicationDelegate" { return Err("Native Quit requires the owned GPUI application delegate".into()); } let selector = sel!(applicationShouldTerminate:); @@ -741,12 +1034,12 @@ mod mac { unsafe extern "C-unwind" fn(&AnyObject, Sel, *mut AnyObject) -> usize, Imp, >(application_should_terminate); - if !objc2::runtime::Bool::from_raw(ffi::class_addMethod( + if !ffi::class_addMethod( delegate_class as *const _ as *mut _, - selector.as_ptr(), - Some(implementation), + selector, + implementation, c"Q@:@".as_ptr(), - )) + ) .as_bool() { return Err("Could not install the native Quit handler".into()); @@ -795,9 +1088,7 @@ mod mac { NSAppearance::appearanceNamed(unsafe { NSAppearanceNameDarkAqua }) } }; - unsafe { - NSAppearanceCustomization::setAppearance(&*native.0, named.as_deref()); - } + NSAppearanceCustomization::setAppearance(&*native.0, named.as_deref()); } pub fn window_is_dark(window: &Window) -> Option { @@ -808,8 +1099,8 @@ mod mac { }; let ns = ns_window(window)?; - let effective = unsafe { NSAppearanceCustomization::effectiveAppearance(&*ns) }; - let name = unsafe { effective.name() }; + let effective = NSAppearanceCustomization::effectiveAppearance(&*ns); + let name = effective.name(); Some(unsafe { name.isEqualToString(NSAppearanceNameDarkAqua) || name.isEqualToString(NSAppearanceNameVibrantDark) @@ -900,12 +1191,12 @@ mod mac { // `NSGlassEffectView` exists only on macOS 26+; its absence is what // sends `macos-window-material.ts` down the vibrancy branch. if material_override.as_deref() != Some("vibrancy") - && let Some(glass_class) = AnyClass::get("NSGlassEffectView") + && let Some(glass_class) = AnyClass::get(c"NSGlassEffectView") { unsafe { let glass: *mut AnyObject = msg_send![glass_class, alloc]; let glass: *mut AnyObject = msg_send![glass, initWithFrame: bounds]; - if let Some(glass) = Id::from_raw(glass) { + if let Some(glass) = Retained::from_raw(glass) { set_clip_radius(radius); let identifier = NSString::from_str(LIQUID_GLASS_IDENTIFIER); let _: () = msg_send![&*glass, setIdentifier: &*identifier]; @@ -922,7 +1213,7 @@ mod mac { let _: () = msg_send![ &*content_view, addSubview: &*glass, - positioned: NSWindowOrderingMode::NSWindowBelow, + positioned: NSWindowOrderingMode::Below, relativeTo: std::ptr::null_mut::(), ]; return Some(MaterialKind::LiquidGlass); @@ -935,21 +1226,15 @@ mod mac { // 16 })`. Tauri's `Effect.WindowBackground` is `NSVisualEffectMaterial` // `windowBackground` (12) blended behind the window; the radius is // already covered by the content-view squircle clip above. - let vibrancy = unsafe { NSVisualEffectView::initWithFrame(mtm.alloc(), bounds) }; - unsafe { - vibrancy.setMaterial(NSVisualEffectMaterial::WindowBackground); - vibrancy.setBlendingMode(NSVisualEffectBlendingMode::BehindWindow); - vibrancy.setState(NSVisualEffectState::FollowsWindowActiveState); - vibrancy.setAutoresizingMask( - NSAutoresizingMaskOptions::NSViewWidthSizable - | NSAutoresizingMaskOptions::NSViewHeightSizable, - ); - content_view.addSubview_positioned_relativeTo( - &vibrancy, - NSWindowOrderingMode::NSWindowBelow, - None, - ); - } + let vibrancy = NSVisualEffectView::initWithFrame(mtm.alloc(), bounds); + vibrancy.setMaterial(NSVisualEffectMaterial::WindowBackground); + vibrancy.setBlendingMode(NSVisualEffectBlendingMode::BehindWindow); + vibrancy.setState(NSVisualEffectState::FollowsWindowActiveState); + vibrancy.setAutoresizingMask( + NSAutoresizingMaskOptions::ViewWidthSizable + | NSAutoresizingMaskOptions::ViewHeightSizable, + ); + content_view.addSubview_positioned_relativeTo(&vibrancy, NSWindowOrderingMode::Below, None); Some(MaterialKind::Vibrancy) } @@ -995,7 +1280,7 @@ mod mac { use objc2::ffi::class_addMethod; use objc2_foundation::NSRect; - unsafe extern "C" fn constrain_shim( + unsafe extern "C-unwind" fn constrain_shim( _this: *mut AnyObject, _sel: Sel, frame: NSRect, @@ -1005,22 +1290,30 @@ mod mac { } for name in ["GPUIWindow", "GPUIPanel"] { - let Some(class) = objc2::runtime::AnyClass::get(name) else { + let Some(class) = objc2::runtime::AnyClass::get(match name { + "GPUIWindow" => c"GPUIWindow", + "GPUIPanel" => c"GPUIPanel", + _ => continue, + }) else { continue; }; let added = unsafe { class_addMethod( - (class as *const objc2::runtime::AnyClass as *mut objc2::ffi::objc_class) - .cast(), - objc2::sel!(constrainFrameRect:toScreen:).as_ptr(), - Some(std::mem::transmute::< - unsafe extern "C" fn(*mut AnyObject, Sel, NSRect, *mut AnyObject) -> NSRect, - unsafe extern "C" fn(), - >(constrain_shim)), + (class as *const objc2::runtime::AnyClass).cast_mut(), + objc2::sel!(constrainFrameRect:toScreen:), + std::mem::transmute::< + unsafe extern "C-unwind" fn( + *mut AnyObject, + Sel, + NSRect, + *mut AnyObject, + ) -> NSRect, + objc2::runtime::Imp, + >(constrain_shim), c"{CGRect={CGPoint=dd}{CGSize=dd}}@:{CGRect={CGPoint=dd}{CGSize=dd}}@".as_ptr(), ) }; - if objc2::runtime::Bool::from_raw(added).as_bool() { + if added.as_bool() { tracing::info!("installed frame-constraint shim on {name}"); } } @@ -1093,7 +1386,7 @@ mod mac { /// A retained `NSWindow` that can outlive a `&Window` borrow -- what /// [`place_overlay_panel`] operates on. - pub struct NativeWindow(Id); + pub struct NativeWindow(Retained); /// The retained `NSWindow` behind a gpui window, for AppKit calls that /// must run *outside* any gpui update (see [`place_overlay_panel`]). @@ -1149,7 +1442,11 @@ mod mac { pub fn lock_borderless_style(native: &NativeWindow) { use objc2::{ffi, msg_send, runtime::AnyClass}; - unsafe extern "C" fn guarded_set_style_mask(this: *mut AnyObject, sel: Sel, mask: usize) { + unsafe extern "C-unwind" fn guarded_set_style_mask( + this: *mut AnyObject, + sel: Sel, + mask: usize, + ) { let Some(&(_, imp)) = SET_STYLE_MASK_SUPER.get() else { return; }; @@ -1162,7 +1459,7 @@ mod mac { tracing::info!(mask, "kept the main window's borderless style mask"); BORDERLESS_STYLE_MASK }; - let imp: unsafe extern "C" fn(*mut AnyObject, Sel, usize) = + let imp: unsafe extern "C-unwind" fn(*mut AnyObject, Sel, usize) = unsafe { std::mem::transmute(imp) }; unsafe { imp(this, sel, mask) }; } @@ -1172,25 +1469,27 @@ mod mac { let sel = objc2::sel!(setStyleMask:); let &(installed_on, _) = SET_STYLE_MASK_SUPER.get_or_init(|| { let superclass = ffi::class_getSuperclass(class.cast()); - let Some(imp) = ffi::method_getImplementation(ffi::class_getInstanceMethod( - superclass, - sel.as_ptr(), - )) else { + let Some(imp) = + ffi::method_getImplementation(ffi::class_getInstanceMethod(superclass, sel)) + else { tracing::warn!( "no inherited setStyleMask: for the main window style-mask lock" ); return (0, 0); }; + let added = ffi::class_addMethod( class.cast_mut().cast(), - sel.as_ptr(), - Some(std::mem::transmute::< - unsafe extern "C" fn(*mut AnyObject, Sel, usize), - unsafe extern "C" fn(), - >(guarded_set_style_mask)), - c"v@:Q".as_ptr(), - ); - if !objc2::runtime::Bool::from_raw(added).as_bool() { + sel, + std::mem::transmute::< + unsafe extern "C-unwind" fn(*mut AnyObject, Sel, usize), + objc2::runtime::Imp, + >(guarded_set_style_mask), + c"v@:Q".as_ptr(), // (void) + ) + .as_bool(); + + if !added { tracing::warn!("could not install the main window style-mask lock"); return (0, 0); } @@ -1291,10 +1590,8 @@ mod mac { apply_fullscreen_overlay_behavior(ns_window); // `.shadow(false)` in the Tauri builder. ns_window.setHasShadow(false); - unsafe { - ns_window.setAnimationBehavior(objc2_app_kit::NSWindowAnimationBehavior::None); - ns_window.orderFrontRegardless(); - } + ns_window.setAnimationBehavior(objc2_app_kit::NSWindowAnimationBehavior::None); + ns_window.orderFrontRegardless(); } /// The window's raw AppKit frame (bottom-left origin). The dev-restore @@ -1429,12 +1726,12 @@ mod mac { /// rebuild and must not steal focus from whatever the user is typing in. /// Same task-only rule as every other ordering call here. pub fn order_front_native(native: &NativeWindow) { - unsafe { native.0.orderFrontRegardless() }; + native.0.orderFrontRegardless(); rerun_occlusion_handler_after_show(&native.0); } /// The `NSWindow` behind a gpui window. - pub fn ns_window(window: &Window) -> Option> { + pub fn ns_window(window: &Window) -> Option> { // Fully qualified: `Window` also has an inherent `window_handle()` // returning gpui's own `AnyWindowHandle`. let handle = HasWindowHandle::window_handle(window).ok()?; @@ -1459,7 +1756,7 @@ mod mac { ns_window.setLevel(behavior.level); tracing::debug!( requested = behavior.level, - actual = unsafe { ns_window.level() }, + actual = ns_window.level(), "panel level applied" ); if behavior.join_all_spaces { @@ -1543,7 +1840,7 @@ mod mac { pub fn set_window_click_through(native: &NativeWindow, click_through: bool) -> bool { native.0.setIgnoresMouseEvents(click_through); - unsafe { native.0.ignoresMouseEvents() == click_through } + native.0.ignoresMouseEvents() == click_through } /// `orderOut:` -- hide without closing, the way the Tauri main window @@ -1641,7 +1938,7 @@ mod mac { static SOURCE: OnceLock = OnceLock::new(); *SOURCE.get_or_init(|| { - let Some(mut builder) = ClassBuilder::new("CapFileDragSource", NSObject::class()) + let Some(mut builder) = ClassBuilder::new(c"CapFileDragSource", NSObject::class()) else { return 0; }; @@ -1657,7 +1954,7 @@ mod mac { ) -> usize, ); } - if let Some(protocol) = AnyProtocol::get("NSDraggingSource") { + if let Some(protocol) = AnyProtocol::get(c"NSDraggingSource") { builder.add_protocol(protocol); } let class = builder.register(); @@ -1804,7 +2101,7 @@ mod mac { use objc2::{class, msg_send}; use objc2_foundation::NSString; unsafe { - let mut image: Option> = None; + let mut image: Option> = None; let cap_app = NSString::from_str("/Applications/Cap.app"); let exists: bool = { @@ -1816,7 +2113,7 @@ mod mac { if !workspace.is_null() { let raw: *mut AnyObject = msg_send![workspace, iconForFile: &*cap_app]; // `iconForFile:` is autoreleased; retain for the setter. - image = Id::retain(raw); + image = Retained::retain(raw); if let Some(image) = &image { // The workspace icon's nominal size is small (32pt); // the reps go up to 1024. Ask for dock resolution. @@ -1837,7 +2134,7 @@ mod mac { if !data.is_null() { let alloc: *mut AnyObject = msg_send![class!(NSImage), alloc]; let raw: *mut AnyObject = msg_send![alloc, initWithData: data]; - image = Id::from_raw(raw); + image = Retained::from_raw(raw); } } @@ -2120,7 +2417,7 @@ mod mac { /// Tauri app calls on the recording controls panel. pub fn show_window_without_focus(window: &Window) { if let Some(ns_window) = ns_window(window) { - unsafe { ns_window.orderFrontRegardless() }; + ns_window.orderFrontRegardless(); rerun_occlusion_handler_after_show(&ns_window); } } @@ -2128,7 +2425,7 @@ mod mac { /// The CGWindowID, which is also what `scap_targets::WindowId` wraps on /// macOS -- used to exclude our own controls bar from the capture. pub fn window_number(window: &Window) -> Option { - ns_window(window).map(|w| unsafe { w.windowNumber() }) + ns_window(window).map(|w| w.windowNumber()) } // -- The open panel ------------------------------------------------------ @@ -2172,11 +2469,11 @@ mod mac { let _: () = msg_send![panel, setCanCreateDirectories: true]; let _: () = msg_send![panel, setNameFieldStringValue: &*NSString::from_str(suggested)]; if !extensions.is_empty() { - let types: Vec> = extensions + let types: Vec> = extensions .iter() .map(|extension| NSString::from_str(extension)) .collect(); - let types = NSArray::from_vec(types); + let types = NSArray::from_retained_slice(&types); let _: () = msg_send![panel, setAllowedFileTypes: &*types]; } let response: isize = msg_send![panel, runModal]; @@ -2250,7 +2547,7 @@ mod mac { let data = NSData::with_bytes(&image.0); let image: *mut AnyObject = msg_send![class!(NSImage), alloc]; let image: *mut AnyObject = msg_send![image, initWithData: &*data]; - let image = Id::from_raw(image).ok_or("Failed to load image")?; + let image = Retained::from_raw(image).ok_or("Failed to load image")?; let objects: *mut AnyObject = msg_send![class!(NSArray), arrayWithObject: &*image]; let _: isize = msg_send![pasteboard, clearContents]; let ok: Bool = msg_send![pasteboard, writeObjects: objects]; @@ -2275,11 +2572,11 @@ mod mac { let _: () = msg_send![panel, setCanChooseDirectories: false]; let _: () = msg_send![panel, setAllowsMultipleSelection: false]; - let types: Vec> = extensions + let types: Vec> = extensions .iter() .map(|extension| NSString::from_str(extension)) .collect(); - let types = NSArray::from_vec(types); + let types = NSArray::from_retained_slice(&types); let _: () = msg_send![panel, setAllowedFileTypes: &*types]; // `NSModalResponseOK`. @@ -2320,7 +2617,7 @@ mod mac { cancel: &str, warning: bool, ) -> bool { - use objc2::{class, msg_send, msg_send_id}; + use objc2::{class, msg_send}; use objc2_foundation::NSString; /// `NSAlertStyle`: `Warning = 0`, `Informational = 1`. `NSUInteger`, @@ -2333,7 +2630,7 @@ mod mac { const NS_ALERT_FIRST_BUTTON_RETURN: isize = 1000; unsafe { - let alert: Id = msg_send_id![class!(NSAlert), new]; + let alert: Retained = msg_send![class!(NSAlert), new]; let _: () = msg_send![&*alert, setMessageText: &*NSString::from_str(title)]; let _: () = msg_send![&*alert, setInformativeText: &*NSString::from_str(message)]; let _: () = msg_send![ @@ -2356,13 +2653,13 @@ mod mac { } pub fn alert_dialog(title: &str, message: &str) { - use objc2::{class, msg_send, msg_send_id}; + use objc2::{class, msg_send}; use objc2_foundation::NSString; const NS_ALERT_STYLE_INFORMATIONAL: usize = 1; unsafe { - let alert: Id = msg_send_id![class!(NSAlert), new]; + let alert: Retained = msg_send![class!(NSAlert), new]; let _: () = msg_send![&*alert, setMessageText: &*NSString::from_str(title)]; let _: () = msg_send![&*alert, setInformativeText: &*NSString::from_str(message)]; let _: () = msg_send![&*alert, setAlertStyle: NS_ALERT_STYLE_INFORMATIONAL]; @@ -2619,48 +2916,6 @@ mod mac { } } - // -- The About panel ------------------------------------------------------ - - /// `PredefinedMenuItem::about(.., AboutMetadata { name, version, .. })`, - /// which on macOS is `orderFrontStandardAboutPanelWithOptions:`. - /// - /// The Tauri metadata also carries `copyright` and `publisher` from - /// `tauri.conf.json` -- neither key exists in that file, so both are `None` - /// there and there is nothing to pass here either. - /// - /// Spins no modal run loop of its own (the panel is an ordinary window), but - /// ordering a window front re-enters gpui's window callbacks, so it keeps - /// the [`place_overlay_panel`] rule: call it from a task, never inside an - /// update. - pub fn show_about_panel(name: &str, version: &str) { - use objc2::{class, msg_send}; - use objc2_foundation::{NSArray, NSString}; - - unsafe { - let app: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication]; - if app.is_null() { - return; - } - // `dictionaryWithObjects:forKeys:` rather than a typed - // `NSDictionary` constructor, for the reason - // [`desktop_picture_path`] gives: the typed binding needs another - // objc2-app-kit/foundation feature, and the rule here is that those - // versions stay pinned to gpui's. - let values = - NSArray::from_vec(vec![NSString::from_str(name), NSString::from_str(version)]); - let keys = NSArray::from_vec(vec![ - NSString::from_str("ApplicationName"), - NSString::from_str("ApplicationVersion"), - ]); - let options: *mut AnyObject = msg_send![ - class!(NSDictionary), - dictionaryWithObjects: &*values, - forKeys: &*keys, - ]; - let _: () = msg_send![app, orderFrontStandardAboutPanelWithOptions: options]; - } - } - /// `NSApp.windowsMenu` and `NSApp.helpMenu`, looked up by the titles the /// menu bar was just built with. `None` clears the Help menu. pub fn register_system_menus(window_title: &str, help_title: Option<&str>) { @@ -2706,12 +2961,27 @@ mod mac { "occlusion_raw={:#x} visible={} on_active_space={} alpha={} level={} number={}", w.occlusionState().bits(), w.isVisible(), - unsafe { w.isOnActiveSpace() }, - unsafe { w.alphaValue() }, - unsafe { w.level() }, - unsafe { w.windowNumber() }, + w.isOnActiveSpace(), + w.alphaValue(), + w.level(), + w.windowNumber(), )) } + + pub fn set_nsmenu_item_prefers_visible_image(item: &NSMenuItem) { + if objc2::available!(macos = 27.0) { + // NSMenuItemImageVisibility: automatic = 0, visible = 1, hidden = 2 + let _: () = unsafe { objc2::msg_send![item, setPreferredImageVisibility: 1isize] }; + } + } + + fn set_nsmenu_system_symbol(item: &NSMenuItem, symbol: &str) { + let image = objc2_app_kit::NSImage::imageWithSystemSymbolName_accessibilityDescription( + &objc2_foundation::NSString::from_str(symbol), + None, + ); + item.setImage(image.as_deref()); + } } #[cfg(target_os = "windows")] @@ -2911,6 +3181,7 @@ mod stub { pub fn close_native(_native: &NativeWindow) {} pub fn set_dock_icon(_png: &[u8]) {} + pub fn show_about_panel(_name: &str, _version: &str) {} pub fn escape_hotkey_events() -> flume::Receiver<()> { // A channel whose sender is dropped immediately: the drain task's // `recv` errors once and the task exits. @@ -3045,7 +3316,6 @@ mod stub { pub fn activation_policy() -> isize { -1 } - pub fn show_about_panel(_name: &str, _version: &str) {} } #[cfg(not(any(target_os = "macos", target_os = "windows")))] diff --git a/apps/desktop-gpui/src/platform/macos_exception_guard.rs b/apps/desktop-gpui/src/platform/macos_exception_guard.rs index 0348df8652a..e3c1f875df5 100644 --- a/apps/desktop-gpui/src/platform/macos_exception_guard.rs +++ b/apps/desktop-gpui/src/platform/macos_exception_guard.rs @@ -16,7 +16,7 @@ use std::sync::OnceLock; use objc2::{ ffi, msg_send, - rc::Id, + rc::Retained, runtime::{AnyClass, AnyObject, Imp, Sel}, sel, }; @@ -46,9 +46,7 @@ impl GuardedMethod { if self.original.get().is_some() || class.verify_sel::<(), ()>(selector).is_err() { return false; } - let method = unsafe { - ffi::class_getInstanceMethod((class as *const AnyClass).cast(), selector.as_ptr()) - }; + let method = unsafe { ffi::class_getInstanceMethod(class, selector) }; if method.is_null() { return false; } @@ -63,7 +61,7 @@ impl GuardedMethod { if self.original.set(original).is_err() { return false; } - unsafe { ffi::method_setImplementation(method, Some(guard_imp)) }; + let _ = unsafe { ffi::method_setImplementation(method, guard_imp) }; true } @@ -72,12 +70,12 @@ impl GuardedMethod { return false; }; let exception = unsafe { cap_call_void_imp_catching(original, receiver, selector) }; - let Some(exception) = (unsafe { Id::from_raw(exception) }) else { + let Some(exception) = (unsafe { Retained::from_raw(exception) }) else { return false; }; tracing::warn!( exception = %describe(&exception), - selector = %selector.name(), + selector = %selector.name().to_string_lossy(), "ignored an AppKit exception that would have crashed the app" ); true @@ -110,7 +108,7 @@ unsafe extern "C" fn touch_bar_finder_invalidate(receiver: *mut AnyObject, selec /// Call once on the main thread, before the app's run loop starts. pub(super) fn install() { - let Some(class) = AnyClass::get("_NSTouchBarFinderObservation") else { + let Some(class) = AnyClass::get(c"_NSTouchBarFinderObservation") else { tracing::info!("AppKit has no Touch Bar finder observation class; nothing to guard"); return; }; @@ -124,7 +122,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use objc2::{ - ClassType, msg_send, msg_send_id, + ClassType, msg_send, runtime::{ClassBuilder, NSObject}, }; @@ -145,7 +143,7 @@ mod tests { static CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); CLASS.get_or_init(|| { let mut builder = - ClassBuilder::new("CapExceptionGuardTestObservation", NSObject::class()).unwrap(); + ClassBuilder::new(c"CapExceptionGuardTestObservation", NSObject::class()).unwrap(); unsafe { builder.add_method( sel!(invalidate), @@ -170,7 +168,7 @@ mod tests { sel!(invalidate), ) }; - let exception = unsafe { Id::from_raw(exception) } + let exception = unsafe { Retained::from_raw(exception) } .expect("removing an unregistered observer must throw"); let description = describe(&exception); assert!( @@ -186,7 +184,7 @@ mod tests { assert!(TEST_GUARD.install(class, sel!(invalidate), guarded)); assert!(!TEST_GUARD.install(class, sel!(invalidate), guarded)); - let instance: Id = unsafe { msg_send_id![class, new] }; + let instance: Retained = unsafe { msg_send![class, new] }; for _ in 0..3 { let before = removal_calls(); let _: () = unsafe { msg_send![&instance, invalidate] }; @@ -197,10 +195,10 @@ mod tests { #[test] fn appkit_still_has_the_touch_bar_finder_method_this_guards() { assert!( - AnyClass::get("NSApplication").is_some(), + AnyClass::get(c"NSApplication").is_some(), "AppKit is not loaded" ); - let class = AnyClass::get("_NSTouchBarFinderObservation") + let class = AnyClass::get(c"_NSTouchBarFinderObservation") .expect("AppKit renamed _NSTouchBarFinderObservation; re-check the guard"); assert!(class.verify_sel::<(), ()>(sel!(invalidate)).is_ok()); } diff --git a/apps/desktop-gpui/src/platform/macos_occlusion.rs b/apps/desktop-gpui/src/platform/macos_occlusion.rs index 0c440597919..6e6dc8fffba 100644 --- a/apps/desktop-gpui/src/platform/macos_occlusion.rs +++ b/apps/desktop-gpui/src/platform/macos_occlusion.rs @@ -7,7 +7,7 @@ use objc2::{ sel, }; -type OcclusionStateFn = unsafe extern "C" fn(*mut AnyObject, Sel) -> usize; +type OcclusionStateFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel) -> usize; struct OcclusionShim { original: OnceLock, @@ -46,15 +46,12 @@ impl OcclusionShim { let added = unsafe { class_addMethod( (class as *const AnyClass).cast_mut().cast(), - selector.as_ptr(), - Some(std::mem::transmute::< - OcclusionStateFn, - unsafe extern "C" fn(), - >(shim)), + selector, + std::mem::transmute::(shim), c"Q@:".as_ptr(), ) }; - Ok(Bool::from_raw(added).as_bool()) + Ok(added.as_bool()) } unsafe fn state(&self, receiver: *mut AnyObject, selector: Sel) -> usize { @@ -121,23 +118,29 @@ unsafe fn is_visible(receiver: *mut AnyObject) -> bool { static WINDOW_SHIM: OcclusionShim = OcclusionShim::new(); static PANEL_SHIM: OcclusionShim = OcclusionShim::new(); -unsafe extern "C" fn window_occlusion_state(receiver: *mut AnyObject, selector: Sel) -> usize { +unsafe extern "C-unwind" fn window_occlusion_state( + receiver: *mut AnyObject, + selector: Sel, +) -> usize { unsafe { WINDOW_SHIM.state(receiver, selector) } } -unsafe extern "C" fn panel_occlusion_state(receiver: *mut AnyObject, selector: Sel) -> usize { +unsafe extern "C-unwind" fn panel_occlusion_state( + receiver: *mut AnyObject, + selector: Sel, +) -> usize { unsafe { PANEL_SHIM.state(receiver, selector) } } pub(super) fn install() { for (name, state, implementation) in [ ( - "GPUIWindow", + c"GPUIWindow", &WINDOW_SHIM, window_occlusion_state as OcclusionStateFn, ), ( - "GPUIPanel", + c"GPUIPanel", &PANEL_SHIM, panel_occlusion_state as OcclusionStateFn, ), @@ -146,20 +149,27 @@ pub(super) fn install() { continue; }; match state.install(class, implementation) { - Ok(true) => tracing::info!("installed macOS occlusion shim on {name}"), + Ok(true) => { + tracing::info!(name = %name.to_string_lossy(), "installed macOS occlusion shim") + } Ok(false) => {} - Err(error) => tracing::warn!(%error, name, "could not install macOS occlusion shim"), + Err(error) => { + tracing::warn!(%error, name = %name.to_string_lossy(), "could not install macOS occlusion shim") + } } } } #[cfg(test)] mod tests { - use std::cell::Cell; + use std::{ + cell::Cell, + ffi::{CStr, CString}, + }; use objc2::{ - ClassType, msg_send_id, - rc::Id, + ClassType, msg_send, + rc::Retained, runtime::{ClassBuilder, NSObject}, }; @@ -181,37 +191,49 @@ mod tests { overridden_panel: &'static AnyClass, } - unsafe extern "C" fn native_window_state(_receiver: *mut AnyObject, _selector: Sel) -> usize { + unsafe extern "C-unwind" fn native_window_state( + _receiver: *mut AnyObject, + _selector: Sel, + ) -> usize { WINDOW_CALLS.set(WINDOW_CALLS.get() + 1); RAW_STATE.get() } - unsafe extern "C" fn native_is_visible(_receiver: *mut AnyObject, _selector: Sel) -> Bool { + unsafe extern "C-unwind" fn native_is_visible( + _receiver: *mut AnyObject, + _selector: Sel, + ) -> Bool { Bool::new(VISIBLE.get()) } - unsafe extern "C" fn native_panel_state(_receiver: *mut AnyObject, _selector: Sel) -> usize { + unsafe extern "C-unwind" fn native_panel_state( + _receiver: *mut AnyObject, + _selector: Sel, + ) -> usize { PANEL_CALLS.set(PANEL_CALLS.get() + 1); RAW_STATE.get() } - unsafe extern "C" fn overriding_window_state( + unsafe extern "C-unwind" fn overriding_window_state( receiver: *mut AnyObject, _selector: Sel, ) -> usize { unsafe { msg_send![super(receiver, classes().window), occlusionState] } } - unsafe extern "C" fn overriding_panel_state(receiver: *mut AnyObject, _selector: Sel) -> usize { + unsafe extern "C-unwind" fn overriding_panel_state( + receiver: *mut AnyObject, + _selector: Sel, + ) -> usize { unsafe { msg_send![super(receiver, classes().panel), occlusionState] } } - fn inherit(name: &str, superclass: &AnyClass) -> &'static AnyClass { + fn inherit(name: &CStr, superclass: &AnyClass) -> &'static AnyClass { ClassBuilder::new(name, superclass).unwrap().register() } fn with_method( - name: &str, + name: &CStr, superclass: &AnyClass, implementation: OcclusionStateFn, ) -> &'static AnyClass { @@ -225,7 +247,7 @@ mod tests { CLASSES.get_or_init(|| { let native_window = { let mut builder = - ClassBuilder::new("CapOcclusionTestNativeWindow", NSObject::class()).unwrap(); + ClassBuilder::new(c"CapOcclusionTestNativeWindow", NSObject::class()).unwrap(); unsafe { builder.add_method( sel!(occlusionState), @@ -233,40 +255,38 @@ mod tests { ); builder.add_method( sel!(isVisible), - native_is_visible as unsafe extern "C" fn(_, _) -> _, + native_is_visible as unsafe extern "C-unwind" fn(_, _) -> _, ); } builder.register() }; let native_panel = with_method( - "CapOcclusionTestNativePanel", + c"CapOcclusionTestNativePanel", native_window, native_panel_state, ); - let window = inherit("CapOcclusionTestWindow", native_window); - let panel = inherit("CapOcclusionTestPanel", native_panel); + let window = inherit(c"CapOcclusionTestWindow", native_window); + let panel = inherit(c"CapOcclusionTestPanel", native_panel); assert!(WINDOW_SHIM.install(window, window_occlusion_state).unwrap()); assert!(PANEL_SHIM.install(panel, panel_occlusion_state).unwrap()); let mut inherited_window = window; let mut inherited_panel = panel; for depth in 0..4 { - inherited_window = inherit( - &format!("CapOcclusionTestWindowSubclass{depth}"), - inherited_window, - ); - inherited_panel = inherit( - &format!("CapOcclusionTestPanelSubclass{depth}"), - inherited_panel, - ); + let window_name = + CString::new(format!("CapOcclusionTestWindowSubclass{depth}")).unwrap(); + inherited_window = inherit(&window_name, inherited_window); + let panel_name = + CString::new(format!("CapOcclusionTestPanelSubclass{depth}")).unwrap(); + inherited_panel = inherit(&panel_name, inherited_panel); } let window_override = with_method( - "CapOcclusionTestWindowOverride", + c"CapOcclusionTestWindowOverride", window, overriding_window_state, ); let panel_override = with_method( - "CapOcclusionTestPanelOverride", + c"CapOcclusionTestPanelOverride", panel, overriding_panel_state, ); @@ -276,16 +296,19 @@ mod tests { inherited_window, inherited_panel, overridden_window: inherit( - "CapOcclusionTestInheritedWindowOverride", + c"CapOcclusionTestInheritedWindowOverride", window_override, ), - overridden_panel: inherit("CapOcclusionTestInheritedPanelOverride", panel_override), + overridden_panel: inherit( + c"CapOcclusionTestInheritedPanelOverride", + panel_override, + ), } }) } fn verify_states(class: &AnyClass, expected_window_calls: usize, expected_panel_calls: usize) { - let instance: Id = unsafe { msg_send_id![class, new] }; + let instance: Retained = unsafe { msg_send![class, new] }; for visible in [true, false] { VISIBLE.set(visible); for raw in [0, 0x2, 0x2000, 0x2002] { @@ -372,7 +395,7 @@ mod tests { #[test] fn promoting_a_window_switches_to_the_panel_implementation() { let classes = classes(); - let instance: Id = unsafe { msg_send_id![classes.window, new] }; + let instance: Retained = unsafe { msg_send![classes.window, new] }; RAW_STATE.set(0x2000); WINDOW_CALLS.set(0); PANEL_CALLS.set(0); @@ -392,17 +415,20 @@ mod tests { #[test] fn incompatible_native_method_does_not_install_a_shim() { - unsafe extern "C" fn wrong_signature(_receiver: *mut AnyObject, _selector: Sel) -> f64 { + unsafe extern "C-unwind" fn wrong_signature( + _receiver: *mut AnyObject, + _selector: Sel, + ) -> f64 { 0.0 } let state = OcclusionShim::new(); let mut builder = - ClassBuilder::new("CapOcclusionTestWrongSignature", NSObject::class()).unwrap(); + ClassBuilder::new(c"CapOcclusionTestWrongSignature", NSObject::class()).unwrap(); unsafe { builder.add_method( sel!(occlusionState), - wrong_signature as unsafe extern "C" fn(_, _) -> _, + wrong_signature as unsafe extern "C-unwind" fn(_, _) -> _, ); } let class = builder.register(); @@ -414,12 +440,12 @@ mod tests { fn existing_class_override_is_not_replaced() { let state = OcclusionShim::new(); let class = with_method( - "CapOcclusionTestExistingOverride", + c"CapOcclusionTestExistingOverride", NSObject::class(), native_window_state, ); assert!(!state.install(class, window_occlusion_state).unwrap()); - let instance: Id = unsafe { msg_send_id![class, new] }; + let instance: Retained = unsafe { msg_send![class, new] }; RAW_STATE.set(0x2000); let raw: usize = unsafe { msg_send![&*instance, occlusionState] }; assert_eq!(raw, 0x2000); @@ -428,7 +454,7 @@ mod tests { #[test] fn missing_native_method_does_not_install_a_shim() { let state = OcclusionShim::new(); - let class = inherit("CapOcclusionTestMissingMethod", NSObject::class()); + let class = inherit(c"CapOcclusionTestMissingMethod", NSObject::class()); assert!(state.install(class, window_occlusion_state).is_err()); assert!(state.original.get().is_none()); assert!(class.instance_method(sel!(occlusionState)).is_none()); diff --git a/apps/desktop-gpui/src/settings_window.rs b/apps/desktop-gpui/src/settings_window.rs index 19b3740a460..62bfdacf3df 100644 --- a/apps/desktop-gpui/src/settings_window.rs +++ b/apps/desktop-gpui/src/settings_window.rs @@ -38,7 +38,7 @@ use crate::{ store::{ self, AppTheme, DEFAULT_PROJECT_NAME_TEMPLATE, DEFAULT_SERVER_URL, GENERAL_SETTINGS, GeneralSettings, MainWindowStartBehaviour, PostDeletionBehaviour, RECORDING_START_SAFETY, - SettingsEnum, StudioQuality, UpdateChannel, WindowExclusion, + SettingsEnum, StudioQuality, UpdateChannel, UpdateDownloadPolicy, WindowExclusion, }, theme::Theme, ui, @@ -1849,7 +1849,10 @@ impl Render for SettingsWindow { // the frame to the app; without this the window had no way to close. #[cfg(target_os = "linux")] let shell = shell.when( - matches!(window.window_decorations(), gpui::Decorations::Client { .. }), + matches!( + window.window_decorations(), + gpui::Decorations::Client { .. } + ), |shell| shell.child(linux_close_button(theme)), ); @@ -2074,6 +2077,7 @@ impl SettingsWindow { let theme = self.theme; let (update_label, update_ready) = match crate::updates::status(cx) { crate::updates::UpdateStatus::Idle => ("Check for updates".to_string(), false), + crate::updates::UpdateStatus::Checking => ("Checking for updates…".to_string(), false), crate::updates::UpdateStatus::Downloading { version, fraction: Some(fraction), @@ -4388,6 +4392,7 @@ impl SettingsWindow { fn render_updates(&self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; let current = self.settings.update_channel; + let current_policy = self.settings.update_download_policy; let description = UPDATE_CHANNEL_DESCRIPTIONS .iter() .find(|(channel, _)| *channel == current) @@ -4400,6 +4405,67 @@ impl SettingsWindow { .gap(px(12.)) .px(px(16.)) .py(px(16.)) + .child( + div() + .flex() + .flex_row() + .justify_between() + .items_start() + .gap(px(16.)) + .child( + div() + .flex() + .flex_col() + .gap(px(2.)) + .min_w_0() + .child( + div() + .text_size(px(13.)) + .child(tr::settings::general::updates::download_policy::label()), + ) + .child( + div() + .text_size(px(12.)) + .line_height(px(16.)) + .text_color(theme.settings_muted()) + .child( + tr::settings::general::updates::download_policy::description(), + ), + ), + ) + .child(self.segmented_raw( + "update-download-policy", + vec![ + ui::SegmentOption::new( + tr::settings::general::updates::download_policy::auto(), + current_policy == UpdateDownloadPolicy::Auto, + ), + ui::SegmentOption::new( + tr::settings::general::updates::download_policy::manual(), + current_policy == UpdateDownloadPolicy::Manual, + ), + ui::SegmentOption::new( + tr::settings::general::updates::download_policy::never(), + current_policy == UpdateDownloadPolicy::Never, + ), + ], + cx, + |this, index, cx| { + let value = match index { + 0 => UpdateDownloadPolicy::Auto, + 1 => UpdateDownloadPolicy::Manual, + _ => UpdateDownloadPolicy::Never, + }; + if this.settings.update_download_policy == value { + return; + } + this.settings.update_download_policy = value; + this.write_enum("updateDownloadPolicy", value, cx); + crate::updates::update_download_policy_changed(value, cx); + }, + )), + ) + .child(div().h(px(1.)).bg(theme.settings_border())) .child( div() .flex() diff --git a/apps/desktop-gpui/src/store.rs b/apps/desktop-gpui/src/store.rs index 01fa3a5088e..72721d21298 100644 --- a/apps/desktop-gpui/src/store.rs +++ b/apps/desktop-gpui/src/store.rs @@ -845,6 +845,15 @@ settings_enum! { default = Stable } +settings_enum! { + UpdateDownloadPolicy { + Manual = "manual", "Manual"; + Auto = "auto", "Auto"; + Never = "never", "Never"; + } + default = Manual +} + settings_enum! { EditorPreviewQuality { Quarter = "quarter", "Quarter"; @@ -1031,6 +1040,7 @@ pub struct GeneralSettings { pub default_project_name_template: Option, pub excluded_windows: Vec, pub update_channel: UpdateChannel, + pub update_download_policy: UpdateDownloadPolicy, pub server_url: String, pub enable_telemetry: bool, pub editor_preview_quality: EditorPreviewQuality, @@ -1097,7 +1107,7 @@ pub fn set_studio_sound_defaults(enabled: bool, isolation: cap_project::VoiceIso set_store_value(AUDIO_ENHANCEMENT, Value::Object(section)) } /// `RecordingSettingsStore::KEY` (`src-tauri/src/recording_settings.rs:37`) -- -/// the section the tray's Select Mode submenu reads and writes. +/// the section the tray's mode picker reads and writes. pub const RECORDING_SETTINGS: &str = "recording_settings"; #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -1381,6 +1391,7 @@ impl GeneralSettings { }) .unwrap_or_else(default_excluded_windows), update_channel: enum_at(general, "updateChannel"), + update_download_policy: enum_at(general, "updateDownloadPolicy"), server_url: string_at(general, "serverUrl", DEFAULT_SERVER_URL), enable_telemetry: bool_at(general, "enableTelemetry", true), editor_preview_quality: enum_at(general, "editorPreviewQuality"), diff --git a/apps/desktop-gpui/src/tray.rs b/apps/desktop-gpui/src/tray.rs index 4a2ba5e4099..1601cd06b21 100644 --- a/apps/desktop-gpui/src/tray.rs +++ b/apps/desktop-gpui/src/tray.rs @@ -13,16 +13,22 @@ //! foreground task drains that channel and dispatches with a clean borrow. //! Same seam the editor's overlay panels use for AppKit callbacks. //! -//! Menu strings, order and separators are byte-identical to `build_tray_menu`. +//! Labels mirror `build_tray_menu`, except on macOS where the mode group +//! names the current mode and its section header follows the palette's +//! highlight in italic; the grouping is [`build_menu`]'s own. //! "Upload Logs" renders disabled because the log-upload path does not exist //! here. +use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::LazyLock; +use global_hotkey::hotkey::{Code, HotKey, Modifiers}; use gpui::{App, Global}; use crate::{ app_windows, + hotkeys::{self, HotkeyAction}, i18n::tr, library::{self, MediaKind}, main_window::{Mode, TargetType}, @@ -65,6 +71,9 @@ pub enum TrayItem { RecordWindow, RecordArea, TakeScreenshot, + ScreenshotDisplay, + ScreenshotWindow, + ScreenshotArea, ImportMedia, ViewAllRecordings, ViewAllScreenshots, @@ -77,24 +86,168 @@ pub enum TrayItem { ModeScreenshot, } +/// A global hotkey as the menu renders it: the NSMenuItem key equivalent on +/// macOS, a `muda` accelerator on Windows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Accelerator { + pub modifiers: Modifiers, + pub code: Code, +} + +impl From for Accelerator { + fn from(hotkey: HotKey) -> Self { + Self { + modifiers: hotkey.mods, + code: hotkey.key, + } + } +} + /// One row of the menu. #[derive(Clone, Debug, PartialEq)] pub enum Entry { Separator, + ModePicker { + mode: Mode, + }, Item { title: String, item: Option, enabled: bool, - /// A 32x32 PNG, already cover-cropped. `IconMenuItem`'s image. + /// The row's image: a cover-cropped 32x32 photo thumbnail for + /// Previous, a rasterised vector glyph anywhere else. `IconMenuItem`'s + /// image -- Windows draws it; macOS draws `row_symbol`'s SF Symbol + /// instead (photos aside). icon: Option>, + /// The row's global shortcut, if one is registered for its action. + accelerator: Option, }, Submenu { title: String, enabled: bool, items: Vec, + /// The header's glyph, rasterised at [`ROW_ICON_SIZE`] -- the raster + /// Windows draws; macOS draws `PREVIOUS_SYMBOL` instead. + icon: Option>, }, + /// A non-interactive group label: an `NSMenu` section header on macOS 14+. + SectionHeader { + title: String, + }, +} + +/// The glyphs the action rows carry, keyed for [`menu_icon`] -- the same files +/// their in-app counterparts use. macOS prefers `row_symbol` over these; +/// Windows draws them. +const ROW_ICON_SOURCES: &[(&str, &[u8])] = &[ + ( + "display", + include_bytes!("../assets/icons/screen.svg") as &[u8], + ), + ( + "window", + include_bytes!("../assets/icons/window.svg") as &[u8], + ), + ("area", include_bytes!("../assets/icons/area.svg") as &[u8]), + ( + "screenshot", + include_bytes!("../assets/icons/screenshot.svg") as &[u8], + ), + ( + "import", + include_bytes!("../assets/icons/import.svg") as &[u8], + ), + ( + "settings", + include_bytes!("../assets/icons/settings.svg") as &[u8], + ), + ( + "previous", + include_bytes!("../assets/icons/clock.svg") as &[u8], + ), + ( + "mode-instant", + include_bytes!("../assets/icons/instant.svg") as &[u8], + ), + ( + "mode-studio", + include_bytes!("../assets/icons/film-cut.svg") as &[u8], + ), + ( + "mode-screenshot", + include_bytes!("../assets/icons/screenshot.svg") as &[u8], + ), +]; + +/// `Entry::Item`'s `icon` size, and the raster size of every row glyph. +const ROW_ICON_SIZE: u32 = 32; +/// The mode picker draws its icons at 17 points. Rasterise at exactly 2x so +/// the image maps 1:1 to device pixels on a Retina display. +const PICKER_ICON_SIZE: u32 = 34; + +/// An SVG rendered to a `size`×`size` PNG, scaled to fit and centred -- the +/// conversion `cap_mode_icon_png` makes at 90 for the palette, shared with the +/// menu rows at [`ROW_ICON_SIZE`]. Only the alpha survives a template tint. +fn svg_icon_png(source: &[u8], size: u32) -> Option> { + let tree = resvg::usvg::Tree::from_data(source, &resvg::usvg::Options::default()).ok()?; + let mut pixmap = resvg::tiny_skia::Pixmap::new(size, size)?; + let bounds = tree.size(); + let scale = (size as f32 / bounds.width()).min(size as f32 / bounds.height()); + let transform = resvg::tiny_skia::Transform::from_translate( + (size as f32 - bounds.width() * scale) / 2.0, + (size as f32 - bounds.height() * scale) / 2.0, + ) + .pre_scale(scale, scale); + resvg::render(&tree, transform, &mut pixmap.as_mut()); + pixmap.encode_png().ok() } +/// A row glyph as a PNG, rasterised once: a menu rebuild is cheap, seven SVG +/// renders are not. +fn menu_icon(name: &str) -> Option> { + static ICONS: LazyLock>> = LazyLock::new(|| { + ROW_ICON_SOURCES + .iter() + .filter_map(|(name, svg)| Some((*name, svg_icon_png(svg, ROW_ICON_SIZE)?))) + .collect() + }); + ICONS.get(name).cloned() +} + +fn mode_picker_icon(mode: Mode) -> Option> { + let source = match mode { + Mode::Instant => include_bytes!("../assets/icons/instant.svg") as &[u8], + Mode::Studio => include_bytes!("../assets/icons/film-cut.svg") as &[u8], + Mode::Screenshot => include_bytes!("../assets/icons/screenshot.svg") as &[u8], + }; + svg_icon_png(source, PICKER_ICON_SIZE) +} + +/// The SF Symbol the macOS menu draws for an action row, keyed by its action; +/// Windows renders [`Entry::Item`]'s raster instead. Rows outside this list +/// (the chrome rows, the mode rows of the pre-14 menu) stay bare on both. +#[cfg(target_os = "macos")] +fn row_symbol(item: &TrayItem) -> Option<&'static str> { + let window_symbol = if objc2::available!(macos = 26.0) { + "interface.window" + } else { + "macwindow" + }; + match item { + TrayItem::RecordDisplay | TrayItem::ScreenshotDisplay => Some("display"), + TrayItem::RecordWindow | TrayItem::ScreenshotWindow => Some(window_symbol), + TrayItem::RecordArea | TrayItem::ScreenshotArea => Some("rectangle.dashed"), + TrayItem::TakeScreenshot => Some("camera.on.rectangle"), + TrayItem::ImportMedia => Some("square.and.arrow.down"), + TrayItem::OpenSettings => Some("gear"), + _ => None, + } +} + +/// The Previous submenu's header glyph, macOS side. +#[cfg(target_os = "macos")] +const PREVIOUS_SYMBOL: &str = "clock.arrow.circlepath"; + impl Entry { fn item(title: impl Into, item: TrayItem) -> Self { Self::Item { @@ -102,6 +255,17 @@ impl Entry { item: Some(item), enabled: true, icon: None, + accelerator: None, + } + } + + fn icon_item(title: impl Into, item: TrayItem, icon: &str) -> Self { + Self::Item { + title: title.into(), + item: Some(item), + enabled: true, + icon: menu_icon(icon), + accelerator: None, } } @@ -113,6 +277,7 @@ impl Entry { item: None, enabled: false, icon: None, + accelerator: None, } } } @@ -265,12 +430,14 @@ fn previous_submenu(previous: &[PreviousItem]) -> Entry { return Entry::Submenu { title: tr::tray::previous().into(), enabled: false, + icon: menu_icon("previous"), items: vec![Entry::disabled(tr::tray::no_recent_items())], }; } Entry::Submenu { title: tr::tray::previous().into(), enabled: true, + icon: menu_icon("previous"), items: previous .iter() .map(|item| Entry::Item { @@ -282,11 +449,28 @@ fn previous_submenu(previous: &[PreviousItem]) -> Entry { item: Some(TrayItem::PreviousItem(item.path.clone())), enabled: true, icon: item.thumbnail.clone(), + accelerator: None, }) .collect(), } } +fn mode_name(mode: Mode) -> &'static str { + match mode { + Mode::Studio => tr::main::mode::name::studio(), + Mode::Instant => tr::main::mode::name::instant(), + Mode::Screenshot => tr::main::mode::name::screenshot(), + } +} + +fn mode_heading(mode: Mode) -> &'static str { + match mode { + Mode::Studio => tr::main::mode::header::studio(), + Mode::Instant => tr::main::mode::header::instant(), + Mode::Screenshot => tr::main::mode::header::screenshot(), + } +} + /// `create_mode_submenu`: a `✓ ` prefix on the current mode and three spaces on /// the others, so the labels stay aligned without a real check mark. fn mode_submenu(mode: Mode) -> Entry { @@ -307,6 +491,7 @@ fn mode_submenu(mode: Mode) -> Entry { Entry::Submenu { title: tr::tray::select_mode().into(), enabled: true, + icon: None, items: vec![ label(Mode::Studio, tr::tray::mode::studio()), label(Mode::Instant, tr::tray::mode::instant()), @@ -315,46 +500,98 @@ fn mode_submenu(mode: Mode) -> Entry { } } +fn capture_title(target: TargetType, screenshot: bool) -> &'static str { + match (target, screenshot) { + (TargetType::Display, true) => tr::tray::screenshot_display(), + (TargetType::Window, true) => tr::tray::screenshot_window(), + (TargetType::Area, true) => tr::tray::screenshot_area(), + (TargetType::Display, false) => tr::tray::record_display(), + (TargetType::Window, false) => tr::tray::record_window(), + (TargetType::Area, false) => tr::tray::record_area(), + (TargetType::CameraOnly, _) => "", + } +} + +fn capture_icon(target: TargetType) -> &'static str { + match target { + TargetType::Display => "display", + TargetType::Window => "window", + TargetType::Area => "area", + TargetType::CameraOnly => "display", + } +} + +fn capture_entry(target: TargetType, mode: Mode) -> Entry { + let screenshot = mode == Mode::Screenshot; + let (title, item) = match target { + TargetType::Display => ( + if screenshot { + tr::tray::screenshot_display() + } else { + tr::tray::record_display() + }, + if screenshot { + TrayItem::ScreenshotDisplay + } else { + TrayItem::RecordDisplay + }, + ), + TargetType::Window => ( + if screenshot { + tr::tray::screenshot_window() + } else { + tr::tray::record_window() + }, + if screenshot { + TrayItem::ScreenshotWindow + } else { + TrayItem::RecordWindow + }, + ), + TargetType::Area => ( + if screenshot { + tr::tray::screenshot_area() + } else { + tr::tray::record_area() + }, + if screenshot { + TrayItem::ScreenshotArea + } else { + TrayItem::RecordArea + }, + ), + TargetType::CameraOnly => unreachable!("capture slots are display, window and area"), + }; + Entry::icon_item(title, item, capture_icon(target)) +} + /// `build_tray_menu`, minus the onboarding-minimal branch. pub fn build_menu(mode: Mode, previous: &[PreviousItem], version: &str) -> Vec { - let mut entries = vec![Entry::item(tr::tray::open_main_window(), TrayItem::OpenCap)]; + let mut entries = Vec::new(); - if mode == Mode::Screenshot { - entries.push(Entry::item( - tr::tray::screenshot_display(), - TrayItem::RecordDisplay, - )); - entries.push(Entry::item( - tr::tray::screenshot_window(), - TrayItem::RecordWindow, - )); - entries.push(Entry::item( - tr::tray::screenshot_area(), - TrayItem::RecordArea, - )); + entries.push(Entry::item(tr::tray::open_main_window(), TrayItem::OpenCap)); + entries.push(Entry::Separator); + if cfg!(target_os = "macos") { + entries.push(Entry::ModePicker { mode }); + entries.push(Entry::Separator); } else { - entries.push(Entry::item( - tr::tray::record_display(), - TrayItem::RecordDisplay, - )); - entries.push(Entry::item( - tr::tray::record_window(), - TrayItem::RecordWindow, - )); - entries.push(Entry::item(tr::tray::record_area(), TrayItem::RecordArea)); - entries.push(Entry::item( + entries.push(mode_submenu(mode)); + entries.push(Entry::Separator); + } + + for target in [TargetType::Display, TargetType::Window, TargetType::Area] { + entries.push(capture_entry(target, mode)); + } + if mode != Mode::Screenshot { + entries.push(Entry::icon_item( tr::tray::take_screenshot(), TrayItem::TakeScreenshot, + "screenshot", )); } - entries.push(Entry::item(tr::tray::import_media(), TrayItem::ImportMedia)); - entries.push(Entry::Separator); - entries.push(mode_submenu(mode)); entries.push(previous_submenu(previous)); - entries.push(Entry::Separator); - entries.push(Entry::item( tr::tray::view_all_recordings(), TrayItem::ViewAllRecordings, @@ -363,14 +600,32 @@ pub fn build_menu(mode: Mode, previous: &[PreviousItem], version: &str) -> Vec Vec { ] } +/// The hotkey that performs a capture row's action. +fn hotkey_action(item: &TrayItem) -> Option { + match item { + TrayItem::RecordDisplay => Some(HotkeyAction::OpenRecordingPickerDisplay), + TrayItem::RecordWindow => Some(HotkeyAction::OpenRecordingPickerWindow), + TrayItem::RecordArea => Some(HotkeyAction::OpenRecordingPickerArea), + TrayItem::TakeScreenshot | TrayItem::ScreenshotDisplay => { + Some(HotkeyAction::ScreenshotDisplay) + } + TrayItem::ScreenshotWindow => Some(HotkeyAction::ScreenshotWindow), + TrayItem::ScreenshotArea => Some(HotkeyAction::ScreenshotArea), + _ => None, + } +} + +fn decorate_accelerators(entries: &mut [Entry], cx: &App) { + for entry in entries { + match entry { + Entry::Item { + item: Some(item), + accelerator, + .. + } => { + *accelerator = hotkey_action(item) + .and_then(|action| hotkeys::accelerator(action, cx)) + .map(Into::into); + } + Entry::Submenu { items, .. } => decorate_accelerators(items, cx), + _ => {} + } + } +} + fn current_menu_entries(cx: &App, mode: Mode, previous: &[PreviousItem]) -> Vec { if crate::app_windows::onboarding_is_open(cx) && !crate::permissions::necessary_granted() { return build_onboarding_menu(crate::menus::app_version()); } - build_menu(mode, previous, crate::menus::app_version()) + let mut entries = build_menu(mode, previous, crate::menus::app_version()); + decorate_accelerators(&mut entries, cx); + entries } // --------------------------------------------------------------------------- @@ -410,20 +700,19 @@ pub fn handle_item(item: TrayItem, cx: &mut App) { // the window (and the menu bar) to come forward. cx.activate(true); } - TrayItem::RecordDisplay => app_windows::arm_target_mode(TargetType::Display, cx), - TrayItem::RecordWindow => app_windows::arm_target_mode(TargetType::Window, cx), - TrayItem::RecordArea => app_windows::arm_target_mode(TargetType::Area, cx), - TrayItem::TakeScreenshot => { - // `TrayItem::TakeScreenshot` captures the display under the - // cursor, primary as the fallback (`tray.rs:816` over there). - let display = scap_targets::Display::get_containing_cursor() - .unwrap_or_else(scap_targets::Display::primary); - crate::screenshot::take_screenshot( - cap_recording::sources::screen_capture::ScreenCaptureTarget::Display { - id: display.id(), - }, - cx, - ); + TrayItem::RecordDisplay => record_target(TargetType::Display, cx), + TrayItem::RecordWindow => record_target(TargetType::Window, cx), + TrayItem::RecordArea => record_target(TargetType::Area, cx), + // `TrayItem::TakeScreenshot` captures the display under the cursor, + // primary as the fallback (`tray.rs:816` over there). + TrayItem::TakeScreenshot | TrayItem::ScreenshotDisplay => take_display_screenshot(cx), + TrayItem::ScreenshotWindow => { + set_mode(Mode::Screenshot, cx); + app_windows::arm_target_mode(TargetType::Window, cx); + } + TrayItem::ScreenshotArea => { + set_mode(Mode::Screenshot, cx); + app_windows::arm_target_mode(TargetType::Area, cx); } // `TrayItem::ImportVideo` (`src-tauri/src/tray.rs:839-911`): one // picker over both media filters, routed by extension. @@ -452,6 +741,22 @@ pub fn handle_item(item: TrayItem, cx: &mut App) { } } +fn record_target(target: TargetType, cx: &mut App) { + if Mode::from_store() == Mode::Screenshot { + set_mode(Mode::Studio, cx); + } + app_windows::arm_target_mode(target, cx); +} + +fn take_display_screenshot(cx: &mut App) { + let display = scap_targets::Display::get_containing_cursor() + .unwrap_or_else(scap_targets::Display::primary); + crate::screenshot::take_screenshot( + cap_recording::sources::screen_capture::ScreenCaptureTarget::Display { id: display.id() }, + cx, + ); +} + /// `handle_mode_selection`: persist, swap the icon, rebuild the menu. All three /// live in `MainWindow::set_mode` here, which every mode affordance funnels /// through, so this is `app_windows::set_recording_mode` and nothing else. @@ -464,15 +769,16 @@ fn set_mode(mode: Mode, cx: &mut App) { } /// `handle_previous_item_click`. -fn open_previous_item(path: PathBuf, cx: &mut App) { +pub(crate) fn open_previous_item(path: PathBuf, cx: &mut App) { use cap_project::{RecordingMeta, RecordingMetaInner}; let screenshots_dir = library::screenshots_dir(); - let is_screenshot = path.extension().and_then(|ext| ext.to_str()) == Some("cap") + let is_screenshot = (path.extension().and_then(|ext| ext.to_str()) == Some("cap") && path .parent() .map(|parent| parent == screenshots_dir) - .unwrap_or(false); + .unwrap_or(false)) + || library::is_screenshot_bundle(&path); if is_screenshot { app_windows::open_screenshot_editor(path, cx); @@ -519,13 +825,23 @@ fn stop_recording(cx: &mut App) { /// A flat, greppable rendering of the menu -- what the verification run diffs /// against `build_tray_menu`. Submenu rows are indented two spaces; a disabled -/// row is suffixed with ` [disabled]`; a row with a thumbnail with ` [icon]`. +/// row is suffixed with ` [disabled]`; a row with an image with ` [icon]`; +/// a section header with ` [section]`. pub fn describe_menu(entries: &[Entry]) -> String { fn walk(entries: &[Entry], depth: usize, out: &mut String) { for entry in entries { let pad = " ".repeat(depth); match entry { Entry::Separator => out.push_str(&format!("{pad}---\n")), + Entry::ModePicker { mode } => { + out.push_str(&format!("{pad}{}\n", mode_name(*mode))); + for name in ["Studio", "Instant", "Screenshot"] { + out.push_str(&format!("{}{name}\n", " ".repeat(depth + 1))); + } + } + Entry::SectionHeader { title } => { + out.push_str(&format!("{pad}{title} [section]\n")); + } Entry::Item { title, enabled, @@ -540,10 +856,12 @@ pub fn describe_menu(entries: &[Entry]) -> String { title, enabled, items, + icon, } => { out.push_str(&format!( - "{pad}{title}{}\n", - if *enabled { "" } else { " [disabled]" } + "{pad}{title}{}{}\n", + if *enabled { "" } else { " [disabled]" }, + if icon.is_some() { " [icon]" } else { "" } )); walk(items, depth + 1, out); } @@ -635,21 +953,35 @@ fn harness_item(spec: &str, cx: &App) -> Option { #[cfg(target_os = "macos")] pub use mac::*; +#[cfg(target_os = "macos")] +mod mac_mode_picker; + #[cfg(target_os = "macos")] mod mac { - use std::cell::RefCell; + use std::cell::{Cell, RefCell}; + + use std::ptr::NonNull; use gpui::{App, Global}; - use objc2::rc::Id; - use objc2::runtime::{AnyObject, Sel}; - use objc2::{ClassType, DeclaredClass, class, msg_send, msg_send_id, sel}; - use objc2_foundation::NSString; + use objc2::rc::Retained; + use objc2::runtime::{NSObjectProtocol, ProtocolObject, Sel}; + use objc2::{AnyThread, MainThreadOnly, define_class, msg_send, sel}; + use objc2_app_kit::{ + NSEvent, NSEventModifierFlags, NSEventTrackingRunLoopMode, NSImage, NSMenu, NSMenuDelegate, + NSMenuItem, NSStatusBar, NSStatusBarButton, NSStatusItem, + }; + use objc2_foundation::{MainThreadMarker, NSData, NSRunLoop, NSString, NSTimer}; + use super::mac_mode_picker::{ + append_mode_picker, clear_hover, clear_hover_unless, fit_to_menu_width, hover_from_pointer, + install_key_hook, stop_flash, + }; use super::{ - Entry, PreviousItem, STOP_ICON, TrayItem, current_menu_entries, handle_item, mode_icon, - scan_previous, stop_recording, + Accelerator, Entry, PREVIOUS_SYMBOL, PreviousItem, STOP_ICON, TrayItem, + current_menu_entries, handle_item, mode_icon, mode_submenu, row_symbol, scan_previous, + stop_recording, }; - use crate::main_window::Mode; + use crate::main_window::{Mode, TargetType}; /// `NSVariableStatusItemLength`. const NS_VARIABLE_STATUS_ITEM_LENGTH: f64 = -1.0; @@ -658,49 +990,98 @@ mod mac { const ICON_HEIGHT: f64 = 18.0; /// Not a menu item: the status button itself, clicked mid-recording. const STOP_TAG: isize = -1; + const REBUILD_TAG: isize = -2; + + #[derive(Clone, Copy, PartialEq, Eq)] + enum Intent { + Record, + Screenshot, + } + + /// One capture row, with both actions registered up front. A mode or + /// Option change only retargets the tag; the menu is never rebuilt while + /// it is open. + struct ActionRow { + item: Retained, + target: TargetType, + record_item: TrayItem, + screenshot_item: TrayItem, + record_tag: isize, + screenshot_tag: isize, + record_accelerator: Option, + screenshot_accelerator: Option, + } thread_local! { - /// The live sender, read by the ObjC action. `thread_local` for the - /// reason `COLOR_PANEL_TX` is: AppKit only calls the action on the main - /// thread, so it needs no lock. static TRAY_TX: RefCell>> = const { RefCell::new(None) }; + static MENU_OPEN: Cell = const { Cell::new(false) }; + static REBUILD_PENDING: Cell = const { Cell::new(false) }; + static OPTION_TIMER: RefCell>> = const { RefCell::new(None) }; + static OPTION_HELD: Cell = const { Cell::new(false) }; + static CURRENT_MODE: Cell> = const { Cell::new(None) }; + static ROWS: RefCell> = const { RefCell::new(Vec::new()) }; + static STATUS_ITEM: RefCell>> = const { RefCell::new(None) }; } - objc2::declare_class!( - /// The receiver for every tray action. No ivars: the tag identifies the - /// item and the sender lives in the thread-local above. + define_class!( + // SAFETY: NSObject has no subclassing requirements; this class has no ivars or Drop. + #[unsafe(super(objc2::runtime::NSObject))] + #[name = "CapGpuiTrayTarget"] + #[thread_kind = MainThreadOnly] struct TrayTarget; - unsafe impl ClassType for TrayTarget { - type Super = objc2::runtime::NSObject; - type Mutability = objc2::mutability::InteriorMutable; - const NAME: &'static str = "CapGpuiTrayTarget"; - } - - impl DeclaredClass for TrayTarget {} - - unsafe impl TrayTarget { + impl TrayTarget { /// Fires inside AppKit's menu-tracking run loop. Touching gpui here /// is what "RefCell already borrowed" looks like, so it only posts /// the tag. - #[method(capTrayItem:)] - fn tray_item(&self, sender: *mut AnyObject) { - if sender.is_null() { - return; - } - let tag: isize = unsafe { msg_send![sender, tag] }; - post(tag); + #[unsafe(method(capTrayItem:))] + fn tray_item(&self, sender: &NSMenuItem) { + post(sender.tag()); + } + + #[unsafe(method(capTrayMode:))] + fn tray_mode(&self, sender: &NSMenuItem) { + post(sender.tag()); } /// The status button's action while a recording is running. - #[method(capTrayStop:)] - fn tray_stop(&self, _sender: *mut AnyObject) { + #[unsafe(method(capTrayStop:))] + fn tray_stop(&self, _sender: &NSStatusBarButton) { post(STOP_TAG); } } + + unsafe impl NSObjectProtocol for TrayTarget {} + + unsafe impl NSMenuDelegate for TrayTarget { + #[unsafe(method(menu:willHighlightItem:))] + fn menu_will_highlight_item(&self, _menu: &NSMenu, item: Option<&NSMenuItem>) { + clear_hover_unless(item); + } + + #[unsafe(method(menuWillOpen:))] + fn menu_will_open(&self, menu: &NSMenu) { + MENU_OPEN.with(|o| o.set(true)); + fit_to_menu_width(menu.size().width); + clear_hover(); + hover_from_pointer(); + start_option_tracking(); + } + + #[unsafe(method(menuDidClose:))] + fn menu_did_close(&self, _menu: &NSMenu) { + MENU_OPEN.with(|o| o.set(false)); + clear_hover(); + stop_option_tracking(); + stop_flash(); + if REBUILD_PENDING.with(|p| p.replace(false)) { + post(REBUILD_TAG); + } + } + } ); - fn post(tag: isize) { + pub(super) fn post(tag: isize) { TRAY_TX.with(|tx| { if let Some(sender) = tx.borrow().as_ref() { let _ = sender.send(tag); @@ -709,11 +1090,11 @@ mod mac { } struct Tray { - status_item: Id, - target: Id, + status_item: Retained, + target: Retained, /// Retained so the menu can be detached (recording) and reattached /// (stopped) without rebuilding it. - menu: Option>, + menu: Option>, /// Tag -> item, rebuilt with the menu. The index *is* the tag. actions: Vec, mode: Mode, @@ -732,6 +1113,7 @@ mod mac { return; }; cx.set_global(tray); + install_key_hook(); let (tx, rx) = flume::unbounded::(); TRAY_TX.with(|slot| *slot.borrow_mut() = Some(tx)); @@ -741,9 +1123,16 @@ mod mac { cx.spawn(async move |cx| { while let Ok(tag) = rx.recv_async().await { cx.update(|cx| { - if tag == STOP_TAG { - stop_recording(cx); - return; + match tag { + STOP_TAG => { + stop_recording(cx); + return; + } + REBUILD_TAG => { + rebuild(cx); + return; + } + _ => {} } let item = cx.global::().actions.get(tag as usize).cloned(); match item { @@ -774,78 +1163,76 @@ mod mac { } fn create_status_item() -> Option { - unsafe { - let status_bar: *mut AnyObject = msg_send![class!(NSStatusBar), systemStatusBar]; - if status_bar.is_null() { - return None; - } - let raw: *mut AnyObject = - msg_send![status_bar, statusItemWithLength: NS_VARIABLE_STATUS_ITEM_LENGTH]; - // `statusItemWithLength:` hands back an autoreleased object owned - // by the status bar; retain it for as long as we hold it, and never - // release it -- removing a status item is what makes it disappear - // from the menu bar, and this one lives for the process. - let status_item = Id::retain(raw)?; - let target: Id = msg_send_id![TrayTarget::alloc(), init]; - - let mode = Mode::from_store(); - let tray = Tray { - status_item, - target, - menu: None, - actions: Vec::new(), - mode, - previous: Vec::new(), - previous_generation: 0, - recording: false, - }; - set_button_image(&tray, mode_icon(mode)); - let button: *mut AnyObject = msg_send![&*tray.status_item, button]; - let visible: bool = msg_send![&*tray.status_item, isVisible]; - let frame: objc2_foundation::NSRect = if button.is_null() { + let mtm = MainThreadMarker::new()?; + let status_item = + NSStatusBar::systemStatusBar().statusItemWithLength(NS_VARIABLE_STATUS_ITEM_LENGTH); + let target: Retained = unsafe { msg_send![TrayTarget::alloc(mtm), init] }; + + let mode = Mode::from_store(); + let tray = Tray { + status_item, + target, + menu: None, + actions: Vec::new(), + mode, + previous: Vec::new(), + previous_generation: 0, + recording: false, + }; + set_button_image(&tray, mode_icon(mode)); + let button = tray.status_item.button(mtm); + let frame = button.as_ref().map_or_else( + || { objc2_foundation::NSRect::new( - objc2_foundation::NSPoint::new(0., 0.), - objc2_foundation::NSSize::new(0., 0.), + objc2_foundation::NSPoint::new(0.0, 0.0), + objc2_foundation::NSSize::new(0.0, 0.0), ) - } else { - msg_send![button, frame] - }; - tracing::info!( - has_button = !button.is_null(), - visible, - width = frame.size.width, - height = frame.size.height, - ?mode, - "status item created" - ); - Some(tray) - } + }, + |button| button.frame(), + ); + + tracing::info!( + has_button = button.is_some(), + visible = tray.status_item.isVisible(), + width = frame.size.width, + height = frame.size.height, + ?mode, + "status item created" + ); + Some(tray) } /// `set_icon_for_ns_status_item_button`: an 18pt-tall template image on the /// status item's button. fn set_button_image(tray: &Tray, png: &[u8]) { - unsafe { - let button: *mut AnyObject = msg_send![&*tray.status_item, button]; - if button.is_null() { - return; + set_status_item_image(&tray.status_item, png); + } + + pub(super) fn set_status_mode(mode: Mode) { + STATUS_ITEM.with(|slot| { + if let Some(status_item) = slot.borrow().as_ref() { + set_status_item_image(status_item, mode_icon(mode)); } - let Some(image) = ns_image(png, ICON_HEIGHT) else { - return; - }; - let _: () = msg_send![button, setImage: &*image]; - // `icon_as_template(cfg!(target_os = "macos"))` in `create_tray`. - let _: () = msg_send![&*image, setTemplate: true]; - // The verification seam. A machine with a full menu bar parks - // overflow status items behind the notch, where they cannot be - // photographed -- so which icon is on the button is logged, keyed - // by the asset's byte length (each of the four differs). - tracing::info!( - icon = icon_name(png), - bytes = png.len(), - "status item icon set" - ); - } + }); + } + + fn set_status_item_image(status_item: &NSStatusItem, png: &[u8]) { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let Some(button) = status_item.button(mtm) else { + return; + }; + let Some(image) = ns_image(png, ICON_HEIGHT) else { + return; + }; + button.setImage(Some(&image)); + image.setTemplate(true); + tracing::info!( + icon = icon_name(png), + bytes = png.len(), + "status item icon set" + ); } fn icon_name(png: &[u8]) -> &'static str { @@ -866,143 +1253,520 @@ mod mac { /// An `NSImage` from PNG bytes, scaled to `height` points with its aspect /// preserved -- the conversion muda and tray-icon both do. - fn ns_image(png: &[u8], height: f64) -> Option> { - unsafe { - let data: *mut AnyObject = msg_send![ - class!(NSData), - dataWithBytes: png.as_ptr().cast::(), - length: png.len(), - ]; - if data.is_null() { - return None; - } - let alloc: *mut AnyObject = msg_send![class!(NSImage), alloc]; - let raw: *mut AnyObject = msg_send![alloc, initWithData: data]; - let image = Id::from_raw(raw)?; - let size: objc2_foundation::NSSize = msg_send![&*image, size]; - if size.height > 0.0 { - let width = size.width / (size.height / height); - let _: () = msg_send![ - &*image, - setSize: objc2_foundation::NSSize::new(width, height) - ]; - } - Some(image) + fn ns_image(bytes: &[u8], height: f64) -> Option> { + let data = NSData::with_bytes(bytes); + let image = NSImage::initWithData(NSImage::alloc(), &data)?; + let size = image.size(); + if size.height > 0.0 { + image.setSize(objc2_foundation::NSSize::new( + size.width / (size.height / height), + height, + )); } + Some(image) + } + + /// An SF Symbol at the size the system serves it (15-20pt, the range menu + /// icons live in) -- already a template image. The API only exists on + /// macOS 11+, and an unknown name comes back nil; both cases leave the + /// row bare rather than falling back to the raster, which Windows owns. + pub(super) fn symbol_image(name: &str) -> Option> { + if !objc2::available!(macos = 11.0) { + return None; + } + NSImage::imageWithSystemSymbolName_accessibilityDescription(&NSString::from_str(name), None) } /// Rebuild the menu from the current mode and Previous cache, and attach it /// (unless a recording is running, in which case the *button* owns the - /// click -- see [`set_recording`]). + /// click -- see [`set_recording`]). While the menu is open the rows are + /// retargeted in place instead; the menu is only rebuilt once it closes. pub fn rebuild(cx: &mut App) { + if MENU_OPEN.with(Cell::get) { + REBUILD_PENDING.with(|p| p.set(true)); + return; + } if !cx.has_global::() { return; } - let (mode, previous) = { + let (mode, previous, target) = { let tray = cx.global::(); - (tray.mode, tray.previous.clone()) + (tray.mode, tray.previous.clone(), tray.target.clone()) }; let entries = current_menu_entries(cx, mode, &previous); - let mut actions = Vec::new(); - let menu = { - let tray = cx.global::(); - build_ns_menu(&entries, &tray.target, &mut actions) - }; + let menu = build_ns_menu(&entries, &target, &mut actions, cx); + menu.setDelegate(Some(ProtocolObject::from_ref(&*target))); - let recording = cx.global::().recording; - unsafe { - if !recording { - let tray = cx.global::(); - let _: () = msg_send![&*tray.status_item, setMenu: &*menu]; - } + let tray = cx.global::(); + if !tray.recording { + tray.status_item.setMenu(Some(&menu)); } let tray = cx.global_mut::(); tray.actions = actions; tray.menu = Some(menu); + apply(mode, current_option()); } fn build_ns_menu( entries: &[Entry], target: &TrayTarget, actions: &mut Vec, - ) -> Id { - unsafe { - let alloc: *mut AnyObject = msg_send![class!(NSMenu), alloc]; - let raw: *mut AnyObject = msg_send![alloc, init]; - let menu = Id::from_raw(raw).expect("NSMenu init returned nil"); - // Without this AppKit decides an item's enabled state from whether - // its target responds to the action -- which ours always does, so - // the four deliberately-disabled rows would come back enabled. - let _: () = msg_send![&*menu, setAutoenablesItems: false]; - - for entry in entries { - let item = build_ns_item(entry, target, actions); - let _: () = msg_send![&*menu, addItem: &*item]; + cx: &App, + ) -> Retained { + let mtm = + MainThreadMarker::new().expect("tray menu construction must run on the main thread"); + let menu = NSMenu::new(mtm); + menu.setAutoenablesItems(false); + fill_ns_menu(&menu, entries, target, actions, cx); + menu + } + + fn fill_ns_menu( + menu: &NSMenu, + entries: &[Entry], + target: &TrayTarget, + actions: &mut Vec, + cx: &App, + ) { + let mtm = + MainThreadMarker::new().expect("tray menu construction must run on the main thread"); + menu.removeAllItems(); + ROWS.with(|rows| rows.borrow_mut().clear()); + + for entry in entries { + if let Entry::ModePicker { mode } = entry { + if objc2::available!(macos = 14.0) { + append_mode_picker(menu, *mode, actions, mtm); + } else { + let submenu = mode_submenu(*mode); + menu.addItem(&build_ns_item(&submenu, target, actions, mtm)); + } + continue; + } + + if let Entry::Item { + title, + item: Some(tray_item), + enabled, + .. + } = entry + && let Some((target_type, record_item, screenshot_item)) = capture_pair(tray_item) + { + let menu_item = new_menu_item(title, Some(sel!(capTrayItem:)), mtm); + unsafe { menu_item.setTarget(Some(target)) }; + menu_item.setEnabled(*enabled); + let record_tag = actions.len() as isize; + actions.push(record_item.clone()); + let screenshot_tag = actions.len() as isize; + actions.push(screenshot_item.clone()); + let record_accelerator = accelerator_for(&record_item, cx); + let screenshot_accelerator = accelerator_for(&screenshot_item, cx); + ROWS.with(|rows| { + rows.borrow_mut().push(ActionRow { + item: menu_item.clone(), + target: target_type, + record_item, + screenshot_item, + record_tag, + screenshot_tag, + record_accelerator, + screenshot_accelerator, + }); + }); + menu.addItem(&menu_item); + continue; } - menu + + let menu_item = build_ns_item(entry, target, actions, mtm); + menu.addItem(&menu_item); + } + } + + fn capture_pair(item: &TrayItem) -> Option<(TargetType, TrayItem, TrayItem)> { + match item { + TrayItem::RecordDisplay | TrayItem::ScreenshotDisplay => Some(( + TargetType::Display, + TrayItem::RecordDisplay, + TrayItem::ScreenshotDisplay, + )), + TrayItem::RecordWindow | TrayItem::ScreenshotWindow => Some(( + TargetType::Window, + TrayItem::RecordWindow, + TrayItem::ScreenshotWindow, + )), + TrayItem::RecordArea | TrayItem::ScreenshotArea => Some(( + TargetType::Area, + TrayItem::RecordArea, + TrayItem::ScreenshotArea, + )), + _ => None, + } + } + + fn accelerator_for(item: &TrayItem, cx: &App) -> Option { + super::hotkey_action(item) + .and_then(|action| crate::hotkeys::accelerator(action, cx)) + .map(Into::into) + } + + fn intent_for(mode: Mode, option: bool) -> Intent { + match (mode == Mode::Screenshot, option) { + (false, false) | (true, true) => Intent::Record, + (false, true) | (true, false) => Intent::Screenshot, + } + } + + pub(super) fn current_option() -> bool { + NSEvent::modifierFlags_class().contains(NSEventModifierFlags::Option) + } + + /// Retarget the four capture rows for `mode` and the Option key. Both + /// actions are already in `Tray::actions`, so swapping is only a tag + /// change. Native AppKit alternate items pair on a shared key equivalent + /// and different modifier masks; these rows need a different accelerator + /// per intent, so they cannot be a native pair. + pub(super) fn apply(mode: Mode, option: bool) { + CURRENT_MODE.set(Some(mode)); + let intent = intent_for(mode, option); + ROWS.with(|rows| { + let rows = rows.borrow(); + for row in rows.iter() { + let (title, item, tag, accelerator) = match intent { + Intent::Record => ( + super::capture_title(row.target, false), + &row.record_item, + row.record_tag, + row.record_accelerator, + ), + Intent::Screenshot => ( + super::capture_title(row.target, true), + &row.screenshot_item, + row.screenshot_tag, + row.screenshot_accelerator, + ), + }; + row.item.setTitle(&NSString::from_str(title)); + row.item.setTag(tag); + set_key_equivalent(&row.item, accelerator); + set_row_image(&row.item, item); + } + }); + } + + fn set_key_equivalent(item: &NSMenuItem, accelerator: Option) { + item.setKeyEquivalent(&NSString::from_str("")); + item.setKeyEquivalentModifierMask(NSEventModifierFlags::empty()); + if let Some(accelerator) = accelerator + && let Some(key) = key_equivalent(accelerator.code) + { + item.setKeyEquivalent(&NSString::from_str(&key)); + item.setKeyEquivalentModifierMask(modifier_flags(accelerator.modifiers)); + } + } + + fn set_row_image(item: &NSMenuItem, tray_item: &TrayItem) { + let Some(symbol) = row_symbol(tray_item) else { + return; + }; + let Some(image) = symbol_image(symbol) else { + tracing::warn!(symbol, "SF Symbol not resolved"); + return; + }; + image.setTemplate(true); + item.setImage(Some(&image)); + crate::platform::set_nsmenu_item_prefers_visible_image(item); + } + + pub(super) fn start_option_tracking() { + // Sync to the real modifier state first; the rows may have been stamped + // by a rebuild that ran while Option was still held. + let held = current_option(); + OPTION_HELD.set(held); + if let Some(mode) = CURRENT_MODE.get() { + apply(mode, held); } + + OPTION_TIMER.with(|slot| { + if slot.borrow().is_some() { + return; + } + let block = block2::RcBlock::new(|_timer: NonNull| { + let held = current_option(); + if OPTION_HELD.replace(held) != held + && let Some(mode) = CURRENT_MODE.get() + { + apply(mode, held); + refit_picker_to_menu(); + } + }); + let timer = unsafe { NSTimer::timerWithTimeInterval_repeats_block(0.05, true, &block) }; + let run_loop = NSRunLoop::currentRunLoop(); + unsafe { + run_loop.addTimer_forMode(&timer, NSEventTrackingRunLoopMode); + } + *slot.borrow_mut() = Some(timer); + }); + } + + pub(super) fn refit_picker_to_menu() { + ROWS.with(|rows| { + let rows = rows.borrow(); + let Some(row) = rows.first() else { + return; + }; + let Some(menu) = (unsafe { row.item.menu() }) else { + return; + }; + menu.update(); + super::mac_mode_picker::fit_to_menu_width(menu.size().width); + }); + } + + pub(super) fn stop_option_tracking() { + OPTION_TIMER.with(|slot| { + if let Some(timer) = slot.borrow_mut().take() { + timer.invalidate(); + } + }); + OPTION_HELD.set(false); } fn build_ns_item( entry: &Entry, target: &TrayTarget, actions: &mut Vec, - ) -> Id { - unsafe { - match entry { - Entry::Separator => { - let raw: *mut AnyObject = msg_send![class!(NSMenuItem), separatorItem]; - Id::retain(raw).expect("separatorItem returned nil") + mtm: MainThreadMarker, + ) -> Retained { + match entry { + Entry::Separator => NSMenuItem::separatorItem(mtm), + Entry::ModePicker { mode } => { + let submenu = mode_submenu(*mode); + build_ns_item(&submenu, target, actions, mtm) + } + Entry::Item { + title, + item, + enabled, + icon, + accelerator, + .. + } => { + let menu_item = new_menu_item(title, Some(sel!(capTrayItem:)), mtm); + unsafe { menu_item.setTarget(Some(target)) }; + menu_item.setEnabled(*enabled && item.is_some()); + if let Some(item) = item { + menu_item.setTag(actions.len() as isize); + actions.push(item.clone()); } - Entry::Item { - title, - item, - enabled, - icon, - } => { - let menu_item = new_menu_item(title, sel!(capTrayItem:)); - let _: () = msg_send![&*menu_item, setTarget: target]; - let _: () = msg_send![&*menu_item, setEnabled: *enabled && item.is_some()]; - if let Some(item) = item { - let tag = actions.len() as isize; - let _: () = msg_send![&*menu_item, setTag: tag]; - actions.push(item.clone()); + if let Some(accelerator) = accelerator + && let Some(key) = key_equivalent(accelerator.code) + { + menu_item.setKeyEquivalent(&NSString::from_str(&key)); + menu_item.setKeyEquivalentModifierMask(modifier_flags(accelerator.modifiers)); + } + if *item == Some(TrayItem::OpenSettings) { + menu_item.setKeyEquivalent(&NSString::from_str(",")); + menu_item.setKeyEquivalentModifierMask(NSEventModifierFlags::Command); + } + if *item == Some(TrayItem::Quit) { + // Lowercase: an uppercase equivalent implies Shift, which + // rendered this as ⇧⌘Q. + menu_item.setKeyEquivalent(&NSString::from_str("q")); + menu_item.setKeyEquivalentModifierMask(NSEventModifierFlags::Command); + } + if *item == Some(TrayItem::Quit) { + menu_item.setKeyEquivalent(&NSString::from_str("q")); + menu_item.setKeyEquivalentModifierMask(NSEventModifierFlags::Command); + } + // Previous's rows are photo thumbnails; every other iconed row + // is an SF Symbol, template-tinted by the menu. Windows draws + // `icon`'s raster instead. + let photo = matches!(item, Some(TrayItem::PreviousItem(_))); + let image = if photo { + let image = icon + .as_deref() + .and_then(|bytes| ns_image(bytes, ICON_HEIGHT)); + if image.is_none() { + tracing::warn!(%title, "no thumbnail for a previous row"); } - if let Some(icon) = icon - && let Some(image) = ns_image(icon, ICON_HEIGHT) - { - let _: () = msg_send![&*menu_item, setImage: &*image]; + image + } else if let Some(symbol) = item.as_ref().and_then(row_symbol) { + let image = symbol_image(symbol); + if image.is_none() { + tracing::warn!(%title, symbol, "SF Symbol not resolved"); } - menu_item + image + } else { + None + }; + + if let Some(image) = image { + image.setTemplate(!photo); + menu_item.setImage(Some(&image)); + crate::platform::set_nsmenu_item_prefers_visible_image(&menu_item); } - Entry::Submenu { - title, - enabled, - items, - } => { - let menu_item = new_menu_item(title, sel!(capTrayItem:)); - let submenu = build_ns_menu(items, target, actions); - let _: () = msg_send![&*menu_item, setSubmenu: &*submenu]; - let _: () = msg_send![&*menu_item, setEnabled: *enabled]; + menu_item + } + Entry::SectionHeader { title } => { + if objc2::available!(macos = 14.0) { + NSMenuItem::sectionHeaderWithTitle(&NSString::from_str(title), mtm) + } else { + let menu_item = NSMenuItem::new(mtm); + menu_item.setTitle(&NSString::from_str(title)); + menu_item.setEnabled(false); menu_item } } + Entry::Submenu { + title, + enabled, + items, + icon, + } => { + let menu_item = new_menu_item(title, Some(sel!(capTrayItem:)), mtm); + unsafe { menu_item.setTarget(Some(target)) }; + let submenu = NSMenu::new(mtm); + submenu.setAutoenablesItems(false); + for item in items { + submenu.addItem(&build_ns_item(item, target, actions, mtm)); + } + menu_item.setSubmenu(Some(&submenu)); + // Only Previous carries a header glyph; its own rows keep + // their photos, its header gets the symbol Windows draws the + // raster for. + if icon.is_some() + && let Some(image) = symbol_image(PREVIOUS_SYMBOL) + { + image.setTemplate(true); + menu_item.setImage(Some(&image)); + } + menu_item.setEnabled(*enabled); + menu_item + } } } - fn new_menu_item(title: &str, action: Sel) -> Id { - unsafe { - let alloc: *mut AnyObject = msg_send![class!(NSMenuItem), alloc]; - let raw: *mut AnyObject = msg_send![ - alloc, - initWithTitle: &*NSString::from_str(title), - action: action, - keyEquivalent: &*NSString::from_str(""), - ]; - Id::from_raw(raw).expect("NSMenuItem init returned nil") + pub(super) fn modifier_flags(modifiers: super::Modifiers) -> NSEventModifierFlags { + let mut flags = NSEventModifierFlags::empty(); + if modifiers.contains(super::Modifiers::SUPER) { + flags |= NSEventModifierFlags::Command; } + if modifiers.contains(super::Modifiers::CONTROL) { + flags |= NSEventModifierFlags::Control; + } + if modifiers.contains(super::Modifiers::ALT) { + flags |= NSEventModifierFlags::Option; + } + if modifiers.contains(super::Modifiers::SHIFT) { + flags |= NSEventModifierFlags::Shift; + } + flags + } + + /// The character AppKit wants for a menu key equivalent. Function keys use + /// the `NSF1FunctionKey`..`NSF35FunctionKey` private-use range. + pub(super) fn key_equivalent(code: super::Code) -> Option { + use super::Code; + + let character = match code { + Code::KeyA => "a", + Code::KeyB => "b", + Code::KeyC => "c", + Code::KeyD => "d", + Code::KeyE => "e", + Code::KeyF => "f", + Code::KeyG => "g", + Code::KeyH => "h", + Code::KeyI => "i", + Code::KeyJ => "j", + Code::KeyK => "k", + Code::KeyL => "l", + Code::KeyM => "m", + Code::KeyN => "n", + Code::KeyO => "o", + Code::KeyP => "p", + Code::KeyQ => "q", + Code::KeyR => "r", + Code::KeyS => "s", + Code::KeyT => "t", + Code::KeyU => "u", + Code::KeyV => "v", + Code::KeyW => "w", + Code::KeyX => "x", + Code::KeyY => "y", + Code::KeyZ => "z", + Code::Digit0 => "0", + Code::Digit1 => "1", + Code::Digit2 => "2", + Code::Digit3 => "3", + Code::Digit4 => "4", + Code::Digit5 => "5", + Code::Digit6 => "6", + Code::Digit7 => "7", + Code::Digit8 => "8", + Code::Digit9 => "9", + Code::Backquote => "`", + Code::Backslash => "\\", + Code::BracketLeft => "[", + Code::BracketRight => "]", + Code::Comma => ",", + Code::Equal => "=", + Code::Minus => "-", + Code::Period => ".", + Code::Quote => "'", + Code::Semicolon => ";", + Code::Slash => "/", + Code::Space => " ", + Code::Enter => "\r", + Code::Tab => "\t", + Code::Escape => "\u{1b}", + Code::Backspace => "\u{8}", + Code::Delete => "\u{7f}", + Code::ArrowUp => "\u{f700}", + Code::ArrowDown => "\u{f701}", + Code::ArrowLeft => "\u{f702}", + Code::ArrowRight => "\u{f703}", + Code::Home => "\u{f729}", + Code::End => "\u{f72b}", + Code::PageUp => "\u{f72c}", + Code::PageDown => "\u{f72d}", + Code::F1 => "\u{f704}", + Code::F2 => "\u{f705}", + Code::F3 => "\u{f706}", + Code::F4 => "\u{f707}", + Code::F5 => "\u{f708}", + Code::F6 => "\u{f709}", + Code::F7 => "\u{f70a}", + Code::F8 => "\u{f70b}", + Code::F9 => "\u{f70c}", + Code::F10 => "\u{f70d}", + Code::F11 => "\u{f70e}", + Code::F12 => "\u{f70f}", + Code::F13 => "\u{f710}", + Code::F14 => "\u{f711}", + Code::F15 => "\u{f712}", + Code::F16 => "\u{f713}", + Code::F17 => "\u{f714}", + Code::F18 => "\u{f715}", + Code::F19 => "\u{f716}", + Code::F20 => "\u{f717}", + _ => return None, + }; + Some(character.to_string()) + } + + fn new_menu_item( + title: &str, + action: Option, + mtm: MainThreadMarker, + ) -> Retained { + let item = NSMenuItem::new(mtm); + item.setTitle(&NSString::from_str(title)); + unsafe { item.setAction(action) }; + item.setKeyEquivalent(&NSString::from_str("")); + item } /// `RecordingStarted` / `RecordingStopped`. @@ -1018,27 +1782,24 @@ mod mac { } cx.global_mut::().recording = recording; let tray = cx.global::(); - unsafe { - let button: *mut AnyObject = msg_send![&*tray.status_item, button]; - if recording { - let _: () = - msg_send![&*tray.status_item, setMenu: std::ptr::null_mut::()]; - if !button.is_null() { - let _: () = msg_send![button, setTarget: &*tray.target]; - let _: () = msg_send![button, setAction: Some(sel!(capTrayStop:))]; - } - set_button_image(tray, STOP_ICON); - } else { - if !button.is_null() { - let _: () = msg_send![button, setAction: None::]; - let _: () = msg_send![button, setTarget: std::ptr::null_mut::()]; - } - if let Some(menu) = &tray.menu { - let _: () = msg_send![&*tray.status_item, setMenu: &**menu]; - } - let icon = mode_icon(tray.mode); - set_button_image(tray, icon); + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let button = tray.status_item.button(mtm); + if recording { + tray.status_item.setMenu(None); + if let Some(button) = button { + unsafe { button.setTarget(Some(&*tray.target)) }; + unsafe { button.setAction(Some(sel!(capTrayStop:))) }; + } + set_button_image(tray, STOP_ICON); + } else { + if let Some(button) = button { + unsafe { button.setAction(None) }; + unsafe { button.setTarget(None) }; } + tray.status_item.setMenu(tray.menu.as_deref()); + set_button_image(tray, mode_icon(tray.mode)); } tracing::info!(recording, "tray recording state"); } @@ -1395,6 +2156,8 @@ mod tests { .iter() .map(|entry| match entry { Entry::Separator => "-".to_string(), + Entry::ModePicker { mode } => mode_name(*mode).to_string(), + Entry::SectionHeader { title } => title.clone(), Entry::Item { title, .. } => title.clone(), Entry::Submenu { title, .. } => title.clone(), }) @@ -1413,28 +2176,37 @@ mod tests { #[test] fn menu_matches_build_tray_menu() { let menu = build_menu(Mode::Studio, &[], "0.1.0"); - assert_eq!( - titles(&menu), - vec![ - "Open Main Window", - "Record Display", - "Record Window", - "Record Area", - "Take a Screenshot", - "Import Media...", - "-", - "Select Mode", - "Previous", - "-", - "View all recordings", - "View all screenshots", - "Settings", - "-", - "Upload Logs", - "Cap v0.1.0", - "Quit Cap", - ] - ); + // macOS renders the group as a section header naming the current + // mode; the `mode_submenu` other platforms get keeps its group label. + let mode_label = if cfg!(target_os = "macos") { + "Studio" + } else { + "Select Mode" + }; + let mut expected = vec!["Open Main Window", "-", mode_label, "-"]; + expected.extend([ + "Record Display", + "Record Window", + "Record Area", + "Take a Screenshot", + "-", + "Previous", + "View all recordings", + "View all screenshots", + "-", + "Import Media...", + "Settings", + "Upload Logs", + "-", + ]); + // The version row is a section header below Quit on macOS and a + // disabled row above it everywhere else. + if cfg!(target_os = "macos") { + expected.extend(["Quit Cap", "Cap v0.1.0"]); + } else { + expected.extend(["Cap v0.1.0", "Quit Cap"]); + } + assert_eq!(titles(&menu), expected); } /// Screenshot mode relabels the three target rows and drops "Take a @@ -1443,15 +2215,20 @@ mod tests { fn screenshot_mode_relabels_the_capture_rows() { let menu = build_menu(Mode::Screenshot, &[], "0.1.0"); let titles = titles(&menu); + let mode_label = if cfg!(target_os = "macos") { + "Screenshot" + } else { + "Select Mode" + }; assert_eq!( &titles[..6], &[ "Open Main Window", + "-", + mode_label, + "-", "Screenshot Display", "Screenshot Window", - "Screenshot Area", - "Import Media...", - "-", ] ); assert!(!titles.iter().any(|title| title == "Take a Screenshot")); @@ -1474,7 +2251,7 @@ mod tests { ), ] { let Entry::Submenu { items, .. } = mode_submenu(mode) else { - panic!("Select Mode is a submenu"); + panic!("the mode picker is a submenu"); }; assert_eq!(titles(&items), expected.to_vec()); } @@ -1486,6 +2263,7 @@ mod tests { title, enabled, items, + .. } = previous_submenu(&[]) else { panic!("Previous is a submenu"); @@ -1543,6 +2321,134 @@ mod tests { _ => None, }) .collect(); - assert_eq!(disabled, vec!["Upload Logs", "Cap v0.1.0"]); + assert_eq!( + disabled, + if cfg!(target_os = "macos") { + vec!["Upload Logs"] + } else { + vec!["Upload Logs", "Cap v0.1.0"] + } + ); + } + #[test] + fn capture_rows_have_one_entry_per_action() { + let rows: Vec<_> = build_menu(Mode::Studio, &[], "0.1.0") + .into_iter() + .filter_map(|entry| match entry { + Entry::Item { + title, + item: Some(item), + .. + } if matches!( + item, + TrayItem::RecordDisplay + | TrayItem::RecordWindow + | TrayItem::RecordArea + | TrayItem::ScreenshotDisplay + | TrayItem::ScreenshotWindow + | TrayItem::ScreenshotArea + ) => + { + Some((title, item)) + } + _ => None, + }) + .collect(); + assert_eq!( + rows, + vec![ + ("Record Display".to_string(), TrayItem::RecordDisplay), + ("Record Window".to_string(), TrayItem::RecordWindow), + ("Record Area".to_string(), TrayItem::RecordArea), + ] + ); + + let rows: Vec<_> = build_menu(Mode::Screenshot, &[], "0.1.0") + .into_iter() + .filter_map(|entry| match entry { + Entry::Item { + title, + item: Some(item), + .. + } if matches!( + item, + TrayItem::RecordDisplay + | TrayItem::RecordWindow + | TrayItem::RecordArea + | TrayItem::ScreenshotDisplay + | TrayItem::ScreenshotWindow + | TrayItem::ScreenshotArea + ) => + { + Some((title, item)) + } + _ => None, + }) + .collect(); + assert_eq!( + rows, + vec![ + ( + "Screenshot Display".to_string(), + TrayItem::ScreenshotDisplay + ), + ("Screenshot Window".to_string(), TrayItem::ScreenshotWindow), + ("Screenshot Area".to_string(), TrayItem::ScreenshotArea), + ] + ); + } + + #[test] + fn screenshot_mode_drops_take_a_screenshot_and_recording_mode_keeps_it() { + let has = |mode| { + build_menu(mode, &[], "0.1.0").iter().any(|entry| { + matches!( + entry, + Entry::Item { + item: Some(TrayItem::TakeScreenshot), + .. + } + ) + }) + }; + assert!(has(Mode::Studio)); + assert!(has(Mode::Instant)); + assert!(!has(Mode::Screenshot)); + } + + #[test] + fn capture_rows_look_up_their_own_hotkey() { + assert_eq!( + hotkey_action(&TrayItem::RecordDisplay), + Some(HotkeyAction::OpenRecordingPickerDisplay) + ); + assert_eq!( + hotkey_action(&TrayItem::ScreenshotDisplay), + Some(HotkeyAction::ScreenshotDisplay) + ); + assert_eq!( + hotkey_action(&TrayItem::TakeScreenshot), + Some(HotkeyAction::ScreenshotDisplay) + ); + assert_eq!( + hotkey_action(&TrayItem::ScreenshotWindow), + Some(HotkeyAction::ScreenshotWindow) + ); + assert_eq!( + hotkey_action(&TrayItem::RecordWindow), + Some(HotkeyAction::OpenRecordingPickerWindow) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn accelerators_render_as_appkit_key_equivalents() { + let flags = mac::modifier_flags(Modifiers::SUPER | Modifiers::SHIFT); + assert!(flags.contains(objc2_app_kit::NSEventModifierFlags::Command)); + assert!(flags.contains(objc2_app_kit::NSEventModifierFlags::Shift)); + assert_eq!(mac::key_equivalent(Code::Digit4).as_deref(), Some("4")); + assert_eq!(mac::key_equivalent(Code::KeyS).as_deref(), Some("s")); + assert_eq!(mac::key_equivalent(Code::F1).as_deref(), Some("\u{f704}")); + assert_eq!(mac::key_equivalent(Code::MetaLeft), None); } } diff --git a/apps/desktop-gpui/src/tray/mac_mode_picker.rs b/apps/desktop-gpui/src/tray/mac_mode_picker.rs new file mode 100644 index 00000000000..843c9a0698f --- /dev/null +++ b/apps/desktop-gpui/src/tray/mac_mode_picker.rs @@ -0,0 +1,771 @@ +//! The mode picker row: the current mode's heading on the left, a capsule of pills on the right. +//! +//! Layers (`CALayer`) draw the capsule and pills because only they have continuous +//! corners; icons are image views above them, and `drawRect:` draws just the text. +//! Nothing in here touches gpui: a pick only posts a tag through [`post`], the same +//! seam every other tray action uses. + +use std::cell::{Cell, RefCell}; +use std::ptr::NonNull; + +use block2::RcBlock; +use objc2::rc::Retained; +use objc2::runtime::{AnyClass, AnyObject, Bool, Sel}; +use objc2::{AnyThread, DefinedClass, MainThreadOnly, define_class, msg_send, sel}; +use objc2_app_kit::{ + NSAccessibility, NSAccessibilityElement, NSAccessibilityRadioButtonRole, + NSAccessibilityRadioGroupRole, NSAppearanceCustomization, NSAutoresizingMaskOptions, + NSBitmapImageRep, NSColor, NSEvent, NSEventTrackingRunLoopMode, NSEventType, NSFont, + NSFontAttributeName, NSFontManager, NSFontTraitMask, NSFontWeightMedium, + NSForegroundColorAttributeName, NSImage, NSImageScaling, NSImageView, NSMenu, NSMenuItem, + NSStringDrawing, NSTrackingArea, NSTrackingAreaOptions, NSView, NSWindow, +}; +use objc2_foundation::{ + MainThreadMarker, NSArray, NSData, NSDictionary, NSPoint, NSRect, NSRunLoop, NSSize, NSString, + NSTimer, +}; +use objc2_quartz_core::{CALayer, CATransaction, kCACornerCurveContinuous}; + +use super::mac::{post, set_status_mode}; +use super::{TrayItem, mode_heading, mode_name, mode_picker_icon}; +use crate::i18n::tr; +use crate::main_window::Mode; + +const PICKER_MODES: [(Mode, TrayItem); 3] = [ + (Mode::Instant, TrayItem::ModeInstant), + (Mode::Studio, TrayItem::ModeStudio), + (Mode::Screenshot, TrayItem::ModeScreenshot), +]; + +const FLASH_INTERVAL: f64 = 0.08; +const FLASH_ON_PICK: bool = true; + +const PICKER_MIN_W: f64 = 300.0; +/// Clear space between the widest heading and the capsule. +const HEADING_GAP: f64 = 12.0; +const PICKER_H: f64 = 41.0; +const PILL_W: f64 = 42.0; +const PILL_H: f64 = 29.0; +const PILL_GAP: f64 = 4.0; +const CAPSULE_PAD: f64 = 3.0; +const RIGHT_INSET: f64 = 12.0; +const TITLE_INSET: f64 = 18.0; +const SYMBOL_SIZE: f64 = 17.0; +const PILL_RADIUS: f64 = 14.0; +const CAPSULE_RADIUS: f64 = 17.0; +/// Optical nudge for a pill's glyph, left of its centred position. +const STUDIO_ICON_NUDGE: f64 = -1.0; + +thread_local! { + /// The live picker and its menu item, read by the key hook. Set on every rebuild. + static PICKER: RefCell, Retained)>> = + const { RefCell::new(None) }; + static FLASH_TIMER: RefCell>> = const { RefCell::new(None) }; +} + +struct PillA11yIvars { + index: usize, +} + +define_class!( + // SAFETY: NSAccessibilityElement has no subclassing requirements; ivars hold a plain index. + #[unsafe(super(NSAccessibilityElement))] + #[name = "CapModePillAccessibilityElement"] + #[ivars = PillA11yIvars] + struct PillAccessibilityElement; + + impl PillAccessibilityElement { + #[unsafe(method(accessibilityPerformPress))] + fn accessibility_perform_press(&self) -> bool { + let index = self.ivars().index; + PICKER.with(|p| { + if let Some((_, view)) = p.borrow().as_ref() { + view.choose(index); + } + }); + true + } + } +); + +impl PillAccessibilityElement { + fn new(index: usize, label: &str) -> Retained { + let this = ::alloc().set_ivars(PillA11yIvars { index }); + let this: Retained = unsafe { msg_send![super(this), init] }; + this.setAccessibilityRole(Some(unsafe { NSAccessibilityRadioButtonRole })); + this.setAccessibilityLabel(Some(&NSString::from_str(label))); + this.setAccessibilityEnabled(true); + this + } +} + +struct PickerIvars { + selected: Cell, + focused: Cell>, + flash: Cell, + pressed: Cell, + /// `actions` index of the first pill; pill `i` posts `tag_base + i`. + tag_base: isize, + capsule: RefCell>>, + pills: RefCell>>, + icons: RefCell>>, + tracking: RefCell>>, + accessibility_children: RefCell>>, +} + +define_class!( + // SAFETY: NSView has no extra subclassing requirements; the ivars only log on drop. + #[unsafe(super(NSView))] + #[name = "CapModePickerView"] + #[thread_kind = MainThreadOnly] + #[ivars = PickerIvars] + struct ModePickerView; + + impl ModePickerView { + #[unsafe(method(acceptsFirstMouse:))] + fn accepts_first_mouse(&self, _event: Option<&NSEvent>) -> bool { + true + } + + #[unsafe(method(viewWillMoveToWindow:))] + fn view_will_move_to_window(&self, new_window: Option<&NSWindow>) { + unsafe { + let _: () = msg_send![super(self), viewWillMoveToWindow: new_window]; + } + if new_window.is_none() + && let Some(area) = self.ivars().tracking.borrow_mut().take() + { + self.removeTrackingArea(&area); + } + } + + #[unsafe(method(viewDidMoveToWindow))] + fn view_did_move_to_window(&self) { + unsafe { + let _: () = msg_send![super(self), viewDidMoveToWindow]; + } + if let Some(window) = self.window() { + window.setAcceptsMouseMovedEvents(true); + self.install_tracking_area(); + } + self.sync(); + self.hover_from_pointer(); + } + + #[unsafe(method(updateTrackingAreas))] + fn update_tracking_areas(&self) { + unsafe { + let _: () = msg_send![super(self), updateTrackingAreas]; + } + if let Some(area) = self.ivars().tracking.borrow_mut().take() { + self.removeTrackingArea(&area); + } + self.install_tracking_area(); + } + + #[unsafe(method(viewDidChangeEffectiveAppearance))] + fn view_did_change_effective_appearance(&self) { + unsafe { + let _: () = msg_send![super(self), viewDidChangeEffectiveAppearance]; + } + self.sync(); + } + + #[unsafe(method(mouseMoved:))] + fn mouse_moved(&self, event: &NSEvent) { + self.update_focus(self.pill_at(event)); + } + + #[unsafe(method(mouseExited:))] + fn mouse_exited(&self, _event: &NSEvent) { + if self.ivars().pressed.get() { + return; + } + self.ivars().focused.set(None); + self.refresh(); + } + + #[unsafe(method(mouseDown:))] + fn mouse_down(&self, event: &NSEvent) { + self.ivars().pressed.set(true); + self.update_focus(self.pill_at(event)); + } + + #[unsafe(method(mouseDragged:))] + fn mouse_dragged(&self, event: &NSEvent) { + self.update_focus(self.pill_at(event)); + } + + #[unsafe(method(mouseUp:))] + fn mouse_up(&self, event: &NSEvent) { + self.ivars().pressed.set(false); + // The release point chooses the pill; a release outside the menu + // changes nothing, so the shown and applied modes stay in sync. + match self.pill_at(event) { + Some(i) => self.choose(i), + None => self.update_focus(None), + } + } + + #[unsafe(method(drawRect:))] + fn draw_rect(&self, _dirty: NSRect) { + self.draw_text(); + } + } +); + +impl ModePickerView { + fn new(mtm: MainThreadMarker, selected: usize, tag_base: isize) -> Retained { + let this = mtm.alloc::().set_ivars(PickerIvars { + selected: Cell::new(selected), + focused: Cell::new(None), + flash: Cell::new(false), + pressed: Cell::new(false), + tag_base, + capsule: RefCell::new(None), + pills: RefCell::new(Vec::new()), + icons: RefCell::new(Vec::new()), + tracking: RefCell::new(None), + accessibility_children: RefCell::new(Vec::new()), + }); + let frame = NSRect::new( + NSPoint::new(0.0, 0.0), + NSSize::new(picker_width(), PICKER_H), + ); + // Initialise first; everything below touches the live view. + let this: Retained = unsafe { msg_send![super(this), initWithFrame: frame] }; + + this.setAutoresizingMask(NSAutoresizingMaskOptions::ViewWidthSizable); + this.setWantsLayer(true); + let root = this.layer().expect("layer-backed view"); + let make_layer = |radius: f64| -> Retained { + let layer = CALayer::layer(); + layer.setCornerCurve(unsafe { kCACornerCurveContinuous }); + layer.setCornerRadius(radius); + root.addSublayer(&layer); + layer + }; + *this.ivars().capsule.borrow_mut() = Some(make_layer(CAPSULE_RADIUS)); + for _ in PICKER_MODES.iter() { + this.ivars() + .pills + .borrow_mut() + .push(make_layer(PILL_RADIUS)); + } + + for (mode, _) in PICKER_MODES.iter() { + let view = NSImageView::new(mtm); + if let Some(bytes) = mode_picker_icon(*mode) + && let Some(rep) = NSBitmapImageRep::initWithData( + NSBitmapImageRep::alloc(), + &NSData::with_bytes(&bytes), + ) + { + let size = NSSize::new(SYMBOL_SIZE, SYMBOL_SIZE); + rep.setSize(size); + let image = NSImage::initWithSize(NSImage::alloc(), size); + image.addRepresentation(&rep); + image.setTemplate(true); + view.setImage(Some(&image)); + } + view.setImageScaling(NSImageScaling::ScaleNone); + this.addSubview(&view); + this.ivars().icons.borrow_mut().push(view); + } + + this.setAccessibilityRole(Some(unsafe { NSAccessibilityRadioGroupRole })); + this.setAccessibilityLabel(Some(&NSString::from_str(tr::tray::recording_mode()))); + let children: Vec> = PICKER_MODES + .iter() + .enumerate() + .map(|(index, (mode, _))| { + let child = PillAccessibilityElement::new(index, mode_name(*mode)); + unsafe { Retained::cast_unchecked::(child) } + }) + .collect(); + let children_array = NSArray::from_retained_slice(&children); + unsafe { this.setAccessibilityChildren(Some(&children_array)) }; + *this.ivars().accessibility_children.borrow_mut() = children + .into_iter() + .map(|child| unsafe { Retained::cast_unchecked::(child) }) + .collect(); + + this.sync(); + this + } + + fn install_tracking_area(&self) { + let options = NSTrackingAreaOptions::MouseMoved + | NSTrackingAreaOptions::MouseEnteredAndExited + | NSTrackingAreaOptions::ActiveAlways + | NSTrackingAreaOptions::InVisibleRect; + let area = unsafe { + NSTrackingArea::initWithRect_options_owner_userInfo( + NSTrackingArea::alloc(), + NSRect::ZERO, + options, + Some(self), + None, + ) + }; + self.addTrackingArea(&area); + *self.ivars().tracking.borrow_mut() = Some(area); + } + + fn update_focus(&self, hit: Option) { + if self.ivars().focused.get() != hit { + self.ivars().focused.set(hit); + self.refresh(); + } + } + + fn clear_focus(&self) { + if self.ivars().pressed.get() { + return; + } + self.update_focus(None); + } + + // -- geometry ---------------------------------------------------------- + + fn capsule_rect(&self) -> NSRect { + let w = capsule_width(); + let h = PILL_H + 2.0 * CAPSULE_PAD; + let bounds = self.bounds(); + NSRect::new( + NSPoint::new( + bounds.size.width - RIGHT_INSET - w, + (bounds.size.height - h) / 2.0, + ), + NSSize::new(w, h), + ) + } + + fn pill_rect(&self, i: usize) -> NSRect { + let c = self.capsule_rect(); + NSRect::new( + NSPoint::new( + c.origin.x + CAPSULE_PAD + i as f64 * (PILL_W + PILL_GAP), + c.origin.y + CAPSULE_PAD, + ), + NSSize::new(PILL_W, PILL_H), + ) + } + + fn icon_rect(&self, i: usize) -> NSRect { + let mut r = self.pill_rect(i); + if PICKER_MODES[i].0 == Mode::Studio { + r.origin.x += STUDIO_ICON_NUDGE; + } + r + } + + fn pill_at(&self, event: &NSEvent) -> Option { + self.pill_at_point(self.convertPoint_fromView(event.locationInWindow(), None)) + } + + fn pill_at_point(&self, p: NSPoint) -> Option { + (0..PICKER_MODES.len()).find(|&i| { + let r = self.pill_rect(i); + p.x >= r.origin.x - PILL_GAP / 2.0 + && p.x <= r.origin.x + r.size.width + PILL_GAP / 2.0 + && p.y >= r.origin.y + && p.y <= r.origin.y + r.size.height + }) + } + + fn hover_from_pointer(&self) { + let Some(window) = self.window() else { + return; + }; + let point = self.convertPoint_fromView(window.mouseLocationOutsideOfEventStream(), None); + let hit = self.pill_at_point(point); + if self.ivars().focused.get() != hit { + self.ivars().focused.set(hit); + self.refresh(); + } + } + + // -- state -> visuals -------------------------------------------------- + + /// Layers and icons for the current state, then the text. + fn refresh(&self) { + self.sync(); + self.setNeedsDisplay(true); + } + + /// The only place state becomes layer properties. + fn sync(&self) { + let appearance = self.effectiveAppearance(); + let block = RcBlock::new(|| self.apply_visuals()); + appearance.performAsCurrentDrawingAppearance(&block); + } + + fn apply_visuals(&self) { + let selected = self.ivars().selected.get(); + let focused = self.ivars().focused.get(); + let label = NSColor::labelColor(); + CATransaction::begin(); + CATransaction::setDisableActions(true); + + if let Some(cap) = self.ivars().capsule.borrow().as_ref() { + cap.setFrame(self.capsule_rect()); + let color = label.colorWithAlphaComponent(0.08).CGColor(); + cap.setBackgroundColor(Some(&color)); + } + + let accent = NSColor::controlAccentColor(); + let white = NSColor::whiteColor(); + let flash = self.ivars().flash.get(); + let icons = self.ivars().icons.borrow(); + for (i, child) in self + .ivars() + .accessibility_children + .borrow() + .iter() + .enumerate() + { + child.setAccessibilitySelected(i == selected); + if let Some(window) = self.window() { + let rect = self.convertRect_toView(self.pill_rect(i), None); + child.setAccessibilityFrame(window.convertRectToScreen(rect)); + } + } + for (i, pill) in self.ivars().pills.borrow().iter().enumerate() { + let is_sel = i == selected; + let is_hover = focused == Some(i) && !is_sel; + let fill = if is_sel { + if flash { + accent.clone() + } else { + white.colorWithAlphaComponent(0.95) + } + } else if is_hover { + label.colorWithAlphaComponent(0.14) + } else { + NSColor::clearColor() + }; + let r = self.pill_rect(i); + pill.setFrame(r); + let color = fill.CGColor(); + pill.setBackgroundColor(Some(&color)); + + if let Some(icon) = icons.get(i) { + icon.setFrame(self.icon_rect(i)); + let tint = if is_sel { + if flash { white.clone() } else { accent.clone() } + } else { + label.clone() + }; + icon.setContentTintColor(Some(&tint)); + } + } + CATransaction::commit(); + } + + // -- drawing: text only ------------------------------------------------ + + fn draw_text(&self) { + let selected = self.ivars().selected.get(); + let focused = self.ivars().focused.get(); + let shown = focused.unwrap_or(selected); + + let mut font = heading_font(); + if shown != selected { + font = italic(&font); + } + + let secondary = NSColor::secondaryLabelColor(); + let subtitle = NSString::from_str(mode_heading(PICKER_MODES[shown].0)); + let attrs = NSDictionary::from_slices( + &[unsafe { NSFontAttributeName }, unsafe { + NSForegroundColorAttributeName + }], + &[&*font as &AnyObject, &*secondary as &AnyObject], + ); + + let size = unsafe { subtitle.sizeWithAttributes(Some(&attrs)) }; + let y = (self.bounds().size.height - size.height) / 2.0; + unsafe { + subtitle.drawAtPoint_withAttributes(NSPoint::new(TITLE_INSET, y), Some(&attrs)); + } + } + + // -- actions ----------------------------------------------------------- + + /// Selects pill `i`, gives it one AppKit-style highlight pulse, then closes + /// the menu. + fn choose(&self, i: usize) { + if FLASH_TIMER.with(|timer| timer.borrow().is_some()) { + return; + } + let mode = PICKER_MODES[i].0; + self.ivars().selected.set(i); + self.ivars().focused.set(Some(i)); + self.refresh(); + post(self.ivars().tag_base + i as isize); + super::mac::apply(mode, super::mac::current_option()); + set_status_mode(mode); + + if !FLASH_ON_PICK { + return; + } + self.ivars().flash.set(true); + self.refresh(); + let block = RcBlock::new(|_timer: NonNull| Self::finish_flash()); + let timer = + unsafe { NSTimer::timerWithTimeInterval_repeats_block(FLASH_INTERVAL, false, &block) }; + unsafe { NSRunLoop::currentRunLoop().addTimer_forMode(&timer, NSEventTrackingRunLoopMode) }; + FLASH_TIMER.with(|slot| *slot.borrow_mut() = Some(timer)); + } + + fn finish_flash() { + stop_flash(); + PICKER.with(|p| { + if let Some((item, view)) = p.borrow().as_ref() { + view.set_flash(false); + if let Some(menu) = unsafe { item.menu() } { + menu.cancelTracking(); + } + } + }); + } + + fn set_flash(&self, flash: bool) { + if self.ivars().flash.replace(flash) != flash { + self.refresh(); + } + } + + fn fit_to_width(&self, width: f64) { + let frame = self.frame(); + let width = width.max(picker_width()); + if width > frame.size.width + 1.0 { + self.setFrameSize(NSSize::new(width, frame.size.height)); + self.refresh(); + } + } + + /// Moves keyboard focus one pill. Returns false at either end, so the key falls + /// through to the menu and moves to the neighbouring tray menu. + fn move_focus(&self, delta: isize) -> bool { + let cur = self + .ivars() + .focused + .get() + .unwrap_or(self.ivars().selected.get()) as isize; + let next = cur + delta; + if next < 0 || next >= PICKER_MODES.len() as isize { + return false; + } + self.ivars().focused.set(Some(next as usize)); + self.refresh(); + true + } + + fn activate_focused(&self) { + let i = self + .ivars() + .focused + .get() + .unwrap_or(self.ivars().selected.get()); + self.choose(i); + } +} + +pub(super) fn clear_hover_unless(item: Option<&NSMenuItem>) { + PICKER.with(|p| { + let p = p.borrow(); + let Some((picker, view)) = p.as_ref() else { + return; + }; + if !item.is_some_and(|item| std::ptr::eq(item, &**picker)) { + view.clear_focus(); + } + }); +} + +pub(super) fn clear_hover() { + PICKER.with(|p| { + if let Some((_, view)) = p.borrow().as_ref() { + view.clear_focus(); + } + }); +} + +pub(super) fn fit_to_menu_width(width: f64) { + PICKER.with(|p| { + if let Some((_, view)) = p.borrow().as_ref() { + view.fit_to_width(width); + } + }); +} + +pub(super) fn hover_from_pointer() { + PICKER.with(|p| { + if let Some((_, view)) = p.borrow().as_ref() { + view.hover_from_pointer(); + } + }); +} + +pub(super) fn stop_flash() { + FLASH_TIMER.with(|slot| { + if let Some(timer) = slot.borrow_mut().take() { + timer.invalidate(); + } + }); + PICKER.with(|p| { + if let Some((_, view)) = p.borrow().as_ref() { + view.set_flash(false); + } + }); +} + +fn capsule_width() -> f64 { + let n = PICKER_MODES.len() as f64; + n * PILL_W + (n - 1.0) * PILL_GAP + 2.0 * CAPSULE_PAD +} + +fn heading_font() -> Retained { + let menu_size = NSFont::menuFontOfSize(0.0).pointSize(); + NSFont::systemFontOfSize_weight(menu_size, unsafe { NSFontWeightMedium }) +} + +fn italic(font: &NSFont) -> Retained { + NSFontManager::sharedFontManager(MainThreadMarker::new().expect("main thread")) + .convertFont_toHaveTrait(font, NSFontTraitMask::ItalicFontMask) +} + +/// The picker is as wide as the widest heading needs, so a long translation +/// (German "Bildschirmfoto-Modus") cannot run past the capsule. Every mode is +/// measured both upright and italic, because hovering a pill draws the heading +/// italic and italic glyphs can be wider; sizing for upright alone let the +/// hovered heading overflow, and the width shift when the hover changed. +fn picker_width() -> f64 { + let upright = heading_font(); + let slanted = italic(&upright); + let widest = PICKER_MODES + .iter() + .flat_map(|(mode, _)| { + let text = NSString::from_str(mode_heading(*mode)); + [&upright, &slanted].map(|font| { + let attrs = NSDictionary::from_slices( + &[unsafe { NSFontAttributeName }], + &[&**font as &AnyObject], + ); + unsafe { text.sizeWithAttributes(Some(&attrs)) }.width + }) + }) + .fold(0.0_f64, f64::max); + let needed = TITLE_INSET + widest + HEADING_GAP + capsule_width() + RIGHT_INSET; + needed.max(PICKER_MIN_W) +} + +/// Appends the mode picker row to `menu` and registers one action per pill, so a click +/// arrives through the usual tag channel as `ModeInstant` / `ModeStudio` / `ModeScreenshot`. +pub(super) fn append_mode_picker( + menu: &NSMenu, + selected: Mode, + actions: &mut Vec, + mtm: MainThreadMarker, +) -> Retained { + let tag_base = actions.len() as isize; + actions.extend(PICKER_MODES.iter().map(|(_, item)| item.clone())); + + let index = PICKER_MODES + .iter() + .position(|(m, _)| *m == selected) + .unwrap_or(0); + + let view = ModePickerView::new(mtm, index, tag_base); + + let item = NSMenuItem::new(mtm); + item.setTitle(&NSString::from_str(tr::tray::recording_mode())); // also its accessibility title + item.setEnabled(true); + item.setView(Some(&view)); + menu.addItem(&item); + + PICKER.with(|p| *p.borrow_mut() = Some((item.clone(), view))); + item +} + +// --------------------------------------------------------------------------- +// Private keyboard hook +// --------------------------------------------------------------------------- + +/// ←/→/Return/Space while the picker row is highlighted. True means consumed. +fn handle_key(event: &NSEvent) -> bool { + PICKER.with(|p| { + let p = p.borrow(); + let Some((item, view)) = p.as_ref() else { + return false; + }; + // Up/down leave the row, so drop hover/focus whether or not the menu + // considers the row highlighted (mouse hover may not highlight it). + // Return false so the menu still moves the highlight. + if matches!(event.keyCode(), 125 | 126) { + view.clear_focus(); + return false; + } + if !item.isHighlighted() { + return false; + } + match event.keyCode() { + 123 => view.move_focus(-1), + 124 => view.move_focus(1), + 36 | 76 | 49 => { + view.activate_focused(); + true + } + _ => false, + } + }) +} + +/// Wraps `NSPopupMenuWindow -_handleKeyEvent:`. Private API: it does nothing if the +/// method is missing or has another shape, and it may break on any macOS update. +pub(super) fn install_key_hook() { + static INSTALLED: std::sync::Once = std::sync::Once::new(); + INSTALLED.call_once(install_key_hook_once); +} + +fn install_key_hook_once() { + let sel = sel!(_handleKeyEvent:); + let Some(cls) = AnyClass::get(c"NSPopupMenuWindow") else { + return; + }; + let Some(method) = cls.instance_method(sel) else { + return; + }; + let enc = unsafe { + let p = objc2::ffi::method_getTypeEncoding(method as *const objc2::runtime::Method); + if p.is_null() { + return; + } + std::ffi::CStr::from_ptr(p) + }; + if enc.to_bytes() != b"B24@0:8@16" { + tracing::warn!(encoding = ?enc, "key hook: unexpected encoding, not installing"); + return; + } + let orig = method.implementation(); + + let block = RcBlock::new(move |this: *mut AnyObject, event: *mut NSEvent| -> Bool { + let ev = unsafe { &*event }; + let handled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handle_key(ev))) + .unwrap_or(false); + if ev.r#type() == NSEventType::KeyDown && handled { + return Bool::YES; + } + let orig: unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut NSEvent) -> Bool = + unsafe { std::mem::transmute(orig) }; + unsafe { orig(this, sel, event) } + }); + unsafe { + let imp = objc2::ffi::imp_implementationWithBlock(&*block as *const _ as *mut AnyObject); + method.set_implementation(imp); + } + std::mem::forget(block); // the replacement must outlive the process + tracing::info!("key hook installed"); +} diff --git a/apps/desktop-gpui/src/tray/windows.rs b/apps/desktop-gpui/src/tray/windows.rs index 1545f480d64..aec176d7c66 100644 --- a/apps/desktop-gpui/src/tray/windows.rs +++ b/apps/desktop-gpui/src/tray/windows.rs @@ -155,11 +155,14 @@ fn build_native_item( ) -> anyhow::Result> { match entry { Entry::Separator => Ok(Box::new(PredefinedMenuItem::separator())), + Entry::ModePicker { mode } => build_native_item(&super::mode_submenu(*mode), actions), Entry::Item { title, item, enabled, icon, + accelerator, + .. } => { let icon = icon .as_deref() @@ -171,24 +174,31 @@ fn build_native_item( } }); let enabled = *enabled && item.is_some(); + let accelerator = accelerator.map(|accelerator| { + menu::accelerator::Accelerator::new(Some(accelerator.modifiers), accelerator.code) + }); let native_item: Box = match icon { Some(icon) => Box::new(IconMenuItem::new( menu_text(title), enabled, Some(icon), - None, + accelerator, )), - None => Box::new(MenuItem::new(menu_text(title), enabled, None)), + None => Box::new(MenuItem::new(menu_text(title), enabled, accelerator)), }; if enabled && let Some(item) = item { let _ = actions.insert(native_item.id().clone(), item.clone()); } Ok(native_item) } + Entry::SectionHeader { title } => { + Ok(Box::new(MenuItem::new(menu_text(title), false, None))) + } Entry::Submenu { title, enabled, items, + .. } => { let submenu = Submenu::new(menu_text(title), *enabled); for entry in items { diff --git a/apps/desktop-gpui/src/updates.rs b/apps/desktop-gpui/src/updates.rs index ae48b344e1f..f1f97418e75 100644 --- a/apps/desktop-gpui/src/updates.rs +++ b/apps/desktop-gpui/src/updates.rs @@ -7,7 +7,7 @@ use semver::Version; use crate::{ installer::{self, ArtifactKind, Release}, session::RecordingSession, - store::{GeneralSettings, UpdateChannel}, + store::{GeneralSettings, UpdateChannel, UpdateDownloadPolicy}, }; const STABLE_FIRST_CHECK_DELAY: Duration = Duration::from_secs(10); @@ -21,6 +21,7 @@ const BUSY_MESSAGE: &str = pub(crate) enum UpdateStatus { #[default] Idle, + Checking, Downloading { version: Version, fraction: Option, @@ -70,6 +71,8 @@ struct UpdateScheduler { pending: PendingUpdateRequests, manual_in_flight: bool, status: UpdateStatus, + download_policy: UpdateDownloadPolicy, + simulation_version: Option, staged: Option, download: Option>, download_epoch: u64, @@ -173,6 +176,7 @@ fn set_status(status: UpdateStatus, cx: &mut App) { } scheduler.status = status; crate::app_windows::refresh_settings(cx); + crate::menus::refresh_update_menu(cx); } pub(crate) fn check_manually(cx: &mut App) { @@ -185,6 +189,17 @@ pub(crate) fn check_manually(cx: &mut App) { prompt_restart(cx); return; } + UpdateStatus::Downloading { + version, + fraction: None, + } if cx + .try_global::() + .and_then(|scheduler| scheduler.simulation_version.as_ref()) + == Some(&version) => + { + simulate_download(version, cx); + return; + } UpdateStatus::Downloading { version, .. } | UpdateStatus::Installing { version } => { cx.spawn(async move |_| { crate::platform::activate_app(); @@ -196,7 +211,7 @@ pub(crate) fn check_manually(cx: &mut App) { .detach(); return; } - UpdateStatus::Idle => {} + UpdateStatus::Idle | UpdateStatus::Checking => {} } let scheduler = cx.global_mut::(); @@ -221,7 +236,7 @@ pub(crate) fn update_channel_changed(channel: UpdateChannel, cx: &mut App) { fn channel_switch_drops_pending_package(status: &UpdateStatus) -> bool { matches!( status, - UpdateStatus::Downloading { .. } | UpdateStatus::Ready { .. } + UpdateStatus::Checking | UpdateStatus::Downloading { .. } | UpdateStatus::Ready { .. } ) } @@ -261,6 +276,93 @@ fn busy_alert(cx: &mut App) { .detach(); } +/// `CAP_GPUI_UPDATE_SIMULATION_VERSION=` (debug builds only): skip +/// the network check, announce a newer version, play a fake download, then +/// stop at Ready without installing anything. +#[cfg(debug_assertions)] +fn harness_update_version() -> Option { + let value = std::env::var("CAP_GPUI_UPDATE_SIMULATION_VERSION").ok()?; + let version = Version::parse(&value).ok()?; + if current_version().is_ok_and(|current| version > current) { + Some(version) + } else { + tracing::warn!( + version = %version, + "CAP_GPUI_UPDATE_SIMULATION_VERSION must be newer than the running version" + ); + None + } +} + +#[cfg(debug_assertions)] +fn simulate_update(version: Version, cx: &mut App) { + tracing::info!(%version, "CAP_GPUI_UPDATE_SIMULATION_VERSION: available"); + cx.global_mut::().simulation_version = Some(version.clone()); + set_status( + UpdateStatus::Downloading { + version: version.clone(), + fraction: None, + }, + cx, + ); + cx.spawn(async move |cx| { + cx.background_executor() + .timer(Duration::from_millis(400)) + .await; + cx.update(crate::menus::reapply_update_row); + }) + .detach(); + + if GeneralSettings::load().update_download_policy != UpdateDownloadPolicy::Auto { + tracing::info!("CAP_GPUI_UPDATE_SIMULATION_VERSION: manual policy, waiting"); + return; + } + cx.spawn(async move |cx| { + cx.background_executor().timer(Duration::from_secs(3)).await; + cx.update(|cx| simulate_download(version, cx)); + }) + .detach(); +} + +#[cfg(debug_assertions)] +fn simulate_download(version: Version, cx: &mut App) { + if !matches!(status(cx), UpdateStatus::Downloading { fraction: None, .. }) { + return; + } + tracing::info!(%version, "CAP_GPUI_UPDATE_SIMULATION_VERSION: downloading"); + set_status( + UpdateStatus::Downloading { + version: version.clone(), + fraction: Some(0.0), + }, + cx, + ); + cx.spawn(async move |cx| { + for step in 1..=10u8 { + cx.background_executor() + .timer(Duration::from_millis(800)) + .await; + cx.update(|cx| { + set_status( + UpdateStatus::Downloading { + version: version.clone(), + fraction: Some(step as f32 / 10.0), + }, + cx, + ); + }); + } + cx.update(|cx| { + tracing::info!(%version, "CAP_GPUI_UPDATE_SIMULATION_VERSION: ready"); + set_status(UpdateStatus::Ready { version }, cx); + }); + }) + .detach(); +} + +#[cfg(not(debug_assertions))] +fn simulate_download(_version: Version, _cx: &mut App) {} + pub(crate) fn schedule_startup_check(cx: &mut App) { let (wake, requests) = flume::bounded(1); cx.set_global(UpdateScheduler { @@ -268,17 +370,35 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { pending: PendingUpdateRequests::default(), manual_in_flight: false, status: UpdateStatus::Idle, + download_policy: GeneralSettings::load().update_download_policy, + simulation_version: None, staged: None, download: None, download_epoch: 0, }); + #[cfg(debug_assertions)] + if let Some(version) = harness_update_version() { + simulate_update(version, cx); + return; + } + #[cfg(not(debug_assertions))] + if std::env::var_os("CAP_GPUI_UPDATE_SIMULATION_VERSION").is_some() { + tracing::warn!("CAP_GPUI_UPDATE_SIMULATION_VERSION is ignored in release builds"); + } + cx.spawn(async move |cx| { let mut channel = cx .background_executor() .spawn(async { GeneralSettings::load().update_channel }) .await; - let mut delay = (!cfg!(debug_assertions)).then(|| first_check_delay(channel)); + let mut policy = cx + .update(|cx| cx.global::().download_policy); + let mut delay = if policy == UpdateDownloadPolicy::Never { + None + } else { + (!cfg!(debug_assertions)).then(|| first_check_delay(channel)) + }; let mut ignored_version: Option = None; loop { @@ -306,6 +426,19 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { std::mem::take(&mut scheduler.pending) }); + let policy_now = cx.update(|cx| cx.global::().download_policy); + if policy_now != policy { + let was_never = policy == UpdateDownloadPolicy::Never; + policy = policy_now; + // Moving between Ask first and Auto keeps the schedule, so a + // switch before the first check does not cancel it. + if policy == UpdateDownloadPolicy::Never { + delay = None; + } else if was_never { + delay = Some(first_check_delay(channel)); + } + } + if signaled && request == PendingUpdateRequests::default() { continue; } @@ -313,10 +446,14 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { if let Some(next_channel) = request.channel { channel = next_channel; ignored_version = None; - let _ = cx.update(drop_pending_update_from_previous_channel); + cx.update(drop_pending_update_from_previous_channel); } let manual = request.manual; + if policy == UpdateDownloadPolicy::Never && !manual { + delay = None; + continue; + } if cfg!(debug_assertions) && !manual { delay = None; continue; @@ -333,6 +470,8 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { continue; } + cx.update(|cx| set_status(UpdateStatus::Checking, cx)); + let result = match cx .update(|cx| gpui_tokio::Tokio::spawn(cx, available_release(channel))) .await @@ -351,12 +490,14 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { channel_changed || (!manual && scheduler.pending.manual) }); if superseded { + cx.update(|cx| set_status(UpdateStatus::Idle, cx)); continue; } let release = match result { Ok(Some(release)) => release, Ok(None) => { + cx.update(|cx| set_status(UpdateStatus::Idle, cx)); if manual { crate::platform::activate_app(); crate::platform::alert_dialog( @@ -368,6 +509,7 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { continue; } Err(error) => { + cx.update(|cx| set_status(UpdateStatus::Idle, cx)); tracing::warn!("update check failed: {error}"); if manual { crate::platform::activate_app(); @@ -382,6 +524,7 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { }; if !manual && ignored_version.as_ref() == Some(&release.version) { + cx.update(|cx| set_status(UpdateStatus::Idle, cx)); continue; } @@ -389,6 +532,11 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { // check holds it until a recording, export, import or // transcription has finished rather than interrupting it. let mut superseded = false; + if holds_scheduled_prompt(manual, cx.update(work_in_flight)) { + // Leave the menu's Check for Update row usable for the length + // of a recording; a manual check replaces the held prompt. + cx.update(|cx| set_status(UpdateStatus::Idle, cx)); + } while holds_scheduled_prompt(manual, cx.update(work_in_flight)) { let timer = cx.background_executor().timer(BUSY_RETRY_DELAY); let request = requests.recv_async(); @@ -405,10 +553,25 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { } if superseded { // Run the queued manual check or channel change straight away. + cx.update(|cx| set_status(UpdateStatus::Idle, cx)); delay = Some(Duration::ZERO); continue; } + let policy = cx.update(|cx| cx.global::().download_policy); + if policy == UpdateDownloadPolicy::Never && !manual { + cx.update(|cx| set_status(UpdateStatus::Idle, cx)); + delay = next_check_delay(channel); + continue; + } + if policy == UpdateDownloadPolicy::Auto { + cx.update(|cx| finish_manual_check(cx, manual)); + cx.update(|cx| start_download(release, cx)); + continue; + } + + cx.update(|cx| set_status(UpdateStatus::Idle, cx)); + crate::platform::activate_app(); let accepted = crate::platform::confirm_dialog( "Update Cap", @@ -431,6 +594,14 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { .detach(); } +pub(crate) fn update_download_policy_changed(policy: UpdateDownloadPolicy, cx: &mut App) { + if !cx.has_global::() { + return; + } + cx.global_mut::().download_policy = policy; + let _ = cx.global::().wake.try_send(()); +} + fn start_download(release: Release, cx: &mut App) { let kind = match installer::update_platform() .and_then(|platform| ArtifactKind::for_platform(&platform))