Compare commits

...
9 Commits
Author SHA1 Message Date
harry bcdeab1aa3 Merge feat/background-tray: tray, background running, autostart, audio-loss warning
Rust checks / windows-cross-check (push) Successful in 1m31s
Rust checks / linux (push) Failing after 7m25s
2026-10-13 19:05:43 +02:00
harry 072a4ab7ca fix: preserve GPUI lifetime and avoid WASAPI silence warnings 2026-10-13 18:58:37 +02:00
harry d065ad7775 build: vendor published GPUI 0.2.2 for lifecycle patch 2026-10-13 18:56:54 +02:00
harry f1c31d6c90 feat: keep Clipbin recording in the tray
Add native Linux SNI and Windows notification-area trays, single-instance reopening, background startup and login entries. Preserve recording and discovery after close, with clean Quit and reachable no-tray fallbacks.

Track capture-source audio delivery independently of silent output tracks and warn without stopping video. Give clip metadata its own row and shape labels at final bounds to avoid GPUI 0.2.2 truncation cache and text-run bugs.

Validated with strict workspace checks, 57 tests, Windows core/OBS/probe cross-clippy, all ten capture scenarios, tray/session/clips/playback rigs and narrow/wide screenshots. Document runtime validation gaps.
2026-10-13 18:29:37 +02:00
harry 178c400eeb fix: share reliable virtual key delivery across Linux rigs
Rust checks / windows-cross-check (push) Successful in 1m6s
Rust checks / linux (push) Failing after 5m44s
2026-10-08 19:38:35 +02:00
harry dd016f7727 Merge feat/playback: session playback, synced audio and thumbnails
# Conflicts:
#	README.md
#	crates/clipbin-desktop/Cargo.toml
#	crates/clipbin-desktop/src/main.rs
#	crates/clipbin-media/src/lib.rs
#	docs/validation.md
2026-10-08 19:26:04 +02:00
harry 6db5964d06 Merge feat/clips-bookmarks: clip export, hotkeys, bookmarks and storage cleanup 2026-10-08 19:22:07 +02:00
harry 0a7d62a807 Add bounded session playback, synced audio and frame thumbnails 2026-10-08 19:20:44 +02:00
harry 7ac7e66ff1 feat: export clips, protect bookmarks and clean session storage 2026-10-08 18:56:51 +02:00
239 changed files with 96980 additions and 218 deletions
Generated
+505 -9
View File
@@ -60,6 +60,28 @@ dependencies = [
"equator", "equator",
] ]
[[package]]
name = "alsa"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
dependencies = [
"alsa-sys",
"bitflags 2.13.2",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04"
dependencies = [
"libc",
"pkg-config",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.104" version = "1.0.104"
@@ -440,7 +462,7 @@ dependencies = [
"log", "log",
"num-rational", "num-rational",
"num-traits", "num-traits",
"pastey", "pastey 0.1.1",
"rayon", "rayon",
"thiserror 2.0.21", "thiserror 2.0.21",
"v_frame", "v_frame",
@@ -863,13 +885,21 @@ dependencies = [
name = "clipbin-core" name = "clipbin-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ashpd 0.12.3",
"async-io",
"dirs 5.0.1", "dirs 5.0.1",
"futures-lite 2.6.1",
"futures-util",
"ksni",
"rustix 1.1.5", "rustix 1.1.5",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
"tray-icon",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"winreg 0.55.0", "winreg 0.55.0",
"x11rb",
"zbus",
] ]
[[package]] [[package]]
@@ -877,8 +907,10 @@ name = "clipbin-desktop"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clipbin-core", "clipbin-core",
"clipbin-media",
"clipbin-obs", "clipbin-obs",
"gpui", "gpui",
"image",
] ]
[[package]] [[package]]
@@ -886,7 +918,11 @@ name = "clipbin-media"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clipbin-core", "clipbin-core",
"cpal",
"ffmpeg-next", "ffmpeg-next",
"image",
"rustix 1.1.5",
"windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -983,6 +1019,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
version = "4.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
dependencies = [
"bytes",
"memchr",
]
[[package]] [[package]]
name = "command-fds" name = "command-fds"
version = "0.3.3" version = "0.3.3"
@@ -1193,6 +1239,20 @@ dependencies = [
"libm", "libm",
] ]
[[package]]
name = "coreaudio-rs"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
dependencies = [
"bitflags 2.13.2",
"libc",
"objc2-audio-toolbox",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "cosmic-text" name = "cosmic-text"
version = "0.14.2" version = "0.14.2"
@@ -1216,6 +1276,39 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "cpal"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1"
dependencies = [
"alsa",
"block2",
"coreaudio-rs",
"dasp_sample",
"futures",
"jni",
"js-sys",
"libc",
"mach2",
"ndk",
"ndk-context",
"num-derive",
"num-traits",
"objc2",
"objc2-audio-toolbox",
"objc2-avf-audio",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation",
"portable-atomic",
"pulseaudio",
"web-sys",
"windows 0.62.2",
"windows-core 0.62.2",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -1243,6 +1336,15 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.8" version = "0.8.8"
@@ -1319,6 +1421,12 @@ version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]] [[package]]
name = "data-url" name = "data-url"
version = "0.3.2" version = "0.3.2"
@@ -1449,6 +1557,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]] [[package]]
name = "dtor" name = "dtor"
version = "0.0.6" version = "0.0.6"
@@ -1528,6 +1642,17 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-primitive-derive"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba7795da175654fe16979af73f81f26a8ea27638d8d9823d317016888a63dc4c"
dependencies = [
"num-traits",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "enumflags2" name = "enumflags2"
version = "0.7.12" version = "0.7.12"
@@ -2167,8 +2292,6 @@ dependencies = [
[[package]] [[package]]
name = "gpui" name = "gpui"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "979b45cfa6ec723b6f42330915a1b3769b930d02b2d505f9697f8ca602bee707"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"as-raw-xcb-connection", "as-raw-xcb-connection",
@@ -2250,7 +2373,7 @@ dependencies = [
"wayland-protocols-plasma", "wayland-protocols-plasma",
"windows 0.61.3", "windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-numerics", "windows-numerics 0.2.0",
"windows-registry 0.5.3", "windows-registry 0.5.3",
"x11-clipboard", "x11-clipboard",
"x11rb", "x11rb",
@@ -2917,6 +3040,64 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "jobserver" name = "jobserver"
version = "0.1.35" version = "0.1.35"
@@ -2938,6 +3119,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "keyboard-types"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
dependencies = [
"bitflags 2.13.2",
"serde",
]
[[package]] [[package]]
name = "khronos-egl" name = "khronos-egl"
version = "6.0.0" version = "6.0.0"
@@ -2948,6 +3139,24 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "ksni"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "814b44c24cd2cb236c3b8a41c7f08237b452a8e76ecaa81f1cec40b5b678215b"
dependencies = [
"async-executor",
"async-io",
"async-lock",
"futures-channel",
"futures-lite 2.6.1",
"futures-util",
"pastey 0.2.3",
"serde",
"task-local",
"zbus",
]
[[package]] [[package]]
name = "kurbo" name = "kurbo"
version = "0.11.3" version = "0.11.3"
@@ -3159,6 +3368,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mach2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
[[package]] [[package]]
name = "malloc_buf" name = "malloc_buf"
version = "0.0.6" version = "0.0.6"
@@ -3282,6 +3497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [ dependencies = [
"libc", "libc",
"log",
"wasi", "wasi",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -3296,6 +3512,24 @@ dependencies = [
"pxfm", "pxfm",
] ]
[[package]]
name = "muda"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"png 0.18.1",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "multiversion_no_op" name = "multiversion_no_op"
version = "1.0.0" version = "1.0.0"
@@ -3336,6 +3570,35 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "ndk"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.13.2",
"jni-sys 0.3.1",
"log",
"ndk-sys",
"num_enum",
"thiserror 1.0.69",
]
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
dependencies = [
"jni-sys 0.3.1",
]
[[package]] [[package]]
name = "new_debug_unreachable" name = "new_debug_unreachable"
version = "1.0.6" version = "1.0.6"
@@ -3521,6 +3784,28 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "num_enum"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
dependencies = [
"num_enum_derive",
"rustversion",
]
[[package]]
name = "num_enum_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "objc" name = "objc"
version = "0.2.7" version = "0.2.7"
@@ -3564,6 +3849,55 @@ dependencies = [
"objc2-quartz-core", "objc2-quartz-core",
] ]
[[package]]
name = "objc2-audio-toolbox"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
dependencies = [
"bitflags 2.13.2",
"libc",
"objc2",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-avf-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
dependencies = [
"dispatch2",
"objc2",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-core-audio-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
dependencies = [
"bitflags 2.13.2",
"objc2",
]
[[package]] [[package]]
name = "objc2-core-foundation" name = "objc2-core-foundation"
version = "0.3.2" version = "0.3.2"
@@ -3571,10 +3905,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2",
"dispatch2", "dispatch2",
"libc",
"objc2", "objc2",
] ]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.2",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "objc2-encode" name = "objc2-encode"
version = "4.1.0" version = "4.1.0"
@@ -3588,6 +3934,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -3771,6 +4119,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pathfinder_geometry" name = "pathfinder_geometry"
version = "0.5.1" version = "0.5.1"
@@ -3907,6 +4261,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7"
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]] [[package]]
name = "postage" name = "postage"
version = "0.5.0" version = "0.5.0"
@@ -4044,6 +4404,22 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740"
[[package]]
name = "pulseaudio"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d70623bd7967a9ca4c2ae0e807fc380b291f98480fc037042305ec643a4d3373"
dependencies = [
"bitflags 2.13.2",
"byteorder",
"enum-primitive-derive",
"futures",
"log",
"mio",
"num-traits",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "pxfm" name = "pxfm"
version = "0.1.30" version = "0.1.30"
@@ -4965,6 +5341,16 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]] [[package]]
name = "simd_helpers" name = "simd_helpers"
version = "0.1.0" version = "0.1.0"
@@ -5414,6 +5800,15 @@ dependencies = [
"objc", "objc",
] ]
[[package]]
name = "task-local"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2972044a9e5e448a506a7ff6f0d03b566d8ef4cd6918a58fc59835a0f8666626"
dependencies = [
"pin-project-lite",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@@ -5752,6 +6147,25 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "tray-icon"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f5cc5858ecf9574ddd8f0032a2ff7ffd6f7724d2b81e48b9374984b3fd6ddc9"
dependencies = [
"crossbeam-channel",
"muda",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png 0.18.1",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
@@ -6334,11 +6748,23 @@ version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [ dependencies = [
"windows-collections", "windows-collections 0.2.0",
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-future", "windows-future 0.2.1",
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-numerics", "windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
] ]
[[package]] [[package]]
@@ -6351,7 +6777,7 @@ dependencies = [
"rayon", "rayon",
"thiserror 2.0.21", "thiserror 2.0.21",
"windows 0.61.3", "windows 0.61.3",
"windows-future", "windows-future 0.2.1",
] ]
[[package]] [[package]]
@@ -6363,6 +6789,15 @@ dependencies = [
"windows-core 0.61.2", "windows-core 0.61.2",
] ]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.57.0" version = "0.57.0"
@@ -6388,6 +6823,19 @@ dependencies = [
"windows-strings 0.4.2", "windows-strings 0.4.2",
] ]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]] [[package]]
name = "windows-future" name = "windows-future"
version = "0.2.1" version = "0.2.1"
@@ -6396,7 +6844,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [ dependencies = [
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-threading", "windows-threading 0.1.0",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
"windows-threading 0.2.1",
] ]
[[package]] [[package]]
@@ -6465,6 +6924,16 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "windows-registry" name = "windows-registry"
version = "0.4.0" version = "0.4.0"
@@ -6505,6 +6974,15 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "windows-strings" name = "windows-strings"
version = "0.3.1" version = "0.3.1"
@@ -6523,6 +7001,15 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.48.0" version = "0.48.0"
@@ -6616,6 +7103,15 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.48.5" version = "0.48.5"
+4
View File
@@ -1,4 +1,5 @@
[workspace] [workspace]
exclude = ["vendor/gpui"]
members = [ members = [
"crates/clipbin-core", "crates/clipbin-core",
"crates/clipbin-desktop", "crates/clipbin-desktop",
@@ -42,3 +43,6 @@ opt-level = 2
lto = "thin" lto = "thin"
codegen-units = 1 codegen-units = 1
strip = "debuginfo" strip = "debuginfo"
[patch.crates-io]
gpui = { path = "vendor/gpui" }
+13 -8
View File
@@ -6,11 +6,11 @@ An open-source game recorder for Windows and Linux, inspired by Plays.tv. Rust a
## Current state ## Current state
The recording foundation is being validated (see [docs/validation.md](docs/validation.md)). `clipbin-obs` records official OBS capture sources through one hardware encoder into keyframe-aligned segments and reports honest status; `clipbin-probe` exercises it. It passes the Linux headless rig and the hardware sign-off on CachyOS/Hyprland/RX 9070 XT; all Windows validation is still to do. The recording foundation is being validated (see [docs/validation.md](docs/validation.md)). `clipbin-obs` records official OBS capture sources through one hardware encoder into keyframe-aligned segments and reports honest status; `clipbin-probe` exercises it. It passes the Linux headless rig and the hardware sign-off on CachyOS/Hyprland/RX 9070 XT; Windows runtime validation is still to do.
The desktop app detects Steam games, owns the recorder on a background thread, persists settings and sessions, and loads recorded sessions from disk. Linux detects Steam’s `reaper` processes and watches their pidfds; Windows reads Steam’s registry state and watches native process handles (cross-checked, not run). Game exit finalises a session; closing the window keeps the current session recording until the game exits, then exits the app. Ctrl+Q or Quit stops the output cleanly. The desktop app detects Steam games, owns the recorder on a background thread, persists settings and sessions, and loads recorded sessions from disk. Linux detects Steam’s `reaper` processes and watches their pidfds; Windows reads Steam’s registry state and watches native process handles (cross-checked, not run). Game exit finalises a session; closing the window leaves recording, game detection, hotkeys and cleanup running in the tray. Open Clipbin from the tray or launch it again to reopen the same process and restore its last screen. Tray Quit, the app-menu Quit action or Ctrl+Q finalises the current session and exits. Without an available tray, closing quits cleanly so the process stays reachable.
The desktop interface follows the web Clipbin's look (zinc dark theme, purple accent, Geist Mono): a session library, a session timeline with bookmarks and a clip selection, clips, and settings. It shows sessions from disk; `CLIPBIN_SAMPLE_LIBRARY=1` fills it with example sessions for interface work. Playback, export, global hotkeys, storage cleanup, autostart and a tray are not implemented yet. After closing the only window, reopening it without a tray is a follow-up. The desktop interface follows the web Clipbin's look (zinc dark theme, purple accent, Geist Mono): a session library, a session timeline with bookmarks and a clip selection, clips, and settings. It shows sessions from disk; `CLIPBIN_SAMPLE_LIBRARY=1` fills it with example sessions for interface work. Playback and frame seeking work across completed segments, including active sessions and missing footage, with combined audio, mute/volume, Space/K to play/pause and J/L or arrows to step frames. Sessions and clips use cached frame thumbnails. Exports copy the recorded codec to MP4 in a background job, save local clip metadata, and support opening/deleting clips. Ctrl+Shift+F9 clips the last configured interval; Ctrl+Shift+F10 protects a bookmark. Both also have sidebar buttons. Wayland uses the GlobalShortcuts portal (first-use approval, portal rebinding); X11 uses native key grabs and Windows uses RegisterHotKey. Settings offer independent alternate function keys. Storage cleanup keeps unprotected footage within the budget. The tray shows idle, recording, failed and degraded audio states and offers clip/bookmark actions. Start on login writes an XDG autostart entry on Linux or an HKCU Run value on Windows. `--background` starts with only a tray; without a tray it opens the window. Linux capture delivery loss or Windows WASAPI device errors produce an “audio capture lost” warning while video continues.
Windows is a first-class target. Every change must keep the Windows code paths building (`cargo clippy --target x86_64-pc-windows-msvc`) and list what still needs validating on Windows. Windows is a first-class target. Every change must keep the Windows code paths building (`cargo clippy --target x86_64-pc-windows-msvc`) and list what still needs validating on Windows.
@@ -19,6 +19,7 @@ Windows is a first-class target. Every change must keep the Windows code paths b
```text ```text
crates/ crates/
clipbin-core/ Settings, Steam detection and session storage; independent of GPUI and OBS. clipbin-core/ Settings, Steam detection and session storage; independent of GPUI and OBS.
clipbin-media/ FFmpeg: stream-copy clip export, scaled video/audio playback and cached thumbnails.
clipbin-obs/ Recording engine over the pinned libobs runtime; all unsafe code is in src/ffi.rs. clipbin-obs/ Recording engine over the pinned libobs runtime; all unsafe code is in src/ffi.rs.
clipbin-probe/ Validation probe for capture, encoders, segments and status. clipbin-probe/ Validation probe for capture, encoders, segments and status.
clipbin-desktop/ GPUI application and initial session-library view. clipbin-desktop/ GPUI application and initial session-library view.
@@ -34,14 +35,14 @@ Keep capture and session operations out of the UI thread. Core uses serde/JSON,
## Development ## Development
Use Rust **1.98.0**, as pinned in rust-toolchain.toml. GPUI is pinned to **0.2.2**, with Wayland and X11 enabled. Cargo.lock pins transitive dependencies. Use Rust **1.98.0**, as pinned in rust-toolchain.toml. GPUI is pinned to **0.2.2**, with Wayland and X11 enabled. Its vendored [background-loop patch](vendor/gpui/CLIPBIN.md) keeps one application alive while its windows close and reopen. Cargo.lock pins transitive dependencies.
Keep the lockfile checked in and use `--locked`. It currently pins `libc` to 0.2.186 because GPUI’s transitive `xattr` 0.2.3 dependency fails to compile against 0.2.190 on Linux. Keep the lockfile checked in and use `--locked`. It currently pins `libc` to 0.2.186 because GPUI’s transitive `xattr` 0.2.3 dependency fails to compile against 0.2.190 on Linux.
On Arch/CachyOS, install the native build dependencies: On Arch/CachyOS, install the native build dependencies:
```sh ```sh
sudo pacman -S --needed base-devel pkgconf fontconfig freetype2 wayland libxcb libxkbcommon libxkbcommon-x11 openssl clang sudo pacman -S --needed base-devel pkgconf fontconfig freetype2 wayland libxcb libxkbcommon libxkbcommon-x11 openssl clang ffmpeg alsa-lib pipewire-alsa libpulse
``` ```
### OBS runtime ### OBS runtime
@@ -62,9 +63,11 @@ On Debian/Ubuntu:
```sh ```sh
sudo apt-get update sudo apt-get update
sudo apt-get install build-essential pkg-config libfontconfig1-dev libfreetype6-dev libwayland-dev libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libssl-dev sudo apt-get install build-essential pkg-config libfontconfig1-dev libfreetype6-dev libwayland-dev libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libssl-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev libasound2-dev libpulse-dev pipewire-alsa
``` ```
Media dynamically links FFmpeg through `ffmpeg-next` 9. Linux uses system FFmpeg 8. On Windows, set `FFMPEG_DIR` to an FFmpeg **7.1 shared development** package with MSVC import libraries, matching the OBS-bundled `avcodec-61`, `avformat-61`, `avutil-59`, `swscale-8` and `swresample-5` DLLs. Keep those OBS DLLs alongside the desktop executable; do not mix FFmpeg major versions. Audio uses cpal (PulseAudio or ALSA/PipeWire on Linux, WASAPI on Windows).
For Windows, use the MSVC Rust toolchain and Visual Studio Build Tools with the Desktop development with C++ workload. Windows support needs validation on a Windows machine; see [docs/validation.md](docs/validation.md#windows). For Windows, use the MSVC Rust toolchain and Visual Studio Build Tools with the Desktop development with C++ workload. Windows support needs validation on a Windows machine; see [docs/validation.md](docs/validation.md#windows).
Run the desktop shell from an active graphical session: Run the desktop shell from an active graphical session:
@@ -75,7 +78,7 @@ cargo run --locked -p clipbin-desktop
The desktop app needs a working Wayland or X11 session on Linux, or a Windows desktop. It does not start a web server. On this remote development machine, bind any future development server to its tailnet IP. The desktop app needs a working Wayland or X11 session on Linux, or a Windows desktop. It does not start a web server. On this remote development machine, bind any future development server to its tailnet IP.
Use the Quit button or Ctrl+Q to quit. Use tray Quit or Ctrl+Q to quit. Closing the window keeps Clipbin in the background when a tray is available. Start on login is enabled by default and can be disabled in Settings. On Linux a session D-Bus and an SNI host (for example Waybar or KDE) provide the tray; bare Sway falls back to close=Quit.
Checks: Checks:
@@ -87,7 +90,9 @@ cargo test --locked --workspace
cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
``` ```
Recording validation on Linux, headless: `scripts/validate/linux-rig.sh`. Automatic desktop sessions: `scripts/validate/linux-session.sh` (fake Steam reaper, recording and library screenshots, close-window survival, game exit and explicit Quit). Interface screenshots, headless: `scripts/rig/screenshot-ui.sh` (screens: sessions, session, clips, settings, failed, empty). The rig has no input devices; `rig-pointer` drives a virtual pointer (`move`, `click`, `down`/`up`, `scroll`, `sleep` on stdin) and `wtype` types. `CLIPBIN_TRACE_FRAMES=1` logs frame times. Clips/bookmarks/cleanup validation: `scripts/validate/linux-clips.sh`. Native X11 hotkey validation: `scripts/validate/linux-hotkeys.sh`. Both run in the headless rig. Real Hyprland portal hotkeys and Windows runtime behaviour still need validation.
Background/tray/autostart validation: `scripts/validate/linux-background.sh` uses a minimal SNI host and activates the real D-Bus menu. Recording validation on Linux, headless: `scripts/validate/linux-rig.sh`. Automatic desktop sessions: `scripts/validate/linux-session.sh` (fake Steam reaper, recording and library screenshots, close-window survival, game exit and explicit Quit). Playback fixtures: `scripts/validate/playback-fixture.sh`. Playback/audio e2e: `scripts/validate/linux-playback.sh [output-dir]` (moving preview, active recording, gaps, thumbnails, audio monitor, timing and CPU report). Fake Steam rigs must run serially because discovery reads the machine’s `/proc`. Interface screenshots, headless: `scripts/rig/screenshot-ui.sh` (screens: sessions, session, clips, settings, failed, empty). The rig has no input devices; `rig-pointer` drives a virtual pointer (`move`, `click`, `down`/`up`, `scroll`, `sleep` on stdin) and `wtype` types. `CLIPBIN_TRACE_FRAMES=1` logs frame times.
Development builds optimise dependencies (`[profile.dev.package."*"]`): unoptimised GPUI made screens noticeably slow, with first frames of 200–300 ms. Development builds optimise dependencies (`[profile.dev.package."*"]`): unoptimised GPUI made screens noticeably slow, with first frames of 200–300 ms.
+9 -1
View File
@@ -18,8 +18,16 @@ serde_json = "1"
tempfile = "3" tempfile = "3"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
ksni = { version = "0.3.6", default-features = false, features = ["async-io", "blocking"] }
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
ashpd = { version = "0.12.3", default-features = false, features = ["async-std"] }
async-io = "2"
futures-lite = "2"
futures-util = "0.3"
x11rb = { version = "0.13", features = ["xkb"] }
rustix = { version = "1", features = ["process", "event"] } rustix = { version = "1", features = ["process", "event"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
tray-icon = { version = "0.26", default-features = false }
winreg = "0.55" winreg = "0.55"
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp"] } windows-sys = { version = "0.61", features = ["Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", "Win32_Foundation", "Win32_Security", "Win32_System_LibraryLoader", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp"] }
+27
View File
@@ -0,0 +1,27 @@
//! Validation helper: exits after both native global actions arrive.
use std::{sync::mpsc, time::Duration};
fn main() {
let (sender, receiver) = mpsc::channel();
let _hotkeys = clipbin_core::hotkeys::start(
9,
10,
move |action, _| {
sender.send(action).unwrap();
},
|status| println!("hotkeys={status}"),
);
let first = receiver
.recv_timeout(Duration::from_secs(20))
.expect("first action");
let second = receiver
.recv_timeout(Duration::from_secs(20))
.expect("second action");
assert!(matches!(
(first, second),
(
clipbin_core::hotkeys::Action::Clip,
clipbin_core::hotkeys::Action::Bookmark
)
));
println!("PASS native clip and bookmark hotkeys");
}
+37
View File
@@ -0,0 +1,37 @@
//! Minimal SNI watcher/host for the headless validation rig (no desktop bar).
#[cfg(target_os = "linux")]
fn main() -> Result<(), Box<dyn std::error::Error>> {
use std::sync::Mutex;
struct Watcher(Mutex<Vec<String>>);
#[zbus::interface(name = "org.kde.StatusNotifierWatcher")]
impl Watcher {
fn register_status_notifier_item(&self, service: String) {
eprintln!("registered={service}");
self.0.lock().unwrap().push(service);
}
fn register_status_notifier_host(&self, service: String) {
let _ = service;
}
#[zbus(property)]
fn is_status_notifier_host_registered(&self) -> bool {
true
}
#[zbus(property)]
fn protocol_version(&self) -> i32 {
0
}
#[zbus(property)]
fn registered_status_notifier_items(&self) -> Vec<String> {
self.0.lock().unwrap().clone()
}
}
let _connection = zbus::blocking::connection::Builder::session()?
.name("org.kde.StatusNotifierWatcher")?
.serve_at("/StatusNotifierWatcher", Watcher(Mutex::new(Vec::new())))?
.build()?;
loop {
std::thread::park();
}
}
#[cfg(not(target_os = "linux"))]
fn main() {}
+60
View File
@@ -0,0 +1,60 @@
//! Reconcile the OS login entry with the saved setting.
use std::{io, path::Path};
pub fn reconcile(enabled: bool) -> io::Result<()> {
apply(enabled, &std::env::current_exe()?)
}
#[cfg(target_os = "linux")]
fn apply(enabled: bool, executable: &Path) -> io::Result<()> {
let path = dirs::config_dir()
.ok_or_else(|| io::Error::other("No config directory"))?
.join("autostart/clipbin.desktop");
if enabled {
std::fs::create_dir_all(path.parent().unwrap())?;
let executable = executable
.to_str()
.ok_or_else(|| io::Error::other("Executable path is not UTF-8"))?;
// Desktop Entry Exec quoting includes reserved characters and doubles %.
let quoted = executable
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('`', "\\`")
.replace('$', "\\$")
.replace('%', "%%");
let escaped = quoted
.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t");
std::fs::write(
path,
format!(
"[Desktop Entry]\nType=Application\nName=Clipbin\nExec=\"{escaped}\" --background\nTerminal=false\n"
),
)
} else {
match std::fs::remove_file(path) {
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
result => result,
}
}
}
#[cfg(windows)]
fn apply(enabled: bool, executable: &Path) -> io::Result<()> {
use winreg::{RegKey, enums::HKEY_CURRENT_USER};
let (key, _) = RegKey::predef(HKEY_CURRENT_USER)
.create_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run")?;
if enabled {
key.set_value(
"Clipbin",
&format!("\"{}\" --background", executable.display()),
)
} else {
match key.delete_value("Clipbin") {
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
result => result,
}
}
}
+201
View File
@@ -0,0 +1,201 @@
//! Background actions shared by the native tray and single-instance endpoint.
use crate::RecordingStatus;
#[derive(Clone, Copy, Debug)]
pub enum Action {
Open,
Clip,
Bookmark,
Quit,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct State {
pub status: RecordingStatus,
pub game: Option<String>,
}
impl State {
pub fn tooltip(&self) -> String {
let mut text = format!("Clipbin · {}", self.status.label());
if let Some(game) = &self.game {
text.push_str(&format!(" · {game}"));
}
match &self.status {
RecordingStatus::Failed { reason } | RecordingStatus::Degraded { reason } => {
text.push_str(&format!(" · {reason}"));
}
_ => {}
}
text
}
pub fn colour(&self) -> [u8; 3] {
match self.status {
RecordingStatus::Recording => [239, 68, 68],
RecordingStatus::Failed { .. } | RecordingStatus::Degraded { .. } => [245, 158, 11],
_ => [168, 85, 247],
}
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
use ksni::blocking::TrayMethods;
use std::sync::mpsc::Sender;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
struct Endpoint(Sender<Action>);
#[zbus::interface(name = "dev.clipbin.Clipbin")]
impl Endpoint {
fn open(&self) {
let _ = self.0.send(Action::Open);
}
}
pub struct Instance {
_connection: zbus::blocking::Connection,
}
impl Instance {
pub fn acquire(actions: Sender<Action>) -> Result<Option<Self>, String> {
let connection = zbus::blocking::connection::Builder::session()
.map_err(|e| e.to_string())?
.serve_at("/dev/clipbin/Clipbin", Endpoint(actions))
.map_err(|e| e.to_string())?
.build()
.map_err(|e| e.to_string())?;
match connection.request_name_with_flags(
"dev.clipbin.Clipbin",
zbus::fdo::RequestNameFlags::DoNotQueue.into(),
) {
Ok(zbus::fdo::RequestNameReply::PrimaryOwner) => Ok(Some(Self {
_connection: connection,
})),
Err(zbus::Error::NameTaken) => {
let proxy = zbus::blocking::Proxy::new(
&connection,
"dev.clipbin.Clipbin",
"/dev/clipbin/Clipbin",
"dev.clipbin.Clipbin",
)
.map_err(|e| e.to_string())?;
proxy
.call::<_, _, ()>("Open", &())
.map_err(|e| e.to_string())?;
Ok(None)
}
Err(error) => Err(error.to_string()),
Ok(reply) => Err(format!("Unexpected D-Bus name response: {reply:?}")),
}
}
}
struct Item {
state: State,
actions: Sender<Action>,
available: Arc<AtomicBool>,
}
impl ksni::Tray for Item {
fn id(&self) -> String {
"clipbin".into()
}
fn title(&self) -> String {
self.state.tooltip()
}
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
let [r, g, b] = self.state.colour();
let mut data = Vec::with_capacity(32 * 32 * 4);
for y in 0_i32..32 {
for x in 0_i32..32 {
data.extend_from_slice(&[
if (x - 16).pow(2) + (y - 16).pow(2) < 196 {
255
} else {
0
},
r,
g,
b,
]);
}
}
vec![ksni::Icon {
width: 32,
height: 32,
data,
}]
}
fn tool_tip(&self) -> ksni::ToolTip {
ksni::ToolTip {
title: self.state.tooltip(),
..Default::default()
}
}
fn activate(&mut self, _: i32, _: i32) {
let _ = self.actions.send(Action::Open);
}
fn watcher_offline(&self, _: ksni::OfflineReason) -> bool {
self.available.store(false, Ordering::Relaxed);
let _ = self.actions.send(Action::Open);
true
}
fn watcher_online(&self) {
self.available.store(true, Ordering::Relaxed);
}
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
[
("Open Clipbin", Action::Open),
("Save clip", Action::Clip),
("Add bookmark", Action::Bookmark),
("Quit", Action::Quit),
]
.into_iter()
.map(|(label, action)| {
ksni::menu::StandardItem {
label: label.into(),
activate: Box::new(move |item: &mut Self| {
let _ = item.actions.send(action);
}),
..Default::default()
}
.into()
})
.collect()
}
}
pub struct Tray {
handle: ksni::blocking::Handle<Item>,
available: Arc<AtomicBool>,
}
impl Tray {
pub fn start(actions: Sender<Action>) -> Result<Self, String> {
let available = Arc::new(AtomicBool::new(true));
let handle = Item {
actions,
available: available.clone(),
state: State {
status: RecordingStatus::Idle,
game: None,
},
}
.spawn()
.map_err(|e| e.to_string())?;
Ok(Self { handle, available })
}
pub fn available(&self) -> bool {
self.available.load(Ordering::Relaxed) && !self.handle.is_closed()
}
pub fn update(&self, state: State) {
self.handle.update(move |item| item.state = state);
}
}
impl Drop for Tray {
fn drop(&mut self) {
self.handle.shutdown().wait();
}
}
}
#[cfg(windows)]
pub use crate::windows_background::{Instance, Tray};
#[cfg(target_os = "linux")]
pub use linux::{Instance, Tray};
+170
View File
@@ -0,0 +1,170 @@
//! Budget selection is pure; the recorder performs deletions and persists spans.
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone)]
pub struct Candidate {
pub session: u64,
pub index: usize,
pub started: SystemTime,
pub active: bool,
pub start: Duration,
pub bytes: u64,
pub protected: bool,
pub complete: bool,
}
/// Touching endpoints do not overlap. Bookmark post-roll reaches ahead of the
/// current session length, protecting each segment as it arrives.
pub fn protected(
segment: &crate::store::Segment,
bookmarks: &[crate::library::Bookmark],
ranges: &[(Duration, Duration)],
) -> bool {
let overlaps = |(start, end)| start < segment.end && end > segment.start;
bookmarks
.iter()
.any(|b| b.protected && overlaps((b.at.saturating_sub(b.before), b.at + b.after)))
|| ranges.iter().copied().any(overlaps)
}
/// Incomplete files count towards usage but can never be removed.
pub fn select(candidates: &[Candidate], budget: u64) -> Vec<(u64, usize)> {
let mut usage: u64 = candidates
.iter()
.filter(|c| !c.protected)
.map(|c| c.bytes)
.sum();
let mut eligible: Vec<_> = candidates
.iter()
.filter(|c| !c.protected && c.complete)
.collect();
eligible.sort_by_key(|c| (c.active, c.started, c.start, c.index));
let mut selected = Vec::new();
for c in eligible {
if usage <= budget {
break;
}
selected.push((c.session, c.index));
usage = usage.saturating_sub(c.bytes);
}
selected
}
#[cfg(test)]
mod tests {
use super::*;
fn candidate(id: u64, active: bool, protected: bool, complete: bool) -> Candidate {
Candidate {
session: id,
index: 0,
started: SystemTime::UNIX_EPOCH + Duration::from_secs(id),
active,
start: Duration::ZERO,
bytes: 10,
protected,
complete,
}
}
#[test]
fn protection_covers_future_post_roll_and_export_but_not_touching_edges() {
use crate::{library::Bookmark, store::Segment};
let segment = Segment {
path: "s.mkv".into(),
start: Duration::from_secs(12),
end: Duration::from_secs(14),
complete: true,
available: true,
frame_start: 720,
frames_per_second: 60,
dropped_frames: 0,
audio_start_samples: Vec::new(),
};
let mut bookmark = Bookmark {
at: Duration::from_secs(10),
before: Duration::from_secs(2),
after: Duration::from_secs(4),
protected: true,
};
assert!(protected(&segment, &[bookmark], &[]));
bookmark.after = Duration::from_secs(2);
assert!(!protected(&segment, &[bookmark], &[]));
bookmark.after = Duration::from_secs(10);
bookmark.protected = false;
assert!(!protected(&segment, &[bookmark], &[]));
assert!(protected(
&segment,
&[],
&[(Duration::from_secs(13), Duration::from_secs(15))]
));
assert!(!protected(
&segment,
&[],
&[(Duration::ZERO, Duration::from_secs(12))]
));
}
#[test]
fn every_budget_and_protection_writer_combination_keeps_selection_safe() {
for flags in 0..64 {
let c: Vec<_> = (0..3)
.map(|i| {
candidate(
i + 1,
i == 0,
flags & (1 << (i * 2)) != 0,
flags & (1 << (i * 2 + 1)) != 0,
)
})
.collect();
for budget in 0..=30 {
let chosen = select(&c, budget);
for (id, _) in &chosen {
let item = &c[(*id - 1) as usize];
assert!(!item.protected && item.complete);
}
let usage: u64 = c
.iter()
.filter(|item| !item.protected)
.map(|item| item.bytes)
.sum();
let remaining = usage - chosen.len() as u64 * 10;
let writer: u64 = c
.iter()
.filter(|item| !item.protected && !item.complete)
.map(|item| item.bytes)
.sum();
assert!(remaining <= budget.max(writer));
if !chosen.is_empty() {
assert!(remaining + 10 > budget);
}
}
}
}
#[test]
fn completed_sessions_precede_older_active_footage() {
let c = [
candidate(1, true, false, true),
candidate(3, false, false, true),
candidate(2, false, false, true),
];
assert_eq!(select(&c, 10), [(2, 0), (3, 0)]);
}
#[test]
fn protected_bytes_are_excluded_and_writer_is_never_selected() {
let c = [
candidate(1, false, true, true),
candidate(2, true, false, false),
candidate(3, true, false, true),
];
assert_eq!(select(&c, 10), [(3, 0)]);
assert_eq!(select(&c, 0), [(3, 0)]);
assert!(select(&c, 20).is_empty());
}
#[test]
fn oldest_segments_first_and_stop_at_budget() {
let mut c = candidate(1, false, false, true);
c.index = 1;
c.start = Duration::from_secs(2);
assert_eq!(select(&[c, candidate(1, false, false, true)], 10), [(1, 0)]);
assert!(select(&[], 0).is_empty());
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Saved MP4s and their source ranges. Clips are never part of automatic cleanup.
use crate::{
library::Clip,
store::{Store, atomic_json},
};
use serde::{Deserialize, Serialize};
use std::{fs, io, path::PathBuf, time::Duration};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedClip {
pub clip: Clip,
pub source_session: u64,
pub start: Duration,
pub end: Duration,
pub separate_audio: bool,
}
/// Pending and failed exports retain their source leases across app restarts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingExport {
pub session: u64,
pub start: Duration,
pub end: Duration,
pub separate: bool,
pub failed: bool,
}
impl Store {
pub fn pending_exports(&self) -> io::Result<Vec<PendingExport>> {
match fs::read(self.clips_directory().join("export-requests.json")) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
pub fn save_pending_exports(&self, exports: &[PendingExport]) -> io::Result<()> {
atomic_json(
&self.clips_directory().join("export-requests.json"),
&exports,
)
}
pub fn clips_directory(&self) -> PathBuf {
self.root.parent().unwrap_or(&self.root).join("clips")
}
pub fn clip_path(&self, id: u64) -> PathBuf {
self.clips_directory().join(format!("{id}.mp4"))
}
pub fn save_clip(&self, clip: &SavedClip) -> io::Result<()> {
atomic_json(
&self
.clips_directory()
.join(format!("{}.json", clip.clip.id)),
clip,
)
}
pub fn clips(&self) -> io::Result<Vec<SavedClip>> {
let entries = match fs::read_dir(self.clips_directory()) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
Err(e) => return Err(e),
};
let mut clips = vec![];
for entry in entries {
let path = entry?.path();
if path.extension().is_some_and(|e| e == "json")
&& path
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|s| s.parse::<u64>().is_ok())
{
let saved: SavedClip =
serde_json::from_slice(&fs::read(path)?).map_err(io::Error::other)?;
if self.clip_path(saved.clip.id).is_file() {
clips.push(saved);
}
}
}
clips.sort_by_key(|s| std::cmp::Reverse(s.clip.created));
Ok(clips)
}
pub fn delete_clip(&self, id: u64) -> io::Result<()> {
fs::remove_file(self.clip_path(id))?;
fs::remove_file(self.clips_directory().join(format!("{id}.json")))
}
}
/// Launch an OS association without shell interpolation, off the UI thread.
pub fn open(path: PathBuf) -> io::Result<()> {
#[cfg(target_os = "linux")]
let status = std::process::Command::new("xdg-open").arg(path).status()?;
#[cfg(windows)]
let status = std::process::Command::new("explorer.exe")
.arg(path)
.status()?;
#[cfg(not(any(target_os = "linux", windows)))]
return Err(io::Error::other(
"Opening files is unsupported on this platform",
));
#[cfg(any(target_os = "linux", windows))]
if status.success() {
Ok(())
} else {
Err(io::Error::other(format!(
"File opener exited with {status}"
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pending_and_failed_export_leases_round_trip_without_becoming_clips() {
let temp = tempfile::tempdir().unwrap();
let store = Store {
root: temp.path().join("sessions"),
};
let request = PendingExport {
session: 42,
start: Duration::from_secs(3),
end: Duration::from_secs(5),
separate: false,
failed: true,
};
store.save_pending_exports(&[request]).unwrap();
let requests = store.pending_exports().unwrap();
assert_eq!(requests.len(), 1);
assert!(requests[0].failed);
assert_eq!(requests[0].session, 42);
assert!(store.clips().unwrap().is_empty());
store.save_pending_exports(&[]).unwrap();
assert!(store.pending_exports().unwrap().is_empty());
}
#[test]
fn clips_require_a_published_mp4_and_survive_session_recovery() {
let temp = tempfile::tempdir().unwrap();
let store = Store {
root: temp.path().join("sessions"),
};
let saved = SavedClip {
clip: Clip {
id: 1,
game: "Test".into(),
created: std::time::SystemTime::UNIX_EPOCH,
length: Duration::from_secs(2),
size_bytes: 4,
},
source_session: 42,
start: Duration::from_secs(3),
end: Duration::from_secs(5),
separate_audio: false,
};
store.save_clip(&saved).unwrap();
assert!(store.clips().unwrap().is_empty());
fs::write(store.clip_path(1), b"test").unwrap();
store.recover().unwrap();
assert_eq!(store.clips().unwrap()[0].source_session, 42);
let library = store.library(1).unwrap();
assert_eq!(library.storage.clip_bytes, 4);
assert_eq!(library.storage.unprotected_bytes, 0);
store.delete_clip(1).unwrap();
assert!(store.clips().unwrap().is_empty());
}
}
+251
View File
@@ -0,0 +1,251 @@
//! OS global actions; capture and UI work stay on their own threads.
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
time::Instant,
};
#[derive(Debug, Clone, Copy)]
pub enum Action {
Clip,
Bookmark,
}
pub struct Hotkeys {
stop: Arc<AtomicBool>,
}
impl Drop for Hotkeys {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
pub fn start(
clip: u8,
bookmark: u8,
action: impl Fn(Action, Instant) + Send + Sync + 'static,
status: impl Fn(String) + Send + Sync + 'static,
) -> Hotkeys {
let stop = Arc::new(AtomicBool::new(false));
let flag = stop.clone();
thread::spawn(move || {
// Give a previous registration time to release its OS grabs on rebind.
thread::sleep(std::time::Duration::from_millis(150));
if flag.load(Ordering::Relaxed) {
return;
}
if !(1..=12).contains(&clip) || !(1..=12).contains(&bookmark) || clip == bookmark {
status("Invalid global bindings; use the Clip/Bookmark buttons".into());
return;
}
let result = platform(clip, bookmark, action, &status, flag.clone());
if let Err(e) = result
&& !flag.load(Ordering::Relaxed)
{
status(format!(
"Global hotkeys unavailable: {e}. Use Clip/Bookmark buttons."
));
}
});
Hotkeys { stop }
}
#[cfg(windows)]
mod windows;
#[cfg(windows)]
use windows::platform;
#[cfg(target_os = "linux")]
fn platform(
clip: u8,
bookmark: u8,
action: impl Fn(Action, Instant) + Send + Sync + 'static,
status: &(impl Fn(String) + Send + Sync + 'static),
stop: Arc<AtomicBool>,
) -> Result<(), String> {
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
async_io::block_on(wayland(clip, bookmark, action, status, &stop))
} else {
use x11rb::{
connection::Connection,
protocol::{
Event,
xkb::{self, ConnectionExt as _},
xproto::{ConnectionExt, GrabMode, ModMask},
},
};
let (connection, screen) = x11rb::connect(None).map_err(|e| e.to_string())?;
connection
.xkb_use_extension(1, 0)
.map_err(|e| e.to_string())?
.reply()
.map_err(|e| e.to_string())?;
connection
.xkb_per_client_flags(
xkb::ID::USE_CORE_KBD.into(),
xkb::PerClientFlag::DETECTABLE_AUTO_REPEAT,
xkb::PerClientFlag::DETECTABLE_AUTO_REPEAT,
0u32.into(),
0u32.into(),
0u32.into(),
)
.map_err(|e| e.to_string())?
.reply()
.map_err(|e| e.to_string())?;
let root = connection.setup().roots[screen].root;
let setup = connection.setup();
let keys = connection
.get_keyboard_mapping(setup.min_keycode, setup.max_keycode - setup.min_keycode + 1)
.map_err(|e| e.to_string())?
.reply()
.map_err(|e| e.to_string())?;
let lookup = |f: u8| {
keys.keysyms
.chunks(keys.keysyms_per_keycode as usize)
.position(|row| row.contains(&(0xffbd + u32::from(f))))
.map(|i| setup.min_keycode + i as u8)
.ok_or("Function key missing in X11 map")
};
let codes = [lookup(clip)?, lookup(bookmark)?];
// Ignore the common Caps/Num lock masks when grabbing.
for code in codes {
for locks in [0, 2, 16, 18] {
connection
.grab_key(
false,
root,
ModMask::CONTROL | ModMask::SHIFT | ModMask::from(locks as u16),
code,
GrabMode::ASYNC,
GrabMode::ASYNC,
)
.map_err(|e| e.to_string())?
.check()
.map_err(|e| e.to_string())?;
}
}
connection.flush().map_err(|e| e.to_string())?;
status(format!(
"Clip: Ctrl+Shift+F{clip} · Bookmark: Ctrl+Shift+F{bookmark}"
));
let mut held = [false; 2];
while !stop.load(Ordering::Relaxed) {
match connection.poll_for_event().map_err(|e| e.to_string())? {
Some(Event::KeyPress(key)) => {
if let Some(index) = codes.iter().position(|code| *code == key.detail)
&& !held[index]
{
held[index] = true;
action(
if index == 0 {
Action::Clip
} else {
Action::Bookmark
},
Instant::now(),
);
}
}
Some(Event::KeyRelease(key)) => {
if let Some(index) = codes.iter().position(|code| *code == key.detail) {
held[index] = false;
}
}
_ => {}
}
thread::sleep(std::time::Duration::from_millis(10));
}
Ok(())
}
}
#[cfg(not(any(target_os = "linux", windows)))]
fn platform(
_: u8,
_: u8,
_: impl Fn(Action, Instant) + Send + Sync + 'static,
_: &(impl Fn(String) + Send + Sync + 'static),
_: Arc<AtomicBool>,
) -> Result<(), String> {
Err("Unsupported OS".into())
}
#[cfg(target_os = "linux")]
async fn wayland(
clip: u8,
bookmark: u8,
action: impl Fn(Action, Instant),
status: &impl Fn(String),
stop: &AtomicBool,
) -> Result<(), String> {
use ashpd::desktop::global_shortcuts::{GlobalShortcuts, NewShortcut};
use futures_util::StreamExt;
let portal = GlobalShortcuts::new().await.map_err(|e| e.to_string())?;
let session = portal.create_session().await.map_err(|e| e.to_string())?;
let mut activated = portal
.receive_activated()
.await
.map_err(|e| e.to_string())?;
// ashpd exposes the session handle through Serialize, not a public accessor.
let session_handle = serde_json::to_value(&session).map_err(|e| e.to_string())?;
let clip_trigger = format!("CTRL+SHIFT+F{clip}");
let bookmark_trigger = format!("CTRL+SHIFT+F{bookmark}");
let binding = async {
portal
.bind_shortcuts(
&session,
&[
NewShortcut::new("clip", "Save recent Clipbin footage")
.preferred_trigger(Some(clip_trigger.as_str())),
NewShortcut::new("bookmark", "Protect a Clipbin bookmark")
.preferred_trigger(Some(bookmark_trigger.as_str())),
],
None,
)
.await
.map_err(|e| e.to_string())?
.response()
.map_err(|e| e.to_string())
};
let Some(bindings) = until_stopped(binding, stop).await else {
return session.close().await.map_err(|e| e.to_string());
};
let bindings = match bindings {
Ok(b) => b,
Err(e) => {
let _ = session.close().await;
return Err(e);
}
};
status(
bindings
.shortcuts()
.iter()
.map(|b| format!("{}: {}", b.id(), b.trigger_description()))
.collect::<Vec<_>>()
.join(" · "),
);
while let Some(Some(signal)) = until_stopped(activated.next(), stop).await {
if session_handle.as_str() != Some(signal.session_handle().as_str()) {
continue;
}
let kind = match signal.shortcut_id() {
"clip" => Action::Clip,
"bookmark" => Action::Bookmark,
_ => continue,
};
action(kind, Instant::now());
}
session.close().await.map_err(|e| e.to_string())
}
#[cfg(target_os = "linux")]
async fn until_stopped<F: std::future::Future>(future: F, stop: &AtomicBool) -> Option<F::Output> {
futures_lite::future::race(async { Some(future.await) }, async {
while !stop.load(Ordering::Relaxed) {
async_io::Timer::after(std::time::Duration::from_millis(100)).await;
}
None
})
.await
}
@@ -0,0 +1,53 @@
//! RegisterHotKey and its thread-local message queue are confined to this bridge.
#![allow(unsafe_code)]
use super::*;
use windows_sys::Win32::UI::{
Input::KeyboardAndMouse::{
MOD_CONTROL, MOD_NOREPEAT, MOD_SHIFT, RegisterHotKey, UnregisterHotKey, VK_F1,
},
WindowsAndMessaging::{MSG, PM_REMOVE, PeekMessageW, WM_HOTKEY},
};
pub(super) fn platform(
clip: u8,
bookmark: u8,
action: impl Fn(Action, Instant) + Send + Sync + 'static,
status: &(impl Fn(String) + Send + Sync + 'static),
stop: Arc<AtomicBool>,
) -> Result<(), String> {
// No window handle: registration and message consumption belong to this thread.
unsafe {
for (id, key) in [(1, clip), (2, bookmark)] {
if RegisterHotKey(
std::ptr::null_mut(),
id,
MOD_CONTROL | MOD_SHIFT | MOD_NOREPEAT,
u32::from(VK_F1) + u32::from(key) - 1,
) == 0
{
let error = std::io::Error::last_os_error();
UnregisterHotKey(std::ptr::null_mut(), 1);
return Err(error.to_string());
}
}
status(format!(
"Clip: Ctrl+Shift+F{clip} · Bookmark: Ctrl+Shift+F{bookmark}"
));
let mut message: MSG = std::mem::zeroed();
while !stop.load(Ordering::Relaxed) {
while PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) != 0 {
if message.message == WM_HOTKEY {
match message.wParam {
1 => action(Action::Clip, Instant::now()),
2 => action(Action::Bookmark, Instant::now()),
_ => {}
}
}
}
thread::sleep(std::time::Duration::from_millis(10));
}
UnregisterHotKey(std::ptr::null_mut(), 1);
UnregisterHotKey(std::ptr::null_mut(), 2);
}
Ok(())
}
+10
View File
@@ -15,3 +15,13 @@ pub mod store;
mod windows_process; mod windows_process;
pub mod segments; pub mod segments;
pub mod cleanup;
pub mod clips;
pub mod hotkeys;
pub mod autostart;
pub mod background;
#[cfg(windows)]
mod windows_background;
+21 -6
View File
@@ -115,13 +115,19 @@ pub fn export_range(
keyframe_interval: Duration, keyframe_interval: Duration,
session_length: Duration, session_length: Duration,
) -> (Duration, Duration) { ) -> (Duration, Duration) {
let interval = keyframe_interval.as_millis().max(1); let interval = keyframe_interval.as_nanos().max(1);
let floor = let duration = |nanos: u128| {
|time: Duration| Duration::from_millis((time.as_millis() / interval * interval) as u64); Duration::new(
let ceil = |time: Duration| { (nanos / 1_000_000_000) as u64,
Duration::from_millis((time.as_millis().div_ceil(interval) * interval) as u64) (nanos % 1_000_000_000) as u32,
)
}; };
(floor(start), ceil(end).min(session_length)) let floor = |time: Duration| duration(time.as_nanos() / interval * interval);
let ceil = |time: Duration| duration(time.as_nanos().div_ceil(interval) * interval);
(
floor(start).min(session_length),
ceil(end).min(session_length),
)
} }
/// `1:02:03` or `2:03`. /// `1:02:03` or `2:03`.
@@ -348,6 +354,15 @@ mod tests {
fn export_ranges_grow_to_keyframes_and_stop_at_the_session_end() { fn export_ranges_grow_to_keyframes_and_stop_at_the_session_end() {
let ms = Duration::from_millis; let ms = Duration::from_millis;
let second = Duration::from_secs(1); let second = Duration::from_secs(1);
assert_eq!(
export_range(
Duration::from_nanos(1_000_000_001),
Duration::from_nanos(2_000_000_001),
second,
ms(60_000)
),
(second, Duration::from_secs(3))
);
assert_eq!( assert_eq!(
export_range(ms(10_400), ms(20_100), second, ms(60_000)), export_range(ms(10_400), ms(20_100), second, ms(60_000)),
(ms(10_000), ms(21_000)) (ms(10_000), ms(21_000))
+6
View File
@@ -33,6 +33,9 @@ pub struct Settings {
pub microphone_enabled: bool, pub microphone_enabled: bool,
pub start_on_login: bool, pub start_on_login: bool,
pub clip_duration: Duration, pub clip_duration: Duration,
pub export_separate_audio: bool,
pub clip_hotkey: u8,
pub bookmark_hotkey: u8,
pub bookmark_before: Duration, pub bookmark_before: Duration,
pub bookmark_after: Duration, pub bookmark_after: Duration,
/// Protected bookmarks and exported clips are excluded from this limit. /// Protected bookmarks and exported clips are excluded from this limit.
@@ -50,6 +53,9 @@ impl Default for Settings {
microphone_enabled: false, microphone_enabled: false,
start_on_login: true, start_on_login: true,
clip_duration: Duration::from_secs(120), clip_duration: Duration::from_secs(120),
export_separate_audio: false,
clip_hotkey: 9,
bookmark_hotkey: 10,
bookmark_before: Duration::from_secs(120), bookmark_before: Duration::from_secs(120),
bookmark_after: Duration::from_secs(30), bookmark_after: Duration::from_secs(30),
unprotected_session_limit_bytes: 50_000_000_000, unprotected_session_limit_bytes: 50_000_000_000,
+6 -1
View File
@@ -1,4 +1,4 @@
/// The recorder will own these transitions once capture is implemented. /// The recorder's externally visible health.
/// ///
/// A game being detected or permission being granted is insufficient to enter /// A game being detected or permission being granted is insufficient to enter
/// Recording: a usable capture source and successful output must be confirmed. /// Recording: a usable capture source and successful output must be confirmed.
@@ -9,6 +9,10 @@ pub enum RecordingStatus {
WaitingForCapture, WaitingForCapture,
Starting, Starting,
Recording, Recording,
/// Video continues while a configured audio source is no longer delivering.
Degraded {
reason: String,
},
Failed { Failed {
reason: String, reason: String,
}, },
@@ -21,6 +25,7 @@ impl RecordingStatus {
Self::WaitingForCapture => "Waiting for capture", Self::WaitingForCapture => "Waiting for capture",
Self::Starting => "Starting", Self::Starting => "Starting",
Self::Recording => "Recording", Self::Recording => "Recording",
Self::Degraded { .. } => "Recording degraded",
Self::Failed { .. } => "Recording failed", Self::Failed { .. } => "Recording failed",
} }
} }
+155 -4
View File
@@ -26,6 +26,9 @@ pub struct Segment {
/// Cumulative dropped frames in this run when the segment completed. /// Cumulative dropped frames in this run when the segment completed.
#[serde(default)] #[serde(default)]
pub dropped_frames: u64, pub dropped_frames: u64,
/// Exact cumulative AAC samples before this file, independently per audio stream.
#[serde(default)]
pub audio_start_samples: Vec<u64>,
} }
fn available_default() -> bool { fn available_default() -> bool {
@@ -181,15 +184,24 @@ impl Store {
} }
pub fn library(&self, budget: u64) -> io::Result<Library> { pub fn library(&self, budget: u64) -> io::Result<Library> {
self.library_with_protection(budget, &[])
}
pub fn library_with_protection(
&self,
budget: u64,
ranges: &[(u64, Duration, Duration)],
) -> io::Result<Library> {
let mut library = Library::empty(budget); let mut library = Library::empty(budget);
for metadata in self.read()? { for metadata in self.read()? {
let leased: Vec<_> = ranges
.iter()
.filter(|(id, _, _)| *id == metadata.session.id)
.map(|(_, start, end)| (*start, *end))
.collect();
for segment in &metadata.segments { for segment in &metadata.segments {
let bytes = fs::metadata(self.directory(metadata.session.id).join(&segment.path)) let bytes = fs::metadata(self.directory(metadata.session.id).join(&segment.path))
.map_or(0, |f| f.len()); .map_or(0, |f| f.len());
if metadata.session.bookmarks.iter().any(|b| { if crate::cleanup::protected(segment, &metadata.session.bookmarks, &leased) {
let (start, end) = b.range(metadata.session.length);
b.protected && start < segment.end && end > segment.start
}) {
library.storage.protected_bytes += bytes; library.storage.protected_bytes += bytes;
} else { } else {
library.storage.unprotected_bytes += bytes; library.storage.unprotected_bytes += bytes;
@@ -197,6 +209,10 @@ impl Store {
} }
library.sessions.push(metadata.session); library.sessions.push(metadata.session);
} }
for saved in self.clips()? {
library.storage.clip_bytes += saved.clip.size_bytes;
library.clips.push(saved.clip);
}
library library
.sessions .sessions
.sort_by_key(|s| std::cmp::Reverse(s.started)); .sort_by_key(|s| std::cmp::Reverse(s.started));
@@ -264,6 +280,82 @@ impl Metadata {
.collect() .collect()
} }
/// Keyframes are anchored to each capture run, which may start after a gap.
pub fn export_range(&self, start: Duration, end: Duration) -> (Duration, Duration) {
let anchor = |time| {
self.segments
.iter()
.rev()
.find(|s| s.available && s.start <= time)
.map_or(time, |segment| {
segment.start.saturating_sub(frame_offset(
segment.frame_start,
segment.frames_per_second,
))
})
};
let a = anchor(start);
let b = anchor(end.saturating_sub(Duration::from_nanos(1)));
let (from, _) = crate::library::export_range(
start.saturating_sub(a),
start.saturating_sub(a),
Duration::from_secs(1),
self.session.length.max(end + Duration::from_secs(1)),
);
let (_, to) = crate::library::export_range(
end.saturating_sub(b),
end.saturating_sub(b),
Duration::from_secs(1),
self.session.length.max(end + Duration::from_secs(1)),
);
let start = (a + from).min(self.session.length);
let end = if self.session.active {
b + to
} else {
(b + to).min(self.session.length)
};
let pieces = self.covering(start, end);
let start = pieces.first().map_or(start, |p| p.session_start);
let end = if self.session.active {
end
} else {
pieces.last().map_or(end, |p| p.segment.start + p.to)
};
(start, end)
}
/// Split existing spans so deleted footage remains visible on the timeline.
pub fn removed(&mut self, index: usize) {
let segment = &mut self.segments[index];
segment.available = false;
let (start, end) = (segment.start, segment.end);
let mut spans = Vec::new();
for span in &self.session.spans {
if span.kind != SpanKind::Footage || span.end <= start || span.start >= end {
spans.push(*span);
continue;
}
if span.start < start {
spans.push(Span {
end: start,
..*span
});
}
spans.push(Span {
start: span.start.max(start),
end: span.end.min(end),
kind: SpanKind::Removed,
});
if span.end > end {
spans.push(Span {
start: end,
..*span
});
}
}
self.session.spans = spans;
}
pub fn update_run( pub fn update_run(
&mut self, &mut self,
span_base: usize, span_base: usize,
@@ -314,6 +406,7 @@ mod tests {
frame_start: start * 60, frame_start: start * 60,
frames_per_second: 60, frames_per_second: 60,
dropped_frames: 0, dropped_frames: 0,
audio_start_samples: Vec::new(),
} }
} }
@@ -333,6 +426,63 @@ mod tests {
} }
} }
#[test]
fn older_metadata_defaults_audio_clocks_without_losing_bookmarks() {
let m = timeline(vec![segment(0, 2, true)]);
let mut json = serde_json::to_value(m).unwrap();
json["segments"][0]
.as_object_mut()
.unwrap()
.remove("audio_start_samples");
let restored: Metadata = serde_json::from_value(json).unwrap();
assert!(restored.segments[0].audio_start_samples.is_empty());
assert!(restored.segments[0].available);
}
#[test]
fn export_keyframes_follow_run_anchor_and_outer_gaps_are_trimmed() {
let mut metadata = timeline(vec![segment(2, 4, true), segment(4, 6, true)]);
metadata.segments[0].frame_start = 0;
metadata.segments[1].frame_start = 120;
assert_eq!(
metadata.export_range(Duration::from_millis(2400), Duration::from_millis(5100)),
(Duration::from_secs(2), Duration::from_secs(6))
);
assert_eq!(
metadata.export_range(Duration::ZERO, Duration::from_secs(10)),
(Duration::from_secs(2), Duration::from_secs(6))
);
metadata.session.active = true;
assert_eq!(
metadata
.export_range(Duration::ZERO, Duration::from_secs(9))
.1,
Duration::from_secs(9)
);
}
#[test]
fn removal_splits_footage_preserving_missing_spans_and_is_idempotent() {
let mut metadata = timeline(vec![segment(2, 4, true)]);
metadata.session.spans = vec![
Span {
start: Duration::ZERO,
end: Duration::from_secs(6),
kind: SpanKind::Footage,
},
Span {
start: Duration::from_secs(6),
end: Duration::from_secs(8),
kind: SpanKind::Missing,
},
];
metadata.removed(0);
assert_eq!(metadata.session.removed_time(), Duration::from_secs(2));
assert_eq!(metadata.session.missing_time(), Duration::from_secs(2));
assert!(!metadata.segments[0].available);
let spans = metadata.session.spans.clone();
metadata.removed(0);
assert_eq!(metadata.session.spans, spans);
assert!(metadata.locate(Duration::from_secs(3)).is_none());
}
#[test] #[test]
fn times_map_to_segment_offsets_and_gaps_have_no_footage() { fn times_map_to_segment_offsets_and_gaps_have_no_footage() {
// 0-2 s, 2-4 s, a gap, 6-8 s, and an unavailable tail at 8-10 s. // 0-2 s, 2-4 s, a gap, 6-8 s, and an unavailable tail at 8-10 s.
@@ -450,6 +600,7 @@ mod tests {
frame_start: 0, frame_start: 0,
frames_per_second: 60, frames_per_second: 60,
dropped_frames: 0, dropped_frames: 0,
audio_start_samples: Vec::new(),
}); });
fs::write( fs::write(
store.directory(metadata.session.id).join("segment.mkv"), store.directory(metadata.session.id).join("segment.mkv"),
@@ -0,0 +1,295 @@
//! Win32 message pumps stay off GPUI's main thread.
#![allow(unsafe_code)]
use crate::background::{Action, State};
use std::{
ptr,
sync::{
Arc, OnceLock,
atomic::{AtomicBool, Ordering},
mpsc::{self, Sender},
},
thread,
time::Duration,
};
use windows_sys::Win32::{
Foundation::{
CloseHandle, ERROR_ALREADY_EXISTS, GetLastError, HANDLE, HWND, LPARAM, LRESULT, WPARAM,
},
System::{LibraryLoader::GetModuleHandleW, Threading::CreateMutexW},
UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, FindWindowW, GetMessageW,
PM_REMOVE, PeekMessageW, PostMessageW, PostQuitMessage, RegisterClassW, TranslateMessage,
UnregisterClassW, WM_APP, WM_CLOSE, WM_DESTROY, WNDCLASSW,
},
};
fn wide(text: &str) -> Vec<u16> {
text.encode_utf16().chain(Some(0)).collect()
}
static ACTIONS: OnceLock<Sender<Action>> = OnceLock::new();
const OPEN: u32 = WM_APP + 1;
unsafe extern "system" fn procedure(
window: HWND,
message: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
if message == OPEN {
if let Some(actions) = ACTIONS.get() {
let _ = actions.send(Action::Open);
}
return 0;
}
// SAFETY: this is a registered window procedure with Win32-provided arguments.
unsafe {
if message == WM_CLOSE {
DestroyWindow(window);
return 0;
}
if message == WM_DESTROY {
PostQuitMessage(0);
return 0;
}
DefWindowProcW(window, message, wparam, lparam)
}
}
pub struct Instance {
mutex: HANDLE,
window: usize,
worker: Option<thread::JoinHandle<()>>,
}
impl Instance {
pub fn acquire(actions: Sender<Action>) -> Result<Option<Self>, String> {
let name = wide(&format!(
"Local\\Clipbin-{}",
std::env::var("USERNAME").unwrap_or_default()
));
let class = wide("ClipbinSingleInstance");
// SAFETY: names are NUL-terminated, no security attributes or initial ownership.
let mutex = unsafe { CreateMutexW(ptr::null(), 0, name.as_ptr()) };
if mutex.is_null() {
return Err(std::io::Error::last_os_error().to_string());
}
// SAFETY: GetLastError is read immediately after CreateMutexW.
if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
// Startup can race the first instance's window registration.
for _ in 0..100 {
// SAFETY: class string is live; FindWindow and PostMessage accept borrowed handles.
let window = unsafe { FindWindowW(class.as_ptr(), ptr::null()) };
if !window.is_null() {
// SAFETY: send a private message to the live endpoint window.
let sent = unsafe { PostMessageW(window, OPEN, 0, 0) };
let error = std::io::Error::last_os_error();
// SAFETY: release this launch's mutex reference exactly once.
unsafe {
CloseHandle(mutex);
}
return if sent != 0 {
Ok(None)
} else {
Err(error.to_string())
};
}
thread::sleep(Duration::from_millis(50));
}
// SAFETY: release our reference, leaving the first instance's mutex intact.
unsafe {
CloseHandle(mutex);
}
return Err("Running Clipbin did not expose its reopen endpoint".into());
}
let _ = ACTIONS.set(actions);
let (ready, result) = mpsc::sync_channel(1);
let worker = thread::spawn(move || {
// SAFETY: window and message loop are owned by this thread; the class
// and instance handle remain valid through DestroyWindow.
unsafe {
let instance = GetModuleHandleW(ptr::null());
let definition = WNDCLASSW {
lpfnWndProc: Some(procedure),
hInstance: instance,
lpszClassName: class.as_ptr(),
..std::mem::zeroed()
};
RegisterClassW(&definition);
let window = CreateWindowExW(
0,
class.as_ptr(),
class.as_ptr(),
0,
0,
0,
0,
0,
ptr::null_mut(),
ptr::null_mut(),
instance,
ptr::null(),
);
let _ = ready.send(if window.is_null() {
Err(std::io::Error::last_os_error().to_string())
} else {
Ok(window as usize)
});
if window.is_null() {
return;
}
let mut message = std::mem::zeroed();
while GetMessageW(&mut message, ptr::null_mut(), 0, 0) > 0 {
TranslateMessage(&message);
DispatchMessageW(&message);
}
UnregisterClassW(class.as_ptr(), instance);
}
});
match result.recv().map_err(|e| e.to_string())? {
Ok(window) => Ok(Some(Self {
mutex,
window,
worker: Some(worker),
})),
Err(error) => {
// SAFETY: endpoint creation failed; release the acquired mutex.
unsafe {
CloseHandle(mutex);
}
let _ = worker.join();
Err(error)
}
}
}
}
impl Drop for Instance {
fn drop(&mut self) {
// SAFETY: the endpoint thread owns this HWND; posting close lets it clean up.
unsafe {
PostMessageW(self.window as HWND, WM_CLOSE, 0, 0);
}
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
// SAFETY: release the mutex reference acquired above.
unsafe {
CloseHandle(self.mutex);
}
}
}
enum Command {
Update(State),
Stop,
}
pub struct Tray {
commands: Sender<Command>,
available: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
impl Tray {
pub fn start(actions: Sender<Action>) -> Result<Self, String> {
let (commands, receiver) = mpsc::channel();
let (ready, result) = mpsc::sync_channel(1);
let available = Arc::new(AtomicBool::new(true));
let alive = available.clone();
let worker = thread::spawn(move || {
use tray_icon::{
Icon, TrayIconBuilder, TrayIconEvent,
menu::{Menu, MenuEvent, MenuItem},
};
let run = || -> Result<(), String> {
let menu = Menu::new();
let entries: Vec<_> = [
("Open Clipbin", Action::Open),
("Save clip", Action::Clip),
("Add bookmark", Action::Bookmark),
("Quit", Action::Quit),
]
.into_iter()
.map(|(label, action)| (MenuItem::new(label, true, None), action))
.collect();
for (item, _) in &entries {
menu.append(item).map_err(|e| e.to_string())?;
}
let icon = |state: &State| {
let [r, g, b] = state.colour();
Icon::from_rgba([r, g, b, 255].repeat(32 * 32), 32, 32)
.map_err(|e| e.to_string())
};
let state = State {
status: crate::RecordingStatus::Idle,
game: None,
};
let tray = TrayIconBuilder::new()
.with_menu(Box::new(menu))
.with_tooltip(state.tooltip())
.with_icon(icon(&state)?)
.build()
.map_err(|e| e.to_string())?;
let _ = ready.send(Ok(()));
loop {
// SAFETY: the tray is created and dispatched on this thread.
unsafe {
let mut message = std::mem::zeroed();
while PeekMessageW(&mut message, ptr::null_mut(), 0, 0, PM_REMOVE) != 0 {
TranslateMessage(&message);
DispatchMessageW(&message);
}
}
while let Ok(event) = MenuEvent::receiver().try_recv() {
if let Some((_, action)) =
entries.iter().find(|(item, _)| item.id() == &event.id)
{
let _ = actions.send(*action);
}
}
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
if matches!(event, TrayIconEvent::DoubleClick { .. }) {
let _ = actions.send(Action::Open);
}
}
match receiver.recv_timeout(Duration::from_millis(20)) {
Ok(Command::Update(state)) => {
tray.set_tooltip(Some(state.tooltip()))
.map_err(|e| e.to_string())?;
tray.set_icon(Some(icon(&state)?))
.map_err(|e| e.to_string())?;
}
Ok(Command::Stop) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
}
Ok(())
};
if let Err(error) = run() {
let _ = ready.send(Err(error.clone()));
eprintln!("Windows tray unavailable: {error}");
let _ = actions.send(Action::Open);
}
alive.store(false, Ordering::Relaxed);
});
result.recv().map_err(|e| e.to_string())??;
Ok(Self {
commands,
available,
worker: Some(worker),
})
}
pub fn available(&self) -> bool {
self.available.load(Ordering::Relaxed)
&& self
.worker
.as_ref()
.is_some_and(|worker| !worker.is_finished())
}
pub fn update(&self, state: State) {
let _ = self.commands.send(Command::Update(state));
}
}
impl Drop for Tray {
fn drop(&mut self) {
let _ = self.commands.send(Command::Stop);
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
+2
View File
@@ -10,6 +10,8 @@ publish.workspace = true
[dependencies] [dependencies]
clipbin-core.workspace = true clipbin-core.workspace = true
clipbin-media.workspace = true
image = { version = "0.25.10", default-features = false }
clipbin-obs.workspace = true clipbin-obs.workspace = true
gpui.workspace = true gpui.workspace = true
+73 -3
View File
@@ -17,7 +17,7 @@ use gpui::{
}; };
use crate::{ use crate::{
components::{icon, meter}, components::{ButtonStyle, button, icon, meter},
screens, screens,
text_field::{Committed, TextField}, text_field::{Committed, TextField},
theme, theme,
@@ -62,6 +62,7 @@ pub struct ClipbinApp {
pub settings: Settings, pub settings: Settings,
pub recorder: Recorder, pub recorder: Recorder,
pub editor: Editor, pub editor: Editor,
pub playback: crate::playback::Playback,
/// A short message for actions that are not connected yet. /// A short message for actions that are not connected yet.
pub notice: Option<SharedString>, pub notice: Option<SharedString>,
pub drag: Option<Drag>, pub drag: Option<Drag>,
@@ -69,6 +70,9 @@ pub struct ClipbinApp {
pub timeline_bounds: Rc<Cell<Option<Bounds<Pixels>>>>, pub timeline_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
pub fields: SettingsFields, pub fields: SettingsFields,
pub service: crate::recorder_service::Client, pub service: crate::recorder_service::Client,
pub hotkeys: String,
pub failed_exports: usize,
last_service_notice: Option<String>,
} }
/// The typed settings and the fields that edit them. /// The typed settings and the fields that edit them.
@@ -201,7 +205,7 @@ impl ClipbinApp {
let library = if sample { let library = if sample {
Library::sample(SystemTime::now()) Library::sample(SystemTime::now())
} else { } else {
Library::empty(settings.unprotected_session_limit_bytes) service.snapshot().library
}; };
let recorder = match (sample, std::env::var("CLIPBIN_SAMPLE_STATUS").as_deref()) { let recorder = match (sample, std::env::var("CLIPBIN_SAMPLE_STATUS").as_deref()) {
(_, Ok("failed")) => Recorder { (_, Ok("failed")) => Recorder {
@@ -234,11 +238,15 @@ impl ClipbinApp {
selection: (Duration::ZERO, Duration::ZERO), selection: (Duration::ZERO, Duration::ZERO),
playing: false, playing: false,
}, },
playback: crate::playback::Playback::default(),
notice: None, notice: None,
drag: None, drag: None,
timeline_bounds: Rc::default(), timeline_bounds: Rc::default(),
fields, fields,
service, service,
hotkeys: "Registering global hotkeys…".into(),
failed_exports: 0,
last_service_notice: None,
}; };
match std::env::var("CLIPBIN_ROUTE").as_deref() { match std::env::var("CLIPBIN_ROUTE").as_deref() {
Ok("clips") => app.route = Route::Clips, Ok("clips") => app.route = Route::Clips,
@@ -262,6 +270,25 @@ impl ClipbinApp {
if entity if entity
.update(cx, |app, cx| { .update(cx, |app, cx| {
let snapshot = app.service.snapshot(); let snapshot = app.service.snapshot();
if app.last_service_notice != snapshot.notice {
app.notice = snapshot.notice.clone().map(Into::into);
app.last_service_notice = snapshot.notice.clone();
cx.notify();
}
if app.failed_exports != snapshot.failed_exports {
app.failed_exports = snapshot.failed_exports;
cx.notify();
}
if app.hotkeys != snapshot.hotkeys {
app.hotkeys = snapshot.hotkeys.clone();
cx.notify();
}
app.service.protect(match app.route {
Route::Session(id) => {
Some((id, app.editor.selection.0, app.editor.selection.1))
}
_ => None,
});
if app.recorder.status != snapshot.status if app.recorder.status != snapshot.status
|| app.recorder.game != snapshot.game || app.recorder.game != snapshot.game
|| app.library != snapshot.library || app.library != snapshot.library
@@ -287,6 +314,7 @@ impl ClipbinApp {
}) })
.detach(); .detach();
} }
Self::start_playback_updates(window, cx);
app app
} }
@@ -298,7 +326,11 @@ impl ClipbinApp {
self.notice = None; self.notice = None;
match route { match route {
Route::Session(id) => self.open_session(id), Route::Session(id) => self.open_session(id),
route => self.route = route, route => {
self.playback.player.close();
self.editor.playing = false;
self.route = route;
}
} }
cx.notify(); cx.notify();
} }
@@ -322,6 +354,19 @@ impl ClipbinApp {
selection, selection,
playing: false, playing: false,
}; };
if let Ok(store) = clipbin_core::store::Store::discover() {
let preview_time = playhead.min(
session
.length
.saturating_sub(Duration::from_nanos(16_666_667)),
);
self.editor.playhead = preview_time;
if session.bookmarks.is_empty() {
self.playback.player.open_latest(store, id);
} else {
self.playback.player.open(store, id, preview_time);
}
}
self.route = Route::Session(id); self.route = Route::Session(id);
} }
@@ -438,6 +483,30 @@ impl ClipbinApp {
)), )),
) )
.child(div().flex_1()) .child(div().flex_1())
.child(
button(
"save-recent",
ButtonStyle::Secondary,
Some("scissors"),
Some("Clip"),
)
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
app.service.clip();
cx.notify();
})),
)
.child(
button(
"add-bookmark",
ButtonStyle::Secondary,
Some("bookmark"),
Some("Bookmark"),
)
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
app.service.bookmark();
cx.notify();
})),
)
.child(self.recorder_card()) .child(self.recorder_card())
.child(self.storage_card()) .child(self.storage_card())
} }
@@ -448,6 +517,7 @@ impl ClipbinApp {
theme::destructive().into(), theme::destructive().into(),
self.recorder.game.clone().unwrap_or_default(), self.recorder.game.clone().unwrap_or_default(),
), ),
RecordingStatus::Degraded { reason } => (theme::warning().into(), reason.clone()),
RecordingStatus::Starting | RecordingStatus::WaitingForCapture => ( RecordingStatus::Starting | RecordingStatus::WaitingForCapture => (
theme::warning().into(), theme::warning().into(),
self.recorder.game.clone().unwrap_or_default(), self.recorder.game.clone().unwrap_or_default(),
+209
View File
@@ -0,0 +1,209 @@
//! A bounded queue and one remux thread; source leases live on the recorder.
use clipbin_core::{
clips::{PendingExport, SavedClip},
library::Clip,
store::Store,
};
use std::{
sync::mpsc::{self, Receiver},
thread,
time::{Duration, SystemTime, UNIX_EPOCH},
};
#[derive(Clone)]
pub struct Request {
pub session: u64,
pub start: Duration,
pub end: Duration,
pub separate: bool,
pub running: bool,
pub failed: bool,
}
#[derive(Default)]
pub struct Exports {
pub requests: Vec<Request>,
result: Option<Receiver<Result<String, String>>>,
}
impl Exports {
pub fn load(store: &Store) -> Result<Self, String> {
Ok(Self {
requests: store
.pending_exports()
.map_err(|e| e.to_string())?
.into_iter()
.map(|p| Request {
session: p.session,
start: p.start,
end: p.end,
separate: p.separate,
running: false,
failed: p.failed,
})
.collect(),
result: None,
})
}
pub fn save(&self, store: &Store) -> Result<(), String> {
store
.save_pending_exports(
&self
.requests
.iter()
.map(|r| PendingExport {
session: r.session,
start: r.start,
end: r.end,
separate: r.separate,
failed: r.failed,
})
.collect::<Vec<_>>(),
)
.map_err(|e| e.to_string())
}
pub fn request(
&mut self,
store: &Store,
session: u64,
start: Duration,
end: Duration,
separate: bool,
) -> Result<(), String> {
if end <= start {
return Err("Select a non-empty range".into());
}
if self.requests.len() >= 8 {
return Err("Export queue is full; finish or release existing requests".into());
}
self.requests.push(Request {
session,
start,
end,
separate,
running: false,
failed: false,
});
if let Err(e) = self.save(store) {
self.requests.pop();
return Err(format!("Could not persist export request: {e}"));
}
Ok(())
}
pub fn poll(&mut self, store: &Store) -> Option<String> {
if let Some(receiver) = &self.result {
match receiver.try_recv() {
Ok(result) => {
let index = self.requests.iter().position(|r| r.running).unwrap();
self.result = None;
let notice = match result {
Ok(message) => {
self.requests.remove(index);
message
}
Err(error) => {
self.requests[index].running = false;
self.requests[index].failed = true;
format!("Export failed: {error}. Source range remains protected.")
}
};
if let Err(e) = self.save(store) {
return Some(format!("{notice}; could not persist export queue: {e}"));
}
return Some(notice);
}
Err(mpsc::TryRecvError::Empty) => return None,
Err(mpsc::TryRecvError::Disconnected) => {
self.result = None;
if let Some(r) = self.requests.iter_mut().find(|r| r.running) {
r.running = false;
r.failed = true;
}
let _ = self.save(store);
return Some("Export worker stopped; source range remains protected".into());
}
}
}
for request in &mut self.requests {
if request.failed {
continue;
}
let metadata = match store.metadata(request.session) {
Ok(m) => m,
Err(e) => {
request.failed = true;
let message = format!("Export failed: {e}");
let _ = self.save(store);
return Some(message);
}
};
let available_end = metadata
.segments
.iter()
.filter(|s| s.complete && s.available)
.map(|s| s.end)
.max()
.unwrap_or_default();
let (start, end) = metadata.export_range(request.start, request.end);
if metadata.session.active && available_end < end {
continue;
}
let (sender, receiver) = mpsc::channel();
let store = store.clone();
let separate = request.separate;
match thread::Builder::new()
.name("clipbin-export".into())
.spawn(move || {
let result = (|| {
std::fs::create_dir_all(store.clips_directory())
.map_err(|e| e.to_string())?;
let created = SystemTime::now();
let id = created
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let path = store.clip_path(id);
clipbin_media::export::export(&metadata, start, end, separate, &path)?;
let size_bytes = std::fs::metadata(&path).map_err(|e| e.to_string())?.len();
let saved = SavedClip {
clip: Clip {
id,
game: metadata.session.game.clone(),
created,
length: end.saturating_sub(start),
size_bytes,
},
source_session: metadata.session.id,
start,
end,
separate_audio: separate,
};
if let Err(e) = store.save_clip(&saved) {
let _ = std::fs::remove_file(path);
return Err(e.to_string());
}
Ok(format!(
"Saved clip · {} · {} – {}",
clipbin_core::library::format_length(saved.clip.length),
clipbin_core::library::format_clock(start),
clipbin_core::library::format_clock(end)
))
})();
let _ = sender.send(result);
}) {
Ok(_) => {
request.running = true;
self.result = Some(receiver);
}
Err(e) => {
request.failed = true;
return Some(format!("Could not start export: {e}"));
}
}
break;
}
None
}
pub fn busy(&self) -> bool {
self.result.is_some() || self.requests.iter().any(|r| !r.failed)
}
}
+142 -45
View File
@@ -3,11 +3,17 @@ use gpui::{
actions, px, size, actions, px, size,
}; };
use clipbin_core::background::{Action, Instance, Tray};
use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration};
mod app; mod app;
mod assets; mod assets;
mod components; mod components;
mod exports;
mod playback;
mod recorder_service; mod recorder_service;
mod screens; mod screens;
mod storage;
mod text_field; mod text_field;
mod theme; mod theme;
mod timeline_view; mod timeline_view;
@@ -22,61 +28,152 @@ fn main() {
Some(format!("Could not load settings: {error}")), Some(format!("Could not load settings: {error}")),
), ),
}; };
let (actions, receiver) = std::sync::mpsc::channel();
let _instance = match Instance::acquire(actions.clone()) {
Ok(Some(instance)) => instance,
Ok(None) => return,
Err(error) => {
eprintln!("Could not establish single instance: {error}");
return;
}
};
let tray = match Tray::start(actions) {
Ok(tray) => Some(tray),
Err(error) => {
eprintln!("Tray unavailable; closing the window will quit: {error}");
None
}
};
let initial_error = clipbin_core::autostart::reconcile(settings.start_on_login)
.err()
.map(|e| format!("Could not configure start on login: {e}"))
.or(initial_error);
let (service, worker) = recorder_service::Client::start(settings.clone(), initial_error); let (service, worker) = recorder_service::Client::start(settings.clone(), initial_error);
let interface_service = service.clone(); let interface_service = service.clone();
let tray = tray.map(Arc::new);
let background = std::env::args().any(|arg| arg == "--background");
Application::new() Application::new()
.with_assets(assets::Assets) .with_assets(assets::Assets)
.run(|cx: &mut App| { .run(move |cx: &mut App| {
cx.set_quit_on_last_window_close(false);
if let Err(error) = assets::load_fonts(cx) { if let Err(error) = assets::load_fonts(cx) {
eprintln!("Could not load the interface font: {error}"); eprintln!("Could not load the interface font: {error}");
} }
let quitter = interface_service.clone(); let quitter = interface_service.clone();
cx.on_action(move |_: &Quit, cx| { cx.on_action(move |_: &Quit, _| quitter.quit());
quitter.quit(); cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
let service = quitter.clone(); cx.set_menus(vec![gpui::Menu {
cx.spawn(async move |cx| { name: "Clipbin".into(),
loop { items: vec![gpui::MenuItem::action("Quit Clipbin", Quit)],
gpui::Timer::after(std::time::Duration::from_millis(100)).await; }]);
if service.snapshot().exited { text_field::bind_keys(cx);
let _ = cx.update(|cx| cx.quit()); playback::bind_keys(cx);
break; let ui = Rc::new(RefCell::new(Interface {
window: None,
route: app::Route::Sessions,
}));
if !background || tray.is_none() {
open_window(cx, &interface_service, &ui, &tray);
}
cx.spawn(async move |cx| {
let mut last_state = None;
loop {
gpui::Timer::after(Duration::from_millis(100)).await;
let state = interface_service.background_state();
if last_state.as_ref() != Some(&state) {
if let Some(tray) = &tray {
tray.update(state.clone());
}
last_state = Some(state);
}
while let Ok(action) = receiver.try_recv() {
match action {
Action::Open => {
let _ =
cx.update(|cx| open_window(cx, &interface_service, &ui, &tray));
}
Action::Clip => interface_service.clip(),
Action::Bookmark => interface_service.bookmark(),
Action::Quit => interface_service.quit(),
} }
} }
}) if interface_service.exited() {
.detach(); let _ = cx.update(|cx| cx.quit());
}); break;
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]); }
text_field::bind_keys(cx); }
})
let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx); .detach();
let result = cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
window_min_size: Some(size(px(1040.), px(640.))),
titlebar: Some(TitlebarOptions {
title: Some("Clipbin".into()),
..Default::default()
}),
app_id: Some("dev.clipbin.Clipbin".into()),
..Default::default()
},
|window, cx| {
cx.new(|cx| app::ClipbinApp::new(window, cx, settings, interface_service))
},
);
if let Err(error) = result {
eprintln!("Could not open Clipbin: {error}");
cx.quit();
return;
}
cx.activate(true);
}); });
// GPUI 0.2.2 ends its Linux event loop after the last window closes. service.quit();
// Finish the current session after closing; stop discovery and exit when
// its game ends. Reopening the window needs the future tray integration.
service.window_closed();
let _ = worker.join(); let _ = worker.join();
drop(service); }
struct Interface {
window: Option<gpui::WindowHandle<app::ClipbinApp>>,
route: app::Route,
}
fn open_window(
cx: &mut App,
service: &recorder_service::Client,
ui: &Rc<RefCell<Interface>>,
tray: &Option<Arc<Tray>>,
) {
let current = ui.borrow().window;
if let Some(window) = current
&& window
.update(cx, |_, window, _| window.activate_window())
.is_ok()
{
return;
}
let settings = clipbin_core::store::load_settings().unwrap_or_default();
let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx);
let window_service = service.clone();
let result = cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
window_min_size: Some(size(px(1040.), px(640.))),
titlebar: Some(TitlebarOptions {
title: Some("Clipbin".into()),
..Default::default()
}),
app_id: Some("dev.clipbin.Clipbin".into()),
..Default::default()
},
move |window, cx| cx.new(|cx| app::ClipbinApp::new(window, cx, settings, window_service)),
);
let window = match result {
Ok(window) => window,
Err(error) => {
eprintln!("Could not open Clipbin: {error}");
service.quit();
return;
}
};
let route = ui.borrow().route;
ui.borrow_mut().window = Some(window);
let close_service = service.clone();
let close_tray = tray.clone();
let close_ui = ui.clone();
let _ = window.update(cx, |app, window, cx| {
if route != app::Route::Sessions {
app.navigate(route, cx);
}
let entity = cx.entity();
window.on_window_should_close(cx, move |_, cx| {
if !close_tray.as_ref().is_some_and(|tray| tray.available()) {
close_service.quit();
return false;
}
let mut ui = close_ui.borrow_mut();
ui.route = entity.read(cx).route;
ui.window = None;
eprintln!("Window closed: Clipbin is running in the tray");
true
});
});
playback::bind_actions(window, cx);
cx.activate(true);
} }
+279
View File
@@ -0,0 +1,279 @@
//! GPUI image upload and controls; file I/O and decoding belong to the player.
use crate::app::{ClipbinApp, Route};
use clipbin_core::store::Store;
use clipbin_media::{
player::{Picture, Player},
thumbnail::Thumbnails,
};
use gpui::{Context, RenderImage};
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration};
pub struct Playback {
pub player: Player,
pub image: Option<Arc<RenderImage>>,
pub message: Option<String>,
revision: u64,
last_frame: Option<Arc<clipbin_media::decode::Frame>>,
pub muted: bool,
pub volume: f32,
pub audio_error: Option<String>,
pub preview_height: f32,
pub thumbnails: HashMap<u64, PathBuf>,
pub clip_thumbnails: HashMap<u64, PathBuf>,
}
impl Default for Playback {
fn default() -> Self {
Self {
player: Player::new(),
image: None,
message: Some("Loading footage…".into()),
revision: 0,
last_frame: None,
muted: false,
volume: 1.,
audio_error: None,
preview_height: 416.,
thumbnails: HashMap::new(),
clip_thumbnails: HashMap::new(),
}
}
}
impl ClipbinApp {
pub fn seek_playback(&mut self, time: Duration) {
self.editor.playhead = time;
self.playback.player.seek(time);
}
pub fn toggle_playback(&mut self) {
if let Route::Session(id) = self.route {
if self
.library
.session(id)
.is_some_and(|session| self.editor.playhead >= session.length)
{
self.seek_playback(Duration::ZERO);
}
self.editor.playing = !self.editor.playing;
self.playback.player.play(self.editor.playing);
}
}
pub fn start_playback_updates(window: &gpui::Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async |entity, cx| {
loop {
gpui::Timer::after(Duration::from_millis(16)).await;
if entity
.update_in(cx, |app, window, cx| {
if !matches!(app.route, Route::Session(_)) {
if let Some(image) = app.playback.image.take() {
let _ = window.drop_image(image);
}
app.playback.last_frame = None;
return;
}
let snapshot = app.playback.player.snapshot();
if snapshot.revision == app.playback.revision {
return;
}
app.playback.revision = snapshot.revision;
app.editor.playhead = snapshot.time;
app.editor.playing = snapshot.playing;
app.playback.audio_error = snapshot.audio_error;
match snapshot.picture {
Picture::Frame(frame) => {
// Swscale already produced GPUI-native BGRA off the UI thread.
if app
.playback
.last_frame
.as_ref()
.is_none_or(|last| !Arc::ptr_eq(last, &frame))
{
let upload_started = std::time::Instant::now();
let bytes = frame.bgra.clone();
if let Some(buffer) =
image::RgbaImage::from_raw(frame.width, frame.height, bytes)
{
let image =
Arc::new(RenderImage::new(vec![image::Frame::new(
buffer,
)]));
if let Some(old) = app.playback.image.replace(image) {
let _ = window.drop_image(old);
}
if std::env::var_os("CLIPBIN_TRACE_FRAMES").is_some() {
eprintln!(
"playback_ui upload_ms={:.3} width={} height={}",
upload_started.elapsed().as_secs_f64() * 1000.,
frame.width,
frame.height
);
}
}
}
app.playback.last_frame = Some(frame);
app.playback.message = None;
}
Picture::Loading => {
if let Some(old) = app.playback.image.take() {
let _ = window.drop_image(old);
}
app.playback.last_frame = None;
app.playback.message = Some("Loading footage…".into());
}
Picture::Gap => {
if let Some(old) = app.playback.image.take() {
let _ = window.drop_image(old);
}
app.playback.last_frame = None;
app.playback.message =
Some("No footage (not captured / cleaned up)".into());
}
Picture::Error(error) => {
if let Some(old) = app.playback.image.take() {
let _ = window.drop_image(old);
}
app.playback.last_frame = None;
app.playback.message = Some(error);
}
}
cx.notify();
})
.is_err()
{
break;
}
}
})
.detach();
// Discovery and thumbnail work never touch the UI thread's filesystem.
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let thumbnails = Thumbnails::new();
loop {
let mut ready = HashMap::new();
let mut clips = HashMap::new();
if let Ok(store) = Store::discover()
&& let Ok(metadata) = store.read()
{
for metadata in metadata {
if let Some(segment) = metadata
.segments
.iter()
.find(|segment| segment.complete && segment.available)
&& let Some(path) = thumbnails
.request(&store.directory(metadata.session.id).join(&segment.path))
{
ready.insert(metadata.session.id, path);
}
}
}
if let Ok(store) = Store::discover()
&& let Some(root) = store.root.parent()
&& let Ok(entries) = std::fs::read_dir(root.join("clips"))
{
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|extension| extension == "mp4")
&& let Some(id) = path
.file_stem()
.and_then(|id| id.to_str())
.and_then(|id| id.parse().ok())
&& let Some(thumbnail) = thumbnails.request(&path)
{
clips.insert(id, thumbnail);
}
}
}
if matches!(
sender.try_send((ready, clips)),
Err(std::sync::mpsc::TrySendError::Disconnected(_))
) {
break;
}
std::thread::sleep(Duration::from_secs(1));
}
});
cx.spawn(async move |entity, cx| {
loop {
gpui::Timer::after(Duration::from_millis(250)).await;
let ready = receiver.try_recv().ok();
if entity
.update(cx, |app, cx| {
if let Some((sessions, clips)) = ready
&& (app.playback.thumbnails != sessions
|| app.playback.clip_thumbnails != clips)
{
app.playback.thumbnails = sessions;
app.playback.clip_thumbnails = clips;
cx.notify();
}
})
.is_err()
{
break;
}
}
})
.detach();
}
}
gpui::actions!(playback, [TogglePlayback, StepBack, StepForward]);
pub fn bind_keys(cx: &mut gpui::App) {
cx.bind_keys([
gpui::KeyBinding::new("space", TogglePlayback, None),
gpui::KeyBinding::new("k", TogglePlayback, None),
gpui::KeyBinding::new("left", StepBack, None),
gpui::KeyBinding::new("j", StepBack, None),
gpui::KeyBinding::new("right", StepForward, None),
gpui::KeyBinding::new("l", StepForward, None),
]);
cx.bind_keys(
["space", "k", "j", "l"]
.map(|key| gpui::KeyBinding::new(key, gpui::NoAction {}, Some("TextField"))),
);
}
/// Window actions also work when a mouse click leaves no focused GPUI element.
/// Text-field contexts keep their own typing and arrow bindings.
pub fn bind_actions(window: gpui::WindowHandle<ClipbinApp>, cx: &mut gpui::App) {
// The event dispatcher already borrows the window; update its entity directly.
let editor = window.entity(cx).expect("editor window").downgrade();
let toggle = editor.clone();
cx.on_action(move |_: &TogglePlayback, cx| {
let _ = toggle.update(cx, |app, cx| {
app.toggle_playback();
cx.notify();
});
});
let back = editor.clone();
cx.on_action(move |_: &StepBack, cx| {
let _ = back.update(cx, |app, cx| {
app.step_playback(false);
cx.notify();
});
});
cx.on_action(move |_: &StepForward, cx| {
let _ = editor.update(cx, |app, cx| {
app.step_playback(true);
cx.notify();
});
});
}
impl ClipbinApp {
pub fn step_playback(&mut self, forward: bool) {
if !matches!(self.route, Route::Session(_)) {
return;
}
self.editor.playing = false;
self.playback.player.play(false);
let snapshot = self.playback.player.snapshot();
let step = Duration::from_secs_f64(1. / snapshot.frames_per_second.max(1) as f64);
let time = snapshot.video_time.unwrap_or(self.editor.playhead);
// Round towards the selected frame when nanoseconds cannot represent a frame period.
let at = if forward {
time + step
} else {
time.saturating_sub(step)
} + Duration::from_nanos(2);
self.seek_playback(at);
}
}
+332 -21
View File
@@ -2,7 +2,7 @@
use clipbin_core::{ use clipbin_core::{
Library, RecordingStatus, Settings, Library, RecordingStatus, Settings,
games::{self, ExitWatch, Game}, games::{self, ExitWatch, Game},
library::{Span, SpanKind}, library::{Bookmark, Span, SpanKind},
store::{self, Metadata, Segment, Store, frame_offset, segment_offset}, store::{self, Metadata, Segment, Store, frame_offset, segment_offset},
}; };
use clipbin_obs::{ use clipbin_obs::{
@@ -25,6 +25,10 @@ pub struct Snapshot {
pub library: Library, pub library: Library,
pub restore_token: Option<String>, pub restore_token: Option<String>,
pub exited: bool, pub exited: bool,
pub notice: Option<String>,
pub metadata: Vec<Metadata>,
pub hotkeys: String,
pub failed_exports: usize,
} }
#[derive(Clone)] #[derive(Clone)]
pub struct Client { pub struct Client {
@@ -34,8 +38,16 @@ pub struct Client {
enum Command { enum Command {
Settings(Settings), Settings(Settings),
ChooseSource, ChooseSource,
WindowClosed,
Quit, Quit,
Clip(Instant),
Bookmark(Instant),
Export(u64, Duration, Duration),
Release(u64, usize),
Protect(crate::storage::Editing),
DeleteClip(u64),
Open(Option<u64>),
RetryExports,
ReleaseExports,
} }
impl Client { impl Client {
pub fn start( pub fn start(
@@ -51,12 +63,17 @@ impl Client {
library: Library::empty(settings.unprotected_session_limit_bytes), library: Library::empty(settings.unprotected_session_limit_bytes),
restore_token: settings.portal_restore_token.clone(), restore_token: settings.portal_restore_token.clone(),
exited: false, exited: false,
notice: None,
metadata: Vec::new(),
hotkeys: "Registering global hotkeys…".into(),
failed_exports: 0,
})); }));
let state = snapshot.clone(); let state = snapshot.clone();
let action_sender = commands.clone();
let worker = thread::Builder::new() let worker = thread::Builder::new()
.name("clipbin-recorder".into()) .name("clipbin-recorder".into())
.spawn(move || { .spawn(move || {
if let Err(error) = worker(settings, receiver, &state) { if let Err(error) = worker(settings, receiver, &state, action_sender) {
state.lock().unwrap().status = RecordingStatus::Failed { reason: error }; state.lock().unwrap().status = RecordingStatus::Failed { reason: error };
} }
}) })
@@ -67,15 +84,63 @@ impl Client {
pub fn snapshot(&self) -> Snapshot { pub fn snapshot(&self) -> Snapshot {
self.snapshot.lock().unwrap().clone() self.snapshot.lock().unwrap().clone()
} }
pub fn background_state(&self) -> clipbin_core::background::State {
let snapshot = self.snapshot.lock().unwrap();
clipbin_core::background::State {
status: snapshot.status.clone(),
game: snapshot.game.clone(),
}
}
pub fn exited(&self) -> bool {
self.snapshot.lock().unwrap().exited
}
pub fn settings(&self, settings: Settings) { pub fn settings(&self, settings: Settings) {
let _ = self.commands.send(Command::Settings(settings)); let _ = self.commands.send(Command::Settings(settings));
} }
pub fn clip(&self) {
let _ = self.commands.send(Command::Clip(Instant::now()));
}
pub fn bookmark(&self) {
let _ = self.commands.send(Command::Bookmark(Instant::now()));
}
pub fn export(&self, id: u64, start: Duration, end: Duration) {
let _ = self.commands.send(Command::Export(id, start, end));
}
pub fn release(&self, id: u64, index: usize) {
let _ = self.commands.send(Command::Release(id, index));
}
pub fn protect(&self, editing: crate::storage::Editing) {
let _ = self.commands.send(Command::Protect(editing));
}
pub fn delete_clip(&self, id: u64) {
let _ = self.commands.send(Command::DeleteClip(id));
}
pub fn open_clip(&self, id: Option<u64>) {
let _ = self.commands.send(Command::Open(id));
}
pub fn export_range(
&self,
id: u64,
start: Duration,
end: Duration,
) -> Option<(Duration, Duration)> {
self.snapshot
.lock()
.unwrap()
.metadata
.iter()
.find(|m| m.session.id == id)
.map(|m| m.export_range(start, end))
}
pub fn retry_exports(&self) {
let _ = self.commands.send(Command::RetryExports);
}
pub fn release_exports(&self) {
let _ = self.commands.send(Command::ReleaseExports);
}
pub fn choose_source(&self) { pub fn choose_source(&self) {
let _ = self.commands.send(Command::ChooseSource); let _ = self.commands.send(Command::ChooseSource);
} }
pub fn window_closed(&self) {
let _ = self.commands.send(Command::WindowClosed);
}
pub fn quit(&self) { pub fn quit(&self) {
if self.commands.send(Command::Quit).is_err() { if self.commands.send(Command::Quit).is_err() {
self.snapshot.lock().unwrap().exited = true; self.snapshot.lock().unwrap().exited = true;
@@ -93,8 +158,10 @@ struct Active {
run: u32, run: u32,
run_base: Option<Duration>, run_base: Option<Duration>,
span_base: usize, span_base: usize,
span_start: Duration,
last_frames: u64, last_frames: u64,
last_dropped: u64, last_dropped: u64,
audio_samples: Vec<u64>,
fps: u32, fps: u32,
stopping: bool, stopping: bool,
ending: bool, ending: bool,
@@ -137,7 +204,14 @@ impl Active {
self.run += 1; self.run += 1;
self.run_base = None; self.run_base = None;
self.span_base = self.metadata.session.spans.len(); self.span_base = self.metadata.session.spans.len();
self.span_start = self
.metadata
.session
.spans
.last()
.map_or(Duration::ZERO, |s| s.end);
self.last_frames = 0; self.last_frames = 0;
self.audio_samples.clear();
self.last_dropped = 0; self.last_dropped = 0;
self.tracker = StatusTracker::new( self.tracker = StatusTracker::new(
Instant::now(), Instant::now(),
@@ -178,6 +252,11 @@ impl Active {
{ {
segment.end = end.max(segment.start); segment.end = end.max(segment.start);
} }
for index in 0..self.metadata.segments.len() {
if !self.metadata.segments[index].available {
self.metadata.removed(index);
}
}
} }
fn finish_run(&mut self, store: &Store) -> Result<(), String> { fn finish_run(&mut self, store: &Store) -> Result<(), String> {
let mut empty_tail = false; let mut empty_tail = false;
@@ -210,6 +289,14 @@ impl Active {
if let Some(segment) = self.metadata.segments.last_mut() if let Some(segment) = self.metadata.segments.last_mut()
&& !segment.complete && !segment.complete
{ {
if segment.audio_start_samples.is_empty() && segment.frame_start == 0 {
let path = store
.directory(self.metadata.session.id)
.join(&segment.path);
if let Ok(samples) = clipbin_media::export::audio_samples(&path) {
segment.audio_start_samples = vec![0; samples.len()];
}
}
segment.complete = true; segment.complete = true;
segment.dropped_frames = self.last_dropped; segment.dropped_frames = self.last_dropped;
if self.last_dropped > 0 { if self.last_dropped > 0 {
@@ -220,6 +307,12 @@ impl Active {
} }
} }
self.span_base = self.metadata.session.spans.len(); self.span_base = self.metadata.session.spans.len();
self.span_start = self
.metadata
.session
.spans
.last()
.map_or(Duration::ZERO, |s| s.end);
self.run_base = None; self.run_base = None;
self.recording = None; self.recording = None;
self.stopping = false; self.stopping = false;
@@ -254,9 +347,14 @@ fn worker(
mut settings: Settings, mut settings: Settings,
commands: Receiver<Command>, commands: Receiver<Command>,
snapshot: &Arc<Mutex<Snapshot>>, snapshot: &Arc<Mutex<Snapshot>>,
action_sender: Sender<Command>,
) -> Result<(), String> { ) -> Result<(), String> {
let store = Store::discover().map_err(|e| e.to_string())?; let store = Store::discover().map_err(|e| e.to_string())?;
store.recover().map_err(|e| e.to_string())?; store.recover().map_err(|e| e.to_string())?;
if let Err(e) = crate::storage::index_legacy_audio(&store) {
snapshot.lock().unwrap().notice =
Some(format!("Could not recover exact audio clocks: {e}"));
}
snapshot.lock().unwrap().library = store snapshot.lock().unwrap().library = store
.library(settings.unprotected_session_limit_bytes) .library(settings.unprotected_session_limit_bytes)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -273,19 +371,35 @@ fn worker(
None None
} }
}; };
let register = |settings: &Settings| {
let sender = action_sender.clone();
let state = snapshot.clone();
clipbin_core::hotkeys::start(
settings.clip_hotkey,
settings.bookmark_hotkey,
move |action, pressed| {
let _ = sender.send(match action {
clipbin_core::hotkeys::Action::Clip => Command::Clip(pressed),
clipbin_core::hotkeys::Action::Bookmark => Command::Bookmark(pressed),
});
},
move |message| {
state.lock().unwrap().hotkeys = message;
},
)
};
let mut hotkeys = Some(register(&settings));
let mut active: Option<Active> = None; let mut active: Option<Active> = None;
let mut quitting = false; let mut quitting = false;
let mut finish_after_session = false;
let mut next_scan = Instant::now(); let mut next_scan = Instant::now();
let mut next_poll = Instant::now(); let mut next_poll = Instant::now();
let mut next_save = Instant::now(); let mut next_save = Instant::now();
let mut previous_status = None; let mut previous_status = None;
let mut exports = crate::exports::Exports::load(&store)?;
let mut editing = None;
let mut next_cleanup = Instant::now();
loop { loop {
match commands.recv_timeout(Duration::from_millis(200)) { match commands.recv_timeout(Duration::from_millis(200)) {
Ok(Command::WindowClosed) => {
finish_after_session = true;
eprintln!("Window closed: finishing the current game session, then exiting");
}
Ok(Command::Quit) => { Ok(Command::Quit) => {
quitting = true; quitting = true;
if let Some(a) = &mut active { if let Some(a) = &mut active {
@@ -299,14 +413,126 @@ fn worker(
} }
} }
Ok(Command::Settings(new)) => { Ok(Command::Settings(new)) => {
if new.clip_hotkey != settings.clip_hotkey
|| new.bookmark_hotkey != settings.bookmark_hotkey
{
hotkeys.take();
hotkeys = Some(register(&new));
}
settings = new; settings = new;
snapshot.lock().unwrap().library.storage.budget_bytes = snapshot.lock().unwrap().library.storage.budget_bytes =
settings.unprotected_session_limit_bytes; settings.unprotected_session_limit_bytes;
if let Err(e) = store::save_settings(&settings) { if let Err(e) = store::save_settings(&settings) {
fail(snapshot, format!("Could not save settings: {e}")); fail(snapshot, format!("Could not save settings: {e}"));
} }
if let Err(e) = clipbin_core::autostart::reconcile(settings.start_on_login) {
snapshot.lock().unwrap().notice =
Some(format!("Could not configure start on login: {e}"));
}
snapshot.lock().unwrap().restore_token = settings.portal_restore_token.clone(); snapshot.lock().unwrap().restore_token = settings.portal_restore_token.clone();
} }
Ok(Command::Clip(pressed)) => {
let result = if let Some(a) = &active {
let end = pressed.saturating_duration_since(a.started);
exports.request(
&store,
a.metadata.session.id,
end.saturating_sub(settings.clip_duration),
end,
settings.export_separate_audio,
)
} else {
Err("No active session to clip".into())
};
snapshot.lock().unwrap().notice = Some(result.err().unwrap_or_else(|| {
"Clip queued at press time; waiting for the segment to finish".into()
}));
}
Ok(Command::Bookmark(pressed)) => {
if let Some(a) = &mut active {
a.metadata.session.bookmarks.push(Bookmark {
at: pressed.saturating_duration_since(a.started),
before: settings.bookmark_before,
after: settings.bookmark_after,
protected: true,
});
store.save(&a.metadata).map_err(|e| e.to_string())?;
snapshot.lock().unwrap().notice = Some("Bookmark saved and protected".into());
next_save = Instant::now();
} else {
snapshot.lock().unwrap().notice = Some("No active session to bookmark".into());
}
}
Ok(Command::Export(id, start, end)) => {
snapshot.lock().unwrap().notice = Some(
exports
.request(&store, id, start, end, settings.export_separate_audio)
.err()
.unwrap_or_else(|| "Export queued; source range protected".into()),
);
}
Ok(Command::Release(id, index)) => {
let mut m = store
.read()
.map_err(|e| e.to_string())?
.into_iter()
.find(|m| m.session.id == id);
if let Some(m) = &mut m {
if let Some(b) = m.session.bookmarks.get_mut(index) {
b.protected = false;
}
store.save(m).map_err(|e| e.to_string())?;
if let Some(a) = &mut active
&& a.metadata.session.id == id
{
a.metadata.session.bookmarks = m.session.bookmarks.clone();
}
}
next_cleanup = Instant::now();
}
Ok(Command::Protect(range)) => editing = range,
Ok(Command::DeleteClip(id)) => {
if let Err(e) = store.delete_clip(id) {
snapshot.lock().unwrap().notice = Some(format!("Could not delete clip: {e}"));
}
next_cleanup = Instant::now();
}
Ok(Command::Open(id)) => {
let path = id.map_or_else(|| store.clips_directory(), |id| store.clip_path(id));
let state = snapshot.clone();
thread::spawn(move || {
let result = if id.is_none() {
std::fs::create_dir_all(&path)
.and_then(|()| clipbin_core::clips::open(path))
} else {
clipbin_core::clips::open(path)
};
if let Err(e) = result {
state.lock().unwrap().notice = Some(format!("Could not open: {e}"));
}
});
}
Ok(Command::RetryExports) => {
for r in &mut exports.requests {
r.failed = false;
}
snapshot.lock().unwrap().notice = Some(exports.save(&store).err().map_or_else(
|| "Retrying failed exports".into(),
|e| format!("Could not persist export queue: {e}"),
));
}
Ok(Command::ReleaseExports) => {
let previous = exports.requests.clone();
exports.requests.retain(|r| !r.failed);
snapshot.lock().unwrap().notice =
Some("Failed export source protection released".into());
next_cleanup = Instant::now();
if let Err(e) = exports.save(&store) {
exports.requests = previous;
snapshot.lock().unwrap().notice =
Some(format!("Could not persist released protection: {e}"));
}
}
Ok(Command::ChooseSource) => { Ok(Command::ChooseSource) => {
settings.portal_restore_token = None; settings.portal_restore_token = None;
if let Err(e) = store::save_settings(&settings) { if let Err(e) = store::save_settings(&settings) {
@@ -327,7 +553,7 @@ fn worker(
Err(mpsc::RecvTimeoutError::Disconnected) => quitting = true, Err(mpsc::RecvTimeoutError::Disconnected) => quitting = true,
Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Timeout) => {}
} }
if (quitting || finish_after_session) && active.is_none() { if quitting && active.is_none() && !exports.busy() {
break; break;
} }
let now = Instant::now(); let now = Instant::now();
@@ -354,8 +580,10 @@ fn worker(
run: 0, run: 0,
run_base: None, run_base: None,
span_base: 0, span_base: 0,
span_start: Duration::ZERO,
last_frames: 0, last_frames: 0,
last_dropped: 0, last_dropped: 0,
audio_samples: Vec::new(),
fps: settings.frames_per_second, fps: settings.frames_per_second,
stopping: false, stopping: false,
ending: false, ending: false,
@@ -427,16 +655,20 @@ fn worker(
} }
if !a.ending && !a.stopping { if !a.ending && !a.stopping {
let status = a.tracker.sample(&health, now).clone(); let status = a.tracker.sample(&health, now).clone();
if status == RecordingStatus::Recording { if matches!(
status,
RecordingStatus::Recording | RecordingStatus::Degraded { .. }
) {
a.retries = 0; a.retries = 0;
} }
// Keep a failure visible throughout recovery until footage is proven. // Keep a failure visible throughout recovery until footage is proven.
if status == RecordingStatus::Recording if matches!(
|| !matches!( status,
snapshot.lock().unwrap().status, RecordingStatus::Recording | RecordingStatus::Degraded { .. }
RecordingStatus::Failed { .. } ) || !matches!(
) snapshot.lock().unwrap().status,
{ RecordingStatus::Failed { .. }
) {
snapshot.lock().unwrap().status = status.clone(); snapshot.lock().unwrap().status = status.clone();
} }
if matches!(status, RecordingStatus::Failed { .. }) { if matches!(status, RecordingStatus::Failed { .. }) {
@@ -479,6 +711,30 @@ fn worker(
if let Some(previous) = a.metadata.segments.last_mut() if let Some(previous) = a.metadata.segments.last_mut()
&& !previous.complete && !previous.complete
{ {
let path =
store.directory(a.metadata.session.id).join(&previous.path);
match clipbin_media::export::audio_samples(&path) {
Ok(samples) => {
if a.audio_samples.is_empty() {
if !previous.audio_start_samples.is_empty() {
a.audio_samples =
previous.audio_start_samples.clone();
} else if previous.frame_start == 0 {
a.audio_samples = vec![0; samples.len()];
}
}
previous.audio_start_samples = a.audio_samples.clone();
for (total, count) in
a.audio_samples.iter_mut().zip(samples)
{
*total += count;
}
}
Err(e) => {
eprintln!("Could not index segment audio: {e}");
a.audio_samples.clear();
}
}
previous.end = offset; previous.end = offset;
previous.complete = true; previous.complete = true;
previous.dropped_frames = dropped_frames; previous.dropped_frames = dropped_frames;
@@ -503,6 +759,7 @@ fn worker(
frame_start: total_frames.saturating_sub(1), frame_start: total_frames.saturating_sub(1),
frames_per_second: a.fps, frames_per_second: a.fps,
dropped_frames: 0, dropped_frames: 0,
audio_start_samples: a.audio_samples.clone(),
}); });
} }
RecorderEvent::OutputStopped { code, last_error } => { RecorderEvent::OutputStopped { code, last_error } => {
@@ -542,15 +799,69 @@ fn worker(
drop(state); drop(state);
snapshot.lock().unwrap().game = None; snapshot.lock().unwrap().game = None;
active = None; active = None;
next_cleanup = now;
} else { } else {
store.save(&a.metadata).map_err(|e| e.to_string())?; store.save(&a.metadata).map_err(|e| e.to_string())?;
} }
match store.library(settings.unprotected_session_limit_bytes) { match store.library_with_protection(
Ok(library) => snapshot.lock().unwrap().library = library, settings.unprotected_session_limit_bytes,
&crate::storage::ranges(&exports, editing),
) {
Ok(library) => {
let metadata = store.read().map_err(|e| e.to_string())?;
let mut state = snapshot.lock().unwrap();
state.library = library;
state.metadata = metadata;
}
Err(e) => fail(snapshot, e), Err(e) => fail(snapshot, e),
} }
} }
} }
if let Some(message) = exports.poll(&store) {
eprintln!("export_status={message}");
snapshot.lock().unwrap().notice = Some(message);
next_cleanup = now;
}
if now >= next_cleanup {
next_cleanup = now + Duration::from_secs(3);
if let Some(a) = &mut active {
store.save(&a.metadata).map_err(|e| e.to_string())?;
}
match crate::storage::cleanup(
&store,
settings.unprotected_session_limit_bytes,
&exports,
editing,
) {
Ok(metadata) => {
if let Some(a) = &mut active
&& let Some(m) = metadata
.iter()
.find(|m| m.session.id == a.metadata.session.id)
{
a.metadata = m.clone();
a.span_base = a
.metadata
.session
.spans
.iter()
.position(|s| s.start >= a.span_start)
.unwrap_or(a.metadata.session.spans.len());
}
snapshot.lock().unwrap().metadata = metadata;
}
Err(e) => snapshot.lock().unwrap().notice = Some(format!("Cleanup failed: {e}")),
}
match store.library_with_protection(
settings.unprotected_session_limit_bytes,
&crate::storage::ranges(&exports, editing),
) {
Ok(library) => snapshot.lock().unwrap().library = library,
Err(e) => fail(snapshot, e),
}
}
snapshot.lock().unwrap().failed_exports =
exports.requests.iter().filter(|r| r.failed).count();
let status = snapshot.lock().unwrap().status.clone(); let status = snapshot.lock().unwrap().status.clone();
if previous_status.as_ref() != Some(&status) { if previous_status.as_ref() != Some(&status) {
eprintln!("recorder_status={status:?}"); eprintln!("recorder_status={status:?}");
+112 -28
View File
@@ -1,12 +1,14 @@
//! Exported clips: local MP4 files that never expire automatically. //! Exported clips: local MP4 files that never expire automatically.
use clipbin_core::library::{format_ago, format_bytes, format_length}; use clipbin_core::library::{format_ago, format_bytes, format_length};
use gpui::{AnyElement, ClickEvent, Context, FontWeight, IntoElement, div, prelude::*}; use gpui::{
AnyElement, ClickEvent, Context, FontWeight, IntoElement, Pixels, canvas, div, prelude::*, px,
};
use crate::{ use crate::{
app::ClipbinApp, app::ClipbinApp,
components::{ButtonStyle, button, chip, count, empty_state, page_header}, components::{ButtonStyle, button, chip, count, empty_state, page_header},
screens::{corner, muted_line, thumbnail}, screens::{corner, thumbnail},
theme, theme,
}; };
@@ -32,20 +34,50 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
Some("Open folder"), Some("Open folder"),
) )
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| { .on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
app.notice = app.service.open_clip(None);
Some("Opening the clips folder comes with the recorder integration.".into());
cx.notify(); cx.notify();
})) }))
.into_any_element(), .into_any_element(),
], ],
); );
let failed_actions = (app.failed_exports > 0).then(|| {
div()
.flex()
.items_center()
.gap_2()
.child(format!(
"{} failed exports · source footage kept",
app.failed_exports
))
.child(
button("retry-exports", ButtonStyle::Secondary, None, Some("Retry")).on_click(
cx.listener(|app, _: &ClickEvent, _, cx| {
app.service.retry_exports();
cx.notify();
}),
),
)
.child(
button(
"release-exports",
ButtonStyle::Ghost,
None,
Some("Release protection"),
)
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
app.service.release_exports();
cx.notify();
})),
)
});
if clips.is_empty() { if clips.is_empty() {
return div() return div()
.flex() .flex()
.flex_col() .flex_col()
.gap_6() .gap_6()
.child(header) .child(header)
.children(failed_actions)
.child(empty_state( .child(empty_state(
"scissors", "scissors",
"No clips yet", "No clips yet",
@@ -56,12 +88,14 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
let now = app.now(); let now = app.now();
let cards = clips.iter().map(|clip| { let cards = clips.iter().map(|clip| {
let id = clip.id;
div() div()
.flex() .flex()
.flex_col() .flex_col()
.min_w_0()
.gap_3() .gap_3()
.child( .child(
thumbnail(&clip.game).child( thumbnail(&clip.game, app.playback.clip_thumbnails.get(&clip.id)).child(
corner() corner()
.bottom_2p5() .bottom_2p5()
.right_2p5() .right_2p5()
@@ -71,14 +105,14 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
.child( .child(
div() div()
.flex() .flex()
.items_start() .flex_col()
.min_w_0()
.gap_2() .gap_2()
.px_1() .px_1()
.child( .child(
div() div()
.flex() .flex()
.flex_col() .flex_col()
.flex_1()
.min_w_0() .min_w_0()
.gap_0p5() .gap_0p5()
.child( .child(
@@ -86,31 +120,48 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
.text_base() .text_base()
.font_weight(FontWeight::SEMIBOLD) .font_weight(FontWeight::SEMIBOLD)
.text_color(theme::foreground()) .text_color(theme::foreground())
.truncate() .child(label(clip.game.clone(), px(24.))),
.child(clip.game.clone()),
) )
.child(muted_line(format!( .child(div().text_xs().text_color(theme::subtle()).child(label(
"{} · {}", format!(
format_ago(clip.created, now), "{} · {}",
format_bytes(clip.size_bytes) format_ago(clip.created, now),
format_bytes(clip.size_bytes)
),
px(18.),
))), ))),
) )
.child( .child(
button( div()
("open-clip", clip.id), .flex()
ButtonStyle::Ghost, .items_center()
Some("play"), .justify_end()
None, .gap_2()
) .flex_shrink_0()
.rounded_full() .child(
.on_click(cx.listener( button(("open-clip", id), ButtonStyle::Ghost, Some("play"), None)
|app, _: &ClickEvent, _, cx| { .flex_shrink_0()
app.notice = Some( .rounded_full()
"Opening clips comes with the recorder integration.".into(), .on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
); app.service.open_clip(Some(id));
cx.notify(); cx.notify();
}, })),
)), )
.child(
button(
("delete-clip", id),
ButtonStyle::Ghost,
None,
Some("Delete"),
)
.flex_shrink_0()
.on_click(cx.listener(
move |app, _: &ClickEvent, _, cx| {
app.service.delete_clip(id);
cx.notify();
},
)),
),
), ),
) )
}); });
@@ -120,6 +171,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
.flex_col() .flex_col()
.gap_6() .gap_6()
.child(header) .child(header)
.children(failed_actions)
.child( .child(
div() div()
.grid() .grid()
@@ -130,3 +182,35 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
) )
.into_any_element() .into_any_element()
} }
// GPUI 0.2.2 caches nowrap widths and mutates text runs during remeasurement.
// Shape once at the final card bounds to retain both ellipsis and font styling.
fn label(text: String, height: Pixels) -> impl IntoElement {
canvas(
move |bounds, window, cx| {
let style = window.text_style();
let font_size = style.font_size.to_pixels(window.rem_size());
let line_height = style
.line_height
.to_pixels(font_size.into(), window.rem_size());
let text: gpui::SharedString = text.replace(['\n', '\r'], " ").into();
let mut runs = vec![style.to_run(text.len())];
let text = cx
.text_system()
.line_wrapper(style.font(), font_size)
.truncate_line(text, bounds.size.width, "…", &mut runs);
(
window
.text_system()
.shape_line(text, font_size, &runs, None),
line_height,
)
},
|bounds, (line, line_height), window, cx| {
let _ = line.paint(bounds.origin, line_height, window, cx);
},
)
.w_full()
.h(height)
.overflow_hidden()
}
+144 -43
View File
@@ -6,15 +6,14 @@ use clipbin_core::library::{
Session, SpanKind, export_range, format_ago, format_clock, format_length, Session, SpanKind, export_range, format_ago, format_clock, format_length,
}; };
use gpui::{ use gpui::{
AnyElement, Bounds, ClickEvent, Context, FontWeight, Hsla, IntoElement, MouseButton, AnyElement, Bounds, ClickEvent, Context, FontWeight, Hsla, ImageSource, IntoElement,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollWheelEvent, canvas, div, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ObjectFit, Pixels, Point,
pattern_slash, prelude::*, px, relative, ScrollWheelEvent, canvas, div, img, pattern_slash, prelude::*, px, relative,
}; };
use crate::{ use crate::{
app::{ClipbinApp, Drag, Route}, app::{ClipbinApp, Drag, Route},
components::{ButtonStyle, aspect_ratio, button, chip, icon, panel, row}, components::{ButtonStyle, aspect_ratio, button, chip, icon, panel, row},
screens::backdrop,
theme, theme,
timeline_view::TimelineView, timeline_view::TimelineView,
}; };
@@ -73,37 +72,54 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
); );
let preview = aspect_ratio(div(), 16. / 9.) let preview = aspect_ratio(div(), 16. / 9.)
.h(px(app.playback.preview_height))
.flex_shrink_0()
.relative() .relative()
.w_full() .w_full()
.overflow_hidden() .overflow_hidden()
.rounded_lg() .rounded_lg()
.bg(gpui::black()) .bg(gpui::black())
.child(backdrop(&session.game).absolute().size_full().opacity(0.55)) .children(app.playback.image.clone().map(|image| {
.child( img(ImageSource::Render(image))
.absolute()
.size_full()
.object_fit(ObjectFit::Contain)
}))
.children(app.playback.message.clone().map(|message| {
div() div()
.absolute() .absolute()
.size_full() .size_full()
.flex() .flex()
.flex_col()
.items_center() .items_center()
.justify_center() .justify_center()
.gap_3() .text_xs()
.child( .text_color(theme::muted())
div() .child(message)
.flex() }))
.items_center() .child(
.justify_center() canvas(
.size(px(56.)) {
.rounded_full() let player = cx.entity().downgrade();
.bg(theme::overlay()) move |bounds, window, cx| {
.child(icon("play").size_6().text_color(theme::foreground())), let scale = window.scale_factor();
) let size = (
.child( (f32::from(bounds.size.width) * scale) as u32,
div() (f32::from(bounds.size.height) * scale) as u32,
.text_xs() );
.text_color(theme::muted()) let _ = player.update(cx, |app, cx| {
.child("Preview arrives with playback"), app.playback.player.resize(size);
), let height = f32::from(bounds.size.width) * 9. / 16.;
if (app.playback.preview_height - height).abs() > 1. {
app.playback.preview_height = height;
cx.notify();
}
});
}
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
) )
.child( .child(
div() div()
@@ -126,6 +142,7 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
.map(|b| b.at); .map(|b| b.at);
let transport = div() let transport = div()
.flex() .flex()
.flex_wrap()
.items_center() .items_center()
.gap_1() .gap_1()
.child( .child(
@@ -136,8 +153,7 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
None, None,
) )
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| { .on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
app.notice = app.toggle_playback();
Some("Playback isn't connected yet; it comes with the decoder.".into());
cx.notify(); cx.notify();
})), })),
) )
@@ -150,7 +166,7 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
) )
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| { .on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
if let Some(at) = previous_bookmark { if let Some(at) = previous_bookmark {
app.editor.playhead = at; app.seek_playback(at);
cx.notify(); cx.notify();
} }
})), })),
@@ -164,7 +180,7 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
) )
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| { .on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
if let Some(at) = next_bookmark { if let Some(at) = next_bookmark {
app.editor.playhead = at; app.seek_playback(at);
cx.notify(); cx.notify();
} }
})), })),
@@ -187,6 +203,52 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
.child(format!("/ {}", format_clock(length))), .child(format!("/ {}", format_clock(length))),
), ),
) )
.child(
button(
"mute",
ButtonStyle::Ghost,
Some(if app.playback.muted {
"volume-x"
} else {
"volume-2"
}),
None,
)
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
app.playback.muted = !app.playback.muted;
app.playback
.player
.audio(app.playback.muted, app.playback.volume);
cx.notify();
})),
)
.child(
button("volume-down", ButtonStyle::Ghost, None, Some("−")).on_click(cx.listener(
|app, _: &ClickEvent, _, cx| {
app.playback.volume = (app.playback.volume - 0.1).max(0.);
app.playback
.player
.audio(app.playback.muted, app.playback.volume);
cx.notify();
},
)),
)
.child(
div()
.text_xs()
.child(format!("{:.0}%", app.playback.volume * 100.)),
)
.child(
button("volume-up", ButtonStyle::Ghost, None, Some("+")).on_click(cx.listener(
|app, _: &ClickEvent, _, cx| {
app.playback.volume = (app.playback.volume + 0.1).min(1.);
app.playback
.player
.audio(app.playback.muted, app.playback.volume);
cx.notify();
},
)),
)
.child(div().flex_1()) .child(div().flex_1())
.child( .child(
div() div()
@@ -210,9 +272,18 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
.gap_3() .gap_3()
.child(preview) .child(preview)
.child(transport) .child(transport)
.children(
app.playback
.audio_error
.clone()
.map(|error| div().text_xs().text_color(theme::warning()).child(error)),
)
.child(timeline(app, session, cx)); .child(timeline(app, session, cx));
let (export_start, export_end) = export_range(start, end, KEYFRAME_INTERVAL, length); let (export_start, export_end) = app
.service
.export_range(id, start, end)
.unwrap_or_else(|| export_range(start, end, KEYFRAME_INTERVAL, length));
let clip_panel = panel("CLIP") let clip_panel = panel("CLIP")
.child(row("Start", value(format_clock(start)))) .child(row("Start", value(format_clock(start))))
.child(row("End", value(format_clock(end)))) .child(row("End", value(format_clock(end))))
@@ -243,10 +314,10 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
) )
.child(row( .child(row(
"Audio", "Audio",
value(if app.settings.microphone_enabled { value(if app.settings.export_separate_audio {
"Game + mic, mixed".to_owned() "System + mic, if recorded".to_owned()
} else { } else {
"Game audio".to_owned() "Combined source audio".to_owned()
}), }),
)) ))
.child( .child(
@@ -258,11 +329,8 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
Some("Export clip"), Some("Export clip"),
) )
.flex_1() .flex_1()
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| { .on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
app.notice = Some( app.service.export(id, start, end);
"Exporting isn't connected yet; it comes with the recorder integration."
.into(),
);
cx.notify(); cx.notify();
})), })),
), ),
@@ -319,8 +387,25 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
"Released" "Released"
}), }),
) )
.when(bookmark.protected, |row| {
row.child(
button(
("release-bookmark", index),
ButtonStyle::Ghost,
None,
Some("Release"),
)
.on_click(cx.listener(
move |app, _: &ClickEvent, _, cx| {
app.service.release(id, index);
cx.stop_propagation();
cx.notify();
},
)),
)
})
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| { .on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
app.editor.playhead = at; app.seek_playback(at);
app.editor.selection = range; app.editor.selection = range;
cx.notify(); cx.notify();
})) }))
@@ -529,9 +614,25 @@ fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -
.size_full() .size_full()
.overflow_hidden() .overflow_hidden()
.child( .child(
canvas(move |area, _, _| bounds.set(Some(area)), |_, _, _, _| {}) canvas(
.absolute() move |area, _, _| {
.size_full(), if bounds.get() != Some(area)
&& std::env::var_os("CLIPBIN_TRACE_FRAMES").is_some()
{
eprintln!(
"playback_timeline x={} y={} width={} height={}",
f32::from(area.left()),
f32::from(area.top()),
f32::from(area.size.width),
f32::from(area.size.height)
);
}
bounds.set(Some(area));
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
) )
.child( .child(
div() div()
@@ -565,7 +666,7 @@ fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -
} else if near(end) { } else if near(end) {
Drag::SelectionEnd Drag::SelectionEnd
} else { } else {
app.editor.playhead = time; app.seek_playback(time);
Drag::Playhead Drag::Playhead
}); });
cx.notify(); cx.notify();
@@ -583,7 +684,7 @@ fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -
let (start, end) = app.editor.selection; let (start, end) = app.editor.selection;
let minimum = Duration::from_secs(1); let minimum = Duration::from_secs(1);
match drag { match drag {
Drag::Playhead => app.editor.playhead = time, Drag::Playhead => app.seek_playback(time),
Drag::SelectionStart => { Drag::SelectionStart => {
app.editor.selection.0 = time.min(end.saturating_sub(minimum)) app.editor.selection.0 = time.min(end.saturating_sub(minimum))
} }
+14 -1
View File
@@ -9,7 +9,20 @@ use crate::{components::aspect_ratio, theme};
/// A 16:9 thumbnail. Until frames are decoded, each game gets its own colour /// A 16:9 thumbnail. Until frames are decoded, each game gets its own colour
/// with its initials, so cards stay recognisable. /// with its initials, so cards stay recognisable.
pub fn thumbnail(game: &str) -> Div { pub fn thumbnail(game: &str, path: Option<&std::path::PathBuf>) -> Div {
if let Some(path) = path {
return aspect_ratio(div(), 16. / 9.)
.relative()
.w_full()
.overflow_hidden()
.rounded_xl()
.child(
gpui::img(path.clone())
.absolute()
.size_full()
.object_fit(gpui::ObjectFit::Cover),
);
}
let tint = game_tint(game); let tint = game_tint(game);
let initials: String = game let initials: String = game
.split_whitespace() .split_whitespace()
@@ -61,7 +61,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
.cursor_pointer() .cursor_pointer()
.group("card") .group("card")
.child( .child(
thumbnail(&session.game) thumbnail(&session.game, app.playback.thumbnails.get(&session.id))
.border_1() .border_1()
.border_color(gpui::transparent_black()) .border_color(gpui::transparent_black())
.group_hover("card", |style| { .group_hover("card", |style| {
@@ -93,6 +93,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
) )
.child( .child(
section("volume-2", "Audio") section("volume-2", "Audio")
.child(setting("Export separate tracks", "Keep system and microphone tracks for editing; otherwise copy the combined track.",toggle("export-audio", settings.export_separate_audio, |s| s.export_separate_audio = !s.export_separate_audio,cx)))
.child(setting( .child(setting(
"Game audio", "Game audio",
"Everything your speakers play.", "Everything your speakers play.",
@@ -121,7 +122,9 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
.child(labelled("Before", &app.fields.bookmark_before)) .child(labelled("Before", &app.fields.bookmark_before))
.child(labelled("After", &app.fields.bookmark_after)), .child(labelled("After", &app.fields.bookmark_after)),
)) ))
.child(setting("Hotkeys", "Global hotkeys arrive with the hotkey integration.", value("Not set yet".into()))), .child(setting("Clip binding", "Ctrl+Shift plus this function key. Changes apply immediately.", segmented("clip-binding",cx,&[("F9",settings.clip_hotkey==9,|s|s.clip_hotkey=9),("F11",settings.clip_hotkey==11,|s|s.clip_hotkey=11)])))
.child(setting("Bookmark binding", "Ctrl+Shift plus this function key. Portal desktops may override it.", segmented("bookmark-binding",cx,&[("F10",settings.bookmark_hotkey==10,|s|s.bookmark_hotkey=10),("F12",settings.bookmark_hotkey==12,|s|s.bookmark_hotkey=12)])))
.child(setting("Hotkeys", "Ctrl+Shift+F9: clip · Ctrl+Shift+F10: bookmark. Wayland's portal handles approval and rebinding.", value(app.hotkeys.clone()))),
) )
.child( .child(
section("hard-drive", "Storage") section("hard-drive", "Storage")
+124
View File
@@ -0,0 +1,124 @@
//! Cleanup runs only on the recorder, where requests and the current file are known.
use crate::exports::Exports;
use clipbin_core::{
cleanup::{Candidate, select},
store::{Metadata, Store},
};
use std::time::Duration;
pub type Editing = Option<(u64, Duration, Duration)>;
pub fn ranges(exports: &Exports, editing: Editing) -> Vec<(u64, Duration, Duration)> {
let mut ranges: Vec<_> = exports
.requests
.iter()
.map(|r| {
(
r.session,
r.start.saturating_sub(Duration::from_secs(1)),
r.end + Duration::from_secs(1),
)
})
.collect();
if let Some((id, start, end)) = editing {
ranges.push((
id,
start.saturating_sub(Duration::from_secs(1)),
end + Duration::from_secs(1),
));
}
ranges
}
pub fn cleanup(
store: &Store,
budget: u64,
exports: &Exports,
editing: Editing,
) -> Result<Vec<Metadata>, String> {
index_legacy_audio(store)?;
let mut metadata = store.read().map_err(|e| e.to_string())?;
let mut candidates = Vec::new();
let all_ranges = ranges(exports, editing);
for m in &metadata {
let ranges: Vec<_> = all_ranges
.iter()
.filter(|(id, _, _)| *id == m.session.id)
.map(|(_, start, end)| (*start, *end))
.collect();
for (index, s) in m.segments.iter().enumerate().filter(|(_, s)| s.available) {
let protected = clipbin_core::cleanup::protected(s, &m.session.bookmarks, &ranges);
candidates.push(Candidate {
session: m.session.id,
index,
started: m.session.started,
active: m.session.active,
start: s.start,
bytes: std::fs::metadata(store.directory(m.session.id).join(&s.path))
.map_or(0, |f| f.len()),
protected,
complete: s.complete,
});
}
}
for (id, index) in select(&candidates, budget) {
let m = metadata.iter_mut().find(|m| m.session.id == id).unwrap();
let path = store.directory(id).join(&m.segments[index].path);
// Persist unavailability first: a crash must never advertise a deleted file.
let previous = m.clone();
m.removed(index);
store.save(m).map_err(|e| e.to_string())?;
if let Err(e) = std::fs::remove_file(&path) {
*m = previous;
store.save(m).map_err(|e| e.to_string())?;
return Err(format!("Could not clean {}: {e}", path.display()));
}
}
Ok(metadata)
}
/// Upgrade old sessions before cleanup can discard the files needed to recover
/// their separate audio clocks. New recordings index one closed file per split.
pub fn index_legacy_audio(store: &Store) -> Result<(), String> {
for mut m in store.read().map_err(|e| e.to_string())? {
if !m
.segments
.iter()
.any(|s| s.complete && s.available && s.audio_start_samples.is_empty())
{
continue;
}
let mut totals = Vec::new();
for s in &mut m.segments {
if s.frame_start == 0 {
totals.clear();
}
if !s.audio_start_samples.is_empty() {
totals = s.audio_start_samples.clone();
}
if !s.complete {
if !totals.is_empty() {
s.audio_start_samples = totals.clone();
}
continue;
}
if !s.available {
totals.clear();
continue;
}
let samples =
clipbin_media::export::audio_samples(&store.directory(m.session.id).join(&s.path))
.map_err(|e| e.to_string())?;
if totals.is_empty() {
if s.frame_start != 0 {
return Err("Earlier audio clock data is missing".into());
}
totals = vec![0; samples.len()];
}
s.audio_start_samples = totals.clone();
for (total, count) in totals.iter_mut().zip(samples) {
*total += count;
}
}
store.save(&m).map_err(|e| e.to_string())?;
}
Ok(())
}
+10 -2
View File
@@ -11,6 +11,14 @@ publish.workspace = true
[dependencies] [dependencies]
clipbin-core.workspace = true clipbin-core.workspace = true
ffmpeg-next = { version = "9", default-features = false, features = ["codec", "format", "software-scaling", "software-resampling"] } ffmpeg-next = { version = "9", default-features = false, features = ["codec", "format", "software-scaling", "software-resampling"] }
image = { version = "0.25.10", default-features = false, features = ["png"] }
cpal = { version = "0.18.2", features = ["pulseaudio"] }
[lints] [target.'cfg(target_os = "linux")'.dependencies]
workspace = true rustix = { version = "1", features = ["process", "thread"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_System_Threading"] }
[lints.rust]
unsafe_code = "deny"
+23
View File
@@ -0,0 +1,23 @@
//! Validation/repair helper: sessions-dir session-id start-ms end-ms output.mp4.
use std::{path::PathBuf, time::Duration};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
if args.len() != 5 {
return Err("Usage: export sessions-dir session-id start-ms end-ms output.mp4".into());
}
let store = clipbin_core::store::Store {
root: PathBuf::from(&args[0]),
};
let metadata = store.metadata(args[1].parse()?)?;
let (start, end) = metadata.export_range(
Duration::from_millis(args[2].parse()?),
Duration::from_millis(args[3].parse()?),
);
clipbin_media::export::export(&metadata, start, end, false, &PathBuf::from(&args[4]))?;
println!(
"Exported {} – {}",
clipbin_core::library::format_clock(start),
clipbin_core::library::format_clock(end)
);
Ok(())
}
@@ -0,0 +1,67 @@
//! Headless timing check against real segments; run inside the rig for device audio.
use clipbin_core::store::Store;
use clipbin_media::player::{Picture, Player};
use std::{
thread,
time::{Duration, Instant},
};
fn main() {
let directory = std::env::args().nth(1).expect("session root");
let id = std::env::args()
.nth(2)
.unwrap_or_else(|| "1".into())
.parse()
.unwrap();
let player = Player::new();
player.resize((800, 450));
player.open(
Store {
root: directory.into(),
},
id,
Duration::ZERO,
);
thread::sleep(Duration::from_millis(250));
player.play(true);
let begin = Instant::now();
let mut last = None;
let mut frames = 0;
for _ in 0..700 {
thread::sleep(Duration::from_millis(10));
let snapshot = player.snapshot();
assert!(snapshot.audio_error.is_none(), "{:?}", snapshot.audio_error);
if let Picture::Frame(frame) = snapshot.picture {
if let Some(video_time) = snapshot.video_time {
assert!(
video_time.abs_diff(snapshot.time) <= Duration::from_millis(20),
"video out of sync: {video_time:?} vs {:?}",
snapshot.time
);
}
if last
.as_ref()
.is_none_or(|previous| !std::sync::Arc::ptr_eq(previous, &frame))
{
frames += 1;
}
last = Some(frame);
}
}
let snapshot = player.snapshot();
println!(
"playback_check wall={:.3} session={:.3} displayed_frames={frames}",
begin.elapsed().as_secs_f64(),
snapshot.time.as_secs_f64()
);
assert!(
snapshot.time >= Duration::from_secs(6),
"audio master clock stalled: {snapshot:?}"
);
player.audio(false, 0.5);
thread::sleep(Duration::from_secs(1));
player.audio(true, 0.5);
let before = snapshot.time;
thread::sleep(Duration::from_secs(1));
assert!(player.snapshot().time > before, "muted wall clock stalled");
player.close();
}
+454
View File
@@ -0,0 +1,454 @@
//! Combined-track decoding and bounded device output. No decoder runs in a callback.
use crate::ffmpeg::{self, format, media, software::resampling, util::frame::Audio as AudioFrame};
use clipbin_core::store::Metadata;
use cpal::{
FromSample, SizedSample,
traits::{DeviceTrait, HostTrait, StreamTrait},
};
use std::{
collections::VecDeque,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::{Duration, Instant},
};
struct Block {
time: Duration,
samples: Vec<f32>,
consumed: usize,
}
struct Buffer {
blocks: VecDeque<Block>,
queued: usize,
playing: bool,
volume: f32,
// The time of the first sample actually scheduled for the device.
anchors: VecDeque<(Duration, Instant, Duration)>,
error: Option<String>,
}
impl Buffer {
fn time(&mut self, now: Instant) -> Option<Duration> {
while self.anchors.len() > 1 && self.anchors[1].1 <= now {
self.anchors.pop_front();
}
self.anchors.front().and_then(|(time, begins, span)| {
(now <= *begins + *span + Duration::from_millis(100))
.then(|| *time + now.saturating_duration_since(*begins).min(*span))
})
}
}
pub struct Audio {
_stream: cpal::Stream,
buffer: Arc<Mutex<Buffer>>,
rate: u32,
channels: u16,
decoder: Option<(PathBuf, AudioDecoder)>,
next: Duration,
}
impl Audio {
pub fn new() -> Result<Self, String> {
#[cfg(target_os = "linux")]
if let Ok(host) = cpal::host_from_id(cpal::HostId::PulseAudio) {
match Self::with_host(host) {
Ok(audio) => return Ok(audio),
Err(error) => eprintln!("PulseAudio output unavailable: {error}; trying ALSA"),
}
}
Self::with_host(cpal::default_host())
}
fn with_host(host: cpal::Host) -> Result<Self, String> {
let device = host
.default_output_device()
.ok_or("No audio output device")?;
let supported = device.default_output_config().map_err(|e| e.to_string())?;
let format = supported.sample_format();
let config: cpal::StreamConfig = supported.into();
let rate = config.sample_rate;
let channels = config.channels;
let buffer = Arc::new(Mutex::new(Buffer {
blocks: VecDeque::new(),
queued: 0,
playing: false,
volume: 1.,
anchors: VecDeque::new(),
error: None,
}));
let stream = match format {
cpal::SampleFormat::F32 => output::<f32>(&device, config, buffer.clone()),
cpal::SampleFormat::I16 => output::<i16>(&device, config, buffer.clone()),
cpal::SampleFormat::U16 => output::<u16>(&device, config, buffer.clone()),
cpal::SampleFormat::I32 => output::<i32>(&device, config, buffer.clone()),
cpal::SampleFormat::F64 => output::<f64>(&device, config, buffer.clone()),
_ => return Err(format!("Unsupported audio device format: {format}")),
}
.map_err(|e| e.to_string())?;
stream.play().map_err(|e| e.to_string())?;
eprintln!("playback_audio rate={rate} channels={channels} format={format}");
Ok(Self {
_stream: stream,
buffer,
rate,
channels,
decoder: None,
next: Duration::ZERO,
})
}
pub fn reset(&mut self, time: Duration) {
let mut buffer = self.buffer.lock().unwrap();
buffer.blocks.clear();
buffer.queued = 0;
buffer.anchors.clear();
self.decoder = None;
self.next = time;
}
pub fn controls(&self, playing: bool, volume: f32) {
let mut buffer = self.buffer.lock().unwrap();
buffer.playing = playing;
buffer.volume = volume.clamp(0., 1.);
if !playing {
buffer.anchors.clear();
}
}
/// Device time, corrected for callback-to-DAC latency; bounded by scheduled samples.
pub fn time(&self, now: Instant) -> Option<Duration> {
self.buffer.lock().unwrap().time(now)
}
pub fn error(&self) -> Option<String> {
self.buffer.lock().unwrap().error.clone()
}
pub fn catch_up(&mut self, time: Duration) {
if self.next + Duration::from_millis(50) < time && self.buffer.lock().unwrap().queued == 0 {
self.reset(time);
}
}
pub fn fill(&mut self, metadata: &Metadata) -> Result<(), ffmpeg::Error> {
// At most 250 ms buffered. All allocations/decoding happen on the player thread.
let limit = self.rate as usize * self.channels as usize / 4;
for _ in 0..16 {
if self.buffer.lock().unwrap().queued >= limit {
break;
}
let Some(at) = metadata.locate(self.next).filter(|at| at.segment.complete) else {
break;
};
if self
.decoder
.as_ref()
.is_none_or(|(path, _)| path != &at.segment.path)
{
self.decoder = Some((
at.segment.path.clone(),
AudioDecoder::open(&at.segment.path, self.rate, self.channels, at.offset)?,
));
}
let (_, decoder) = self.decoder.as_mut().unwrap();
let Some((offset, mut samples)) = decoder.next()? else {
self.next = at.segment.end;
self.decoder = None;
continue;
};
let begin = at.segment.start + offset;
let skip = ((self.next.saturating_sub(begin).as_secs_f64() * self.rate as f64).round()
as usize)
.min(samples.len() / self.channels as usize);
samples.drain(..skip * self.channels as usize);
let time = begin + Duration::from_secs_f64(skip as f64 / self.rate as f64);
let keep = ((at.segment.end.saturating_sub(time).as_secs_f64() * self.rate as f64)
.round() as usize)
* self.channels as usize;
samples.truncate(keep);
if samples.is_empty() {
continue;
}
self.next = time
+ Duration::from_secs_f64(
(samples.len() / self.channels as usize) as f64 / self.rate as f64,
);
if at.segment.end.saturating_sub(self.next)
< Duration::from_nanos(1_000_000_000 / self.rate as u64)
{
self.next = at.segment.end;
}
let mut buffer = self.buffer.lock().unwrap();
buffer.queued += samples.len();
buffer.blocks.push_back(Block {
time,
samples,
consumed: 0,
});
}
Ok(())
}
}
fn output<T: SizedSample + FromSample<f32>>(
device: &cpal::Device,
config: cpal::StreamConfig,
buffer: Arc<Mutex<Buffer>>,
) -> Result<cpal::Stream, cpal::Error> {
let errors = buffer.clone();
let rate = config.sample_rate;
let channels = config.channels as usize;
device.build_output_stream(
config,
move |data: &mut [T], info: &cpal::OutputCallbackInfo| {
// try_lock guarantees UI/decoder contention cannot block the device callback.
data.fill(T::from_sample(0.));
let Ok(mut buffer) = buffer.try_lock() else {
return;
};
if !buffer.playing {
return;
}
let latency = info
.timestamp()
.playback
.duration_since(info.timestamp().callback);
let mut scheduled = None;
for (frame_number, output) in data.chunks_exact_mut(channels).enumerate() {
let volume = buffer.volume;
let Some(block) = buffer.blocks.front_mut() else {
break;
};
let time = block.time
+ Duration::from_secs_f64((block.consumed / channels) as f64 / rate as f64);
scheduled.get_or_insert((
time,
Instant::now()
+ latency
+ Duration::from_secs_f64(frame_number as f64 / rate as f64),
Duration::ZERO,
));
for sample in output {
*sample =
T::from_sample((block.samples[block.consumed] * volume).clamp(-1., 1.));
block.consumed += 1;
}
let done = block.consumed == block.samples.len();
buffer.queued -= channels;
if let Some((_, _, span)) = scheduled.as_mut() {
*span += Duration::from_secs_f64(1. / rate as f64);
}
if done {
buffer.blocks.pop_front();
}
}
if let Some(scheduled) = scheduled {
if buffer.anchors.len() == 32 {
buffer.anchors.pop_front();
}
buffer.anchors.push_back(scheduled);
}
},
move |error| {
if let Ok(mut buffer) = errors.lock() {
buffer.error = Some(format!("Audio output failed: {error}"));
}
},
None,
)
}
struct AudioDecoder {
input: format::context::Input,
decoder: ffmpeg::decoder::Audio,
resampler: resampling::Context,
stream: usize,
time_base: f64,
origin: i64,
rate: u32,
channels: u16,
eof: bool,
next_time: Duration,
initialised_time: bool,
drained: bool,
}
impl AudioDecoder {
fn open(path: &Path, rate: u32, channels: u16, seek: Duration) -> Result<Self, ffmpeg::Error> {
crate::init();
let mut input = format::input(path)?;
// OBS writes system, mic, combined, in that order; a single track is already mixed.
let tracks: Vec<_> = input
.streams()
.filter(|stream| stream.parameters().medium() == media::Type::Audio)
.collect();
let track = if tracks.len() >= 3 {
&tracks[2]
} else {
tracks.first().ok_or(ffmpeg::Error::StreamNotFound)?
};
let stream = track.index();
let time_base = f64::from(track.time_base());
let origin = track.start_time().max(0);
let decoder = ffmpeg::codec::context::Context::from_parameters(track.parameters())?
.decoder()
.audio()?;
let layout = if decoder.channel_layout().channels() == 0 {
ffmpeg::ChannelLayout::default(decoder.channels() as i32)
} else {
decoder.channel_layout()
};
let resampler = resampling::Context::get(
decoder.format(),
layout,
decoder.rate(),
format::Sample::F32(format::sample::Type::Packed),
ffmpeg::ChannelLayout::default(channels as i32),
rate,
)?;
if seek > Duration::ZERO {
let timestamp = (seek.as_secs_f64() * 1_000_000.) as i64;
input.seek(timestamp, ..timestamp)?;
}
Ok(Self {
input,
decoder,
resampler,
stream,
time_base,
origin,
rate,
channels,
eof: false,
next_time: seek,
initialised_time: false,
drained: false,
})
}
fn next(&mut self) -> Result<Option<(Duration, Vec<f32>)>, ffmpeg::Error> {
loop {
let mut frame = AudioFrame::empty();
match self.decoder.receive_frame(&mut frame) {
Ok(()) => {
let timestamp = frame.timestamp().map_or(self.next_time, |pts| {
Duration::from_secs_f64((pts - self.origin).max(0) as f64 * self.time_base)
});
let time = if self.initialised_time {
self.next_time
} else {
timestamp
};
self.initialised_time = true;
// Allocate enough output for upsampling; ffmpeg-next's empty output uses input count.
let count = ((frame.samples() as u64 * self.rate as u64)
.div_ceil(frame.rate() as u64)
+ 256) as usize;
let mut converted = AudioFrame::new(
format::Sample::F32(format::sample::Type::Packed),
count,
ffmpeg::ChannelLayout::default(self.channels as i32),
);
converted.set_rate(self.rate);
self.resampler.run(&frame, &mut converted)?;
let samples = converted.plane::<f32>(0).to_vec();
self.next_time = time
+ Duration::from_secs_f64(converted.samples() as f64 / self.rate as f64);
return Ok(Some((time, samples)));
}
Err(ffmpeg::Error::Eof) => {
if self.drained {
return Ok(None);
}
self.drained = true;
let mut converted = AudioFrame::new(
format::Sample::F32(format::sample::Type::Packed),
256,
ffmpeg::ChannelLayout::default(self.channels as i32),
);
converted.set_rate(self.rate);
self.resampler.flush(&mut converted)?;
return if converted.samples() == 0 {
Ok(None)
} else {
Ok(Some((self.next_time, converted.plane::<f32>(0).to_vec())))
};
}
Err(ffmpeg::Error::Other { errno }) if errno == ffmpeg::error::EAGAIN => {}
Err(error) => return Err(error),
}
if self.eof {
return Ok(None);
}
let mut packet = ffmpeg::Packet::empty();
loop {
match packet.read(&mut self.input) {
Ok(()) if packet.stream() == self.stream => {
self.decoder.send_packet(&packet)?;
break;
}
Ok(()) => {}
Err(ffmpeg::Error::Eof) => {
self.eof = true;
self.decoder.send_eof()?;
break;
}
Err(error) => return Err(error),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn device_clock_waits_for_latency_and_stops_at_scheduled_samples() {
let now = Instant::now();
let mut buffer = Buffer {
blocks: VecDeque::new(),
queued: 0,
playing: true,
volume: 1.,
anchors: VecDeque::new(),
error: None,
};
buffer.anchors.push_back((
Duration::from_secs(2),
now + Duration::from_millis(100),
Duration::from_millis(50),
));
buffer.anchors.push_back((
Duration::from_millis(2050),
now + Duration::from_millis(150),
Duration::from_millis(50),
));
assert_eq!(buffer.time(now), Some(Duration::from_secs(2)));
assert_eq!(
buffer.time(now + Duration::from_millis(125)),
Some(Duration::from_millis(2025))
);
assert_eq!(
buffer.time(now + Duration::from_millis(200)),
Some(Duration::from_millis(2100))
);
assert_eq!(buffer.anchors.len(), 1);
assert_eq!(buffer.time(now + Duration::from_secs(5)), None);
}
#[test]
fn resampling_selects_combined_track_and_preserves_its_tone() {
let Some(directory) = std::env::var_os("CLIPBIN_TEST_MEDIA_DIR") else {
return;
};
let mut decoder = AudioDecoder::open(
&PathBuf::from(directory).join("combined.mkv"),
44_100,
1,
Duration::ZERO,
)
.unwrap();
let mut samples = Vec::new();
while let Some((_, block)) = decoder.next().unwrap() {
samples.extend(block);
}
assert!(samples.len() > 80_000 && samples.len() < 95_000);
let crossings = samples
.windows(2)
.filter(|pair| pair[0] <= 0. && pair[1] > 0.)
.count();
let hz = crossings as f64 / (samples.len() as f64 / 44_100.);
assert!(
(435. ..445.).contains(&hz),
"combined track was not the 440 Hz tone: {hz}"
);
}
}
+203
View File
@@ -0,0 +1,203 @@
//! Single-threaded file decoding. Scale only frames which will be displayed.
use crate::ffmpeg::{self, codec, format, media, software::scaling, util::frame::video::Video};
use std::{path::Path, time::Duration};
#[derive(Clone, Debug)]
pub struct Frame {
pub width: u32,
pub height: u32,
/// Packed BGRA, without FFmpeg's row padding.
pub bgra: Vec<u8>,
}
pub struct Decoder {
input: format::context::Input,
decoder: ffmpeg::decoder::Video,
stream: usize,
time_base: f64,
origin: i64,
fps: u32,
next_index: u64,
eof: bool,
scaler: Option<(u32, u32, format::Pixel, u32, u32, scaling::context::Context)>,
}
impl Decoder {
pub fn open(path: &Path, fps: u32) -> Result<Self, ffmpeg::Error> {
crate::init();
let input = format::input(path)?;
let video = input
.streams()
.best(media::Type::Video)
.ok_or(ffmpeg::Error::StreamNotFound)?;
let stream = video.index();
let time_base = f64::from(video.time_base());
let origin = video.start_time().max(0);
let mut context = codec::context::Context::from_parameters(video.parameters())?;
// Never let FFmpeg create a pool competing with the recorder.
context.set_threading(codec::threading::Config::count(1));
let decoder = context.decoder().video()?;
Ok(Self {
input,
decoder,
stream,
time_base,
origin,
fps: fps.max(1),
next_index: 0,
eof: false,
scaler: None,
})
}
/// Reuse swscale's setup while the preview dimensions stay unchanged.
pub fn scale(&mut self, video: &Video, target: (u32, u32)) -> Result<Frame, ffmpeg::Error> {
let (width, height) = fitted_size((video.width(), video.height()), target);
if self
.scaler
.as_ref()
.is_none_or(|(w, h, pixel, source_w, source_h, _)| {
(*w, *h) != (width, height)
|| *pixel != video.format()
|| (*source_w, *source_h) != (video.width(), video.height())
})
{
self.scaler = Some((
width,
height,
video.format(),
video.width(),
video.height(),
scaling::context::Context::get(
video.format(),
video.width(),
video.height(),
format::Pixel::BGRA,
width,
height,
scaling::flag::Flags::FAST_BILINEAR,
)?,
));
}
let (_, _, _, _, _, scaler) = self.scaler.as_mut().unwrap();
packed_frame(scaler, video, width, height)
}
/// FFmpeg seeks backwards to a keyframe; callers decode forward by frame index.
pub fn seek(&mut self, time: Duration) -> Result<(), ffmpeg::Error> {
let timestamp = (time.as_secs_f64() * 1_000_000.
+ self.origin as f64 * self.time_base * 1_000_000.) as i64;
self.input.seek(timestamp, ..timestamp)?;
self.decoder.flush();
self.next_index = frame_index(time, self.fps);
self.eof = false;
Ok(())
}
pub fn next_frame(&mut self) -> Result<Option<(u64, Video)>, ffmpeg::Error> {
loop {
let mut frame = Video::empty();
match self.decoder.receive_frame(&mut frame) {
Ok(()) => {
let index = frame.timestamp().map_or(self.next_index, |pts| {
(((pts - self.origin).max(0) as f64 * self.time_base * self.fps as f64)
.round()) as u64
});
self.next_index = index + 1;
return Ok(Some((index, frame)));
}
Err(ffmpeg::Error::Eof) => return Ok(None),
Err(ffmpeg::Error::Other { errno }) if errno == ffmpeg::error::EAGAIN => {}
Err(error) => return Err(error),
}
if self.eof {
return Ok(None);
}
let mut packet = ffmpeg::Packet::empty();
loop {
match packet.read(&mut self.input) {
Ok(()) if packet.stream() == self.stream => {
self.decoder.send_packet(&packet)?;
break;
}
Ok(()) => {}
Err(ffmpeg::Error::Eof) => {
self.eof = true;
self.decoder.send_eof()?;
break;
}
Err(error) => return Err(error),
}
}
}
}
}
pub fn frame_index(time: Duration, fps: u32) -> u64 {
(time.as_nanos() * u128::from(fps.max(1)) / 1_000_000_000) as u64
}
/// Preserve aspect ratio and cap allocation even on a very large display.
pub fn fitted_size(source: (u32, u32), target: (u32, u32)) -> (u32, u32) {
let ratio = (target.0.clamp(1, 1920) as f64 / source.0.max(1) as f64)
.min(target.1.clamp(1, 1080) as f64 / source.1.max(1) as f64)
.min(1.);
(
(source.0 as f64 * ratio).round().max(1.) as u32,
(source.1 as f64 * ratio).round().max(1.) as u32,
)
}
pub fn scale(video: &Video, target: (u32, u32)) -> Result<Frame, ffmpeg::Error> {
let (width, height) = fitted_size((video.width(), video.height()), target);
let mut scaler = scaling::context::Context::get(
video.format(),
video.width(),
video.height(),
format::Pixel::BGRA,
width,
height,
scaling::flag::Flags::FAST_BILINEAR,
)?;
packed_frame(&mut scaler, video, width, height)
}
fn packed_frame(
scaler: &mut scaling::context::Context,
video: &Video,
width: u32,
height: u32,
) -> Result<Frame, ffmpeg::Error> {
let mut output = Video::empty();
scaler.run(video, &mut output)?;
let mut bgra = Vec::with_capacity(width as usize * height as usize * 4);
for row in 0..height as usize {
let start = row * output.stride(0);
bgra.extend_from_slice(&output.data(0)[start..start + width as usize * 4]);
}
Ok(Frame {
width,
height,
bgra,
})
}
/// A thumbnail from the first decoded frame of any MP4/MKV path.
pub fn first_frame(path: &Path, target: (u32, u32)) -> Result<Frame, ffmpeg::Error> {
let mut decoder = Decoder::open(path, 60)?;
let (_, frame) = decoder.next_frame()?.ok_or(ffmpeg::Error::Eof)?;
scale(&frame, target)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_exact_seek_and_bounded_aspect_ratio() {
assert_eq!(frame_index(Duration::from_millis(1999), 60), 119);
assert_eq!(frame_index(Duration::from_secs(2), 60), 120);
assert_eq!(fitted_size((2560, 1440), (800, 450)), (800, 450));
assert_eq!(fitted_size((1440, 2560), (800, 450)), (253, 450));
assert_eq!(fitted_size((8000, 8000), (8000, 8000)), (1080, 1080));
}
}
+283
View File
@@ -0,0 +1,283 @@
//! Stream-copy export. Container timestamps restart independently at every split;
//! video frames and AAC samples therefore have separate exact clocks.
use crate::{
ffmpeg::{self, Rational, Rescale, codec, encoder, format, media},
init,
};
use clipbin_core::store::Metadata;
use std::{fs, path::Path, time::Duration};
/// Writes and closes MP4 under a temporary name before publishing it.
/// Missing/Removed footage is skipped, retaining a visible timestamp jump.
pub fn export(
metadata: &Metadata,
start: Duration,
end: Duration,
separate_audio: bool,
destination: &Path,
) -> Result<(), String> {
init();
let (start, end) = metadata.export_range(start, end);
let pieces = metadata.covering(start, end);
if pieces.is_empty() {
return Err("No retained footage in this range".into());
}
if pieces.iter().any(|p| p.segment.dropped_frames > 0) {
return Err("This range has dropped frames; exact export placement is unvalidated".into());
}
if pieces
.iter()
.any(|p| p.segment.frames_per_second != pieces[0].segment.frames_per_second)
{
return Err("Export ranges spanning a frame-rate change are unsupported".into());
}
if pieces.iter().any(|p| !p.segment.complete) {
return Err("Footage is still being written".into());
}
let temporary = destination.with_extension("partial.mp4");
let result = remux(metadata, start, end, separate_audio, &temporary);
match result {
Ok(()) => {
fs::File::open(&temporary)
.and_then(|f| f.sync_all())
.map_err(|e| e.to_string())?;
fs::rename(&temporary, destination).map_err(|e| e.to_string())
}
Err(error) => {
let _ = fs::remove_file(&temporary);
Err(error.to_string())
}
}
}
/// Counts AAC samples in a finalised file, without decoding or rounded durations.
pub fn audio_samples(path: &Path) -> Result<Vec<u64>, ffmpeg::Error> {
init();
let mut input = format::input(path)?;
let audio: Vec<_> = input
.streams()
.filter(|s| s.parameters().medium() == media::Type::Audio)
.map(|s| s.index())
.collect();
let mut samples = vec![0; audio.len()];
loop {
let mut packet = ffmpeg::Packet::empty();
match packet.read(&mut input) {
Ok(()) => {}
Err(ffmpeg::Error::Eof) => break,
Err(e) => return Err(e),
}
if let Some(index) = audio.iter().position(|s| *s == packet.stream()) {
samples[index] += 1024;
}
}
Ok(samples)
}
fn audio_start(
metadata: &Metadata,
index: usize,
tracks: usize,
) -> Result<Vec<u64>, ffmpeg::Error> {
let segment = &metadata.segments[index];
if segment.audio_start_samples.len() == tracks {
return Ok(segment.audio_start_samples.clone());
}
if segment.frame_start == 0 {
return Ok(vec![0; tracks]);
}
// Old metadata can be reconstructed while its preceding files still exist.
let mut samples = vec![0; tracks];
for previous in metadata.segments[..index].iter().rev() {
if !previous.available || previous.path.parent() != segment.path.parent() {
return Err(ffmpeg::Error::InvalidData);
}
let counts = audio_samples(&previous.path)?;
if counts.len() != tracks {
return Err(ffmpeg::Error::InvalidData);
}
for (sum, count) in samples.iter_mut().zip(counts) {
*sum += count;
}
if previous.audio_start_samples.len() == tracks {
for (sum, start) in samples.iter_mut().zip(&previous.audio_start_samples) {
*sum += start;
}
return Ok(samples);
}
if previous.frame_start == 0 {
return Ok(samples);
}
}
Err(ffmpeg::Error::InvalidData)
}
fn signed_ticks(time: Duration, origin: Duration, rate: i32) -> i64 {
if time >= origin {
ticks(time - origin, rate)
} else {
-ticks(origin - time, rate)
}
}
fn ticks(time: Duration, rate: i32) -> i64 {
((time.as_nanos() * rate as u128 + 500_000_000) / 1_000_000_000) as i64
}
fn remux(
metadata: &Metadata,
start: Duration,
end: Duration,
separate: bool,
path: &Path,
) -> Result<(), ffmpeg::Error> {
let pieces = metadata.covering(start, end);
let first = format::input(&pieces[0].segment.path)?;
let audio: Vec<_> = first
.streams()
.filter(|s| s.parameters().medium() == media::Type::Audio)
.map(|s| s.index())
.collect();
// OBS writes system, mic, combined in this order; one track is already mixed.
let selected_audio: Vec<_> = if audio.len() == 3 {
if separate {
audio[..2].to_vec()
} else {
vec![audio[2]]
}
} else {
audio.clone()
};
let mut output = format::output(path)?;
let mut mapping = vec![None; first.nb_streams() as usize];
let mut clocks = vec![(0, 0); first.nb_streams() as usize];
for stream in first.streams() {
let video = stream.parameters().medium() == media::Type::Video;
if !video && !selected_audio.contains(&stream.index()) {
continue;
}
let rate = if video {
pieces[0].segment.frames_per_second as i32
} else {
codec::context::Context::from_parameters(stream.parameters())?
.decoder()
.audio()?
.rate() as i32
};
let mut target = output.add_stream(encoder::find(codec::Id::None))?;
target.set_parameters(stream.parameters());
target.set_time_base(Rational(1, rate));
mapping[stream.index()] = Some((target.index(), video, rate));
clocks[stream.index()] = (0, if video { 1 } else { 1024 });
}
output.write_header()?;
let mut previous_end = None;
let mut wrote_video = false;
for piece in pieces {
let adjacent = previous_end == Some(piece.segment.start);
let mut input = format::input(&piece.segment.path)?;
if input.nb_streams() != first.nb_streams() {
return Err(ffmpeg::Error::InvalidData);
}
for stream in input.streams() {
let original = first.stream(stream.index()).unwrap();
if stream.parameters().id() != original.parameters().id() {
return Err(ffmpeg::Error::InvalidData);
}
if stream.parameters().medium() == media::Type::Video {
let current = codec::context::Context::from_parameters(stream.parameters())?
.decoder()
.video()?;
let initial = codec::context::Context::from_parameters(original.parameters())?
.decoder()
.video()?;
if (current.width(), current.height()) != (initial.width(), initial.height()) {
return Err(ffmpeg::Error::InvalidData);
}
}
}
let mut counts = vec![0i64; mapping.len()];
let mut first_pts = vec![None; mapping.len()];
let mut bases = vec![0i64; mapping.len()];
let origins = audio_start(metadata, piece.index, audio.len())?;
let run_base = piece
.segment
.start
.saturating_sub(clipbin_core::store::frame_offset(
piece.segment.frame_start,
piece.segment.frames_per_second,
));
for (index, mapped) in mapping.iter().enumerate() {
if let Some((_, video, rate)) = mapped {
bases[index] = if *video {
if adjacent {
clocks[index].0
} else {
signed_ticks(piece.segment.start, start, *rate)
}
} else {
let ordinal = audio.iter().position(|i| *i == index).unwrap();
signed_ticks(run_base, start, *rate) + origins[ordinal] as i64
};
}
}
loop {
let mut packet = ffmpeg::Packet::empty();
match packet.read(&mut input) {
Ok(()) => {}
Err(ffmpeg::Error::Eof) => break,
Err(e) => return Err(e),
}
let index = packet.stream();
let stream = input.stream(index).ok_or(ffmpeg::Error::StreamNotFound)?;
let Some((target, video, rate)) = mapping[index] else {
continue;
};
let step = clocks[index].1;
let local = if video {
let pts = packet.pts().ok_or(ffmpeg::Error::InvalidData)?;
let origin = *first_pts[index].get_or_insert(pts);
(pts - origin).rescale(stream.time_base(), Rational(1, rate))
} else {
counts[index] * step
};
counts[index] += 1;
let from = ticks(piece.from, rate);
let to = ticks(piece.to, rate);
let pts = bases[index] + local;
if video && (local + step <= from || local >= to) {
continue;
}
if !video && pts >= ticks(end - start, rate) {
continue;
}
if pts < 0 {
continue;
}
let tb = Rational(1, rate);
let out_tb = output.stream(target).unwrap().time_base();
let reorder = packet.pts().unwrap_or(0) - packet.dts().unwrap_or(0);
let delay = reorder.rescale(stream.time_base(), tb);
packet.set_pts(Some(pts));
packet.set_dts(Some(pts - delay));
packet.set_duration(step);
packet.rescale_ts(tb, out_tb);
packet.set_position(-1);
packet.set_stream(target);
packet.write_interleaved(&mut output)?;
wrote_video |= video;
}
for (index, mapped) in mapping.iter().enumerate() {
if let Some((_, true, rate)) = mapped
&& counts[index] != ticks(piece.segment.end - piece.segment.start, *rate)
{
return Err(ffmpeg::Error::InvalidData);
}
}
for index in 0..mapping.len() {
clocks[index].0 = bases[index] + counts[index] * clocks[index].1;
}
previous_end = Some(piece.segment.end);
}
if !wrote_video {
return Err(ffmpeg::Error::InvalidData);
}
output.write_trailer()
}
+11
View File
@@ -67,3 +67,14 @@ mod tests {
println!("{probe:?}"); println!("{probe:?}");
} }
} }
pub mod audio;
pub mod decode;
pub mod export;
pub mod player;
pub mod thumbnail;
mod priority;
#[cfg(windows)]
#[allow(unsafe_code)]
mod windows_priority;
+459
View File
@@ -0,0 +1,459 @@
//! On-demand background playback with a one-frame mailbox and coalesced controls.
use crate::decode::{self, Decoder, Frame};
use clipbin_core::store::{Metadata, Store};
use std::{
path::PathBuf,
sync::{Arc, Condvar, Mutex},
thread,
time::{Duration, Instant},
};
#[derive(Clone, Debug)]
pub enum Picture {
Loading,
Frame(Arc<Frame>),
Gap,
Error(String),
}
#[derive(Clone, Debug)]
pub struct Snapshot {
pub time: Duration,
pub playing: bool,
pub picture: Picture,
pub revision: u64,
pub audio_error: Option<String>,
pub video_time: Option<Duration>,
pub frames_per_second: u32,
}
struct Controls {
session: Option<(Store, u64)>,
seek: Option<Duration>,
latest: bool,
playing: bool,
size: (u32, u32),
generation: u64,
muted: bool,
volume: f32,
quit: bool,
snapshot: Snapshot,
}
struct Shared {
state: Mutex<Controls>,
wake: Condvar,
}
pub struct Player {
shared: Arc<Shared>,
}
impl Default for Player {
fn default() -> Self {
Self::new()
}
}
impl Player {
pub fn new() -> Self {
let shared = Arc::new(Shared {
state: Mutex::new(Controls {
session: None,
seek: None,
latest: false,
playing: false,
size: (800, 450),
generation: 0,
muted: false,
volume: 1.,
quit: false,
snapshot: Snapshot {
time: Duration::ZERO,
playing: false,
picture: Picture::Loading,
revision: 0,
audio_error: None,
video_time: None,
frames_per_second: 60,
},
}),
wake: Condvar::new(),
});
let worker = shared.clone();
thread::Builder::new()
.name("clipbin-player".into())
.spawn(move || run(worker))
.expect("player thread");
Self { shared }
}
pub fn open(&self, store: Store, id: u64, time: Duration) {
self.open_session(store, id, time, false);
}
pub fn open_latest(&self, store: Store, id: u64) {
self.open_session(store, id, Duration::ZERO, true);
}
fn open_session(&self, store: Store, id: u64, time: Duration, latest: bool) {
let mut state = self.shared.state.lock().unwrap();
state.session = Some((store, id));
state.latest = latest;
state.seek = Some(time);
state.playing = false;
state.generation += 1;
state.snapshot.picture = Picture::Loading;
state.snapshot.revision += 1;
self.shared.wake.notify_one();
}
pub fn close(&self) {
let mut state = self.shared.state.lock().unwrap();
state.session = None;
state.playing = false;
state.generation += 1;
self.shared.wake.notify_one();
}
pub fn seek(&self, time: Duration) {
let mut state = self.shared.state.lock().unwrap();
state.seek = Some(time);
state.snapshot.time = time;
state.snapshot.picture = Picture::Loading;
state.snapshot.revision += 1;
state.generation += 1;
self.shared.wake.notify_one();
}
pub fn play(&self, playing: bool) {
self.shared.state.lock().unwrap().playing = playing;
self.shared.wake.notify_one();
}
pub fn audio(&self, muted: bool, volume: f32) {
let mut state = self.shared.state.lock().unwrap();
if state.muted != muted {
state.seek = Some(state.snapshot.time);
state.generation += 1;
}
state.muted = muted;
state.volume = volume.clamp(0., 1.);
self.shared.wake.notify_one();
}
pub fn resize(&self, size: (u32, u32)) {
let mut state = self.shared.state.lock().unwrap();
if state.size != size {
state.size = size;
state.generation += 1;
self.shared.wake.notify_one();
}
}
pub fn snapshot(&self) -> Snapshot {
self.shared.state.lock().unwrap().snapshot.clone()
}
}
impl Drop for Player {
fn drop(&mut self) {
self.shared.state.lock().unwrap().quit = true;
self.shared.wake.notify_one();
}
}
/// Monotonic session clock: decoding delays never accumulate into playback time.
#[derive(Debug)]
pub struct Clock {
base: Duration,
started: Instant,
playing: bool,
}
impl Clock {
pub fn new(now: Instant) -> Self {
Self {
base: Duration::ZERO,
started: now,
playing: false,
}
}
pub fn time(&self, now: Instant) -> Duration {
self.base
+ if self.playing {
now.saturating_duration_since(self.started)
} else {
Duration::ZERO
}
}
pub fn set(&mut self, time: Duration, playing: bool, now: Instant) {
self.base = time;
self.started = now;
self.playing = playing;
}
}
fn run(shared: Arc<Shared>) {
crate::priority::lower(false);
let mut clock = Clock::new(Instant::now());
let mut generation = u64::MAX;
let mut metadata: Option<Metadata> = None;
let mut refreshed = Instant::now();
let mut decoder: Option<(PathBuf, Decoder, u64)> = None;
let mut audio: Option<crate::audio::Audio> = None;
let mut audio_error = None;
let mut audio_attempted = false;
loop {
let (session, seek, playing, size, requested, muted, volume, latest) = {
let mut state = shared.state.lock().unwrap();
if state.quit {
break;
}
(
state.session.clone(),
state.seek.take(),
state.playing,
state.size,
state.generation,
state.muted,
state.volume,
std::mem::take(&mut state.latest),
)
};
let changed = requested != generation;
if changed {
generation = requested;
decoder = None;
}
let now = Instant::now();
if changed || now.duration_since(refreshed) >= Duration::from_millis(250) {
metadata = session
.as_ref()
.and_then(|(store, id)| store.metadata(*id).ok());
refreshed = now;
}
let seek = if latest {
metadata
.as_ref()
.and_then(|metadata| {
metadata
.segments
.iter()
.rev()
.find(|segment| {
segment.complete && segment.available && segment.end > segment.start
})
.map(|segment| {
segment.end.saturating_sub(Duration::from_secs_f64(
1. / segment.frames_per_second.max(1) as f64,
))
})
})
.or(Some(Duration::ZERO))
} else {
seek.map(|time| {
metadata
.as_ref()
.map_or(time, |metadata| time.min(metadata.session.length))
})
};
let transport_changed = playing != clock.playing;
if seek.is_some() || transport_changed {
clock.set(seek.unwrap_or_else(|| clock.time(now)), playing, now);
}
if session.is_none() {
audio = None;
audio_attempted = false;
audio_error = None;
}
if session.is_some() && !audio_attempted && !muted {
audio_attempted = true;
match crate::audio::Audio::new() {
Ok(output) => audio = Some(output),
Err(error) => audio_error = Some(error),
}
}
if let Some(audio) = audio.as_mut() {
if changed || seek.is_some() || transport_changed {
audio.reset(clock.time(now));
}
audio.controls(playing && !muted, volume);
}
let Some(metadata) = metadata.as_ref().filter(|_| session.is_some()) else {
wait(&shared, Duration::from_millis(100));
continue;
};
let mut time = clock.time(now).min(metadata.session.length);
if playing
&& !muted
&& let Some(audio) = audio.as_mut()
{
if let Some(device_time) = audio.time(now)
&& metadata
.locate(device_time)
.is_some_and(|at| at.segment.complete)
{
time = device_time.min(metadata.session.length);
clock.set(time, true, now);
}
audio.catch_up(time);
if let Err(error) = audio.fill(metadata) {
audio_error = Some(format!("Cannot play audio: {error}"));
}
if let Some(error) = audio.error() {
audio_error = Some(error);
}
}
if time >= metadata.session.length && playing {
clock.set(time, false, now);
shared.state.lock().unwrap().playing = false;
}
// Completed files only: active files may still lack seekable cues.
let located = metadata.locate(time).filter(|at| at.segment.complete);
let start = located
.as_ref()
.map_or(Duration::ZERO, |at| at.segment.start);
let frames_per_second = located
.as_ref()
.map_or(60, |at| at.segment.frames_per_second);
let mut picture = Picture::Gap;
let mut presented = false;
let started = Instant::now();
if let Some(at) = located {
let target = decode::frame_index(at.offset, at.segment.frames_per_second);
let result = (|| {
if decoder.as_ref().is_none_or(|(path, _, index)| {
path != &at.segment.path
|| target < *index
|| target > index.saturating_add(120)
}) {
let mut opened = Decoder::open(&at.segment.path, at.segment.frames_per_second)?;
if at.offset > Duration::ZERO {
opened.seek(at.offset)?;
}
decoder = Some((at.segment.path.clone(), opened, u64::MAX));
}
let (_, active, last) = decoder.as_mut().unwrap();
if *last == target && !changed {
return Ok(None);
}
loop {
// A drag or navigation cancels decode-forward work between frames.
let state = shared.state.lock().unwrap();
if state.quit || state.generation != generation {
return Ok(None);
}
drop(state);
let Some((index, frame)) = active.next_frame()? else {
return Ok(Some(Picture::Gap));
};
if index >= target {
*last = index;
return active
.scale(&frame, size)
.map(|frame| Some(Picture::Frame(Arc::new(frame))));
}
}
})();
match result {
Ok(Some(frame)) => {
presented = matches!(frame, Picture::Frame(_));
picture = frame;
}
Ok(None) => picture = shared.state.lock().unwrap().snapshot.picture.clone(),
Err(error) => {
picture = Picture::Error(format!("Cannot read footage: {error}"));
decoder = None;
}
}
} else {
decoder = None;
}
let mut state = shared.state.lock().unwrap();
if state.generation == generation {
let updated = time != state.snapshot.time
|| clock.playing != state.snapshot.playing
|| presented
|| std::mem::discriminant(&picture)
!= std::mem::discriminant(&state.snapshot.picture)
|| audio_error != state.snapshot.audio_error;
let revision = state.snapshot.revision + u64::from(updated);
let video_time = matches!(picture, Picture::Frame(_))
.then(|| {
decoder.as_ref().map(|(_, _, index)| {
start + clipbin_core::store::frame_offset(*index, frames_per_second)
})
})
.flatten();
state.snapshot = Snapshot {
time,
playing: clock.playing,
picture,
revision,
audio_error: audio_error.clone(),
video_time,
frames_per_second,
};
if std::env::var_os("CLIPBIN_TRACE_FRAMES").is_some() && (playing || changed) {
eprintln!(
"playback time={:.3} decode_ms={:.3} generation={generation} presented={presented} state={} audio_time={:.3} video_time={:.3}",
time.as_secs_f64(),
started.elapsed().as_secs_f64() * 1000.,
match &state.snapshot.picture {
Picture::Frame(_) => "frame",
Picture::Gap => "gap",
Picture::Loading => "loading",
Picture::Error(_) => "error",
},
audio
.as_ref()
.and_then(|audio| audio.time(Instant::now()))
.map_or(-1., |time| time.as_secs_f64()),
video_time.map_or(-1., |time| time.as_secs_f64())
);
}
}
drop(state);
wait(
&shared,
if clock.playing {
Duration::from_millis(4)
} else {
Duration::from_millis(100)
},
);
}
}
fn wait(shared: &Shared, timeout: Duration) {
let state = shared.state.lock().unwrap();
if !state.quit && state.seek.is_none() {
drop(shared.wake.wait_timeout(state, timeout).unwrap());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_decoder_stall_skips_overdue_frames_without_shifting_the_clock() {
let now = Instant::now();
let mut clock = Clock::new(now);
clock.set(Duration::ZERO, true, now);
let first_target = decode::frame_index(clock.time(now + Duration::from_millis(16)), 60);
let after_stall = decode::frame_index(clock.time(now + Duration::from_millis(250)), 60);
assert_eq!(first_target, 0);
assert_eq!(after_stall, 15);
// Scaling only the target discards the intervening frames; their decoding
// cost must never be added to the clock's next presentation deadline.
assert_eq!(
decode::frame_index(clock.time(now + Duration::from_secs(1)), 60),
60
);
}
#[test]
fn clock_pauses_and_seek_does_not_accumulate_decode_delay() {
let now = Instant::now();
let mut clock = Clock::new(now);
clock.set(Duration::from_secs(2), true, now);
assert_eq!(
clock.time(now + Duration::from_secs(3)),
Duration::from_secs(5)
);
clock.set(Duration::from_secs(5), false, now);
assert_eq!(
clock.time(now + Duration::from_secs(100)),
Duration::from_secs(5)
);
clock.set(Duration::from_millis(1500), true, now);
assert_eq!(
decode::frame_index(clock.time(now + Duration::from_millis(100)), 60),
96
);
}
}
+14
View File
@@ -0,0 +1,14 @@
//! Browsing runs below the recorder's normal scheduling priority.
pub fn lower(thumbnail: bool) {
#[cfg(target_os = "linux")]
if let Err(error) = rustix::process::setpriority_process(
Some(rustix::thread::gettid()),
if thumbnail { 10 } else { 3 },
) {
eprintln!("Cannot lower browsing thread priority: {error}");
}
#[cfg(windows)]
crate::windows_priority::lower(thumbnail);
#[cfg(not(any(windows, target_os = "linux")))]
let _ = thumbnail;
}
+81
View File
@@ -0,0 +1,81 @@
//! One background worker and a bounded queue for persistent PNG thumbnails.
use crate::decode;
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::{Arc, Mutex, mpsc},
thread,
time::Duration,
};
/// Deterministic cache path beside the source; exported MP4s use the same API.
pub fn cache_path(path: &Path) -> PathBuf {
path.with_extension("thumbnail.png")
}
/// Decode any MP4/MKV and atomically publish a small thumbnail.
pub fn generate(path: &Path) -> Result<PathBuf, String> {
let cache = cache_path(path);
if cache.is_file() {
return Ok(cache);
}
let mut frame = decode::first_frame(path, (320, 180)).map_err(|error| error.to_string())?;
let temporary = cache.with_extension("png.part");
for pixel in frame.bgra.as_chunks_mut::<4>().0 {
pixel.swap(0, 2);
}
image::save_buffer_with_format(
&temporary,
&frame.bgra,
frame.width,
frame.height,
image::ColorType::Rgba8,
image::ImageFormat::Png,
)
.map_err(|error| error.to_string())?;
std::fs::rename(&temporary, &cache).map_err(|error| error.to_string())?;
Ok(cache)
}
pub struct Thumbnails {
sender: mpsc::SyncSender<PathBuf>,
pending: Arc<Mutex<HashSet<PathBuf>>>,
}
impl Default for Thumbnails {
fn default() -> Self {
Self::new()
}
}
impl Thumbnails {
pub fn new() -> Self {
let (sender, receiver) = mpsc::sync_channel::<PathBuf>(16);
let pending = Arc::new(Mutex::new(HashSet::new()));
let work = pending.clone();
thread::Builder::new()
.name("clipbin-thumbnails".into())
.spawn(move || {
crate::priority::lower(true);
for path in receiver {
// One decoder thread, one frame, and a duty-cycle cap leave capture priority.
thread::sleep(Duration::from_millis(150));
if let Err(error) = generate(&path) {
eprintln!("thumbnail {}: {error}", path.display());
}
work.lock().unwrap().remove(&path);
}
})
.expect("thumbnail thread");
Self { sender, pending }
}
pub fn request(&self, path: &Path) -> Option<PathBuf> {
let cache = cache_path(path);
if cache.is_file() {
return Some(cache);
}
let mut pending = self.pending.lock().unwrap();
if pending.insert(path.to_owned()) && self.sender.try_send(path.to_owned()).is_err() {
pending.remove(path);
}
None
}
}
@@ -0,0 +1,19 @@
//! The only raw Windows calls in the media crate; no handles escape this bridge.
use windows_sys::Win32::System::Threading::{
GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_BELOW_NORMAL, THREAD_PRIORITY_LOWEST,
};
pub fn lower(thumbnail: bool) {
let priority = if thumbnail {
THREAD_PRIORITY_LOWEST
} else {
THREAD_PRIORITY_BELOW_NORMAL
};
// GetCurrentThread returns a borrowed pseudo-handle, valid for this thread.
if unsafe { SetThreadPriority(GetCurrentThread(), priority) } == 0 {
eprintln!(
"Cannot lower browsing thread priority: {}",
std::io::Error::last_os_error()
);
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Run through scripts/validate/playback-fixture.sh for real FFmpeg seek coverage.
use clipbin_core::store::Store;
use clipbin_media::{
decode::{Decoder, scale},
player::{Picture, Player},
};
use std::{
path::PathBuf,
thread,
time::{Duration, Instant},
};
fn fixture() -> Option<PathBuf> {
std::env::var_os("CLIPBIN_TEST_MEDIA_DIR").map(PathBuf::from)
}
#[test]
fn seeking_matches_sequential_frames_in_each_independent_segment() {
let Some(directory) = fixture() else {
return;
};
for segment in 0..3 {
let path = directory.join(format!("1/segment-{segment:03}.mkv"));
let mut sequential = Decoder::open(&path, 60).unwrap();
let mut expected = Vec::new();
while let Some((index, frame)) = sequential.next_frame().unwrap() {
expected.push((index, scale(&frame, (160, 90)).unwrap().bgra));
}
assert_eq!(expected.len(), 120);
for target in [0, 1, 59, 60, 61, 119] {
let mut decoder = Decoder::open(&path, 60).unwrap();
decoder
.seek(Duration::from_secs_f64(target as f64 / 60.))
.unwrap();
loop {
let (index, frame) = decoder.next_frame().unwrap().unwrap();
if index >= target {
assert_eq!(index, target);
assert_eq!(
scale(&frame, (160, 90)).unwrap().bgra,
expected[target as usize].1
);
break;
}
}
}
}
}
fn wait(player: &Player, at: Duration) -> clipbin_media::player::Snapshot {
let until = Instant::now() + Duration::from_secs(5);
loop {
let snapshot = player.snapshot();
if snapshot.time == at && !matches!(snapshot.picture, Picture::Loading) {
return snapshot;
}
assert!(Instant::now() < until, "seek timed out: {snapshot:?}");
thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn player_crosses_boundaries_and_gaps_and_discards_superseded_seeks() {
let Some(directory) = fixture() else {
return;
};
let player = Player::new();
player.audio(true, 1.);
player.open(Store { root: directory }, 1, Duration::ZERO);
assert!(matches!(
wait(&player, Duration::ZERO).picture,
Picture::Frame(_)
));
for millis in [1999, 2000, 2500, 4000, 5999, 6000, 7000] {
let at = Duration::from_millis(millis);
player.seek(at);
let snapshot = wait(&player, at);
assert_eq!(
matches!(snapshot.picture, Picture::Gap),
(4000..6000).contains(&millis),
"at {millis}"
);
}
for i in 0..50 {
player.seek(Duration::from_millis(i * 40));
}
player.seek(Duration::from_millis(6500));
assert!(matches!(
wait(&player, Duration::from_millis(6500)).picture,
Picture::Frame(_)
));
player.seek(Duration::from_millis(1900));
wait(&player, Duration::from_millis(1900));
player.play(true);
thread::sleep(Duration::from_millis(400));
player.play(false);
let snapshot = player.snapshot();
assert!((Duration::from_millis(2250)..Duration::from_millis(2500)).contains(&snapshot.time));
assert!(matches!(snapshot.picture, Picture::Frame(_)));
assert!(snapshot.video_time.unwrap().abs_diff(snapshot.time) < Duration::from_millis(20));
player.seek(Duration::from_millis(3900));
wait(&player, Duration::from_millis(3900));
player.play(true);
thread::sleep(Duration::from_millis(350));
let snapshot = player.snapshot();
assert!(snapshot.time >= Duration::from_millis(4200));
assert!(matches!(snapshot.picture, Picture::Gap));
player.close();
}
+148 -1
View File
@@ -13,6 +13,7 @@ use std::{
atomic::{AtomicU8, AtomicU64, Ordering}, atomic::{AtomicU8, AtomicU64, Ordering},
mpsc::Sender, mpsc::Sender,
}, },
time::Instant,
}; };
use libobs as sys; use libobs as sys;
@@ -152,6 +153,8 @@ unsafe extern "C" fn log_to_stderr(
if let Some(state) = pipewire_state(&text) { if let Some(state) = pipewire_state(&text) {
PIPEWIRE_STATE.store(state as u8, Ordering::Relaxed); PIPEWIRE_STATE.store(state as u8, Ordering::Relaxed);
} }
#[cfg(windows)]
record_wasapi_event(&text);
eprintln!("obs {label}: {text}"); eprintln!("obs {label}: {text}");
} }
// SAFETY: frees the buffer dstr_vprintf allocated (null is allowed). // SAFETY: frees the buffer dstr_vprintf allocated (null is allowed).
@@ -611,6 +614,108 @@ pub(crate) fn segment_index(path: &str) -> Option<u64> {
stem.strip_prefix("segment-")?.parse().ok() stem.strip_prefix("segment-")?.parse().ok()
} }
#[cfg(windows)]
static AUDIO_FAULTS: Mutex<Vec<(String, std::sync::Weak<std::sync::atomic::AtomicBool>)>> =
Mutex::new(Vec::new());
#[cfg(any(windows, test))]
fn wasapi_event(message: &str) -> Option<(&str, bool)> {
let (message, source) = message.rsplit_once(" (source: ")?;
let source = source.strip_suffix(')')?;
if !source.starts_with("clipbin-") {
return None;
}
if message.starts_with("WASAPI: Device '") && message.contains(" initialized") {
Some((source, false))
} else if (message.starts_with("WASAPI: Device '") && message.contains(" failed to start"))
|| (message.starts_with("Device '") && message.contains(" invalidated. Retrying"))
{
Some((source, true))
} else {
None
}
}
#[cfg(windows)]
fn record_wasapi_event(message: &str) {
if let Some((source, failed)) = wasapi_event(message)
&& let Ok(faults) = AUDIO_FAULTS.lock()
&& let Some(flag) = faults
.iter()
.find(|(name, _)| name == source)
.and_then(|(_, flag)| flag.upgrade())
{
flag.store(failed, Ordering::Relaxed);
}
}
struct AudioClock {
started: Instant,
last_millis: AtomicU64,
#[cfg(windows)]
source_name: String,
#[cfg(windows)]
failed: Arc<std::sync::atomic::AtomicBool>,
}
impl AudioClock {
fn new(source_name: &str) -> Self {
#[cfg(windows)]
let failed = Arc::new(std::sync::atomic::AtomicBool::new(false));
#[cfg(windows)]
AUDIO_FAULTS
.lock()
.unwrap()
.push((source_name.into(), Arc::downgrade(&failed)));
#[cfg(not(windows))]
let _ = source_name;
Self {
started: Instant::now(),
last_millis: AtomicU64::new(0),
#[cfg(windows)]
source_name: source_name.into(),
#[cfg(windows)]
failed,
}
}
fn lost(&self) -> bool {
#[cfg(windows)]
{
self.failed.load(Ordering::Relaxed)
}
#[cfg(not(windows))]
{
(self.started.elapsed().as_millis() as u64)
.saturating_sub(self.last_millis.load(Ordering::Relaxed))
>= 5_000
}
}
}
#[cfg(windows)]
impl Drop for AudioClock {
fn drop(&mut self) {
if let Ok(mut faults) = AUDIO_FAULTS.lock() {
faults.retain(|(name, _)| name != &self.source_name);
}
}
}
unsafe extern "C" fn audio_captured(
param: *mut c_void,
_: *mut sys::obs_source_t,
_: *const sys::audio_data,
_: bool,
) {
// SAFETY: each callback holds the stable AudioClock allocation until removal;
// OBS serialises removal against callbacks with source->audio_cb_mutex.
let clock = unsafe { &*param.cast::<AudioClock>() };
#[cfg(windows)]
clock.failed.store(false, Ordering::Relaxed);
clock.last_millis.store(
clock.started.elapsed().as_millis() as u64,
Ordering::Relaxed,
);
}
/// The live OBS objects for one recording. Capture starts at once; encoders /// The live OBS objects for one recording. Capture starts at once; encoders
/// and the segment output are created only when the source has a picture, /// and the segment output are created only when the source has a picture,
/// so nothing is written while the user picks a source or capture is down. /// so nothing is written while the user picks a source or capture is down.
@@ -622,6 +727,7 @@ pub(crate) struct Pipeline {
sources: Vec<(Option<u32>, *mut sys::obs_source_t)>, sources: Vec<(Option<u32>, *mut sys::obs_source_t)>,
video_source: *mut sys::obs_source_t, video_source: *mut sys::obs_source_t,
microphone: bool, microphone: bool,
audio_clocks: Vec<(*mut sys::obs_source_t, Box<AudioClock>)>,
pending: Mutex<Option<OutputSpec>>, pending: Mutex<Option<OutputSpec>>,
output: Mutex<Option<Output>>, output: Mutex<Option<Output>>,
} }
@@ -648,6 +754,7 @@ impl Pipeline {
sources: Vec::new(), sources: Vec::new(),
video_source: ptr::null_mut(), video_source: ptr::null_mut(),
microphone: capture.microphone.is_some(), microphone: capture.microphone.is_some(),
audio_clocks: Vec::new(),
pending: Mutex::new(None), pending: Mutex::new(None),
output: Mutex::new(None), output: Mutex::new(None),
name_prefix: name_prefix.clone(), name_prefix: name_prefix.clone(),
@@ -670,8 +777,16 @@ impl Pipeline {
(&capture.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK), (&capture.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK),
] { ] {
if let Some(source_spec) = source_spec { if let Some(source_spec) = source_spec {
let source = create_source(source_spec, &name(&format!("audio-{channel}")))?; let source_name = format!("{name_prefix}-audio-{channel}");
let mut clock = Box::new(AudioClock::new(&source_name));
let source = create_source(source_spec, &cstring(&source_name))?;
pipeline.sources.push((Some(channel), source)); pipeline.sources.push((Some(channel), source));
sys::obs_source_add_audio_capture_callback(
source,
Some(audio_captured),
(&mut *clock as *mut AudioClock).cast(),
);
pipeline.audio_clocks.push((source, clock));
let mixers = tracks let mixers = tracks
.iter() .iter()
.filter(|t| **t == track || **t == COMBINED_TRACK) .filter(|t| **t == track || **t == COMBINED_TRACK)
@@ -769,6 +884,7 @@ impl Pipeline {
lagged_frames: unsafe { sys::obs_get_lagged_frames() } as u64, lagged_frames: unsafe { sys::obs_get_lagged_frames() } as u64,
written_bytes: 0, written_bytes: 0,
disk_available_bytes: None, disk_available_bytes: None,
audio_lost: self.audio_clocks.iter().any(|(_, clock)| clock.lost()),
} }
} }
@@ -797,6 +913,13 @@ impl Drop for Pipeline {
drop(self.output.get_mut().ok().and_then(Option::take)); drop(self.output.get_mut().ok().and_then(Option::take));
// SAFETY: runs on the OBS thread (Recording drops the Pipeline there). // SAFETY: runs on the OBS thread (Recording drops the Pipeline there).
unsafe { unsafe {
for (source, clock) in &mut self.audio_clocks {
sys::obs_source_remove_audio_capture_callback(
*source,
Some(audio_captured),
(&mut **clock as *mut AudioClock).cast(),
);
}
sys::obs_set_output_source(VIDEO_CHANNEL, ptr::null_mut()); sys::obs_set_output_source(VIDEO_CHANNEL, ptr::null_mut());
for (channel, source) in &self.sources { for (channel, source) in &self.sources {
if let Some(channel) = channel { if let Some(channel) = channel {
@@ -988,6 +1111,30 @@ unsafe fn create_source(spec: &SourceSpec, name: &CStr) -> Result<*mut sys::obs_
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn wasapi_events_are_scoped_and_distinguish_failure_from_silence() {
assert_eq!(
wasapi_event(
"WASAPI: Device 'Speakers' [48000 Hz] initialized (source: clipbin-7-audio-1)"
),
Some(("clipbin-7-audio-1", false))
);
assert_eq!(
wasapi_event("Device 'Speakers' invalidated. Retrying (source: clipbin-7-audio-1)"),
Some(("clipbin-7-audio-1", true))
);
assert_eq!(
wasapi_event("WASAPI: Device 'default' failed to start (source: clipbin-7-audio-3)"),
Some(("clipbin-7-audio-3", true))
);
assert_eq!(
wasapi_event("Device 'Speakers' invalidated. Retrying (source: another-app)"),
None
);
assert_eq!(wasapi_event("WASAPI: Device 'Speakers' Terminated"), None);
assert_eq!(wasapi_event("WASAPI: Default output device changed"), None);
}
#[test] #[test]
fn pipewire_state_is_read_from_the_plugin_log() { fn pipewire_state_is_read_from_the_plugin_log() {
let state = |line| pipewire_state(line); let state = |line| pipewire_state(line);
+45 -10
View File
@@ -304,6 +304,8 @@ pub struct Health {
/// PipeWire capture's portal and stream state. `None` for other sources, /// PipeWire capture's portal and stream state. `None` for other sources,
/// which report loss through their size, and before the portal answers. /// which report loss through their size, and before the portal answers.
pub pipewire: Option<PipeWireState>, pub pipewire: Option<PipeWireState>,
/// A configured capture source lost delivery (Linux) or reported a device error (Windows).
pub audio_lost: bool,
pub output_active: bool, pub output_active: bool,
pub total_frames: u64, pub total_frames: u64,
pub dropped_frames: u64, pub dropped_frames: u64,
@@ -409,17 +411,27 @@ impl StatusTracker {
(_, true, true) (_, true, true)
if health.output_active && health.total_frames > 0 && self.last_written > 0 => if health.output_active && health.total_frames > 0 && self.last_written > 0 =>
{ {
RecordingStatus::Recording if health.audio_lost {
RecordingStatus::Degraded {
reason: "audio capture lost".into(),
}
} else {
RecordingStatus::Recording
}
}
(RecordingStatus::Recording | RecordingStatus::Degraded { .. }, false, _) => {
RecordingStatus::Failed {
reason: "the capture source stopped providing a picture".into(),
}
}
(RecordingStatus::Recording | RecordingStatus::Degraded { .. }, true, false) => {
RecordingStatus::Failed {
reason: format!(
"no footage was written for {} seconds",
self.stall_limit.as_secs()
),
}
} }
(RecordingStatus::Recording, false, _) => RecordingStatus::Failed {
reason: "the capture source stopped providing a picture".into(),
},
(RecordingStatus::Recording, true, false) => RecordingStatus::Failed {
reason: format!(
"no footage was written for {} seconds",
self.stall_limit.as_secs()
),
},
(_, false, _) => RecordingStatus::WaitingForCapture, (_, false, _) => RecordingStatus::WaitingForCapture,
(status, _, _) => status.clone(), (status, _, _) => status.clone(),
}; };
@@ -511,10 +523,33 @@ mod tests {
total_bytes: bytes, total_bytes: bytes,
lagged_frames: 0, lagged_frames: 0,
written_bytes: bytes, written_bytes: bytes,
audio_lost: false,
disk_available_bytes: Some(u64::MAX), disk_available_bytes: Some(u64::MAX),
} }
} }
#[test]
fn audio_loss_degrades_and_recovers_without_masking_video_loss() {
let now = Instant::now();
let mut tracker = StatusTracker::new(now, 0, SEGMENT);
let mut health = healthy(10_000);
assert_eq!(tracker.sample(&health, now), &RecordingStatus::Recording);
health.audio_lost = true;
assert!(matches!(
tracker.sample(&health, now),
RecordingStatus::Degraded { .. }
));
health.audio_lost = false;
assert_eq!(tracker.sample(&health, now), &RecordingStatus::Recording);
health.audio_lost = true;
tracker.sample(&health, now);
health.source_width = 0;
assert!(matches!(
tracker.sample(&health, now),
RecordingStatus::Failed { .. }
));
}
#[test] #[test]
fn low_disk_space_fails_before_the_disk_is_full() { fn low_disk_space_fails_before_the_disk_is_full() {
let start = Instant::now(); let start = Instant::now();
+7 -1
View File
@@ -367,7 +367,10 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
health.written_bytes health.written_bytes
); );
report_status(&mut status, tracker.sample(&health, now), &elapsed()); report_status(&mut status, tracker.sample(&health, now), &elapsed());
recorded |= status == RecordingStatus::Recording; recorded |= matches!(
status,
RecordingStatus::Recording | RecordingStatus::Degraded { .. }
);
} }
if !stopping && (now >= stop_at || matches!(status, RecordingStatus::Failed { .. })) { if !stopping && (now >= stop_at || matches!(status, RecordingStatus::Failed { .. })) {
stopping = true; stopping = true;
@@ -414,6 +417,9 @@ fn report_status(shown: &mut RecordingStatus, current: &RecordingStatus, elapsed
if shown != current { if shown != current {
*shown = current.clone(); *shown = current.clone();
match current { match current {
RecordingStatus::Degraded { reason } => {
println!("t={elapsed} status=degraded reason={reason}")
}
RecordingStatus::Failed { reason } => { RecordingStatus::Failed { reason } => {
println!("t={elapsed} status=failed reason={reason}") println!("t={elapsed} status=failed reason={reason}")
} }
+488 -14
View File
@@ -8,9 +8,9 @@ pass/fail results.
| Where | Script | Status | | Where | Script | Status |
| --- | --- | --- | | --- | --- | --- |
| Linux, headless rig (Wayland portal + Xwayland) | `scripts/validate/linux-rig.sh` | All 9 scenarios pass on devbox (Intel UHD 770, VA-API) | | Linux, headless rig (Wayland portal + Xwayland) | `scripts/validate/linux-rig.sh` | All 12 scenarios pass on devbox (Intel UHD 770, VA-API), 2026-10-13 |
| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | Passed 2026-10-08 (run 4, dc9cbd6) | | Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | Passed 2026-10-08 (run 4, dc9cbd6) |
| Linux desktop automatic sessions (headless Wayland) | `scripts/validate/linux-session.sh` | Passed 2026-10-08: Recording sidebar, game exit, library reload, window close and active Quit | | Linux desktop automatic sessions (headless Wayland) | `scripts/validate/linux-session.sh` | Passed 2026-10-13: recording sidebar, close-to-tray, game exit, second-launch reopen and active Quit |
| Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested | | Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested |
## Probe commands ## Probe commands
@@ -174,8 +174,12 @@ Windows status:
monitor's own size, set when capture first has a picture (portrait monitors monitor's own size, set when capture first has a picture (portrait monitors
record portrait). If the window is resized mid-session the size stays and record portrait). If the window is resized mid-session the size stays and
the picture is scaled to fit. the picture is scaled to fit.
- **Audio capture failures are not detected yet.** When the PulseAudio server - **Audio delivery health.** The configured system/microphone capture sources
was unreachable, recording continued with a silent track. are monitored through OBS audio capture callbacks on Linux (five seconds
without delivery) and source-scoped WASAPI device errors on Windows. Either
degrades recording with “audio capture lost”; video continues. The silent
muxer track cannot mask capture failure. See
Background operation below for validation and limits.
- **Linux runtime dependencies.** The Linux OBS build links the system's - **Linux runtime dependencies.** The Linux OBS build links the system's
FFmpeg, PipeWire and other libraries, so it is tied to the distribution it FFmpeg, PipeWire and other libraries, so it is tied to the distribution it
was built on. Packaging (for example Flatpak, or bundling FFmpeg) is an open was built on. Packaging (for example Flatpak, or bundling FFmpeg) is an open
@@ -251,14 +255,484 @@ Real Steam validated 2026-10-08 on CachyOS/Hyprland (native Steam): Escape
from Duckov (AppId 3167020, Proton Experimental under SteamLinuxRuntime_4) from Duckov (AppId 3167020, Proton Experimental under SteamLinuxRuntime_4)
runs under `reaper SteamLaunch AppId=3167020 -- …/_v2-entry-point … proton runs under `reaper SteamLaunch AppId=3167020 -- …/_v2-entry-point … proton
waitforexitandrun …/Duckov.exe`. Clipbin detected it, named it from its waitforexitandrun …/Duckov.exe`. Clipbin detected it, named it from its
manifest ("Escape from Duckov") and recorded the session. Audio-source manifest ("Escape from Duckov") and recorded the session. Audio delivery loss
failure detection remains limited as described above. is now detected as described below; the new warning still needs real hardware
and Windows validation.
GPUI 0.2.2 ends its Linux event loop when the last window is removed, even GPUI 0.2.2 normally ends its Linux and Windows event loops when the last
without an application close callback. Main keeps the recorder worker alive window is removed. Clipbin now keeps one Application and native loop alive
after that return. Closing the window continues the current game session; until explicit Quit, with a small vendored backend policy patch (see
when that game exits, it finalises and the process exits. If no session is [vendor/gpui/CLIPBIN.md](../vendor/gpui/CLIPBIN.md)). The visible window is
active, it exits immediately. It stops discovering new games once closed, removed on close and recreated on Open, restoring its previous route and
so there is no indefinitely running invisible app. Reopening the window and reloading the library/settings; unavailable sessions fall back to the library.
persistent background discovery need the future tray integration. Explicit Playback resources are released while closed. No invisible GPUI window is
Quit/Ctrl+Q stops and finalises the current recording immediately. needed. Bounds, scroll offsets and unsaved editor selection are not restored.
Quit waits for the existing worker's clean session finish before joining/exiting.
## Clips, bookmarks and storage cleanup
`clipbin-media::export` copies video and selected AAC tracks to MP4 without
re-encoding. OBS already writes system, microphone and combined tracks when the
mic is enabled: exports default to the combined track, with a Settings option
for the two separate tracks. A session without a mic exports its single track.
The output is closed and synced under a `.partial.mp4` name, then renamed;
only the completed MP4 and saved JSON appear in the Clips library.
Video timestamps use integer frame clocks, retaining presentation/decode
ordering for B-frames. Each segment also persists cumulative AAC sample starts
per stream (`audio_start_samples`, with `#[serde(default)]`). Audio uses its own
1024-sample AAC packet clock across adjacent pieces, avoiding independently reset/rounded Matroska timestamps.
Old session metadata defaults these audio counters to empty. Before cleanup,
the worker reconstructs them from retained finalised files; export can also
reconstruct an older range while its preceding files still exist. If the earlier
files are already unavailable and no exact clock was saved, export fails
honestly rather than guessing the A/V phase.
Keyframes are anchored to the capture run, including its wall-clock offset.
Outer unavailable footage is trimmed from completed ranges. Internal Missing
and Removed spans retain a timestamp jump; players may hold the preceding
picture or skip to the next picture, but the gap is not collapsed. Export
ranges spanning codec, size or frame-rate changes fail honestly. Dropped-frame
placement remains unvalidated, so these ranges fail rather than claiming exact
CFR output.
One bounded export queue runs on a separate thread. Requests retain the action
receipt timestamp while the current segment finalises; cleanup leases are
persisted in `clips/export-requests.json`. Failed exports retain their leases
until retry succeeds or the user releases them from the Clips screen. A crash
between MP4 publication and JSON persistence can leave an unlisted complete MP4;
Open folder lets the user recover it. Partial files are never listed as clips.
Cleanup checks the byte budget every three seconds and after session end. Its
pure selector orders completed sessions first, then active footage, oldest
first. It excludes bookmarks, editor selection and queued/running/failed export
ranges, and never selects the current file. It persists Removed spans before
deleting a segment; a deletion failure restores availability and surfaces an
error. Bookmarks persist in session metadata, protect future post-roll, and can
be released in the editor. Clips remain outside this budget and can be deleted
explicitly.
Global bindings default to Ctrl+Shift+F9 (clip) and Ctrl+Shift+F10 (bookmark).
Settings independently offer F11/F12 alternatives; configuration files accept
F1–F12 (distinct bindings). Wayland uses the GlobalShortcuts portal and displays
its returned binding descriptions; the desktop handles approval and rebinding.
A compositor without that portal reports unavailability, with sidebar buttons
as the fallback. X11 uses XGrabKey with Caps/Num lock variants and detectable
auto-repeat; Windows uses RegisterHotKey with MOD_NOREPEAT on a message thread.
Action timestamps are taken when the native event is received and retained
while the worker queues the export. Real Hyprland first-use approval, portal
restoration/rebinding and shortcuts while a game has focus remain unvalidated.
Native X11 desktop behaviour and Windows registration/conflicts also remain
unvalidated.
New checks:
- `scripts/validate/linux-clips.sh`: fake Steam session, actual sidebar button
clicks, cross-segment MP4, codec preservation, zero-error decode, CFR packet
PTS spacing, continuous audio, original short-clip range, capture continuing
during export, a persisted bookmark, forced one-byte cleanup with neighbouring
Removed spans, a new export of the bookmark after earlier files are deleted,
and a Clips screenshot. Uses only the standard PATH tools,
`rig-pointer` and `wtype`; no ripgrep dependency.
- `scripts/validate/check-clip.py <saved-clip.json>`: reusable packet/decode check
before source cleanup, including selected audio track count, cumulative source
audio clocks and the original per-track A/V phase to within one audio sample.
- `scripts/validate/linux-hotkeys.sh`: actual clip/bookmark X11 key grabs under
Xwayland (`xdotool` injects native X11 keys). The wlr rig has no GlobalShortcuts
portal, so it cannot validate the Wayland shortcut route.
Windows media build setup: use an FFmpeg **7.1 shared development** package
(headers and MSVC import libraries) matching OBS's bundled avcodec-61 DLLs.
Set `FFMPEG_DIR` to that package when building `clipbin-media`; distribute the
matching OBS-bundled FFmpeg DLLs. FFmpeg development/import files for MSVC are
not available in this Linux environment, so `clipbin-media` is excluded from
the Windows cross-lint. `clipbin-core` (including the hotkey bridge),
`clipbin-obs` and `clipbin-probe` are cross-linted. Windows export/remux, the
default player/folder actions, clip deletion, pending-export leases, bookmark
release, cleanup of recently closed files and every new GPUI control still
need linking/running on Windows.
Step 2 validation on the Intel UHD 770 Linux rig (2026-10-08): workspace format,
clippy with `-D warnings`, and all 49 unit tests passed. Windows cross-clippy
passed for core, OBS and probe with the exclusions above. The end-to-end clips
script passed for H.264 with one audio track and with separate system/mic
tracks, and for HEVC with the combined track. The HEVC run requested the default
120 seconds from a shorter session and correctly saved its available 14 seconds.
All checked exports retained their codec, decoded without errors, had exact
1/60-second video spacing and continuous audio, and retained their source A/V
phase. Forced one-byte cleanup removed neighbouring footage while bookmarks
and exported MP4s survived; a retained bookmark could still export after its
preceding files had been deleted. Clips screenshots are produced as `clips.png`
in each script's output directory.
To repeat the extra audio/codec cases, set `CLIPBIN_VALIDATE_MIC=1`, optionally
`CLIPBIN_VALIDATE_SEPARATE_AUDIO=1`, `CLIPBIN_VALIDATE_CODEC=Hevc`, or
`CLIPBIN_VALIDATE_CLIP_SECONDS=120` before `linux-clips.sh`. The original nine
capture scenarios and automatic-session validation also passed using
`env PATH=/usr/bin:/bin:$HOME/.cargo/bin`. Native X11 hotkey registration and
both actions passed under Xwayland. These fake Steam tests run sequentially:
game discovery is machine-wide, although rig teardown only kills its own PIDs.
AV1 export is unvalidated because this GPU has no usable AV1 encoder. B-frame
export and ranges containing an internal timestamp gap need additional media
fixtures; dropped-frame ranges are explicitly rejected. Linux default-player
and file-manager associations still need testing on a real desktop.
## Playback and thumbnails
Playback uses the shared `clipbin-media` crate (`ffmpeg-next` 9) without GPUI.
One background decoder opens only completed, available segments. Session time
comes from `Metadata::locate`; each file's timestamps are normalised independently.
Seeking goes backwards to a keyframe, then decodes forward to the selected frame.
A one-frame mailbox and coalesced seeks bound work; overdue frames are decoded
but discarded before swscale. Playback threads have lower scheduling priority
than recording. Leaving the editor closes its decoder and audio stream.
The editor uploads packed BGRA at its actual pixel size, maintains a 16:9
preview, and releases old GPUI images. Space/K toggle playback; arrows/J/L step
one frame. The timeline seeks and drags across files and gaps; completed footage
from active sessions becomes browsable on the next 250 ms metadata refresh
within the recorder's segment completion delay. Missing/removed footage advances
on the wall clock and shows “No footage (not captured / cleaned up)”. There is
no live recording preview.
Audio uses cpal and swresample at the output device's rate/channel count, with
sample conversion in the device callback. OBS's combined third audio track is
selected when three tracks exist; otherwise the first track is used. The queue
holds at most 250 ms plus one decoded block. Callback timestamps, including DAC
latency, drive the master clock; gaps and mute use the wall clock. Callbacks do
no decoding and never wait for the queue lock. Mute and volume are in the editor;
an unavailable device is shown explicitly while video remains usable. The Linux
rig uses its existing null sink and ALSA's PipeWire plugin as a fallback if cpal's
native PulseAudio backend refuses the device. Install/configure `pipewire-alsa`
or an ALSA default routed to PulseAudio on systems without a physical ALSA card.
One low-priority worker produces 320×180 PNG thumbnails beside the first playable
session segment and exported MP4s. Its request queue holds 16 paths and duplicate
requests coalesce; card discovery and all filesystem work run off the UI thread.
`clipbin_media::thumbnail::{generate,cache_path,Thumbnails}` accept any MP4/MKV.
The gradient remains while a thumbnail is pending or footage is unavailable.
`scripts/validate/playback-fixture.sh` generates independent 1080p60 H.264/AAC
segments with one-second keyframes and a three-tone audio fixture. It passed on
2026-10-08: exact frame pixels at frame indices 0/1/59/60/61/119 in every file,
real-time boundary crossing, gaps, superseded seeks, pause/seek clock maths,
bounded frame placement, resampling 48 kHz to 44.1 kHz and combined-track selection.
The ordinary workspace tests skip the generated-media assertions unless
`CLIPBIN_TEST_MEDIA_DIR` points to the fixture; use the script to exercise them.
Windows: the cpal WASAPI dependency cross-check passed, as did the dedicated
Windows thread-priority bridge. Core/OBS/probe strict MSVC cross-lint passed.
The full media cross-lint is excluded on this Linux machine because the FFmpeg
Windows development import libraries are unavailable. On Windows set `FFMPEG_DIR`
to an FFmpeg **7.1 shared development** package with MSVC import libraries matching
the OBS-bundled avcodec-61/avformat-61/avutil-59/swscale-8/swresample-5 DLLs. Linux
links system FFmpeg 8. Actual Windows decoding, WASAPI device latency/seek behaviour,
GPUI image presentation and thumbnail caching need a Windows machine. Hardware
video decode is not used; HEVC/AV1 software performance and real output devices
other than the rig null sink still need measurement.
Final playback validation passed on 2026-10-08, serially with other rigs stopped:
- `cargo fmt --all -- --check`, strict locked workspace clippy, and locked
workspace tests (with `CLIPBIN_TEST_MEDIA_DIR=target/playback-fixture`).
- `env PATH=/usr/bin:/bin:$HOME/.cargo/bin scripts/validate/linux-rig.sh`:
all nine scenarios. An earlier concurrent run lost its H.264 probe to SIGKILL;
the serial rerun passed without timeout or sandbox changes.
- The same PATH with `scripts/validate/linux-session.sh`: automatic recording,
exact segment placement, close-window survival, game exit, library reload and
clean Quit. Repeated after the final keyboard integration, also passed.
- `scripts/validate/linux-playback.sh target/playback-e2e-final-6`: moving preview
(11.0% changed pixels inside the preview), active recording with zero reported
recording drops, completed-segment availability, real session thumbnail,
cross-file drag, exact post-boundary frame/time, cleaned-up gap, zoom, arrow
stepping, device audio, half-volume and mute. The audio monitor RMS was 0.08044;
the player asserts video time stays within 20 ms of the audio master clock.
Screenshots were inspected: `sessions-thumbnails.png`, `editor-playing-1.png`,
`editor-playing-2.png`, `editor-seek.png`, and `editor-gap.png` in that directory.
The rig runs on an Intel i5-12500 (12 logical CPUs). These are development builds
with dependencies at opt-level 2, software video decoding, 1080p60 H.264 source
and an 800×450 target (the editor used 740×416). Five serial repetitions of
`target/debug/examples/playback-check <sessions-root> <id>` with
`scripts/validate/measure-cpu.py`, inside the null-sink rig, measured process CPU
at **16.7% median / 17.7% p95 of one core** (12.2–17.7% range). The filesystem cache
was warm; each repetition reopened its decoders. Across presented frames,
decode/scale time was **2.39 ms median / 8.80 ms p95**; maximum **101.5 ms** included
opening files and decoding forward on seeks. This is a test pattern with gaps,
not a worst-case game or a release-build benchmark. Per-run logs and CPU reports
are in `target/playback-timing-final`; `report.json` stores the distributions.
During the active-session e2e, the player presented **59.2 frames/s** while
recording remained at zero reported drops. GPUI upload/construction time was
**0.22 ms median / 0.48 ms p95**. UI frame intervals (including vsync, not CPU work)
were 16.2 ms median / 32.5 ms p95; the one-frame mailbox drops presentation work
rather than allowing delay to accumulate. `CLIPBIN_TRACE_FRAMES=1` logs decoded
video time, audio time, decode/scale work, uploads and actual timeline bounds.
The headless e2e restores keyboard focus after virtual-pointer commands and
holds virtual keys briefly: instantaneous virtual input can disappear before
GPUI/Wayland dispatch. Keyboard actions update the editor entity directly,
avoiding a nested window borrow during event dispatch.
After merging clips and playback at `dd016f7`, the clips rig intermittently
reported “desktop did not honour Quit”. A fresh-window probe on 2026-10-08
missed 3 of 8 instantaneous `wtype` Ctrl+Q presses; two missed presses occurred
while Clipbin was focused. Temporary action/GUI-exit/worker-join markers showed
that the missed presses never reached the Quit handler. All 8 held-key attempts
reached the handler and completed shutdown. The temporary markers were removed;
no application shutdown change was needed.
Both scripts now source `scripts/rig/keys.sh`: it restores keyboard focus after
virtual-pointer input, gives the virtual keymap 200 ms to settle, and holds each
key for 100 ms. Modified keys also give the modifier 100 ms to settle. Delivery
has a 10-second timeout; each script still requires the actual desktop process
to exit successfully. Serial reruns passed with
`env PATH=/usr/bin:/bin:$HOME/.cargo/bin`:
- `scripts/validate/linux-clips.sh /tmp/clipbin-clips-quit-fixed`: H.264 retained,
360 CFR frames over a six-second aligned clip, continuous audio, zero decode
errors, original press range, bookmark protection, tiny-budget cleanup,
bookmark re-export after adjacent removal, and both Quit exits.
- `scripts/validate/linux-playback.sh /tmp/clipbin-playback-quit-fixed`: moving
preview (4.1% changed pixels), device audio RMS 0.08045, thumbnails, active
capture, seek/gap/boundary behaviour, volume/mute, frame stepping and both Quit
exits. Decode median/p95 were 2.578/4.073 ms; GPUI upload median/p95 were
0.241/0.408 ms.
Logs are `/tmp/clipbin-clips-quit-fixed.log` and
`/tmp/clipbin-playback-quit-fixed.log`; screenshots are in their corresponding
output directories. `bash -n`, `git diff --check` and `cargo fmt --check` also
passed. This follow-up changes only the Linux rig scripts and documentation;
Windows runtime and real Hyprland validation limits above remain unchanged.
## Background operation (2026-10-13)
Linux uses [ksni 0.3.6](https://github.com/iovxw/ksni) with the blocking,
async-io API. It implements SNI and com.canonical.dbusmenu on its own executor,
works on Wayland and X11, and requires no GTK main loop. Using tray-icon on
Linux would require GTK alongside GPUI's main-thread loop, so it is only a
Windows dependency. Windows uses [tray-icon 0.26.1](https://github.com/tauri-apps/tray-icon)
with its menu and notification-area icon on a dedicated Win32 message thread;
its documented Windows event loop does not need the main thread. GPUI owns the
main thread on both platforms. Platform services live in clipbin-core and are
included in Windows cross-clippy. The new raw Win32 calls are confined to
windows_background.rs, with a module unsafe allowance, SAFETY comments and
crate-level unsafe_code=deny; desktop retains the workspace forbid policy.
The tray has purple idle, red recording and amber failed/degraded variants,
with status, game and error detail in its tooltip. Open Clipbin, Save clip,
Add bookmark and Quit use the recorder's existing actions. Linux requires an
SNI watcher with a registered host: startup failure opens the interface even
with --background, logs the reason and makes close finish/quit. A watcher that
disappears later requests Open and disables close-to-tray until it returns.
This avoids leaving an unreachable process after a bar/watcher disappears.
Linux owns dev.clipbin.Clipbin on the session bus and exposes Open at
/dev/clipbin/Clipbin. A second process requests the same name without queueing,
calls Open on the owner and exits before starting OBS or reconciling autostart.
A session bus is required; endpoint acquisition errors are logged and startup
exits rather than starting a duplicate recorder. Windows holds a per-user
Local named mutex and posts a private message to the first instance's hidden
endpoint window, with a bounded five-second startup race wait. The hidden
endpoint owns its message thread independently of the GPUI windows.
Start on login reconciles on startup and each settings save. Linux writes or
removes $XDG_CONFIG_HOME/autostart/clipbin.desktop (default ~/.config/autostart),
with a quoted executable path and --background; Windows writes/removes the
Clipbin value in HKCU\Software\Microsoft\Windows\CurrentVersion\Run. Changing
login settings does not interrupt capture. The executable must stay at its
installed path; development builds point the entry at the build executable.
Actual desktop login dispatch remains unvalidated.
Audio health uses [OBS 32.0.4's source capture callback API](https://github.com/obsproject/obs-studio/blob/32.0.4/libobs/obs-source.c)
on every configured system/microphone source, independently of the encoder,
mixed audio counters and silent output track. A boxed callback context stores
an atomic monotonic delivery timestamp; it performs no allocation or locking.
The context is removed before source release: OBS's audio_cb_mutex serialises
callback removal with callbacks. On Linux, five seconds without delivery
(including a source that never starts) is a warning-level RecordingStatus::Degraded,
with “audio capture lost” in the sidebar and tray. A new callback clears the
warning; video/source/output/disk failures still take precedence and stop
recording. Disabled audio has no callback and no warning. Valid silent samples
are healthy; an endless synthetic silence stream cannot reveal a driver failure.
Windows does not infer loss from absent buffers. In the pinned
[OBS 32.0.4 win-wasapi source](https://github.com/obsproject/obs-studio/blob/32.0.4/plugins/win-wasapi/win-wasapi.cpp),
WASAPISource::Initialize calls ClearBuffer before InitCapture for DeviceOutput. ClearBuffer
creates a temporary shared renderer and fills its buffer with silence, but
neither starts nor retains that renderer. ProcessCaptureData accepts silent
packets and exits normally when GetNextPacketSize reports no packets. This is
not evidence of uninterrupted callbacks during idle loopback capture. Instead,
Clipbin tracks the plugin's source-scoped “failed to start” and device
“invalidated. Retrying” logs; successful initialization or a delivered capture
buffer clears the warning. The callback remains allocation/lock free; the log
handler separately updates weak registered fault flags. Flags are registered
before source creation and unregistered on cleanup, so unrelated/stale sources
cannot affect a recording. The parser has a unit test. Windows failures that
neither deliver buffers nor produce these pinned log messages remain undetected;
this is preferable to reporting ordinary silence as lost capture. Actual WASAPI
silence, mute, unplug/recovery and changed-device behavior require Windows runtime
validation. No automatic source restart is added; OBS's recovery can clear the
warning. Source identity is not displayed separately for system vs mic loss.
The rig's audio failure injection kills its private pipewire-pulse daemon,
leaving PipeWire video and the portal alive. Unloading the null sink and
requesting kill-source-output did not reliably stop delivery in PipeWire 1.6.8;
killing that private audio server deterministically stops source callbacks.
The original nine scenarios are retained alongside audio loss, idle silence
and muted-microphone cases (twelve total). Silence cases omit the ffplay tone,
assert there are no playback streams, wait twelve seconds and require the null
sink to be SUSPENDED before capture. They then record for sixteen seconds with
no degraded/failed status. The muted-mic case selects the null sink monitor as
the default input and mutes it; physical microphone hardware remains untested.
The rig now uses an explicit private SWAYSOCK and waits for it; discovery by
the previous sway-ipc glob did not locate the installed Sway 1.12 endpoint.
CLIPBIN_RIG_KEEP_LOGS also preserves startup-failure logs.
Windows limitations for this step: core/OBS/probe cross-clippy covers the native
tray, message pump, named mutex/hidden-window endpoint, HKCU autostart and OBS
callback registration/removal. Nothing new has been linked or run on Windows.
GPUI desktop and clipbin-media remain excluded from that lint set because the
MSVC C toolchain/FFmpeg development import libraries are unavailable here.
Unvalidated: tray rendering/menu focus/Explorer restart, icon/tooltips, singleton
startup races and multi-user/desktop behaviour, foreground activation,
GPUI persistent-loop policy (including the vendored Windows backend, which is
not cross-linted), window recreation, clean Quit/file closure, --background,
registry writes and real login launch, WASAPI error-log/recovery/idle semantics, and the Clips layout on Windows.
Real Linux desktop trays (Hyprland
Waybar, KDE, GNOME extensions), login launch, SNI host replacement/re-registration
and native X11 tray lifecycle still need runtime validation.
Clips cards give title/subtitle their own row and keep fixed-size Play/Delete
controls below. The pinned GPUI text element caches nowrap widths independently
of truncation width and mutates its text runs during repeated measurements;
ordinary truncate produced hard clipping, ellipsis-only text, or fallback fonts
in the grid. A small private canvas label uses GPUI's line-wrapper and shaping
APIs once at the final bounds, retaining Geist Mono, colour, weight and proper
ellipsis without patching GPUI. Newlines in game names become spaces. Sample
screenshots at 1100x760 and 1600x1000 were inspected: all sample metadata is
readable and controls cannot steal its width. The amber audio warning was also
inspected in lifecycle-final/audio-lost.png. Screenshot validation exercises
the actual rendering path; no unit test can reproduce the GPU/grid text
measurement chain without the rig.
Validation commands, from the background-tray worktree (all passed):
```sh
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo fmt --all -- --check
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo clippy --locked --workspace --all-targets -- -D warnings
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo test --locked --workspace
RUSTUP_HOME=$HOME/.cache/clipbin/rustup CARGO_HOME=$HOME/.cache/clipbin/cargo-home PATH=$HOME/.cache/clipbin/cargo-home/bin:$PATH CARGO_TARGET_DIR=target/windows-check cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
```
57 workspace tests passed (generated-media tests keep their existing opt-in
fixture behaviour). The added status test covers audio degradation, recovery
and video-loss precedence. The dependency's existing proc-macro-error2 future
incompatibility notice is still printed; strict current clippy passes.
/tmp is a user-quota tmpfs here: an earlier recording probe failed with
Disk quota exceeded despite df reporting free bytes. Successful serial rig
runs used repository output storage, with a short absolute TMPDIR so private
Unix socket paths fit their limits:
```sh
export PATH=/usr/bin:/bin:$HOME/.cargo/bin
export TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp
out=/home/harry/Dev/clipbin-desktop/target/background-validation
mkdir -p "$TMPDIR"
scripts/validate/linux-rig.sh "$out/capture"
scripts/validate/linux-background.sh "$out/lifecycle-final"
scripts/validate/linux-session.sh "$out/sessions"
scripts/validate/linux-clips.sh "$out/clips"
scripts/validate/linux-playback.sh "$out/playback"
CLIPBIN_RIG_WIDTH=1100 CLIPBIN_RIG_HEIGHT=760 scripts/rig/screenshot-ui.sh "$out/screens-narrow-labels" clips
CLIPBIN_RIG_WIDTH=1600 CLIPBIN_RIG_HEIGHT=1000 scripts/rig/screenshot-ui.sh "$out/screens-wide-labels" clips
```
Capture: all original nine scenarios plus audio loss passed. Lifecycle: actual
watcher registration and D-Bus menu Open/Quit, recording tooltip, no-window
background start, autostart toggle removal/write and startup reconciliation,
segments growing after close, second launch reopening the same process,
preserved Settings route, degraded UI/tooltip, clean active-session Quit,
watcher-loss reveal and no-host close=Quit fallback passed. Sessions: game exit,
exact cumulative segment placement, continued background discovery/library
reload, second-launch Open and active Ctrl+Q passed. Clips: H.264 MP4 retained,
360 CFR frames, continuous audio, zero decode errors, original press range,
bookmarks, tiny-budget cleanup and bookmark re-export passed. Playback: moving
preview (6.1% changed pixels), device audio RMS 0.08027, thumbnails, continued
recording, gaps, cross-boundary seek, volume/mute and held-key controls passed.
Decode median/p95 was 6.335/10.472 ms; GPUI upload median/p95 was 0.208/0.404 ms.
Both old UI suites still use explicit Quit and completed successfully.
The simplify quick self-review covered reuse, clarity and efficiency before
commit: removed the obsolete window-close shutdown mode, reused existing clip,
bookmark and clean-finish paths, avoided copying the full library for tray
polling, limited icon updates to state changes, kept callbacks allocation/lock
free, and made native tray/thread cleanup explicit. The text helper is limited
to the demonstrated GPUI 0.2.2 rendering bug. The subsequent lifecycle review
required the small vendored GPUI policy patch described below. Native Windows
runtime and real desktop limits above remain unchanged.
## Background review follow-up (2026-10-13)
The first five-cycle close/Open test exposed a leak in repeated Application
creation: process thread counts grew 99, 114, 129, 144, 159, 174. GPUI's
LinuxDispatcher creates a worker pool and Timer per platform; the Timer ignores
channel closure and keeps running. This invalidates repeated-run ownership even
though each reopened window worked. Keeping one Application removes the repeated
platform construction. The final five-cycle test measured 71 threads initially
and 69 after every reopen; RSS was 243228, 242580, 247344, 243032, 243136,
243364 KiB. The rig saves reopen-metrics.tsv and asserts bounded thread/RSS growth.
The pinned Windows source was also examined: WindowsPlatform::new calls
OleInitialize; Drop destroys the message-only platform window and calls
OleUninitialize. The platform RegisterClassW result is ignored; the visible
window class uses Once, so repeated class registration itself is not a panic.
The VSync thread uses weak platform state and can exit on drop, and Windows
uses the OS thread pool rather than Linux's owned worker pool. Both dispatchers,
however, forget scheduled runnables when the main channel has closed, assuming
process shutdown. Repeated Application lifetimes are therefore not established
as safe on Windows. Clipbin avoids them entirely: fonts, dispatcher, OLE,
classes, DirectX state and native loop initialize once. The Windows last-window
policy is patched in the same backend decision as Linux; its runtime/build
remains unvalidated because the complete GPUI desktop cannot be cross-checked
here. The unmodified published dependency is retained with its Apache license;
clipbin-background.patch records the complete source delta. The only additional
compatibility change gives two taffy float literals explicit f32 types for
Rust 1.98's strict lint.
Follow-up artifacts live under target/background-review; reopen-before and
reopen-after contain lifecycle metrics. The silent-monitor experiment also
passed with the sink initially suspended. An initial muted-mic experiment used
an absent default input and correctly degraded; the committed fixture explicitly
selects a real monitor source before muting it.
Follow-up validation (all exit 0, 2026-10-13):
```sh
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo fmt --all -- --check
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo clippy --locked --workspace --all-targets -- -D warnings
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo test --locked --workspace
env RUSTUP_HOME=$HOME/.cache/clipbin/rustup CARGO_HOME=$HOME/.cache/clipbin/cargo-home PATH=$HOME/.cache/clipbin/cargo-home/bin:$PATH CARGO_TARGET_DIR=target/windows-check cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
env PATH=/usr/bin:/bin:$HOME/.cargo/bin TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp scripts/validate/linux-background.sh /home/harry/Dev/clipbin-desktop/target/background-review/reopen-after
env PATH=/usr/bin:/bin:$HOME/.cargo/bin TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp scripts/validate/linux-rig.sh /home/harry/Dev/clipbin-desktop/target/background-review/capture
env PATH=/usr/bin:/bin:$HOME/.cargo/bin TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp bash -e -c 'for suite in linux-session linux-clips linux-playback; do scripts/validate/${suite}.sh /home/harry/Dev/clipbin-desktop/target/background-review/${suite}; done'
```
All 58 workspace tests passed (fixture-dependent media tests retain their
existing opt-in behavior). All twelve capture scenarios passed; silence and
muted-mic logs show the initially suspended sink and respectively 650 and
650/645 captured packets, with no degradation. Audio-server loss still degrades
while video continues. Lifecycle passed SNI registration/menu actions,
--background, autostart write/remove, close survival, second launch, all five
reopen cycles, audio warning, clean Quit, watcher loss and no-host fallback.
Session finalization/library reload, clips/bookmarks/protected cleanup, and
playback all passed after the persistent-loop change. Playback changed 6.4% of
preview pixels, audio RMS was 0.08044; decode median/p95 was 4.951/11.467 ms and
GPUI upload median/p95 was 0.186/0.334 ms. The simplify quick self-review checked
reuse, clarity and efficiency; it retained existing actions/clean finish, one
application/dispatcher, weak fault registration with explicit cleanup, and no
new callback locks or allocations. The Windows policy flag uses a separate
Cell to avoid adding native state borrows during close handling.
+14 -6
View File
@@ -42,6 +42,10 @@ cleanup() {
wait 2>/dev/null || true wait 2>/dev/null || true
# The D-Bus-activated document portal mounts a FUSE filesystem here. # The D-Bus-activated document portal mounts a FUSE filesystem here.
fusermount3 -u "${XDG_RUNTIME_DIR}/doc" 2>/dev/null || true fusermount3 -u "${XDG_RUNTIME_DIR}/doc" 2>/dev/null || true
if [[ -n "${CLIPBIN_RIG_KEEP_LOGS:-}" ]]; then
mkdir -p "${CLIPBIN_RIG_KEEP_LOGS}"
cp "${rig_dir}"/*.log "${CLIPBIN_RIG_KEEP_LOGS}/" 2>/dev/null || true
fi
rm -rf "${rig_dir}" rm -rf "${rig_dir}"
} }
trap cleanup EXIT trap cleanup EXIT
@@ -70,6 +74,7 @@ pipewire > "${rig_dir}/pipewire.log" 2>&1 & pids+=($!)
wait_for pipewire '[[ -S "${XDG_RUNTIME_DIR}/pipewire-0" ]]' wait_for pipewire '[[ -S "${XDG_RUNTIME_DIR}/pipewire-0" ]]'
wireplumber > "${rig_dir}/wireplumber.log" 2>&1 & pids+=($!) wireplumber > "${rig_dir}/wireplumber.log" 2>&1 & pids+=($!)
pipewire-pulse > "${rig_dir}/pipewire-pulse.log" 2>&1 & pids+=($!) pipewire-pulse > "${rig_dir}/pipewire-pulse.log" 2>&1 & pids+=($!)
echo "${pids[-1]}" > "${rig_dir}/pulse.pid"
wait_for pipewire-pulse '[[ -S "${XDG_RUNTIME_DIR}/pulse/native" ]]' wait_for pipewire-pulse '[[ -S "${XDG_RUNTIME_DIR}/pulse/native" ]]'
# No sound card here: route playback to a virtual sink whose monitor OBS # No sound card here: route playback to a virtual sink whose monitor OBS
# records as desktop audio. # records as desktop audio.
@@ -89,11 +94,12 @@ printf '%s\n' \
"max_fps=60" \ "max_fps=60" \
> "${XDG_CONFIG_HOME}/xdg-desktop-portal-wlr/config" > "${XDG_CONFIG_HOME}/xdg-desktop-portal-wlr/config"
export SWAYSOCK="${XDG_RUNTIME_DIR}/clipbin-sway.sock"
WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_HEADLESS_OUTPUTS=1 \ WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_HEADLESS_OUTPUTS=1 \
sway --config "${XDG_CONFIG_HOME}/sway/config" > "${rig_dir}/sway.log" 2>&1 & pids+=($!) sway --config "${XDG_CONFIG_HOME}/sway/config" > "${rig_dir}/sway.log" 2>&1 & pids+=($!)
wait_for sway 'compgen -G "${XDG_RUNTIME_DIR}/wayland-*" > /dev/null' wait_for sway 'compgen -G "${XDG_RUNTIME_DIR}/wayland-*" > /dev/null'
WAYLAND_DISPLAY="$(basename "$(compgen -G "${XDG_RUNTIME_DIR}/wayland-*" | grep -v lock | head -1)")" WAYLAND_DISPLAY="$(basename "$(compgen -G "${XDG_RUNTIME_DIR}/wayland-*" | grep -v lock | head -1)")"
SWAYSOCK="$(compgen -G "${XDG_RUNTIME_DIR}/sway-ipc.*" | head -1)" wait_for "sway IPC" '[[ -S "${SWAYSOCK}" ]]'
export WAYLAND_DISPLAY SWAYSOCK export WAYLAND_DISPLAY SWAYSOCK
dbus-update-activation-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_RUNTIME_DIR XDG_CONFIG_HOME SWAYSOCK dbus-update-activation-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_RUNTIME_DIR XDG_CONFIG_HOME SWAYSOCK
/usr/lib/xdg-desktop-portal-wlr > "${rig_dir}/xdpw.log" 2>&1 & pids+=($!) /usr/lib/xdg-desktop-portal-wlr > "${rig_dir}/xdpw.log" 2>&1 & pids+=($!)
@@ -110,9 +116,13 @@ if [[ "${mode}" == x11 ]]; then
video_driver=x11 video_driver=x11
fi fi
# A moving 60 FPS picture with a 440 Hz tone, standing in for a game. # A moving 60 FPS picture, optionally without any audio stream for idle tests.
pattern="testsrc2=size=${width}x${height}:rate=60,format=yuv420p[out0]"
if [[ "${CLIPBIN_RIG_NO_AUDIO:-0}" != 1 ]]; then
pattern+=';sine=frequency=440:sample_rate=48000[out1]'
fi
SDL_VIDEODRIVER="${video_driver}" ffplay -loglevel error -fs -window_title clipbin-test-game \ SDL_VIDEODRIVER="${video_driver}" ffplay -loglevel error -fs -window_title clipbin-test-game \
-f lavfi "testsrc2=size=${width}x${height}:rate=60,format=yuv420p[out0];sine=frequency=440:sample_rate=48000[out1]" \ -f lavfi "${pattern}" \
> "${rig_dir}/game.log" 2>&1 & pids+=($!) > "${rig_dir}/game.log" 2>&1 & pids+=($!)
echo "${pids[-1]}" > "${rig_dir}/game.pid" echo "${pids[-1]}" > "${rig_dir}/game.pid"
if [[ "${mode}" == x11 ]]; then if [[ "${mode}" == x11 ]]; then
@@ -126,7 +136,5 @@ export CLIPBIN_RIG_DIR="${rig_dir}"
echo "rig: ready (${mode}, logs in ${rig_dir})" >&2 echo "rig: ready (${mode}, logs in ${rig_dir})" >&2
status=0 status=0
"$@" || status=$? "$@" || status=$?
if [[ -n "${CLIPBIN_RIG_KEEP_LOGS:-}" ]]; then
mkdir -p "${CLIPBIN_RIG_KEEP_LOGS}" && cp "${rig_dir}"/*.log "${CLIPBIN_RIG_KEEP_LOGS}/"
fi
exit "${status}" exit "${status}"
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Source inside the headless rig; accepts a key and an optional modifier.
rig_key() {
local key="$1" modifier="${2:-}"
# Destroying rig-pointer's virtual seat can clear keyboard focus in Sway.
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' >/dev/null
sleep .2
# Let the virtual keymap settle, then hold the key through a UI dispatch.
local args=(-s 200)
if [[ -n "${modifier}" ]]; then args+=(-M "${modifier}" -s 100); fi
args+=(-P "${key}" -s 100 -p "${key}")
if [[ -n "${modifier}" ]]; then args+=(-m "${modifier}"); fi
timeout 10 wtype "${args[@]}"
}
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Validate a persisted clip before any source cleanup (ffmpeg/ffprobe required)."""
import json
import pathlib
import subprocess
import sys
saved_path = pathlib.Path(sys.argv[1])
saved = json.loads(saved_path.read_text())
clip = saved_path.with_suffix('.mp4')
session_path = saved_path.parent.parent / 'sessions' / str(saved['source_session']) / 'session.json'
session = json.loads(session_path.read_text())
seconds = lambda d: d['secs'] + d['nanos'] / 1e9
start, end = seconds(saved['start']), seconds(saved['end'])
assert end > start
pieces = [s for s in session['segments'] if s['available'] and seconds(s['start']) < end and seconds(s['end']) > start]
assert len(pieces) >= 2, 'Export must cross a segment boundary'
def probe(path):
return json.loads(subprocess.check_output(['ffprobe', '-v', 'error', '-show_streams', '-show_packets', '-of', 'json', str(path)]))
source = probe(session_path.parent / pieces[0]['path'])
output = probe(clip)
video = next(s for s in output['streams'] if s['codec_type'] == 'video')
source_video = next(s for s in source['streams'] if s['codec_type'] == 'video')
assert video['codec_name'] == source_video['codec_name']
pts = sorted(float(p['pts_time']) for p in output['packets'] if p['stream_index'] == video['index'])
fps = pieces[0]['frames_per_second']
for a, b in zip(pts, pts[1:]):
assert abs(b-a-1/fps) < 2e-6, ('Video gap/duplicate', a, b, fps)
assert abs(pts[0]) < 1/fps
assert abs(pts[-1]+1/fps-(end-start)) < 1/fps+0.002, (pts[-1], start, end)
# Metadata records independent AAC epochs, even if earlier files are cleaned up later.
runs={}
for segment in session['segments']:
if not segment['complete'] or not segment['available']:
continue
run=str(pathlib.Path(segment['path']).parent)
facts=probe(session_path.parent/segment['path'])
audios=[s for s in facts['streams'] if s['codec_type']=='audio']
totals=runs.setdefault(run,[0]*len(audios))
assert segment['audio_start_samples']==totals,(segment['path'],segment['audio_start_samples'],totals)
for i,stream in enumerate(audios):
totals[i]+=1024*len([p for p in facts['packets'] if p['stream_index']==stream['index']])
for ordinal,stream in enumerate([s for s in output['streams'] if s['codec_type']=='audio']):
if stream['codec_type'] == 'audio':
timestamps = [float(p['pts_time']) for p in output['packets'] if p['stream_index'] == stream['index']]
step = 1024 / int(stream['sample_rate'])
assert timestamps
import math
source_ordinal=ordinal if saved['separate_audio'] or len([s for s in source['streams'] if s['codec_type']=='audio'])!=3 else 2
first=pieces[0]
run_base=seconds(first['start'])-first['frame_start']/fps
phase=run_base+first['audio_start_samples'][source_ordinal]/int(stream['sample_rate'])-start
expected_first=phase+max(0,math.ceil(-phase/step))*step
assert abs(timestamps[0]-expected_first)<1/int(stream['sample_rate'])+2e-6,('A/V phase',timestamps[0],expected_first)
for a, b in zip(timestamps, timestamps[1:]):
assert abs(b-a-step) < 2e-6, ('Audio gap/duplicate', a, b)
source_audio = [s for s in source['streams'] if s['codec_type'] == 'audio']
expected = 2 if saved['separate_audio'] and len(source_audio) == 3 else 1
assert len([s for s in output['streams'] if s['codec_type'] == 'audio']) == expected
result = subprocess.run(['ffmpeg', '-v', 'error', '-xerror', '-i', str(clip), '-f', 'null', '-'], capture_output=True)
assert result.returncode == 0 and not result.stderr, result.stderr.decode()
assert saved['clip']['size_bytes'] == clip.stat().st_size
print(f'PASS clip {clip.name}: {video["codec_name"]}, {len(pts)} CFR frames, continuous audio, zero decode errors, {end-start:.3f}s')
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# Serial tray/window/autostart validation in the private SNI-host rig.
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
out="${1:-${TMPDIR:-/tmp}/clipbin-background-validation}"
mkdir -p "${out}"
cargo build --locked -p clipbin-desktop -p rig-pointer
cargo build --locked -p clipbin-core --example sni-host
export CLIPBIN_BACKGROUND_REPO="${repo}" CLIPBIN_BACKGROUND_OUT="${out}"
"${repo}/scripts/rig/headless.sh" bash <<'INNER'
set -euo pipefail
repo="${CLIPBIN_BACKGROUND_REPO}" out="${CLIPBIN_BACKGROUND_OUT}"
export XDG_CONFIG_HOME="${out}/config" XDG_DATA_HOME="${out}/data"
mkdir -p "${XDG_CONFIG_HOME}" "${XDG_DATA_HOME}"
source "${repo}/scripts/rig/keys.sh"
"${repo}/target/debug/examples/sni-host" > "${out}/host.log" 2>&1 & host=$!
app= game=
trap 'kill "${app:-}" "${game:-}" "${host:-}" 2>/dev/null || true' EXIT
sleep 1
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] floating enable' >/dev/null
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] resize set width 1360 px height 860 px' >/dev/null
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] move position 280 110' >/dev/null
windows() {
swaymsg -t get_tree | python3 -c 'import json,sys
nodes=[json.load(sys.stdin)]; total=0
while nodes:
n=nodes.pop(); nodes.extend(n.get("nodes",[])+n.get("floating_nodes",[])); total+=n.get("app_id")=="dev.clipbin.Clipbin"
print(total)'
}
wait_windows() {
for _ in $(seq 50); do [[ "$(windows)" == "$1" ]] && return 0; sleep .2; done
echo "FAIL expected $1 Clipbin windows" >&2; exit 1
}
wait_exit() {
for _ in $(seq 100); do if ! kill -0 "${app}" 2>/dev/null; then wait "${app}"; return; fi; sleep .2; done
echo 'FAIL Quit did not exit' >&2; exit 1
}
CLIPBIN_ROUTE=settings "${repo}/target/debug/clipbin-desktop" --background > "${out}/desktop.log" 2>&1 & app=$!
sleep 3
kill -0 "${app}"
[[ "$(windows)" == 0 ]]
[[ -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]]
grep -q -- '--background' "${XDG_CONFIG_HOME}/autostart/clipbin.desktop"
item="$(sed -n 's/^registered=//p' "${out}/host.log" | tail -1)"
[[ -n "${item}" ]]
gdbus call --session --dest org.kde.StatusNotifierWatcher --object-path /StatusNotifierWatcher \
--method org.freedesktop.DBus.Properties.Get org.kde.StatusNotifierWatcher RegisteredStatusNotifierItems > "${out}/registered.txt"
grep -q "${item}" "${out}/registered.txt"
gdbus call --session --dest "${item}" --object-path /MenuBar \
--method com.canonical.dbusmenu.GetLayout -- 0 -1 '[]' > "${out}/menu.txt"
grep -q 'Open Clipbin' "${out}/menu.txt"
menu() { gdbus call --session --dest "${item}" --object-path /MenuBar --method com.canonical.dbusmenu.Event "$1" clicked '<0>' 0 > /dev/null; }
menu 1
wait_windows 1
swaymsg '[title="clipbin-test-game"] fullscreen disable' >/dev/null
swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set width 1360 px height 860 px, move position center, focus' >/dev/null
sleep 1
grim "${out}/settings.png"
# Coordinates within the centred 1360x860 settings window at 1920x1080.
printf 'move 1330 550\nsleep .2\nclick\nsleep .3\n' | "${repo}/target/debug/rig-pointer" 1920 1080
sleep 1
[[ ! -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]]
printf 'move 1330 550\nsleep .2\nclick\nsleep .3\n' | "${repo}/target/debug/rig-pointer" 1920 1080
sleep 1
[[ -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]]
echo 'PASS SNI registration, menu Open, background startup and autostart setting'
bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' & game=$!
for _ in $(seq 40); do grep -q 'recorder_status=Recording' "${out}/desktop.log" && break; sleep 1; done
grep -q 'recorder_status=Recording' "${out}/desktop.log"
gdbus call --session --dest "${item}" --object-path /StatusNotifierItem --method org.freedesktop.DBus.Properties.Get org.kde.StatusNotifierItem ToolTip > "${out}/recording-tooltip.txt"
grep -q Recording "${out}/recording-tooltip.txt"
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
wait_windows 0
before="$(find "${XDG_DATA_HOME}/clipbin/sessions" -name '*.mkv' | wc -l)"
sleep 5
after="$(find "${XDG_DATA_HOME}/clipbin/sessions" -name '*.mkv' | wc -l)"
(( after > before ))
"${repo}/target/debug/clipbin-desktop" > "${out}/second-launch.log" 2>&1
wait_windows 1
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
wait_windows 0
menu 1
wait_windows 1
# Repeated reopen must release each UI's threads and allocation state.
measure() {
python3 - "${app}" "$1" <<'PYMETRICS'
import pathlib,sys
status=dict(line.split(':',1) for line in pathlib.Path(f'/proc/{sys.argv[1]}/status').read_text().splitlines() if ':' in line)
print(f"{sys.argv[2]}\t{status['Threads'].strip()}\t{status['VmRSS'].split()[0]}")
PYMETRICS
}
measure 0 > "${out}/reopen-metrics.tsv"
for cycle in $(seq 5); do
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
wait_windows 0
sleep 1
menu 1
wait_windows 1
sleep 1
measure "${cycle}" >> "${out}/reopen-metrics.tsv"
done
cat "${out}/reopen-metrics.tsv"
python3 - "${out}/reopen-metrics.tsv" <<'PYBOUNDS'
import pathlib,sys
rows=[tuple(map(int,line.split())) for line in pathlib.Path(sys.argv[1]).read_text().splitlines()]
assert max(r[1] for r in rows[1:]) <= rows[0][1]+4, rows
# Allow the first reopen to warm caches; later windows must stay bounded.
rss=[r[2] for r in rows[1:]]
assert max(rss)-min(rss) <= 32*1024 and rows[-1][2] <= rows[0][2]+64*1024, rows
print('PASS five close/reopen cycles have bounded thread count and RSS')
PYBOUNDS
# Audio delivery loss must be visible in the application and real tray tooltip.
kill "$(cat "${CLIPBIN_RIG_DIR}/pulse.pid")"
for _ in $(seq 15); do grep -q 'recorder_status=Degraded' "${out}/desktop.log" && break; sleep 1; done
grep -q 'recorder_status=Degraded' "${out}/desktop.log"
sleep .3
gdbus call --session --dest "${item}" --object-path /StatusNotifierItem --method org.freedesktop.DBus.Properties.Get org.kde.StatusNotifierItem ToolTip > "${out}/audio-lost-tooltip.txt"
grep -q 'audio capture lost' "${out}/audio-lost-tooltip.txt"
grim "${out}/audio-lost.png"
echo 'PASS audio loss is degraded in the UI and tray while video continues'
# Quit through D-Bus menu while a game is still alive.
menu 4
wait_exit
python3 - "${XDG_DATA_HOME}" <<'PY'
import pathlib,json,sys
files=list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'))
assert files and all(not json.loads(p.read_text())['session']['active'] for p in files)
assert all(s['complete'] for p in files for s in json.loads(p.read_text())['segments'])
PY
kill "${game}"; wait "${game}" 2>/dev/null || true; game=
echo 'PASS close keeps segments growing, second launch reopens, tray Open reopens, tray Quit finalises'
# Losing the watcher must reveal the UI; closing then quits cleanly.
"${repo}/target/debug/clipbin-desktop" --background > "${out}/host-loss.log" 2>&1 & app=$!
sleep 2
wait_windows 0
kill "${host}"; wait "${host}" 2>/dev/null || true; host=
wait_windows 1
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
wait_exit
# Reconcile an externally disabled saved setting, removing the existing entry.
python3 - "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'PYSETTINGS'
import json,pathlib,sys
p=pathlib.Path(sys.argv[1]); settings=json.loads(p.read_text()); settings['start_on_login']=False; p.write_text(json.dumps(settings))
PYSETTINGS
# With no watcher at startup --background must open a reachable window.
"${repo}/target/debug/clipbin-desktop" --background > "${out}/no-tray.log" 2>&1 & app=$!
wait_windows 1
grep -q 'Tray unavailable' "${out}/no-tray.log"
[[ ! -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]]
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
wait_exit
echo 'PASS watcher loss reveals UI; no-tray background falls back to close=Quit'
INNER
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Real desktop buttons, background export, persisted bookmark and forced cleanup.
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
out="${1:-/tmp/clipbin-clips-validation-$(date +%s)}"
mkdir -p "${out}"
cargo build --locked -p clipbin-desktop -p rig-pointer --manifest-path "${repo}/Cargo.toml"
cargo build --locked -p clipbin-media --example export --manifest-path "${repo}/Cargo.toml"
export CLIPBIN_CLIPS_REPO="${repo}" CLIPBIN_CLIPS_OUT="${out}"
"${repo}/scripts/rig/headless.sh" bash <<'INNER'
set -euo pipefail
repo="${CLIPBIN_CLIPS_REPO}"; out="${CLIPBIN_CLIPS_OUT}"
source "${repo}/scripts/rig/keys.sh"
export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config"
mkdir -p "${XDG_CONFIG_HOME}/clipbin"
cat > "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'JSON'
{"clip_duration":{"secs":5,"nanos":0},"bookmark_before":{"secs":2,"nanos":0},"bookmark_after":{"secs":2,"nanos":0}}
JSON
python3 - "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'PYSETTINGS'
import json,os,pathlib,sys
p=pathlib.Path(sys.argv[1]);s=json.loads(p.read_text())
s['clip_duration']['secs']=int(os.environ.get('CLIPBIN_VALIDATE_CLIP_SECONDS','5'))
s['codec']=os.environ.get('CLIPBIN_VALIDATE_CODEC','H264')
assert s['codec'] in ('H264','Hevc','Av1')
s['microphone_enabled']=os.environ.get('CLIPBIN_VALIDATE_MIC','0')=='1'
s['export_separate_audio']=os.environ.get('CLIPBIN_VALIDATE_SEPARATE_AUDIO','0')=='1'
p.write_text(json.dumps(s))
PYSETTINGS
"${repo}/target/debug/clipbin-desktop" > "${out}/desktop.log" 2>&1 & app=$!
trap 'kill "${app}" "${game:-}" 2>/dev/null || true' EXIT
quit_app() {
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' >/dev/null
# A fresh GPUI window may not have received the rig's keyboard focus yet.
# Move/click inside it before injecting the shortcut, then retry boundedly.
read -r qx qy qw qh < <(geometry)
click "$((qx+qw-30))" "$((qy+100))"
sleep 0.5
rig_key q ctrl
for attempt in $(seq 20); do
if ! kill -0 "${app}" 2>/dev/null; then wait "${app}"; return; fi
if ((attempt % 4 == 0)); then
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' >/dev/null
rig_key q ctrl
fi
sleep 1
done
grim "${out}/quit-failed.png"
swaymsg -t get_tree > "${out}/quit-failed-tree.json"
echo 'FAIL desktop did not honour Quit' >&2
exit 1
}
sleep 3
swaymsg '[title="clipbin-test-game"] fullscreen disable' >/dev/null || true
swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set 1360 860, move position center, focus' >/dev/null
bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' & game=$!
for _ in $(seq 30); do
if grep -q 'recorder_status=Recording' "${out}/desktop.log"; then break; fi
kill -0 "${app}"; sleep 1
done
grep -q 'recorder_status=Recording' "${out}/desktop.log"
sleep 5
# Read window geometry rather than depending on output placement/titlebar height.
geometry() { swaymsg -t get_tree | python3 -c 'import json,sys
s=[json.load(sys.stdin)]
while s:
n=s.pop()
if n.get("app_id")=="dev.clipbin.Clipbin":
r=n["rect"];print(r["x"],r["y"],r["width"],r["height"]);break
s.extend(n.get("nodes",[])+n.get("floating_nodes",[]))'; }
read -r x y width height < <(geometry)
click() { printf 'move %s %s\nclick\nsleep 0.3\n' "$1" "$2" | "${repo}/target/debug/rig-pointer" 1920 1080; }
grim "${out}/before-buttons.png"
# Bottom sidebar: storage 88px, recorder 70px, gap24px, bookmark32px.
click "$((x+110))" "$((y+height-248))"
sleep 5
press_ns="$(python3 -c 'import time;print(time.time_ns())')"
click "$((x+110))" "$((y+height-304))"
for _ in $(seq 30); do
if grep -q 'export_status=Saved clip' "${out}/desktop.log"; then break; fi
kill -0 "${app}"; sleep 1
done
grep -q 'export_status=Saved clip' "${out}/desktop.log"
python3 - "${XDG_DATA_HOME}" "${repo}" "${press_ns}" <<'PY'
import pathlib,subprocess,sys,json,os
clips=[p for p in pathlib.Path(sys.argv[1]).glob('clipbin/clips/*.json') if p.stem.isdigit()]
assert len(clips)==1,clips
subprocess.run(['python3',sys.argv[2]+'/scripts/validate/check-clip.py',str(clips[0])],check=True)
m=json.loads(next(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json')).read_text())
assert len(m['session']['bookmarks'])==1 and m['session']['bookmarks'][0]['protected']
assert m['session']['active'],'Export must leave capture active'
saved=json.loads(clips[0].read_text())
configured=int(os.environ.get('CLIPBIN_VALIDATE_CLIP_SECONDS','5'))
seconds=lambda d:d['secs']+d['nanos']/1e9
pressed=int(sys.argv[3])/1e9-m['session']['started']['secs_since_epoch']-m['session']['started']['nanos_since_epoch']/1e9
assert pressed-.2<=seconds(saved['end'])<=pressed+1.2,(pressed,saved['end'])
if pressed>=configured:
assert pressed-configured-1.2<=seconds(saved['start'])<=pressed-configured+.2,(pressed,saved['start'])
assert configured<=seconds(saved['clip']['length'])<=configured+1.1
else:
assert abs(seconds(saved['start'])-min(seconds(s['start']) for s in m['segments'] if s['available']))<.001
assert seconds(saved['clip']['length'])<configured
print('PASS shorter session used its actual available footage')
print('PASS export kept the original button press range while the segment finalised')
PY
click "$((x+100))" "$((y+128))"
sleep 2; grim "${out}/clips.png"
kill "${game}"; wait "${game}" 2>/dev/null || true
sleep 3
quit_app
python3 - "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'PY'
import json,pathlib,sys
p=pathlib.Path(sys.argv[1]);s=json.loads(p.read_text());s['unprotected_session_limit_bytes']=1;p.write_text(json.dumps(s))
PY
"${repo}/target/debug/clipbin-desktop" > "${out}/cleanup.log" 2>&1 & app=$!
sleep 6
python3 - "${XDG_DATA_HOME}" "${repo}" "${out}" <<'PY'
import json,pathlib,sys,subprocess
root=pathlib.Path(sys.argv[1]);p=next(root.glob('clipbin/sessions/*/session.json'));m=json.loads(p.read_text())
seconds=lambda d:d['secs']+d['nanos']/1e9
b=m['session']['bookmarks'][0];start=max(0,seconds(b['at'])-seconds(b['before']));end=min(seconds(m['session']['length']),seconds(b['at'])+seconds(b['after']))
assert b['protected'];removed=kept=0
for s in m['segments']:
overlap=seconds(s['start'])<end and seconds(s['end'])>start
if overlap:
assert s['available'] and (p.parent/s['path']).is_file();kept+=1
elif seconds(s['end'])>seconds(s['start']):
assert not s['available'] and not (p.parent/s['path']).exists();removed+=1
assert kept and removed,(kept,removed)
assert any(s['kind']=='Removed' for s in m['session']['spans'])
assert len(list(root.glob('clipbin/clips/*.mp4')))==1
print('PASS forced tiny-budget cleanup preserved bookmark footage and clip, removed neighbours and persisted Removed spans')
# Earlier unprotected files are gone: the retained bookmark must still remux
# using its persisted per-stream clock, without scanning those deleted files.
export=pathlib.Path(sys.argv[3])/'bookmark-after-cleanup.mp4'
subprocess.run([sys.argv[2]+'/target/debug/examples/export',str(p.parent.parent),str(m['session']['id']),str(int(start*1000)),str(int(end*1000)),str(export)],check=True)
subprocess.run(['ffmpeg','-v','error','-xerror','-i',str(export),'-f','null','-'],check=True)
packets=json.loads(subprocess.check_output(['ffprobe','-v','error','-select_streams','v:0','-show_packets','-of','json',str(export)]))['packets']
pts=sorted(float(p['pts_time']) for p in packets);fps=next(s['frames_per_second'] for s in m['segments'] if s['available'])
assert all(abs(b-a-1/fps)<2e-6 for a,b in zip(pts,pts[1:]))
print('PASS protected bookmark exported after preceding source segments were deleted')
PY
quit_app
echo 'PASS in-app clip and bookmark paths; portal GlobalShortcuts unavailable in wlr rig (real Hyprland remains unvalidated)'
INNER
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# X11 native grabs under the rig's Xwayland; portal actions need real Hyprland.
set -euo pipefail
command -v xdotool >/dev/null || { echo "Install xdotool to inject X11 hotkeys" >&2; exit 127; }
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cargo build --locked -p clipbin-core --example hotkeys --manifest-path "${repo}/Cargo.toml"
export CLIPBIN_HOTKEY_REPO="${repo}"
"${repo}/scripts/rig/headless.sh" --x11 bash <<'INNER'
set -euo pipefail
log="${CLIPBIN_RIG_DIR}/hotkeys.log"
env -u WAYLAND_DISPLAY "${CLIPBIN_HOTKEY_REPO}/target/debug/examples/hotkeys" > "${log}" 2>&1 & helper=$!
trap 'kill "${helper}" 2>/dev/null || true' EXIT
sleep 2
swaymsg '[title="clipbin-test-game"] focus' > /dev/null
xdotool key ctrl+shift+F9
sleep 0.3
xdotool key ctrl+shift+F10
if ! wait "${helper}"; then cat "${log}"; exit 1; fi
cat "${log}"
grep -q 'PASS native clip and bookmark hotkeys' "${log}"
INNER
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# Playback, active browsing, real thumbnails and audio in the private desktop rig.
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
out="${1:-${repo}/target/playback-validation}"
mkdir -p "${out}"
out="$(cd "${out}" && pwd)"
cargo build --locked --workspace --manifest-path "${repo}/Cargo.toml"
cargo build --locked -p clipbin-media --example playback-check --manifest-path "${repo}/Cargo.toml"
export CLIPBIN_PLAYBACK_REPO="${repo}" CLIPBIN_PLAYBACK_OUT="${out}"
"${repo}/scripts/rig/headless.sh" bash <<'INNER'
set -euo pipefail
repo="${CLIPBIN_PLAYBACK_REPO}" out="${CLIPBIN_PLAYBACK_OUT}"
source "${repo}/scripts/rig/keys.sh"
export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config" CLIPBIN_TRACE_FRAMES=1
mkdir -p "${XDG_DATA_HOME}" "${XDG_CONFIG_HOME}"
# ALSA's PipeWire plugin is cpal's fallback on Linux; there is no hardware card.
cat > "${out}/alsa.conf" <<'ALSA'
</usr/share/alsa/alsa.conf>
pcm.!default { type pipewire }
ctl.!default { type pipewire }
ALSA
export ALSA_CONFIG_PATH="${out}/alsa.conf"
swaymsg '[title="clipbin-test-game"] fullscreen disable' > /dev/null
# Timeline bounds come from GPUI's trace rather than guessed screen positions.
pointer_time() {
python3 - "$1" "$2" "$3" "${XDG_DATA_HOME}" <<'PYPOINTER'
import pathlib,re,json,sys
log=pathlib.Path(sys.argv[1]).read_text()
x,y,w,h=map(float,re.findall(r'playback_timeline x=([\d.]+) y=([\d.]+) width=([\d.]+) height=([\d.]+)',log)[-1])
p=max(pathlib.Path(sys.argv[4]).glob('clipbin/sessions/*/session.json'),key=lambda p:int(p.parent.name))
m=json.loads(p.read_text());length=m['session']['length'];length=length['secs']+length['nanos']/1e9
if sys.argv[2] in ('gap','after-gap'):
segment=next(s for s in m['segments'] if s['complete'] and not s['available'])
def seconds(d):return d['secs']+d['nanos']/1e9
at=(seconds(segment['start'])+seconds(segment['end']))/2 if sys.argv[2]=='gap' else seconds(segment['end'])+.5
else:at=float(sys.argv[2])
print(f"move {round(280+x+w*at/length)} {round(110+y+h/2)}\nsleep .15\n{sys.argv[3]}\nsleep .15")
PYPOINTER
}
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] floating enable' > /dev/null
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] resize set width 1360 px height 860 px' > /dev/null
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] move position 280 110' > /dev/null
"${repo}/target/debug/clipbin-desktop" > "${out}/active.log" 2>&1 & app=$!
trap 'kill "${app:-}" "${game:-}" "${audio_capture:-}" 2>/dev/null || true' EXIT
sleep 3
swaymsg '[title="clipbin-test-game"] fullscreen disable' > /dev/null
swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set width 1360 px height 860 px, move position 280 110, focus' > /dev/null
bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' & game=$!
for _ in $(seq 30); do if grep -q recorder_status=Recording "${out}/active.log"; then break; fi; sleep 1; done
grep -q recorder_status=Recording "${out}/active.log"
sleep 8
grim "${out}/sessions-thumbnails.png"
# Open the first session card, seek near the start, and play while recording continues.
printf 'move 670 330\nsleep .2\ndown\nsleep .1\nup\nsleep 1\n' | "${repo}/target/debug/rig-pointer" 1920 1080
grim "${out}/editor-open.png"
pointer_time "${out}/active.log" 1 click | "${repo}/target/debug/rig-pointer" 1920 1080
# GPUI/Wayland needs the virtual keyboard keymap to settle and a held key;
# instantaneous wtype press/release occasionally disappears before dispatch.
rig_key space
sleep 1
grim "${out}/editor-playing-1.png"
sleep 2
grim "${out}/editor-playing-2.png"
python3 - "${XDG_DATA_HOME}" <<'PY'
import pathlib,json,sys
p=max(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'),key=lambda p:int(p.parent.name))
m=json.loads(p.read_text());assert m['session']['active'];assert len([s for s in m['segments'] if s['complete'] and s['available']])>=4
assert list(p.parent.rglob('*.thumbnail.png')), 'thumbnail worker produced no cached frame'
print('PASS playback left recording active and generated real thumbnails')
PY
kill "${game}"; wait "${game}" 2>/dev/null || true
sleep 3
rig_key q ctrl
for _ in $(seq 20); do if ! kill -0 "${app}" 2>/dev/null; then break; fi; sleep 1; done
if kill -0 "${app}" 2>/dev/null; then echo 'FAIL Ctrl+Q did not exit' >&2; exit 1; fi
wait "${app}"
kill "$(cat "${CLIPBIN_RIG_DIR}/game.pid")"
# Simulate a cleaned-up middle segment in this isolated test library. Keep its
# timeline placement: the player must show the gap rather than concatenate it away.
python3 - "${XDG_DATA_HOME}" "${out}" <<'PY'
import pathlib,json,sys
p=max(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'),key=lambda p:int(p.parent.name))
m=json.loads(p.read_text());segments=[s for s in m['segments'] if s['complete'] and s['available']];assert len(segments)>=4
s=segments[2];s['available']=False
m['session']['spans']=[dict(start=m['session']['spans'][0]['start'],end=s['start'],kind='Footage'),dict(start=s['start'],end=s['end'],kind='Removed'),dict(start=s['end'],end=m['session']['length'],kind='Footage')]
p.write_text(json.dumps(m));pathlib.Path(sys.argv[2]+'/session-id').write_text(str(m['session']['id']))
PY
id="$(cat "${out}/session-id")"
# The CLI exercises the same player with audio only; recording tone has stopped.
ffmpeg -v error -y -f pulse -i clipbin-rig-sink.monitor -t 10 "${out}/audio-output.wav" & audio_capture=$!
python3 "${repo}/scripts/validate/measure-cpu.py" "${out}/player-cpu.txt" "${repo}/target/debug/examples/playback-check" "${XDG_DATA_HOME}/clipbin/sessions" "${id}" > "${out}/player.log" 2>&1
wait "${audio_capture}"
CLIPBIN_ROUTE="session:${id}" "${repo}/target/debug/clipbin-desktop" > "${out}/playback.log" 2>&1 & app=$!
sleep 3
swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set width 1360 px height 860 px, move position 280 110, focus' > /dev/null
# Drag through several files, then seek into the removed interval.
{ pointer_time "${out}/playback.log" 1 down; pointer_time "${out}/playback.log" 3 "sleep 0.5"; pointer_time "${out}/playback.log" after-gap up; } | "${repo}/target/debug/rig-pointer" 1920 1080
sleep 1
grim "${out}/editor-seek.png"
pointer_time "${out}/playback.log" gap click | "${repo}/target/debug/rig-pointer" 1920 1080
sleep 1
grim "${out}/editor-gap.png"
pointer_time "${out}/playback.log" 1 $'scroll -1\nsleep 0.5\nscroll 1' | "${repo}/target/debug/rig-pointer" 1920 1080
pointer_time "${out}/playback.log" 2.5 click | "${repo}/target/debug/rig-pointer" 1920 1080
sleep .3
rig_key Right
sleep .3
rig_key Left
sleep .3
rig_key q ctrl
for _ in $(seq 20); do if ! kill -0 "${app}" 2>/dev/null; then break; fi; sleep 1; done
if kill -0 "${app}" 2>/dev/null; then echo 'FAIL Ctrl+Q did not exit' >&2; exit 1; fi
wait "${app}"
python3 - "${out}" <<'PY'
import pathlib,subprocess,re,sys,math,array,statistics,json
out=pathlib.Path(sys.argv[1])
def rgb(path):return subprocess.check_output(['ffmpeg','-v','error','-i',str(path),'-vf','crop=700:300:555:260','-f','rawvideo','-pix_fmt','rgb24','-'])
a,b=map(rgb,[out/'editor-playing-1.png',out/'editor-playing-2.png'])
changed=sum(x!=y for x,y in zip(a,b))/len(a);assert changed>.02, f'preview frozen: {changed}'
raw=subprocess.check_output(['ffmpeg','-v','error','-i',str(out/'audio-output.wav'),'-ac','1','-ar','48000','-f','f32le','-'])
samples=array.array('f',raw);rms=math.sqrt(sum(x*x for x in samples)/len(samples));assert rms>.001, f'no device audio: {rms}'
def window_rms(start,end):
block=samples[int(start*48000):int(end*48000)]
return math.sqrt(sum(x*x for x in block)/max(1,len(block)))
full=window_rms(2,3);half=window_rms(7.9,8.4);muted=window_rms(9.5,9.9)
assert full>.001 and .35<half/full<.65 and muted<full*.02, f'volume/mute wrong: {full}, {half}, {muted}'
log=(out/'player.log').read_text();assert 'state=gap' in log and 'state=frame' in log
assert 'Cannot play audio' not in log and 'Audio output failed' not in log
rows=re.findall(r'playback time=([\d.]+) decode_ms=([\d.]+).*presented=true',log)
times=[float(row[1]) for row in rows];assert times
print(f'PASS moving preview ({changed:.1%} pixels changed), device audio RMS={rms:.5f}, gaps and boundary playback')
print(f'decode_ms median={statistics.median(times):.3f} p95={sorted(times)[int(len(times)*.95)]:.3f} max={max(times):.3f}')
ui_log=(out/'playback.log').read_text()
metadata=json.loads(next((out/'data/clipbin/sessions').glob('*/session.json')).read_text())
def seconds(d):return d['secs']+d['nanos']/1e9
removed=next(s for s in metadata['segments'] if s['complete'] and not s['available'])
gap=(seconds(removed['start'])+seconds(removed['end']))/2
gap_times=[float(t) for t in re.findall(r'playback time=([\d.]+).*state=gap',ui_log)]
assert any(abs(t-gap)<.1 for t in gap_times), f'UI seek never reached gap at {gap}'
frame_times=[(float(t),float(v)) for t,v in re.findall(r'playback time=([\d.]+).*state=frame.*video_time=([\d.]+)',ui_log)]
assert any(abs(t-(seconds(removed['end'])+.5))<.1 and abs(t-v)<.020 for t,v in frame_times), 'UI boundary seek showed the wrong frame/time'
# Right then Left must actually move the selected frame, while remaining paused.
steps=[v for t,v in frame_times if abs(t-2.5)<.15]
assert len(set(steps))>=2, f'frame stepping did not change the video frame: {steps}'
uploads=[float(x) for x in re.findall(r'playback_ui upload_ms=([\d.]+)',(out/'active.log').read_text())]
assert len(uploads)>30, 'too few moving frames reached GPUI'
print(f'GPUI frame upload median={statistics.median(uploads):.3f}ms p95={sorted(uploads)[int(len(uploads)*.95)]:.3f}ms')
(out/'report.txt').write_text(f'changed_preview_pixels={changed:.4f}\naudio_rms={rms:.5f}\ndecode_median_ms={statistics.median(times):.3f}\ndecode_p95_ms={sorted(times)[int(len(times)*.95)]:.3f}\n')
PY
echo "PASS playback validation; screenshots and timing in ${out}"
INNER
+33
View File
@@ -115,6 +115,39 @@ else
result "${name}" 1 result "${name}" 1
fi fi
# 9. Capture audio disappears while video/output remain healthy.
name=audio-source-lost
"${rig}" bash -c "'${probe}' record --seconds 15 --out '${out}/${name}' & p=\$!; sleep 6; kill \$(cat \"\${CLIPBIN_RIG_DIR}/pulse.pid\"); wait \$p" \
> "${out}/${name}.log" 2>&1
probe_exit=$?
if [[ "${probe_exit}" == 0 ]] && has "${out}/${name}.log" "status=Recording" \
&& has "${out}/${name}.log" "audio capture lost" \
&& ! has "${out}/${name}.log" "status=failed"; then
result "${name}" 0 "video continued; audio loss surfaced as degraded"
else
result "${name}" 1
fi
# 10. Silence is healthy: no playback streams, idle >10s before monitor capture.
for mode in silence muted-mic; do
name="audio-${mode}"
mic=""
setup=""
if [[ "${mode}" == muted-mic ]]; then
mic="--mic"
setup="pactl set-default-source clipbin-rig-sink.monitor; pactl set-source-mute clipbin-rig-sink.monitor 1;"
fi
CLIPBIN_RIG_NO_AUDIO=1 "${rig}" bash -c "sleep 12; test -z \"\$(pactl list short sink-inputs)\" || exit 1; pactl list short sinks | grep SUSPENDED || exit 1; ${setup} '${probe}' record ${mic} --seconds 16 --out '${out}/${name}'; status=\$?; pactl list short sinks; exit \$status" \
> "${out}/${name}.log" 2>&1
probe_exit=$?
if [[ "${probe_exit}" == 0 ]] && has "${out}/${name}.log" "status=Recording" \
&& ! has "${out}/${name}.log" "status=(degraded|failed)"; then
result "${name}" 0 "no playback for >10s, capture remains healthy"
else
result "${name}" 1
fi
done
echo echo
if [[ "${failures}" == 0 ]]; then if [[ "${failures}" == 0 ]]; then
echo "All scenarios passed. Logs in ${out}" echo "All scenarios passed. Logs in ${out}"
+12 -12
View File
@@ -20,6 +20,7 @@ done
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
out="${1:-${TMPDIR:-/tmp}/clipbin-session-validation}" out="${1:-${TMPDIR:-/tmp}/clipbin-session-validation}"
mkdir -p "${out}" mkdir -p "${out}"
cargo build --locked -p clipbin-core --example sni-host
cargo build --locked -p clipbin-desktop --manifest-path "${repo}/Cargo.toml" cargo build --locked -p clipbin-desktop --manifest-path "${repo}/Cargo.toml"
export CLIPBIN_SESSION_REPO="${repo}" CLIPBIN_SESSION_OUT="${out}" export CLIPBIN_SESSION_REPO="${repo}" CLIPBIN_SESSION_OUT="${out}"
"${repo}/scripts/rig/headless.sh" bash <<'INNER' "${repo}/scripts/rig/headless.sh" bash <<'INNER'
@@ -46,13 +47,17 @@ wait_log() {
echo "FAIL timed out waiting for ${minimum} '${pattern}' entries; log: ${log}" >&2 echo "FAIL timed out waiting for ${minimum} '${pattern}' entries; log: ${log}" >&2
exit 1 exit 1
} }
"${CLIPBIN_SESSION_REPO}/target/debug/examples/sni-host" > "${CLIPBIN_SESSION_OUT}/tray-host.log" 2>&1 &
host=$!
sleep 1
source "${CLIPBIN_SESSION_REPO}/scripts/rig/keys.sh"
out="${CLIPBIN_SESSION_OUT}" out="${CLIPBIN_SESSION_OUT}"
portal_config="${XDG_CONFIG_HOME}" portal_config="${XDG_CONFIG_HOME}"
export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config" export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config"
mkdir -p "${XDG_DATA_HOME}" "${XDG_CONFIG_HOME}" mkdir -p "${XDG_DATA_HOME}" "${XDG_CONFIG_HOME}"
"${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/desktop.log" 2>&1 & "${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/desktop.log" 2>&1 &
app=$! app=$!
trap 'kill "${app}" "${game:-}" "${portal:-}" 2>/dev/null || true' EXIT trap 'kill "${app}" "${game:-}" "${portal:-}" "${host}" 2>/dev/null || true' EXIT
sleep 3 sleep 3
swaymsg '[title="clipbin-test-game"] fullscreen disable' > /dev/null || true swaymsg '[title="clipbin-test-game"] fullscreen disable' > /dev/null || true
swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set 1360 860, move position center, focus' > /dev/null swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set 1360 860, move position center, focus' > /dev/null
@@ -147,25 +152,20 @@ import pathlib,json,sys
files=list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json')) files=list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'))
assert files and all(not json.loads(p.read_text())['session']['active'] for p in files) assert files and all(not json.loads(p.read_text())['session']['active'] for p in files)
PY PY
for _ in $(seq 10); do kill -0 "${app}"
if ! kill -0 "${app}" 2>/dev/null; then break; fi
sleep 1
done
if kill -0 "${app}" 2>/dev/null; then echo 'FAIL closed app kept discovering after game exit' >&2; exit 1; fi
wait "${app}"
kill "$(cat "${CLIPBIN_RIG_DIR}/game.pid")" kill "$(cat "${CLIPBIN_RIG_DIR}/game.pid")"
# Reopen to prove the library loads from disk, then Quit cleanly with Ctrl+Q. # A second launch signals the original instance and exits; background discovery survives.
"${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/reopened.log" 2>&1 & "${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/second-launch.log" 2>&1
app=$! kill -0 "${app}"
sleep 4 sleep 4
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null
sleep 0.5 sleep 0.5
grim "${out}/library.png" grim "${out}/library.png"
bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' & bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' &
game=$! game=$!
wait_log 'recorder_status=Recording' "${out}/reopened.log" 1 30 wait_log 'recorder_status=Recording' "${out}/desktop.log" 2 30
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null
wtype -M ctrl -k q -m ctrl rig_key q ctrl
for _ in $(seq 20); do for _ in $(seq 20); do
if ! kill -0 "${app}" 2>/dev/null; then break; fi if ! kill -0 "${app}" 2>/dev/null; then break; fi
sleep 1 sleep 1
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""Measure child CPU/wall time without depending on GNU time."""
import pathlib
import resource
import subprocess
import sys
import time
before = resource.getrusage(resource.RUSAGE_CHILDREN)
started = time.monotonic()
result = subprocess.run(sys.argv[2:], check=False)
elapsed = time.monotonic() - started
usage = resource.getrusage(resource.RUSAGE_CHILDREN)
user = usage.ru_utime - before.ru_utime
system = usage.ru_stime - before.ru_stime
pathlib.Path(sys.argv[1]).write_text(
f"cpu_user={user:.3f} cpu_system={system:.3f} wall={elapsed:.3f} "
f"cpu_percent={(user + system) / elapsed * 100:.1f}%\n"
)
sys.exit(result.returncode)
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Deterministic, clock-labelled 1080p60 segments with independent file timestamps.
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
out="${1:-${repo}/target/playback-fixture}"
mkdir -p "${out}/1"
ffmpeg -v error -y -f lavfi -i 'testsrc2=size=1920x1080:rate=60' \
-f lavfi -i 'sine=frequency=440:sample_rate=48000' -t 6 \
-vf "drawtext=fontfile=/usr/share/fonts/TTF/DejaVuSans.ttf:text='%{pts\:hms}':fontsize=64:fontcolor=white:box=1:boxcolor=black:x=100:y=100" \
-c:v libx264 -preset ultrafast -crf 28 -threads 2 -g 60 -keyint_min 60 -sc_threshold 0 -bf 0 \
-c:a aac -f segment -segment_time 2 -reset_timestamps 1 "${out}/1/segment-%03d.mkv"
ffmpeg -v error -y -i "${out}/1/segment-000.mkv" \
-f lavfi -i 'sine=frequency=220:sample_rate=48000' \
-f lavfi -i 'sine=frequency=880:sample_rate=48000' \
-f lavfi -i 'sine=frequency=440:sample_rate=48000' -t 2 \
-map 0:v -map 1:a -map 2:a -map 3:a -c:v copy -c:a aac "${out}/combined.mkv"
python3 - "${out}" <<'PY'
import pathlib,json,sys
root=pathlib.Path(sys.argv[1]); d=lambda s:dict(secs=s,nanos=0)
segments=[dict(path=f'segment-{i:03}.mkv',start=d(t),end=d(t+2),complete=True,available=True,frame_start=i*120,frames_per_second=60,dropped_frames=0) for i,t in enumerate([0,2,6])]
session=dict(id=1,game='Playback fixture',started=dict(secs_since_epoch=1791453600,nanos_since_epoch=0),length=d(8),active=False,spans=[dict(start=d(a),end=d(b),kind=k) for a,b,k in [(0,4,'Footage'),(4,6,'Missing'),(6,8,'Footage')]],bookmarks=[])
(root/'1/session.json').write_text(json.dumps(dict(session=session,app_id=480,segments=segments)))
PY
CLIPBIN_TEST_MEDIA_DIR="${out}" cargo test --locked -p clipbin-media --manifest-path "${repo}/Cargo.toml"
+14
View File
@@ -0,0 +1,14 @@
# Clipbin GPUI 0.2.2 patch
Baseline: crates.io gpui 0.2.2, checksum
`979b45cfa6ec723b6f42330915a1b3769b930d02b2d505f9697f8ca602bee707`.
Original Apache-2.0 license retained. This dependency is excluded from Clipbin's
workspace; its existing third-party unsafe code is unchanged.
`clipbin-background.patch` is the complete source delta from the published
crate (zero-context unified diff; apply with `patch -p1` or
`git apply --unidiff-zero`). It adds App::set_quit_on_last_window_close and an initially true backend
flag on Wayland, X11 and Windows. Clipbin sets it false so one Application/loop
lives until explicit Quit, while visible windows can be destroyed/recreated.
No timers, dispatchers or FFI calls are changed. Two taffy float literals
are explicitly typed f32 to satisfy Rust 1.98 strict clippy.
+749
View File
@@ -0,0 +1,749 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
name = "gpui"
version = "0.2.2"
authors = ["Nathan Sobo <nathan@zed.dev>"]
build = "build.rs"
publish = true
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Zed's GPU-accelerated UI framework"
homepage = "https://gpui.rs"
readme = "README.md"
keywords = [
"desktop",
"gui",
"immediate",
]
categories = ["gui"]
license = "Apache-2.0"
repository = "https://github.com/zed-industries/zed"
resolver = "2"
[features]
default = [
"font-kit",
"wayland",
"x11",
"windows-manifest",
]
inspector = ["gpui_macros/inspector"]
leak-detection = ["backtrace"]
macos-blade = [
"blade-graphics",
"blade-macros",
"blade-util",
"bytemuck",
"objc2",
"objc2-metal",
]
runtime_shaders = []
screen-capture = ["scap"]
test-support = [
"leak-detection",
"collections/test-support",
"rand",
"util/test-support",
"http_client/test-support",
"wayland",
"x11",
]
wayland = [
"blade-graphics",
"blade-macros",
"blade-util",
"bytemuck",
"ashpd/wayland",
"cosmic-text",
"font-kit",
"calloop-wayland-source",
"wayland-backend",
"wayland-client",
"wayland-cursor",
"wayland-protocols",
"wayland-protocols-plasma",
"filedescriptor",
"xkbcommon",
"open",
]
windows-manifest = []
x11 = [
"blade-graphics",
"blade-macros",
"blade-util",
"bytemuck",
"ashpd",
"cosmic-text",
"font-kit",
"as-raw-xcb-connection",
"x11rb",
"xkbcommon",
"xim",
"x11-clipboard",
"filedescriptor",
"open",
"scap?/x11",
]
[lib]
name = "gpui"
path = "src/gpui.rs"
doctest = false
[[example]]
name = "animation"
path = "examples/animation.rs"
[[example]]
name = "data_table"
path = "examples/data_table.rs"
[[example]]
name = "drag_drop"
path = "examples/drag_drop.rs"
[[example]]
name = "gif_viewer"
path = "examples/gif_viewer.rs"
[[example]]
name = "gradient"
path = "examples/gradient.rs"
[[example]]
name = "grid_layout"
path = "examples/grid_layout.rs"
[[example]]
name = "hello_world"
path = "examples/hello_world.rs"
[[example]]
name = "image"
path = "examples/image/image.rs"
[[example]]
name = "image_gallery"
path = "examples/image_gallery.rs"
[[example]]
name = "image_loading"
path = "examples/image_loading.rs"
[[example]]
name = "input"
path = "examples/input.rs"
[[example]]
name = "on_window_close_quit"
path = "examples/on_window_close_quit.rs"
[[example]]
name = "opacity"
path = "examples/opacity.rs"
[[example]]
name = "ownership_post"
path = "examples/ownership_post.rs"
[[example]]
name = "painting"
path = "examples/painting.rs"
[[example]]
name = "paths_bench"
path = "examples/paths_bench.rs"
[[example]]
name = "pattern"
path = "examples/pattern.rs"
[[example]]
name = "scrollable"
path = "examples/scrollable.rs"
[[example]]
name = "set_menus"
path = "examples/set_menus.rs"
[[example]]
name = "shadow"
path = "examples/shadow.rs"
[[example]]
name = "svg"
path = "examples/svg/svg.rs"
[[example]]
name = "tab_stop"
path = "examples/tab_stop.rs"
[[example]]
name = "text"
path = "examples/text.rs"
[[example]]
name = "text_layout"
path = "examples/text_layout.rs"
[[example]]
name = "text_wrapper"
path = "examples/text_wrapper.rs"
[[example]]
name = "tree"
path = "examples/tree.rs"
[[example]]
name = "uniform_list"
path = "examples/uniform_list.rs"
[[example]]
name = "window"
path = "examples/window.rs"
[[example]]
name = "window_positioning"
path = "examples/window_positioning.rs"
[[example]]
name = "window_shadow"
path = "examples/window_shadow.rs"
[[test]]
name = "action_macros"
path = "tests/action_macros.rs"
[dependencies.anyhow]
version = "1.0.86"
[dependencies.async-task]
version = "4.7"
[dependencies.backtrace]
version = "0.3"
optional = true
[dependencies.blade-graphics]
version = "0.7.0"
optional = true
[dependencies.blade-macros]
version = "0.3.0"
optional = true
[dependencies.blade-util]
version = "0.3.0"
optional = true
[dependencies.bytemuck]
version = "1"
optional = true
[dependencies.collections]
version = "0.2.2"
package = "gpui_collections"
[dependencies.ctor]
version = "0.4.0"
[dependencies.derive_more]
version = "0.99.17"
[dependencies.etagere]
version = "0.2"
[dependencies.futures]
version = "0.3"
[dependencies.gpui_macros]
version = "0.2.2"
package = "gpui-macros"
[dependencies.http_client]
version = "0.2.2"
package = "gpui_http_client"
[dependencies.image]
version = "0.25.1"
[dependencies.inventory]
version = "0.3.19"
[dependencies.itertools]
version = "0.14.0"
[dependencies.libc]
version = "0.2"
[dependencies.log]
version = "0.4.16"
features = [
"kv_unstable_serde",
"serde",
]
[dependencies.lyon]
version = "1.0"
[dependencies.num_cpus]
version = "1.13"
[dependencies.parking]
version = "2.0.0"
[dependencies.parking_lot]
version = "0.12.1"
[dependencies.pin-project]
version = "1.1.10"
[dependencies.postage]
version = "0.5"
features = ["futures-traits"]
[dependencies.profiling]
version = "1"
[dependencies.rand]
version = "0.9"
optional = true
[dependencies.raw-window-handle]
version = "0.6"
[dependencies.refineable]
version = "0.2.2"
package = "gpui_refineable"
[dependencies.resvg]
version = "0.45.0"
features = [
"text",
"system-fonts",
"memmap-fonts",
]
default-features = false
[dependencies.schemars]
version = "1.0"
features = ["indexmap2"]
[dependencies.seahash]
version = "4.1"
[dependencies.semantic_version]
version = "0.2.2"
package = "gpui_semantic_version"
[dependencies.serde]
version = "1.0.221"
features = [
"derive",
"rc",
]
[dependencies.serde_json]
version = "1.0.144"
features = [
"preserve_order",
"raw_value",
]
[dependencies.slotmap]
version = "1.0.6"
[dependencies.smallvec]
version = "1.6"
features = ["union"]
[dependencies.smol]
version = "2.0"
[dependencies.stacksafe]
version = "0.1"
[dependencies.strum]
version = "0.27.2"
features = ["derive"]
[dependencies.sum_tree]
version = "0.2.2"
package = "gpui_sum_tree"
[dependencies.taffy]
version = "=0.9.0"
[dependencies.thiserror]
version = "2.0.12"
[dependencies.usvg]
version = "0.45.0"
default-features = false
[dependencies.util]
version = "0.2.2"
package = "gpui_util"
[dependencies.util_macros]
version = "0.2.2"
package = "gpui_util_macros"
[dependencies.uuid]
version = "1.1.2"
features = [
"v4",
"v5",
"v7",
"serde",
]
[dependencies.waker-fn]
version = "1.2.0"
[dev-dependencies.backtrace]
version = "0.3"
[dev-dependencies.collections]
version = "0.2.2"
features = ["test-support"]
package = "gpui_collections"
[dev-dependencies.env_logger]
version = "0.11"
[dev-dependencies.http_client]
version = "0.2.2"
features = ["test-support"]
package = "gpui_http_client"
[dev-dependencies.lyon]
version = "1.0"
features = ["extra"]
[dev-dependencies.pretty_assertions]
version = "1.3.0"
features = ["unstable"]
[dev-dependencies.rand]
version = "0.9"
[dev-dependencies.unicode-segmentation]
version = "1.10"
[dev-dependencies.util]
version = "0.2.2"
features = ["test-support"]
package = "gpui_util"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.as-raw-xcb-connection]
version = "1"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.ashpd]
version = "0.11"
features = ["async-std"]
optional = true
default-features = false
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.blade-graphics]
version = "0.7.0"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.blade-macros]
version = "0.3.0"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.blade-util]
version = "0.3.0"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.bytemuck]
version = "1"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.calloop]
version = "0.13.0"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.calloop-wayland-source]
version = "0.3.0"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.cosmic-text]
version = "0.14.0"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.filedescriptor]
version = "0.8.2"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.flume]
version = "0.11"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.font-kit]
version = "0.14.1-zed"
features = ["source-fontconfig-dlopen"]
optional = true
package = "zed-font-kit"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.oo7]
version = "0.5.0"
features = [
"async-std",
"native_crypto",
]
default-features = false
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.open]
version = "5.2.0"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.wayland-backend]
version = "0.3.3"
features = [
"client_system",
"dlopen",
]
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.wayland-client]
version = "0.31.2"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.wayland-cursor]
version = "0.31.1"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.wayland-protocols]
version = "0.31.2"
features = [
"client",
"staging",
"unstable",
]
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.wayland-protocols-plasma]
version = "0.2.0"
features = ["client"]
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.x11-clipboard]
version = "0.9.3"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.x11rb]
version = "0.13.1"
features = [
"allow-unsafe-code",
"xkb",
"randr",
"xinput",
"cursor",
"resource_manager",
"sync",
]
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.xim]
version = "0.4.0-zed"
features = [
"x11rb-xcb",
"x11rb-client",
]
optional = true
package = "zed-xim"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.xkbcommon]
version = "0.8.0"
features = [
"wayland",
"x11",
]
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.build-dependencies.naga]
version = "25.0"
features = ["wgsl-in"]
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))'.dependencies.pathfinder_geometry]
version = "0.5"
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))'.dependencies.scap]
version = "0.0.8-zed"
optional = true
default-features = false
package = "zed-scap"
[target.'cfg(target_os = "macos")'.dependencies.block]
version = "0.1"
[target.'cfg(target_os = "macos")'.dependencies.cocoa]
version = "=0.26.0"
[target.'cfg(target_os = "macos")'.dependencies.cocoa-foundation]
version = "=0.2.0"
[target.'cfg(target_os = "macos")'.dependencies.core-foundation]
version = "=0.10.0"
[target.'cfg(target_os = "macos")'.dependencies.core-foundation-sys]
version = "0.8.6"
[target.'cfg(target_os = "macos")'.dependencies.core-graphics]
version = "0.24"
[target.'cfg(target_os = "macos")'.dependencies.core-text]
version = "21"
[target.'cfg(target_os = "macos")'.dependencies.core-video]
version = "0.4.3"
features = ["metal"]
[target.'cfg(target_os = "macos")'.dependencies.font-kit]
version = "0.14.1-zed"
optional = true
package = "zed-font-kit"
[target.'cfg(target_os = "macos")'.dependencies.foreign-types]
version = "0.5"
[target.'cfg(target_os = "macos")'.dependencies.log]
version = "0.4.16"
features = [
"kv_unstable_serde",
"serde",
]
[target.'cfg(target_os = "macos")'.dependencies.media]
version = "0.2.2"
package = "gpui_media"
[target.'cfg(target_os = "macos")'.dependencies.metal]
version = "0.29"
[target.'cfg(target_os = "macos")'.dependencies.objc]
version = "0.2"
[target.'cfg(target_os = "macos")'.dependencies.objc2]
version = "0.6"
optional = true
[target.'cfg(target_os = "macos")'.dependencies.objc2-metal]
version = "0.3"
optional = true
[target.'cfg(target_os = "macos")'.build-dependencies.bindgen]
version = "0.71"
[target.'cfg(target_os = "macos")'.build-dependencies.cbindgen]
version = "0.28.0"
default-features = false
[target.'cfg(target_os = "macos")'.build-dependencies.naga]
version = "25.0"
features = ["wgsl-in"]
[target.'cfg(target_os = "windows")'.dependencies.flume]
version = "0.11"
[target.'cfg(target_os = "windows")'.dependencies.rand]
version = "0.9"
[target.'cfg(target_os = "windows")'.dependencies.windows]
version = "0.61"
features = [
"Foundation_Numerics",
"Storage_Search",
"Storage_Streams",
"System_Threading",
"UI_ViewManagement",
"Wdk_System_SystemServices",
"Win32_Globalization",
"Win32_Graphics_Direct3D",
"Win32_Graphics_Direct3D11",
"Win32_Graphics_Direct3D_Fxc",
"Win32_Graphics_DirectComposition",
"Win32_Graphics_DirectWrite",
"Win32_Graphics_Dwm",
"Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
"Win32_Graphics_Gdi",
"Win32_Graphics_Imaging",
"Win32_Graphics_Hlsl",
"Win32_Networking_WinSock",
"Win32_Security",
"Win32_Security_Credentials",
"Win32_Security_Cryptography",
"Win32_Storage_FileSystem",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_System_Console",
"Win32_System_DataExchange",
"Win32_System_IO",
"Win32_System_LibraryLoader",
"Win32_System_Memory",
"Win32_System_Ole",
"Win32_System_Performance",
"Win32_System_Pipes",
"Win32_System_SystemInformation",
"Win32_System_SystemServices",
"Win32_System_Threading",
"Win32_System_Variant",
"Win32_System_WinRT",
"Win32_UI_Controls",
"Win32_UI_HiDpi",
"Win32_UI_Input_Ime",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Shell",
"Win32_UI_Shell_Common",
"Win32_UI_Shell_PropertiesSystem",
"Win32_UI_WindowsAndMessaging",
]
[target.'cfg(target_os = "windows")'.dependencies.windows-core]
version = "0.61"
[target.'cfg(target_os = "windows")'.dependencies.windows-numerics]
version = "0.2"
[target.'cfg(target_os = "windows")'.dependencies.windows-registry]
version = "0.5"
[target.'cfg(target_os = "windows")'.build-dependencies.embed-resource]
version = "3.0"
[lints.clippy]
dbg_macro = "deny"
declare_interior_mutable_const = "deny"
disallowed_methods = "deny"
large_enum_variant = "allow"
let_underscore_future = "allow"
nonminimal_bool = "allow"
redundant_clone = "deny"
single_range_in_vec_init = "allow"
todo = "deny"
too_many_arguments = "allow"
type_complexity = "allow"
[lints.clippy.style]
level = "allow"
priority = -1
[lints.rust.unexpected_cfgs]
level = "allow"
priority = 0
+222
View File
@@ -0,0 +1,222 @@
Copyright 2022 - 2025 Zed Industries, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+66
View File
@@ -0,0 +1,66 @@
# Welcome to GPUI!
GPUI is a hybrid immediate and retained mode, GPU accelerated, UI framework
for Rust, designed to support a wide variety of applications.
## Getting Started
GPUI is still in active development as we work on the Zed code editor, and is still pre-1.0. There will often be breaking changes between versions. You'll also need to use the latest version of stable Rust and be on macOS or Linux. Add the following to your `Cargo.toml`:
```toml
gpui = { version = "*" }
```
- [Ownership and data flow](src/_ownership_and_data_flow.rs)
Everything in GPUI starts with an `Application`. You can create one with `Application::new()`, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()`, and register your first root view. See [gpui.rs](https://www.gpui.rs/) for a complete example.
### Dependencies
GPUI has various system dependencies that it needs in order to work.
#### macOS
On macOS, GPUI uses Metal for rendering. In order to use Metal, you need to do the following:
- Install [Xcode](https://apps.apple.com/us/app/xcode/id497799835?mt=12) from the macOS App Store, or from the [Apple Developer](https://developer.apple.com/download/all/) website. Note this requires a developer account.
> Ensure you launch Xcode after installing, and install the macOS components, which is the default option.
- Install [Xcode command line tools](https://developer.apple.com/xcode/resources/)
```sh
xcode-select --install
```
- Ensure that the Xcode command line tools are using your newly installed copy of Xcode:
```sh
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
```
## The Big Picture
GPUI offers three different [registers](<https://en.wikipedia.org/wiki/Register_(sociolinguistics)>) depending on your needs:
- State management and communication with `Entity`'s. Whenever you need to store application state that communicates between different parts of your application, you'll want to use GPUI's entities. Entities are owned by GPUI and are only accessible through an owned smart pointer similar to an `Rc`. See the `app::context` module for more information.
- High level, declarative UI with views. All UI in GPUI starts with a view. A view is simply an `Entity` that can be rendered, by implementing the `Render` trait. At the start of each frame, GPUI will call this render method on the root view of a given window. Views build a tree of `elements`, lay them out and style them with a tailwind-style API, and then give them to GPUI to turn into pixels. See the `div` element for an all purpose swiss-army knife of rendering.
- Low level, imperative UI with Elements. Elements are the building blocks of UI in GPUI, and they provide a nice wrapper around an imperative API that provides as much flexibility and control as you need. Elements have total control over how they and their child elements are rendered and can be used for making efficient views into large lists, implement custom layouting for a code editor, and anything else you can think of. See the `element` module for more information.
Each of these registers has one or more corresponding contexts that can be accessed from all GPUI services. This context is your main interface to GPUI, and is used extensively throughout the framework.
## Other Resources
In addition to the systems above, GPUI provides a range of smaller services that are useful for building complex applications:
- Actions are user-defined structs that are used for converting keystrokes into logical operations in your UI. Use this for implementing keyboard shortcuts, such as cmd-q. See the `action` module for more information.
- Platform services, such as `quit the app` or `open a URL` are available as methods on the `app::App`.
- An async executor that is integrated with the platform's event loop. See the `executor` module for more information.,
- The `[gpui::test]` macro provides a convenient way to write tests for your GPUI applications. Tests also have their own kind of context, a `TestAppContext` which provides ways of simulating common platform input. See `app::test_context` and `test` modules for more details.
Currently, the best way to learn about these APIs is to read the Zed source code, ask us about it at a fireside hack, or drop a question in the [Zed Discord](https://zed.dev/community-links). We're working on improving the documentation, creating more examples, and will be publishing more guides to GPUI on our [blog](https://zed.dev/blog).
+457
View File
@@ -0,0 +1,457 @@
#![allow(clippy::disallowed_methods, reason = "build scripts are exempt")]
#![cfg_attr(any(not(target_os = "macos"), feature = "macos-blade"), allow(unused))]
//TODO: consider generating shader code for WGSL
//TODO: deprecate "runtime-shaders" and "macos-blade"
use std::env;
fn main() {
let target = env::var("CARGO_CFG_TARGET_OS");
println!("cargo::rustc-check-cfg=cfg(gles)");
#[cfg(any(
not(any(target_os = "macos", target_os = "windows")),
all(target_os = "macos", feature = "macos-blade")
))]
check_wgsl_shaders();
match target.as_deref() {
Ok("macos") => {
#[cfg(target_os = "macos")]
macos::build();
}
Ok("windows") => {
#[cfg(target_os = "windows")]
windows::build();
}
_ => (),
};
}
#[cfg(any(
not(any(target_os = "macos", target_os = "windows")),
all(target_os = "macos", feature = "macos-blade")
))]
fn check_wgsl_shaders() {
use std::path::PathBuf;
use std::process;
use std::str::FromStr;
let shader_source_path = "./src/platform/blade/shaders.wgsl";
let shader_path = PathBuf::from_str(shader_source_path).unwrap();
println!("cargo:rerun-if-changed={}", &shader_path.display());
let shader_source = std::fs::read_to_string(&shader_path).unwrap();
match naga::front::wgsl::parse_str(&shader_source) {
Ok(_) => {
// All clear
}
Err(e) => {
println!("cargo::error=WGSL shader compilation failed:\n{}", e);
process::exit(1);
}
}
}
#[cfg(target_os = "macos")]
mod macos {
use std::{
env,
path::{Path, PathBuf},
};
use cbindgen::Config;
pub(super) fn build() {
generate_dispatch_bindings();
#[cfg(not(feature = "macos-blade"))]
{
let header_path = generate_shader_bindings();
#[cfg(feature = "runtime_shaders")]
emit_stitched_shaders(&header_path);
#[cfg(not(feature = "runtime_shaders"))]
compile_metal_shaders(&header_path);
}
}
fn generate_dispatch_bindings() {
println!("cargo:rustc-link-lib=framework=System");
let bindings = bindgen::Builder::default()
.header("src/platform/mac/dispatch.h")
.allowlist_var("_dispatch_main_q")
.allowlist_var("_dispatch_source_type_data_add")
.allowlist_var("DISPATCH_QUEUE_PRIORITY_HIGH")
.allowlist_var("DISPATCH_TIME_NOW")
.allowlist_function("dispatch_get_global_queue")
.allowlist_function("dispatch_async_f")
.allowlist_function("dispatch_after_f")
.allowlist_function("dispatch_time")
.allowlist_function("dispatch_source_merge_data")
.allowlist_function("dispatch_source_create")
.allowlist_function("dispatch_source_set_event_handler_f")
.allowlist_function("dispatch_resume")
.allowlist_function("dispatch_suspend")
.allowlist_function("dispatch_source_cancel")
.allowlist_function("dispatch_set_context")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.layout_tests(false)
.generate()
.expect("unable to generate bindings");
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("dispatch_sys.rs"))
.expect("couldn't write dispatch bindings");
}
fn generate_shader_bindings() -> PathBuf {
let output_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("scene.h");
let crate_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let mut config = Config {
include_guard: Some("SCENE_H".into()),
language: cbindgen::Language::C,
no_includes: true,
..Default::default()
};
config.export.include.extend([
"Bounds".into(),
"Corners".into(),
"Edges".into(),
"Size".into(),
"Pixels".into(),
"PointF".into(),
"Hsla".into(),
"ContentMask".into(),
"Uniforms".into(),
"AtlasTile".into(),
"PathRasterizationInputIndex".into(),
"PathVertex_ScaledPixels".into(),
"PathRasterizationVertex".into(),
"ShadowInputIndex".into(),
"Shadow".into(),
"QuadInputIndex".into(),
"Underline".into(),
"UnderlineInputIndex".into(),
"Quad".into(),
"BorderStyle".into(),
"SpriteInputIndex".into(),
"MonochromeSprite".into(),
"PolychromeSprite".into(),
"PathSprite".into(),
"SurfaceInputIndex".into(),
"SurfaceBounds".into(),
"TransformationMatrix".into(),
]);
config.no_includes = true;
config.enumeration.prefix_with_name = true;
let mut builder = cbindgen::Builder::new();
let src_paths = [
crate_dir.join("src/scene.rs"),
crate_dir.join("src/geometry.rs"),
crate_dir.join("src/color.rs"),
crate_dir.join("src/window.rs"),
crate_dir.join("src/platform.rs"),
crate_dir.join("src/platform/mac/metal_renderer.rs"),
];
for src_path in src_paths {
println!("cargo:rerun-if-changed={}", src_path.display());
builder = builder.with_src(src_path);
}
builder
.with_config(config)
.generate()
.expect("Unable to generate bindings")
.write_to_file(&output_path);
output_path
}
/// To enable runtime compilation, we need to "stitch" the shaders file with the generated header
/// so that it is self-contained.
#[cfg(feature = "runtime_shaders")]
fn emit_stitched_shaders(header_path: &Path) {
use std::str::FromStr;
fn stitch_header(header: &Path, shader_path: &Path) -> std::io::Result<PathBuf> {
let header_contents = std::fs::read_to_string(header)?;
let shader_contents = std::fs::read_to_string(shader_path)?;
let stitched_contents = format!("{header_contents}\n{shader_contents}");
let out_path =
PathBuf::from(env::var("OUT_DIR").unwrap()).join("stitched_shaders.metal");
std::fs::write(&out_path, stitched_contents)?;
Ok(out_path)
}
let shader_source_path = "./src/platform/mac/shaders.metal";
let shader_path = PathBuf::from_str(shader_source_path).unwrap();
stitch_header(header_path, &shader_path).unwrap();
println!("cargo:rerun-if-changed={}", &shader_source_path);
}
#[cfg(not(feature = "runtime_shaders"))]
fn compile_metal_shaders(header_path: &Path) {
use std::process::{self, Command};
let shader_path = "./src/platform/mac/shaders.metal";
let air_output_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("shaders.air");
let metallib_output_path =
PathBuf::from(env::var("OUT_DIR").unwrap()).join("shaders.metallib");
println!("cargo:rerun-if-changed={}", shader_path);
let output = Command::new("xcrun")
.args([
"-sdk",
"macosx",
"metal",
"-gline-tables-only",
"-mmacosx-version-min=10.15.7",
"-MO",
"-c",
shader_path,
"-include",
(header_path.to_str().unwrap()),
"-o",
])
.arg(&air_output_path)
.output()
.unwrap();
if !output.status.success() {
println!(
"cargo::error=metal shader compilation failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
process::exit(1);
}
let output = Command::new("xcrun")
.args(["-sdk", "macosx", "metallib"])
.arg(air_output_path)
.arg("-o")
.arg(metallib_output_path)
.output()
.unwrap();
if !output.status.success() {
println!(
"cargo::error=metallib compilation failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
process::exit(1);
}
}
}
#[cfg(target_os = "windows")]
mod windows {
use std::{
fs,
io::Write,
path::{Path, PathBuf},
process::{self, Command},
};
pub(super) fn build() {
// Compile HLSL shaders
#[cfg(not(debug_assertions))]
compile_shaders();
// Embed the Windows manifest and resource file
#[cfg(feature = "windows-manifest")]
embed_resource();
}
#[cfg(feature = "windows-manifest")]
fn embed_resource() {
let manifest = std::path::Path::new("resources/windows/gpui.manifest.xml");
let rc_file = std::path::Path::new("resources/windows/gpui.rc");
println!("cargo:rerun-if-changed={}", manifest.display());
println!("cargo:rerun-if-changed={}", rc_file.display());
embed_resource::compile(rc_file, embed_resource::NONE)
.manifest_required()
.unwrap();
}
/// You can set the `GPUI_FXC_PATH` environment variable to specify the path to the fxc.exe compiler.
fn compile_shaders() {
let shader_path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
.join("src/platform/windows/shaders.hlsl");
let out_dir = std::env::var("OUT_DIR").unwrap();
println!("cargo:rerun-if-changed={}", shader_path.display());
// Check if fxc.exe is available
let fxc_path = find_fxc_compiler();
// Define all modules
let modules = [
"quad",
"shadow",
"path_rasterization",
"path_sprite",
"underline",
"monochrome_sprite",
"polychrome_sprite",
];
let rust_binding_path = format!("{}/shaders_bytes.rs", out_dir);
if Path::new(&rust_binding_path).exists() {
fs::remove_file(&rust_binding_path)
.expect("Failed to remove existing Rust binding file");
}
for module in modules {
compile_shader_for_module(
module,
&out_dir,
&fxc_path,
shader_path.to_str().unwrap(),
&rust_binding_path,
);
}
{
let shader_path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
.join("src/platform/windows/color_text_raster.hlsl");
compile_shader_for_module(
"emoji_rasterization",
&out_dir,
&fxc_path,
shader_path.to_str().unwrap(),
&rust_binding_path,
);
}
}
/// You can set the `GPUI_FXC_PATH` environment variable to specify the path to the fxc.exe compiler.
fn find_fxc_compiler() -> String {
// Check environment variable
if let Ok(path) = std::env::var("GPUI_FXC_PATH")
&& Path::new(&path).exists()
{
return path;
}
// Try to find in PATH
// NOTE: This has to be `where.exe` on Windows, not `where`, it must be ended with `.exe`
if let Ok(output) = std::process::Command::new("where.exe")
.arg("fxc.exe")
.output()
&& output.status.success()
{
let path = String::from_utf8_lossy(&output.stdout);
return path.trim().to_string();
}
// Check the default path
if Path::new(r"C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\fxc.exe")
.exists()
{
return r"C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\fxc.exe"
.to_string();
}
panic!("Failed to find fxc.exe");
}
fn compile_shader_for_module(
module: &str,
out_dir: &str,
fxc_path: &str,
shader_path: &str,
rust_binding_path: &str,
) {
// Compile vertex shader
let output_file = format!("{}/{}_vs.h", out_dir, module);
let const_name = format!("{}_VERTEX_BYTES", module.to_uppercase());
compile_shader_impl(
fxc_path,
&format!("{module}_vertex"),
&output_file,
&const_name,
shader_path,
"vs_4_1",
);
generate_rust_binding(&const_name, &output_file, rust_binding_path);
// Compile fragment shader
let output_file = format!("{}/{}_ps.h", out_dir, module);
let const_name = format!("{}_FRAGMENT_BYTES", module.to_uppercase());
compile_shader_impl(
fxc_path,
&format!("{module}_fragment"),
&output_file,
&const_name,
shader_path,
"ps_4_1",
);
generate_rust_binding(&const_name, &output_file, rust_binding_path);
}
fn compile_shader_impl(
fxc_path: &str,
entry_point: &str,
output_path: &str,
var_name: &str,
shader_path: &str,
target: &str,
) {
let output = Command::new(fxc_path)
.args([
"/T",
target,
"/E",
entry_point,
"/Fh",
output_path,
"/Vn",
var_name,
"/O3",
shader_path,
])
.output();
match output {
Ok(result) => {
if result.status.success() {
return;
}
println!(
"cargo::error=Shader compilation failed for {}:\n{}",
entry_point,
String::from_utf8_lossy(&result.stderr)
);
process::exit(1);
}
Err(e) => {
println!("cargo::error=Failed to run fxc for {}: {}", entry_point, e);
process::exit(1);
}
}
}
fn generate_rust_binding(const_name: &str, head_file: &str, output_path: &str) {
let header_content = fs::read_to_string(head_file).expect("Failed to read header file");
let const_definition = {
let global_var_start = header_content.find("const BYTE").unwrap();
let global_var = &header_content[global_var_start..];
let equal = global_var.find('=').unwrap();
global_var[equal + 1..].trim()
};
let rust_binding = format!(
"const {}: &[u8] = &{}\n",
const_name,
const_definition.replace('{', "[").replace('}', "]")
);
let mut options = fs::OpenOptions::new()
.create(true)
.append(true)
.open(output_path)
.expect("Failed to open Rust binding file");
options
.write_all(rust_binding.as_bytes())
.expect("Failed to write Rust binding file");
}
}
+55
View File
@@ -0,0 +1,55 @@
--- a/src/app.rs
+++ b/src/app.rs
@@ -982,0 +983,5 @@
+ /// Keep the native loop alive when the last window closes (for tray apps).
+ pub fn set_quit_on_last_window_close(&self, quit: bool) {
+ self.platform.set_quit_on_last_window_close(quit);
+ }
+
--- a/src/platform/linux/platform.rs
+++ b/src/platform/linux/platform.rs
@@ -96,0 +97 @@
+ pub quit_on_last_window_close: bool,
@@ -122,0 +124 @@
+ quit_on_last_window_close: true,
@@ -232,0 +235,4 @@
+ }
+
+ fn set_quit_on_last_window_close(&self, quit: bool) {
+ self.with_common(|common| common.quit_on_last_window_close = quit);
--- a/src/platform/linux/wayland/client.rs
+++ b/src/platform/linux/wayland/client.rs
@@ -387 +387 @@
- if state.windows.is_empty() {
+ if state.windows.is_empty() && state.common.quit_on_last_window_close {
--- a/src/platform/linux/x11/client.rs
+++ b/src/platform/linux/x11/client.rs
@@ -250 +250 @@
- if state.windows.is_empty() {
+ if state.windows.is_empty() && state.common.quit_on_last_window_close {
--- a/src/platform/windows/platform.rs
+++ b/src/platform/windows/platform.rs
@@ -2 +2 @@
- cell::RefCell,
+ cell::{Cell, RefCell},
@@ -45,0 +46 @@
+ quit_on_last_window_close: Cell<bool>,
@@ -369,0 +371,4 @@
+ fn set_quit_on_last_window_close(&self, quit: bool) {
+ self.inner.quit_on_last_window_close.set(quit);
+ }
+
@@ -682,0 +688 @@
+ quit_on_last_window_close: Cell::new(true),
@@ -744 +750 @@
- lock.is_empty()
+ lock.is_empty() && self.quit_on_last_window_close.get()
--- a/src/platform.rs
+++ b/src/platform.rs
@@ -171,0 +172 @@
+ fn set_quit_on_last_window_close(&self, _quit: bool) {}
--- a/src/taffy.rs
+++ b/src/taffy.rs
@@ -312 +312 @@
- unit.map(|count| vec![repeat(count, vec![minmax(length(0.0), fr(1.0))])])
+ unit.map(|count| vec![repeat(count, vec![minmax(length(0.0_f32), fr(1.0_f32))])])
+33
View File
@@ -0,0 +1,33 @@
# Contexts
GPUI makes extensive use of _context parameters_ (typically named `cx`) to provide access to application state and services. These contexts are references passed to functions, enabling interaction with global state, windows, entities, and system services.
---
## `App`
The root context granting access to the application's global state. This context owns all entities' data and can be used to read or update the data referenced by an `Entity<T>`.
## `Context<T>`
A context provided when interacting with an `Entity<T>`, with additional methods related to that specific entity such as notifying observers and emitting events. This context dereferences into `App`, meaning any function which can take an `App` reference can also take a `Context<T>` reference, allowing you to access the application's global state.
## `AsyncApp` and `AsyncWindowContext`
Whereas the above contexts are always passed to your code as references, you can call `to_async` on the reference to create an async context, which has a static lifetime and can be held across `await` points in async code. When you interact with entities with an async context, the calls become fallible, because the context may outlive the window or even the app itself.
## `TestAppContext`
These are similar to the async contexts above, but they panic if you attempt to access a non-existent app or window, and they also contain other features specific to tests.
---
# Non-Context Core Types
## `Window`
Provides access to the state of an application window. This type has a root view (an `Entity` implementing `Render`) which it can read/update, but since it is not a context, you must pass a `&mut App` (or a context which dereferences to it) to do so, along with other functions interacting with global state. You can obtain a `Window` from an `WindowHandle` by calling `WindowHandle::update`.
## `Entity<T>`
A handle to a structure requiring state. This data is owned by the `App` and can be accessed and modified via references to contexts. If `T` implements `Render`, then the entity is sometimes referred to as a view. Entities can be observed by other entities and windows, allowing a closure to be called when `notify` is called on the entity's `Context`.
+100
View File
@@ -0,0 +1,100 @@
# Key Dispatch
GPUI is designed for keyboard-first interactivity.
To expose functionality to the mouse, you render a button with a click handler.
To expose functionality to the keyboard, you bind an _action_ in a _key context_.
Actions are similar to framework-level events like `MouseDown`, `KeyDown`, etc, but you can define them yourself:
```rust
mod menu {
#[gpui::action]
struct MoveUp;
#[gpui::action]
struct MoveDown;
}
```
Actions are frequently unit structs, for which we have a macro. The above could also be written:
```rust
mod menu {
actions!(gpui, [MoveUp, MoveDown]);
}
```
Actions can also be more complex types:
```rust
mod menu {
#[gpui::action]
struct Move {
direction: Direction,
select: bool,
}
}
```
To bind actions, chain `on_action` on to your element:
```rust
impl Render for Menu {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.on_action(|this: &mut Menu, move: &MoveUp, window: &mut Window, cx: &mut Context<Menu>| {
// ...
})
.on_action(|this, move: &MoveDown, cx| {
// ...
})
.children(unimplemented!())
}
}
```
In order to bind keys to actions, you need to declare a _key context_ for part of the element tree by calling `key_context`.
```rust
impl Render for Menu {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.key_context("menu")
.on_action(|this: &mut Menu, move: &MoveUp, window: &mut Window, cx: &mut Context<Menu>| {
// ...
})
.on_action(|this, move: &MoveDown, cx| {
// ...
})
.children(unimplemented!())
}
}
```
Now you can target your context in the keymap. Note how actions are identified in the keymap by their fully-qualified type name.
```json
{
"context": "menu",
"bindings": {
"up": "menu::MoveUp",
"down": "menu::MoveDown"
}
}
```
If you had opted for the more complex type definition, you'd provide the serialized representation of the action alongside the name:
```json
{
"context": "menu",
"bindings": {
"up": ["menu::Move", {direction: "up", select: false}]
"down": ["menu::Move", {direction: "down", select: false}]
"shift-up": ["menu::Move", {direction: "up", select: true}]
"shift-down": ["menu::Move", {direction: "down", select: true}]
}
}
```
+121
View File
@@ -0,0 +1,121 @@
use std::time::Duration;
use anyhow::Result;
use gpui::{
Animation, AnimationExt as _, App, Application, AssetSource, Bounds, Context, SharedString,
Transformation, Window, WindowBounds, WindowOptions, bounce, div, ease_in_out, percentage,
prelude::*, px, size, svg,
};
struct Assets {}
impl AssetSource for Assets {
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
std::fs::read(path)
.map(Into::into)
.map_err(Into::into)
.map(Some)
}
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
Ok(std::fs::read_dir(path)?
.filter_map(|entry| {
Some(SharedString::from(
entry.ok()?.path().to_string_lossy().into_owned(),
))
})
.collect::<Vec<_>>())
}
}
const ARROW_CIRCLE_SVG: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/examples/image/arrow_circle.svg"
);
struct AnimationExample {}
impl Render for AnimationExample {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.size_full()
.bg(gpui::white())
.text_color(gpui::black())
.justify_around()
.child(
div()
.flex()
.flex_col()
.size_full()
.justify_around()
.child(
div()
.id("content")
.flex()
.flex_col()
.h(px(150.))
.overflow_y_scroll()
.w_full()
.flex_1()
.justify_center()
.items_center()
.text_xl()
.gap_4()
.child("Hello Animation")
.child(
svg()
.size_20()
.overflow_hidden()
.path(ARROW_CIRCLE_SVG)
.text_color(gpui::black())
.with_animation(
"image_circle",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(bounce(ease_in_out)),
|svg, delta| {
svg.with_transformation(Transformation::rotate(
percentage(delta),
))
},
),
),
)
.child(
div()
.flex()
.h(px(64.))
.w_full()
.p_2()
.justify_center()
.items_center()
.border_t_1()
.border_color(gpui::black().opacity(0.1))
.bg(gpui::black().opacity(0.05))
.child("Other Panel"),
),
)
}
}
fn main() {
Application::new()
.with_assets(Assets {})
.run(|cx: &mut App| {
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(300.), px(300.)),
cx,
))),
..Default::default()
};
cx.open_window(options, |_, cx| {
cx.activate(false);
cx.new(|_| AnimationExample {})
})
.unwrap();
});
}
+473
View File
@@ -0,0 +1,473 @@
use std::{ops::Range, rc::Rc, time::Duration};
use gpui::{
App, Application, Bounds, Context, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point,
Render, SharedString, UniformListScrollHandle, Window, WindowBounds, WindowOptions, canvas,
div, point, prelude::*, px, rgb, size, uniform_list,
};
const TOTAL_ITEMS: usize = 10000;
const SCROLLBAR_THUMB_WIDTH: Pixels = px(8.);
const SCROLLBAR_THUMB_HEIGHT: Pixels = px(100.);
pub struct Quote {
name: SharedString,
symbol: SharedString,
last_done: f64,
prev_close: f64,
open: f64,
high: f64,
low: f64,
timestamp: Duration,
volume: i64,
turnover: f64,
ttm: f64,
market_cap: f64,
float_cap: f64,
shares: f64,
pb: f64,
pe: f64,
eps: f64,
dividend: f64,
dividend_yield: f64,
dividend_per_share: f64,
dividend_date: SharedString,
dividend_payment: f64,
}
impl Quote {
pub fn random() -> Self {
use rand::Rng;
let mut rng = rand::rng();
// simulate a base price in a realistic range
let prev_close = rng.random_range(100.0..200.0);
let change = rng.random_range(-5.0..5.0);
let last_done = prev_close + change;
let open = prev_close + rng.random_range(-3.0..3.0);
let high = (prev_close + rng.random_range::<f64, _>(0.0..10.0)).max(open);
let low = (prev_close - rng.random_range::<f64, _>(0.0..10.0)).min(open);
let timestamp = Duration::from_secs(rng.random_range(0..86400));
let volume = rng.random_range(1_000_000..100_000_000);
let turnover = last_done * volume as f64;
let symbol = {
let mut ticker = String::new();
if rng.random_bool(0.5) {
ticker.push_str(&format!(
"{:03}.{}",
rng.random_range(100..1000),
rng.random_range(0..10)
));
} else {
ticker.push_str(&format!(
"{}{}",
rng.random_range('A'..='Z'),
rng.random_range('A'..='Z')
));
}
ticker.push_str(&format!(".{}", rng.random_range('A'..='Z')));
ticker
};
let name = format!(
"{} {} - #{}",
symbol,
rng.random_range(1..100),
rng.random_range(10000..100000)
);
let ttm = rng.random_range(0.0..10.0);
let market_cap = rng.random_range(1_000_000.0..10_000_000.0);
let float_cap = market_cap + rng.random_range(1_000.0..10_000.0);
let shares = rng.random_range(100.0..1000.0);
let pb = market_cap / shares;
let pe = market_cap / shares;
let eps = market_cap / shares;
let dividend = rng.random_range(0.0..10.0);
let dividend_yield = rng.random_range(0.0..10.0);
let dividend_per_share = rng.random_range(0.0..10.0);
let dividend_date = SharedString::new(format!(
"{}-{}-{}",
rng.random_range(2000..2023),
rng.random_range(1..12),
rng.random_range(1..28)
));
let dividend_payment = rng.random_range(0.0..10.0);
Self {
name: name.into(),
symbol: symbol.into(),
last_done,
prev_close,
open,
high,
low,
timestamp,
volume,
turnover,
pb,
pe,
eps,
ttm,
market_cap,
float_cap,
shares,
dividend,
dividend_yield,
dividend_per_share,
dividend_date,
dividend_payment,
}
}
fn change(&self) -> f64 {
(self.last_done - self.prev_close) / self.prev_close * 100.0
}
fn change_color(&self) -> gpui::Hsla {
if self.change() > 0.0 {
gpui::green()
} else {
gpui::red()
}
}
fn turnover_ratio(&self) -> f64 {
self.volume as f64 / self.turnover * 100.0
}
}
#[derive(IntoElement)]
struct TableRow {
ix: usize,
quote: Rc<Quote>,
}
impl TableRow {
fn new(ix: usize, quote: Rc<Quote>) -> Self {
Self { ix, quote }
}
fn render_cell(&self, key: &str, width: Pixels, color: gpui::Hsla) -> impl IntoElement {
div()
.whitespace_nowrap()
.truncate()
.w(width)
.px_1()
.child(match key {
"id" => div().child(format!("{}", self.ix)),
"symbol" => div().child(self.quote.symbol.clone()),
"name" => div().child(self.quote.name.clone()),
"last_done" => div()
.text_color(color)
.child(format!("{:.3}", self.quote.last_done)),
"prev_close" => div()
.text_color(color)
.child(format!("{:.3}", self.quote.prev_close)),
"change" => div()
.text_color(color)
.child(format!("{:.2}%", self.quote.change())),
"timestamp" => div()
.text_color(color)
.child(format!("{:?}", self.quote.timestamp.as_secs())),
"open" => div()
.text_color(color)
.child(format!("{:.2}", self.quote.open)),
"low" => div()
.text_color(color)
.child(format!("{:.2}", self.quote.low)),
"high" => div()
.text_color(color)
.child(format!("{:.2}", self.quote.high)),
"ttm" => div()
.text_color(color)
.child(format!("{:.2}", self.quote.ttm)),
"eps" => div()
.text_color(color)
.child(format!("{:.2}", self.quote.eps)),
"market_cap" => {
div().child(format!("{:.2} M", self.quote.market_cap / 1_000_000.0))
}
"float_cap" => div().child(format!("{:.2} M", self.quote.float_cap / 1_000_000.0)),
"turnover" => div().child(format!("{:.2} M", self.quote.turnover / 1_000_000.0)),
"volume" => div().child(format!("{:.2} M", self.quote.volume as f64 / 1_000_000.0)),
"turnover_ratio" => div().child(format!("{:.2}%", self.quote.turnover_ratio())),
"pe" => div().child(format!("{:.2}", self.quote.pe)),
"pb" => div().child(format!("{:.2}", self.quote.pb)),
"shares" => div().child(format!("{:.2}", self.quote.shares)),
"dividend" => div().child(format!("{:.2}", self.quote.dividend)),
"yield" => div().child(format!("{:.2}%", self.quote.dividend_yield)),
"dividend_per_share" => {
div().child(format!("{:.2}", self.quote.dividend_per_share))
}
"dividend_date" => div().child(format!("{}", self.quote.dividend_date)),
"dividend_payment" => div().child(format!("{:.2}", self.quote.dividend_payment)),
_ => div().child("--"),
})
}
}
const FIELDS: [(&str, f32); 24] = [
("id", 64.),
("symbol", 64.),
("name", 180.),
("last_done", 80.),
("prev_close", 80.),
("open", 80.),
("low", 80.),
("high", 80.),
("ttm", 50.),
("market_cap", 96.),
("float_cap", 96.),
("turnover", 120.),
("volume", 100.),
("turnover_ratio", 96.),
("pe", 64.),
("pb", 64.),
("eps", 64.),
("shares", 96.),
("dividend", 64.),
("yield", 64.),
("dividend_per_share", 64.),
("dividend_date", 96.),
("dividend_payment", 64.),
("timestamp", 120.),
];
impl RenderOnce for TableRow {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let color = self.quote.change_color();
div()
.flex()
.flex_row()
.border_b_1()
.border_color(rgb(0xE0E0E0))
.bg(if self.ix.is_multiple_of(2) {
rgb(0xFFFFFF)
} else {
rgb(0xFAFAFA)
})
.py_0p5()
.px_2()
.children(FIELDS.map(|(key, width)| self.render_cell(key, px(width), color)))
}
}
struct DataTable {
/// Use `Rc` to share the same quote data across multiple items, avoid cloning.
quotes: Vec<Rc<Quote>>,
visible_range: Range<usize>,
scroll_handle: UniformListScrollHandle,
/// The position in thumb bounds when dragging start mouse down.
drag_position: Option<Point<Pixels>>,
}
impl DataTable {
fn new() -> Self {
Self {
quotes: Vec::new(),
visible_range: 0..0,
scroll_handle: UniformListScrollHandle::new(),
drag_position: None,
}
}
fn generate(&mut self) {
self.quotes = (0..TOTAL_ITEMS).map(|_| Rc::new(Quote::random())).collect();
}
fn table_bounds(&self) -> Bounds<Pixels> {
self.scroll_handle.0.borrow().base_handle.bounds()
}
fn scroll_top(&self) -> Pixels {
self.scroll_handle.0.borrow().base_handle.offset().y
}
fn scroll_height(&self) -> Pixels {
self.scroll_handle
.0
.borrow()
.last_item_size
.unwrap_or_default()
.contents
.height
}
fn render_scrollbar(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let scroll_height = self.scroll_height();
let table_bounds = self.table_bounds();
let table_height = table_bounds.size.height;
if table_height == px(0.) {
return div().id("scrollbar");
}
let percentage = -self.scroll_top() / scroll_height;
let offset_top = (table_height * percentage).clamp(
px(4.),
(table_height - SCROLLBAR_THUMB_HEIGHT - px(4.)).max(px(4.)),
);
let entity = cx.entity();
let scroll_handle = self.scroll_handle.0.borrow().base_handle.clone();
div()
.id("scrollbar")
.absolute()
.top(offset_top)
.right_1()
.h(SCROLLBAR_THUMB_HEIGHT)
.w(SCROLLBAR_THUMB_WIDTH)
.bg(rgb(0xC0C0C0))
.hover(|this| this.bg(rgb(0xA0A0A0)))
.rounded_lg()
.child(
canvas(
|_, _, _| (),
move |thumb_bounds, _, window, _| {
window.on_mouse_event({
let entity = entity.clone();
move |ev: &MouseDownEvent, _, _, cx| {
if !thumb_bounds.contains(&ev.position) {
return;
}
entity.update(cx, |this, _| {
this.drag_position = Some(
ev.position - thumb_bounds.origin - table_bounds.origin,
);
})
}
});
window.on_mouse_event({
let entity = entity.clone();
move |_: &MouseUpEvent, _, _, cx| {
entity.update(cx, |this, _| {
this.drag_position = None;
})
}
});
window.on_mouse_event(move |ev: &MouseMoveEvent, _, _, cx| {
if !ev.dragging() {
return;
}
let Some(drag_pos) = entity.read(cx).drag_position else {
return;
};
let inside_offset = drag_pos.y;
let percentage = ((ev.position.y - table_bounds.origin.y
+ inside_offset)
/ (table_bounds.size.height))
.clamp(0., 1.);
let offset_y = ((scroll_height - table_bounds.size.height)
* percentage)
.clamp(px(0.), scroll_height - SCROLLBAR_THUMB_HEIGHT);
scroll_handle.set_offset(point(px(0.), -offset_y));
cx.notify(entity.entity_id());
})
},
)
.size_full(),
)
}
}
impl Render for DataTable {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.bg(gpui::white())
.text_sm()
.size_full()
.p_4()
.gap_2()
.flex()
.flex_col()
.child(format!(
"Total {} items, visible range: {:?}",
self.quotes.len(),
self.visible_range
))
.child(
div()
.flex()
.flex_col()
.flex_1()
.overflow_hidden()
.border_1()
.border_color(rgb(0xE0E0E0))
.rounded_sm()
.child(
div()
.flex()
.flex_row()
.w_full()
.overflow_hidden()
.border_b_1()
.border_color(rgb(0xE0E0E0))
.text_color(rgb(0x555555))
.bg(rgb(0xF0F0F0))
.py_1()
.px_2()
.text_xs()
.children(FIELDS.map(|(key, width)| {
div()
.whitespace_nowrap()
.flex_shrink_0()
.truncate()
.px_1()
.w(px(width))
.child(key.replace("_", " ").to_uppercase())
})),
)
.child(
div()
.relative()
.size_full()
.child(
uniform_list(
"items",
self.quotes.len(),
cx.processor(move |this, range: Range<usize>, _, _| {
this.visible_range = range.clone();
let mut items = Vec::with_capacity(range.end - range.start);
for i in range {
if let Some(quote) = this.quotes.get(i) {
items.push(TableRow::new(i, quote.clone()));
}
}
items
}),
)
.size_full()
.track_scroll(self.scroll_handle.clone()),
)
.child(self.render_scrollbar(window, cx)),
),
)
}
}
fn main() {
Application::new().run(|cx: &mut App| {
cx.open_window(
WindowOptions {
focus: true,
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(1280.0), px(1000.0)),
cx,
))),
..Default::default()
},
|_, cx| {
cx.new(|_| {
let mut table = DataTable::new();
table.generate();
table
})
},
)
.unwrap();
cx.activate(true);
});
}
+137
View File
@@ -0,0 +1,137 @@
use gpui::{
App, Application, Bounds, Context, Half, Hsla, Pixels, Point, Window, WindowBounds,
WindowOptions, div, prelude::*, px, rgb, size,
};
#[derive(Clone, Copy)]
struct DragInfo {
ix: usize,
color: Hsla,
position: Point<Pixels>,
}
impl DragInfo {
fn new(ix: usize, color: Hsla) -> Self {
Self {
ix,
color,
position: Point::default(),
}
}
fn position(mut self, pos: Point<Pixels>) -> Self {
self.position = pos;
self
}
}
impl Render for DragInfo {
fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
let size = gpui::size(px(120.), px(50.));
div()
.pl(self.position.x - size.width.half())
.pt(self.position.y - size.height.half())
.child(
div()
.flex()
.justify_center()
.items_center()
.w(size.width)
.h(size.height)
.bg(self.color.opacity(0.5))
.text_color(gpui::white())
.text_xs()
.shadow_md()
.child(format!("Item {}", self.ix)),
)
}
}
struct DragDrop {
drop_on: Option<DragInfo>,
}
impl DragDrop {
fn new() -> Self {
Self { drop_on: None }
}
}
impl Render for DragDrop {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let items = [gpui::blue(), gpui::red(), gpui::green()];
div()
.size_full()
.flex()
.flex_col()
.gap_5()
.bg(gpui::white())
.justify_center()
.items_center()
.text_color(rgb(0x333333))
.child(div().text_xl().text_center().child("Drop & Drop"))
.child(
div()
.w_full()
.mb_10()
.justify_center()
.flex()
.flex_row()
.gap_4()
.items_center()
.children(items.into_iter().enumerate().map(|(ix, color)| {
let drag_info = DragInfo::new(ix, color);
div()
.id(("item", ix))
.size_32()
.flex()
.justify_center()
.items_center()
.border_2()
.border_color(color)
.text_color(color)
.cursor_move()
.hover(|this| this.bg(color.opacity(0.2)))
.child(format!("Item ({})", ix))
.on_drag(drag_info, |info: &DragInfo, position, _, cx| {
cx.new(|_| info.position(position))
})
})),
)
.child(
div()
.id("drop-target")
.w_128()
.h_32()
.flex()
.justify_center()
.items_center()
.border_3()
.border_color(self.drop_on.map(|info| info.color).unwrap_or(gpui::black()))
.when_some(self.drop_on, |this, info| this.bg(info.color.opacity(0.5)))
.on_drop(cx.listener(|this, info: &DragInfo, _, _| {
this.drop_on = Some(*info);
}))
.child("Drop items here"),
)
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(800.), px(600.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| cx.new(|_| DragDrop::new()),
)
.unwrap();
cx.activate(true);
});
}
+41
View File
@@ -0,0 +1,41 @@
use gpui::{App, Application, Context, Render, Window, WindowOptions, div, img, prelude::*};
use std::path::PathBuf;
struct GifViewer {
gif_path: PathBuf,
}
impl GifViewer {
fn new(gif_path: PathBuf) -> Self {
Self { gif_path }
}
}
impl Render for GifViewer {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(
img(self.gif_path.clone())
.size_full()
.object_fit(gpui::ObjectFit::Contain)
.id("gif"),
)
}
}
fn main() {
env_logger::init();
Application::new().run(|cx: &mut App| {
let gif_path =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/image/black-cat-typing.gif");
cx.open_window(
WindowOptions {
focus: true,
..Default::default()
},
|_, cx| cx.new(|_| GifViewer::new(gif_path)),
)
.unwrap();
cx.activate(true);
});
}
+257
View File
@@ -0,0 +1,257 @@
use gpui::{
App, Application, Bounds, ColorSpace, Context, Half, Render, Window, WindowOptions, canvas,
div, linear_color_stop, linear_gradient, point, prelude::*, px, size,
};
struct GradientViewer {
color_space: ColorSpace,
}
impl GradientViewer {
fn new() -> Self {
Self {
color_space: ColorSpace::default(),
}
}
}
impl Render for GradientViewer {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let color_space = self.color_space;
div()
.bg(gpui::white())
.size_full()
.p_4()
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex()
.gap_2()
.justify_between()
.items_center()
.child("Gradient Examples")
.child(
div().flex().gap_2().items_center().child(
div()
.id("method")
.flex()
.px_3()
.py_1()
.text_sm()
.bg(gpui::black())
.text_color(gpui::white())
.child(format!("{}", color_space))
.active(|this| this.opacity(0.8))
.on_click(cx.listener(move |this, _, _, cx| {
this.color_space = match this.color_space {
ColorSpace::Oklab => ColorSpace::Srgb,
ColorSpace::Srgb => ColorSpace::Oklab,
};
cx.notify();
})),
),
),
)
.child(
div()
.flex()
.flex_1()
.gap_3()
.child(
div()
.size_full()
.rounded_xl()
.flex()
.items_center()
.justify_center()
.bg(gpui::red())
.text_color(gpui::white())
.child("Solid Color"),
)
.child(
div()
.size_full()
.rounded_xl()
.flex()
.items_center()
.justify_center()
.bg(gpui::blue())
.text_color(gpui::white())
.child("Solid Color"),
),
)
.child(
div()
.flex()
.flex_1()
.gap_3()
.h_24()
.text_color(gpui::white())
.child(
div().flex_1().rounded_xl().bg(linear_gradient(
45.,
linear_color_stop(gpui::red(), 0.),
linear_color_stop(gpui::blue(), 1.),
)
.color_space(color_space)),
)
.child(
div().flex_1().rounded_xl().bg(linear_gradient(
135.,
linear_color_stop(gpui::red(), 0.),
linear_color_stop(gpui::green(), 1.),
)
.color_space(color_space)),
)
.child(
div().flex_1().rounded_xl().bg(linear_gradient(
225.,
linear_color_stop(gpui::green(), 0.),
linear_color_stop(gpui::blue(), 1.),
)
.color_space(color_space)),
)
.child(
div().flex_1().rounded_xl().bg(linear_gradient(
315.,
linear_color_stop(gpui::green(), 0.),
linear_color_stop(gpui::yellow(), 1.),
)
.color_space(color_space)),
),
)
.child(
div()
.flex()
.flex_1()
.gap_3()
.h_24()
.text_color(gpui::white())
.child(
div().flex_1().rounded_xl().bg(linear_gradient(
0.,
linear_color_stop(gpui::red(), 0.),
linear_color_stop(gpui::white(), 1.),
)
.color_space(color_space)),
)
.child(
div().flex_1().rounded_xl().bg(linear_gradient(
90.,
linear_color_stop(gpui::blue(), 0.),
linear_color_stop(gpui::white(), 1.),
)
.color_space(color_space)),
)
.child(
div().flex_1().rounded_xl().bg(linear_gradient(
180.,
linear_color_stop(gpui::green(), 0.),
linear_color_stop(gpui::white(), 1.),
)
.color_space(color_space)),
)
.child(
div().flex_1().rounded_xl().bg(linear_gradient(
360.,
linear_color_stop(gpui::yellow(), 0.),
linear_color_stop(gpui::white(), 1.),
)
.color_space(color_space)),
),
)
.child(
div().flex_1().rounded_xl().bg(linear_gradient(
0.,
linear_color_stop(gpui::green(), 0.05),
linear_color_stop(gpui::yellow(), 0.95),
)
.color_space(color_space)),
)
.child(
div().flex_1().rounded_xl().bg(linear_gradient(
90.,
linear_color_stop(gpui::blue(), 0.05),
linear_color_stop(gpui::red(), 0.95),
)
.color_space(color_space)),
)
.child(
div()
.flex()
.flex_1()
.gap_3()
.child(
div().flex().flex_1().gap_3().child(
div().flex_1().rounded_xl().bg(linear_gradient(
90.,
linear_color_stop(gpui::blue(), 0.5),
linear_color_stop(gpui::red(), 0.5),
)
.color_space(color_space)),
),
)
.child(
div().flex_1().rounded_xl().bg(linear_gradient(
180.,
linear_color_stop(gpui::green(), 0.),
linear_color_stop(gpui::blue(), 0.5),
)
.color_space(color_space)),
),
)
.child(div().h_24().child(canvas(
move |_, _, _| {},
move |bounds, _, window, _| {
let size = size(bounds.size.width * 0.8, px(80.));
let square_bounds = Bounds {
origin: point(
bounds.size.width.half() - size.width.half(),
bounds.origin.y,
),
size,
};
let height = square_bounds.size.height;
let horizontal_offset = height;
let vertical_offset = px(30.);
let mut builder = gpui::PathBuilder::fill();
builder.move_to(square_bounds.bottom_left());
builder
.line_to(square_bounds.origin + point(horizontal_offset, vertical_offset));
builder.line_to(
square_bounds.top_right() + point(-horizontal_offset, vertical_offset),
);
builder.line_to(square_bounds.bottom_right());
builder.line_to(square_bounds.bottom_left());
let path = builder.build().unwrap();
window.paint_path(
path,
linear_gradient(
180.,
linear_color_stop(gpui::red(), 0.),
linear_color_stop(gpui::blue(), 1.),
)
.color_space(color_space),
);
},
)))
}
}
fn main() {
Application::new().run(|cx: &mut App| {
cx.open_window(
WindowOptions {
focus: true,
..Default::default()
},
|_, cx| cx.new(|_| GradientViewer::new()),
)
.unwrap();
cx.activate(true);
});
}
+80
View File
@@ -0,0 +1,80 @@
use gpui::{
App, Application, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, div, prelude::*,
px, rgb, size,
};
// https://en.wikipedia.org/wiki/Holy_grail_(web_design)
struct HolyGrailExample {}
impl Render for HolyGrailExample {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let block = |color: Hsla| {
div()
.size_full()
.bg(color)
.border_1()
.border_dashed()
.rounded_md()
.border_color(gpui::white())
.items_center()
};
div()
.gap_1()
.grid()
.bg(rgb(0x505050))
.size(px(500.0))
.shadow_lg()
.border_1()
.size_full()
.grid_cols(5)
.grid_rows(5)
.child(
block(gpui::white())
.row_span(1)
.col_span_full()
.child("Header"),
)
.child(
block(gpui::red())
.col_span(1)
.h_56()
.child("Table of contents"),
)
.child(
block(gpui::green())
.col_span(3)
.row_span(3)
.child("Content"),
)
.child(
block(gpui::blue())
.col_span(1)
.row_span(3)
.child("AD :(")
.text_color(gpui::white()),
)
.child(
block(gpui::black())
.row_span(1)
.col_span_full()
.text_color(gpui::white())
.child("Footer"),
)
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| cx.new(|_| HolyGrailExample {}),
)
.unwrap();
cx.activate(true);
});
}
+106
View File
@@ -0,0 +1,106 @@
use gpui::{
App, Application, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div,
prelude::*, px, rgb, size,
};
struct HelloWorld {
text: SharedString,
}
impl Render for HelloWorld {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.gap_3()
.bg(rgb(0x505050))
.size(px(500.0))
.justify_center()
.items_center()
.shadow_lg()
.border_1()
.border_color(rgb(0x0000ff))
.text_xl()
.text_color(rgb(0xffffff))
.child(format!("Hello, {}!", &self.text))
.child(
div()
.flex()
.gap_2()
.child(
div()
.size_8()
.bg(gpui::red())
.border_1()
.border_dashed()
.rounded_md()
.border_color(gpui::white()),
)
.child(
div()
.size_8()
.bg(gpui::green())
.border_1()
.border_dashed()
.rounded_md()
.border_color(gpui::white()),
)
.child(
div()
.size_8()
.bg(gpui::blue())
.border_1()
.border_dashed()
.rounded_md()
.border_color(gpui::white()),
)
.child(
div()
.size_8()
.bg(gpui::yellow())
.border_1()
.border_dashed()
.rounded_md()
.border_color(gpui::white()),
)
.child(
div()
.size_8()
.bg(gpui::black())
.border_1()
.border_dashed()
.rounded_md()
.rounded_md()
.border_color(gpui::white()),
)
.child(
div()
.size_8()
.bg(gpui::white())
.border_1()
.border_dashed()
.rounded_md()
.border_color(gpui::black()),
),
)
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| {
cx.new(|_| HelloWorld {
text: "World".into(),
})
},
)
.unwrap();
cx.activate(true);
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 8C3 6.67392 3.52678 5.40215 4.46446 4.46447C5.40214 3.52679 6.67391 3.00001 7.99999 3.00001C9.39779 3.00527 10.7394 3.55069 11.7444 4.52223L13 5.77778" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 3.00001V5.77778H10.2222" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 8C13 9.32608 12.4732 10.5978 11.5355 11.5355C10.5978 12.4732 9.32607 13 7.99999 13C6.60219 12.9947 5.26054 12.4493 4.25555 11.4778L3 10.2222" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.77777 10.2222H3V13" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="480" height="480" viewBox="0 0 480 480" fill="none"
stroke="currentColor" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-grip">
<circle cx="240" cy="240" r="50" stroke="#44403c" />
<circle cx="100" cy="100" r="20" stroke="#65a30d" />
<circle cx="240" cy="100" r="30" stroke="#dc2626" />
<circle cx="380" cy="100" r="20" stroke="#d97706" />
<circle cx="380" cy="240" r="30" stroke="#06b6d4" />
<circle cx="100" cy="240" r="30" stroke="#3b82f666" />
<circle cx="240" cy="380" r="30" stroke="#7c3aed" />
<circle cx="380" cy="380" r="20" stroke="#c026d3" />
<circle cx="100" cy="380" r="20" stroke="#e11d4866" />
</svg>

After

Width:  |  Height:  |  Size: 746 B

+194
View File
@@ -0,0 +1,194 @@
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use gpui::{
App, AppContext, Application, AssetSource, Bounds, Context, ImageSource, KeyBinding, Menu,
MenuItem, Point, SharedString, SharedUri, TitlebarOptions, Window, WindowBounds, WindowOptions,
actions, div, img, prelude::*, px, rgb, size,
};
use reqwest_client::ReqwestClient;
struct Assets {
base: PathBuf,
}
impl AssetSource for Assets {
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
fs::read(self.base.join(path))
.map(|data| Some(std::borrow::Cow::Owned(data)))
.map_err(|e| e.into())
}
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
fs::read_dir(self.base.join(path))
.map(|entries| {
entries
.filter_map(|entry| {
entry
.ok()
.and_then(|entry| entry.file_name().into_string().ok())
.map(SharedString::from)
})
.collect()
})
.map_err(|e| e.into())
}
}
#[derive(IntoElement)]
struct ImageContainer {
text: SharedString,
src: ImageSource,
}
impl ImageContainer {
pub fn new(text: impl Into<SharedString>, src: impl Into<ImageSource>) -> Self {
Self {
text: text.into(),
src: src.into(),
}
}
}
impl RenderOnce for ImageContainer {
fn render(self, _window: &mut Window, _: &mut App) -> impl IntoElement {
div().child(
div()
.flex_row()
.size_full()
.gap_4()
.child(self.text)
.child(img(self.src).size(px(256.0))),
)
}
}
struct ImageShowcase {
local_resource: Arc<std::path::Path>,
remote_resource: SharedUri,
asset_resource: SharedString,
}
impl Render for ImageShowcase {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.id("main")
.bg(gpui::white())
.overflow_y_scroll()
.p_5()
.size_full()
.child(
div()
.flex()
.flex_col()
.justify_center()
.items_center()
.gap_8()
.child(img(
"https://github.com/zed-industries/zed/actions/workflows/ci.yml/badge.svg",
))
.child(
div()
.flex()
.flex_row()
.justify_center()
.items_center()
.gap_8()
.child(ImageContainer::new(
"Image loaded from a local file",
self.local_resource.clone(),
))
.child(ImageContainer::new(
"Image loaded from a remote resource",
self.remote_resource.clone(),
))
.child(ImageContainer::new(
"Image loaded from an asset",
self.asset_resource.clone(),
)),
)
.child(
div()
.flex()
.flex_row()
.gap_8()
.child(
div()
.flex_col()
.child("Auto Width")
.child(img("https://picsum.photos/800/400").h(px(180.))),
)
.child(
div()
.flex_col()
.child("Auto Height")
.child(img("https://picsum.photos/800/400").w(px(180.))),
),
)
.child(
div()
.flex()
.flex_col()
.justify_center()
.items_center()
.w_full()
.border_1()
.border_color(rgb(0xC0C0C0))
.child("image with max width 100%")
.child(img("https://picsum.photos/800/400").max_w_full()),
),
)
}
}
actions!(image, [Quit]);
fn main() {
env_logger::init();
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
Application::new()
.with_assets(Assets {
base: manifest_dir.join("examples"),
})
.run(move |cx: &mut App| {
let http_client = ReqwestClient::user_agent("gpui example").unwrap();
cx.set_http_client(Arc::new(http_client));
cx.activate(true);
cx.on_action(|_: &Quit, cx| cx.quit());
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
cx.set_menus(vec![Menu {
name: "Image".into(),
items: vec![MenuItem::action("Quit", Quit)],
}]);
let window_options = WindowOptions {
titlebar: Some(TitlebarOptions {
title: Some(SharedString::from("Image Example")),
appears_transparent: false,
..Default::default()
}),
window_bounds: Some(WindowBounds::Windowed(Bounds {
size: size(px(1100.), px(600.)),
origin: Point::new(px(200.), px(200.)),
})),
..Default::default()
};
cx.open_window(window_options, |_, cx| {
cx.new(|_| ImageShowcase {
// Relative path to your root project path
local_resource: manifest_dir.join("examples/image/app-icon.png").into(),
remote_resource: "https://picsum.photos/800/400".into(),
asset_resource: "image/color.svg".into(),
})
})
.unwrap();
});
}
+288
View File
@@ -0,0 +1,288 @@
use futures::FutureExt;
use gpui::{
App, AppContext, Application, Asset as _, AssetLogger, Bounds, ClickEvent, Context, ElementId,
Entity, ImageAssetLoader, ImageCache, ImageCacheProvider, KeyBinding, Menu, MenuItem,
RetainAllImageCache, SharedString, TitlebarOptions, Window, WindowBounds, WindowOptions,
actions, div, hash, image_cache, img, prelude::*, px, rgb, size,
};
use reqwest_client::ReqwestClient;
use std::{collections::HashMap, sync::Arc};
const IMAGES_IN_GALLERY: usize = 30;
struct ImageGallery {
image_key: String,
items_count: usize,
total_count: usize,
image_cache: Entity<RetainAllImageCache>,
}
impl ImageGallery {
fn on_next_image(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
self.image_cache
.update(cx, |image_cache, cx| image_cache.clear(window, cx));
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
self.image_key = format!("{}", t);
self.total_count += self.items_count;
cx.notify();
}
}
impl Render for ImageGallery {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let image_url: SharedString =
format!("https://picsum.photos/400/200?t={}", self.image_key).into();
div()
.flex()
.flex_col()
.text_color(gpui::white())
.child("Manually managed image cache:")
.child(
div()
.image_cache(self.image_cache.clone())
.id("main")
.text_color(gpui::black())
.bg(rgb(0xE9E9E9))
.overflow_y_scroll()
.p_4()
.size_full()
.flex()
.flex_col()
.items_center()
.gap_2()
.child(
div()
.w_full()
.flex()
.flex_row()
.justify_between()
.child(format!(
"Example to show images and test memory usage (Rendered: {} images).",
self.total_count
))
.child(
div()
.id("btn")
.py_1()
.px_4()
.bg(gpui::black())
.hover(|this| this.opacity(0.8))
.text_color(gpui::white())
.text_center()
.w_40()
.child("Next Photos")
.on_click(cx.listener(Self::on_next_image)),
),
)
.child(
div()
.id("image-gallery")
.flex()
.flex_row()
.flex_wrap()
.gap_x_4()
.gap_y_2()
.justify_around()
.children(
(0..self.items_count)
.map(|ix| img(format!("{}-{}", image_url, ix)).size_20()),
),
),
)
.child(
"Automatically managed image cache:"
)
.child(image_cache(simple_lru_cache("lru-cache", IMAGES_IN_GALLERY)).child(
div()
.id("main")
.bg(rgb(0xE9E9E9))
.text_color(gpui::black())
.overflow_y_scroll()
.p_4()
.size_full()
.flex()
.flex_col()
.items_center()
.gap_2()
.child(
div()
.id("image-gallery")
.flex()
.flex_row()
.flex_wrap()
.gap_x_4()
.gap_y_2()
.justify_around()
.children(
(0..self.items_count)
.map(|ix| img(format!("{}-{}", image_url, ix)).size_20()),
),
)
))
}
}
fn simple_lru_cache(id: impl Into<ElementId>, max_items: usize) -> SimpleLruCacheProvider {
SimpleLruCacheProvider {
id: id.into(),
max_items,
}
}
struct SimpleLruCacheProvider {
id: ElementId,
max_items: usize,
}
impl ImageCacheProvider for SimpleLruCacheProvider {
fn provide(&mut self, window: &mut Window, cx: &mut App) -> gpui::AnyImageCache {
window
.with_global_id(self.id.clone(), |global_id, window| {
window.with_element_state::<Entity<SimpleLruCache>, _>(
global_id,
|lru_cache, _window| {
let mut lru_cache = lru_cache.unwrap_or_else(|| {
cx.new(|cx| SimpleLruCache::new(self.max_items, cx))
});
if lru_cache.read(cx).max_items != self.max_items {
lru_cache = cx.new(|cx| SimpleLruCache::new(self.max_items, cx));
}
(lru_cache.clone(), lru_cache)
},
)
})
.into()
}
}
struct SimpleLruCache {
max_items: usize,
usages: Vec<u64>,
cache: HashMap<u64, gpui::ImageCacheItem>,
}
impl SimpleLruCache {
fn new(max_items: usize, cx: &mut Context<Self>) -> Self {
cx.on_release(|simple_cache, cx| {
for (_, mut item) in std::mem::take(&mut simple_cache.cache) {
if let Some(Ok(image)) = item.get() {
cx.drop_image(image, None);
}
}
})
.detach();
Self {
max_items,
usages: Vec::with_capacity(max_items),
cache: HashMap::with_capacity(max_items),
}
}
}
impl ImageCache for SimpleLruCache {
fn load(
&mut self,
resource: &gpui::Resource,
window: &mut Window,
cx: &mut App,
) -> Option<Result<Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
assert_eq!(self.usages.len(), self.cache.len());
assert!(self.cache.len() <= self.max_items);
let hash = hash(resource);
if let Some(item) = self.cache.get_mut(&hash) {
let current_ix = self
.usages
.iter()
.position(|item| *item == hash)
.expect("cache and usages must stay in sync");
self.usages.remove(current_ix);
self.usages.insert(0, hash);
return item.get();
}
let fut = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
let task = cx.background_executor().spawn(fut).shared();
if self.usages.len() == self.max_items {
let oldest = self.usages.pop().unwrap();
let mut image = self
.cache
.remove(&oldest)
.expect("cache and usages must be in sync");
if let Some(Ok(image)) = image.get() {
cx.drop_image(image, Some(window));
}
}
self.cache
.insert(hash, gpui::ImageCacheItem::Loading(task.clone()));
self.usages.insert(0, hash);
let entity = window.current_view();
window
.spawn(cx, {
async move |cx| {
_ = task.await;
cx.on_next_frame(move |_, cx| {
cx.notify(entity);
});
}
})
.detach();
None
}
}
actions!(image, [Quit]);
fn main() {
env_logger::init();
Application::new().run(move |cx: &mut App| {
let http_client = ReqwestClient::user_agent("gpui example").unwrap();
cx.set_http_client(Arc::new(http_client));
cx.activate(true);
cx.on_action(|_: &Quit, cx| cx.quit());
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
cx.set_menus(vec![Menu {
name: "Image Gallery".into(),
items: vec![MenuItem::action("Quit", Quit)],
}]);
let window_options = WindowOptions {
titlebar: Some(TitlebarOptions {
title: Some(SharedString::from("Image Gallery")),
appears_transparent: false,
..Default::default()
}),
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(1100.), px(860.)),
cx,
))),
..Default::default()
};
cx.open_window(window_options, |_, cx| {
cx.new(|ctx| ImageGallery {
image_key: "".into(),
items_count: IMAGES_IN_GALLERY,
total_count: 0,
image_cache: RetainAllImageCache::new(ctx),
})
})
.unwrap();
});
}
+213
View File
@@ -0,0 +1,213 @@
use std::{path::Path, sync::Arc, time::Duration};
use gpui::{
Animation, AnimationExt, App, Application, Asset, AssetLogger, AssetSource, Bounds, Context,
Hsla, ImageAssetLoader, ImageCacheError, ImgResourceLoader, LOADING_DELAY, Length, RenderImage,
Resource, SharedString, Window, WindowBounds, WindowOptions, black, div, img, prelude::*,
pulsating_between, px, red, size,
};
struct Assets {}
impl AssetSource for Assets {
fn load(&self, path: &str) -> anyhow::Result<Option<std::borrow::Cow<'static, [u8]>>> {
std::fs::read(path)
.map(Into::into)
.map_err(Into::into)
.map(Some)
}
fn list(&self, path: &str) -> anyhow::Result<Vec<SharedString>> {
Ok(std::fs::read_dir(path)?
.filter_map(|entry| {
Some(SharedString::from(
entry.ok()?.path().to_string_lossy().into_owned(),
))
})
.collect::<Vec<_>>())
}
}
const IMAGE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/image/app-icon.png");
#[derive(Copy, Clone, Hash)]
struct LoadImageParameters {
timeout: Duration,
fail: bool,
}
struct LoadImageWithParameters {}
impl Asset for LoadImageWithParameters {
type Source = LoadImageParameters;
type Output = Result<Arc<RenderImage>, ImageCacheError>;
fn load(
parameters: Self::Source,
cx: &mut App,
) -> impl std::future::Future<Output = Self::Output> + Send + 'static {
let timer = cx.background_executor().timer(parameters.timeout);
let data = AssetLogger::<ImageAssetLoader>::load(
Resource::Path(Path::new(IMAGE).to_path_buf().into()),
cx,
);
async move {
timer.await;
if parameters.fail {
log::error!("Intentionally failed to load image");
Err(anyhow::anyhow!("Failed to load image").into())
} else {
data.await
}
}
}
}
struct ImageLoadingExample {}
impl ImageLoadingExample {
fn loading_element() -> impl IntoElement {
div().size_full().flex_none().p_0p5().rounded_xs().child(
div().size_full().with_animation(
"loading-bg",
Animation::new(Duration::from_secs(3))
.repeat()
.with_easing(pulsating_between(0.04, 0.24)),
move |this, delta| this.bg(black().opacity(delta)),
),
)
}
fn fallback_element() -> impl IntoElement {
let fallback_color: Hsla = black().opacity(0.5);
div().size_full().flex_none().p_0p5().child(
div()
.size_full()
.flex()
.items_center()
.justify_center()
.rounded_xs()
.text_sm()
.text_color(fallback_color)
.border_1()
.border_color(fallback_color)
.child("?"),
)
}
}
impl Render for ImageLoadingExample {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().flex().flex_col().size_full().justify_around().child(
div().flex().flex_row().w_full().justify_around().child(
div()
.flex()
.bg(gpui::white())
.size(Length::Definite(px(300.0).into()))
.justify_center()
.items_center()
.child({
let image_source = LoadImageParameters {
timeout: LOADING_DELAY.saturating_sub(Duration::from_millis(25)),
fail: false,
};
// Load within the 'loading delay', should not show loading fallback
img(move |window: &mut Window, cx: &mut App| {
window.use_asset::<LoadImageWithParameters>(&image_source, cx)
})
.id("image-1")
.border_1()
.size_12()
.with_fallback(|| Self::fallback_element().into_any_element())
.border_color(red())
.with_loading(|| Self::loading_element().into_any_element())
.on_click(move |_, _, cx| {
cx.remove_asset::<LoadImageWithParameters>(&image_source);
})
})
.child({
// Load after a long delay
let image_source = LoadImageParameters {
timeout: Duration::from_secs(5),
fail: false,
};
img(move |window: &mut Window, cx: &mut App| {
window.use_asset::<LoadImageWithParameters>(&image_source, cx)
})
.id("image-2")
.with_fallback(|| Self::fallback_element().into_any_element())
.with_loading(|| Self::loading_element().into_any_element())
.size_12()
.border_1()
.border_color(red())
.on_click(move |_, _, cx| {
cx.remove_asset::<LoadImageWithParameters>(&image_source);
})
})
.child({
// Fail to load image after a long delay
let image_source = LoadImageParameters {
timeout: Duration::from_secs(5),
fail: true,
};
// Fail to load after a long delay
img(move |window: &mut Window, cx: &mut App| {
window.use_asset::<LoadImageWithParameters>(&image_source, cx)
})
.id("image-3")
.with_fallback(|| Self::fallback_element().into_any_element())
.with_loading(|| Self::loading_element().into_any_element())
.size_12()
.border_1()
.border_color(red())
.on_click(move |_, _, cx| {
cx.remove_asset::<LoadImageWithParameters>(&image_source);
})
})
.child({
// Ensure that the normal image loader doesn't spam logs
let image_source = Path::new(
"this/file/really/shouldn't/exist/or/won't/be/an/image/I/hope",
)
.to_path_buf();
img(image_source.clone())
.id("image-4")
.border_1()
.size_12()
.with_fallback(|| Self::fallback_element().into_any_element())
.border_color(red())
.with_loading(|| Self::loading_element().into_any_element())
.on_click(move |_, _, cx| {
cx.remove_asset::<ImgResourceLoader>(&image_source.clone().into());
})
}),
),
)
}
}
fn main() {
env_logger::init();
Application::new()
.with_assets(Assets {})
.run(|cx: &mut App| {
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(300.), px(300.)),
cx,
))),
..Default::default()
};
cx.open_window(options, |_, cx| {
cx.activate(false);
cx.new(|_| ImageLoadingExample {})
})
.unwrap();
});
}
+746
View File
@@ -0,0 +1,746 @@
use std::ops::Range;
use gpui::{
App, Application, Bounds, ClipboardItem, Context, CursorStyle, ElementId, ElementInputHandler,
Entity, EntityInputHandler, FocusHandle, Focusable, GlobalElementId, KeyBinding, Keystroke,
LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point,
ShapedLine, SharedString, Style, TextRun, UTF16Selection, UnderlineStyle, Window, WindowBounds,
WindowOptions, actions, black, div, fill, hsla, opaque_grey, point, prelude::*, px, relative,
rgb, rgba, size, white, yellow,
};
use unicode_segmentation::*;
actions!(
text_input,
[
Backspace,
Delete,
Left,
Right,
SelectLeft,
SelectRight,
SelectAll,
Home,
End,
ShowCharacterPalette,
Paste,
Cut,
Copy,
Quit,
]
);
struct TextInput {
focus_handle: FocusHandle,
content: SharedString,
placeholder: SharedString,
selected_range: Range<usize>,
selection_reversed: bool,
marked_range: Option<Range<usize>>,
last_layout: Option<ShapedLine>,
last_bounds: Option<Bounds<Pixels>>,
is_selecting: bool,
}
impl TextInput {
fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.move_to(self.previous_boundary(self.cursor_offset()), cx);
} else {
self.move_to(self.selected_range.start, cx)
}
}
fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.move_to(self.next_boundary(self.selected_range.end), cx);
} else {
self.move_to(self.selected_range.end, cx)
}
}
fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(self.previous_boundary(self.cursor_offset()), cx);
}
fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(self.next_boundary(self.cursor_offset()), cx);
}
fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(0, cx);
self.select_to(self.content.len(), cx)
}
fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(0, cx);
}
fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(self.content.len(), cx);
}
fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.select_to(self.previous_boundary(self.cursor_offset()), cx)
}
self.replace_text_in_range(None, "", window, cx)
}
fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.select_to(self.next_boundary(self.cursor_offset()), cx)
}
self.replace_text_in_range(None, "", window, cx)
}
fn on_mouse_down(
&mut self,
event: &MouseDownEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.is_selecting = true;
if event.modifiers.shift {
self.select_to(self.index_for_mouse_position(event.position), cx);
} else {
self.move_to(self.index_for_mouse_position(event.position), cx)
}
}
fn on_mouse_up(&mut self, _: &MouseUpEvent, _window: &mut Window, _: &mut Context<Self>) {
self.is_selecting = false;
}
fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
if self.is_selecting {
self.select_to(self.index_for_mouse_position(event.position), cx);
}
}
fn show_character_palette(
&mut self,
_: &ShowCharacterPalette,
window: &mut Window,
_: &mut Context<Self>,
) {
window.show_character_palette();
}
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
self.replace_text_in_range(None, &text.replace("\n", " "), window, cx);
}
}
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
if !self.selected_range.is_empty() {
cx.write_to_clipboard(ClipboardItem::new_string(
self.content[self.selected_range.clone()].to_string(),
));
}
}
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
if !self.selected_range.is_empty() {
cx.write_to_clipboard(ClipboardItem::new_string(
self.content[self.selected_range.clone()].to_string(),
));
self.replace_text_in_range(None, "", window, cx)
}
}
fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) {
self.selected_range = offset..offset;
cx.notify()
}
fn cursor_offset(&self) -> usize {
if self.selection_reversed {
self.selected_range.start
} else {
self.selected_range.end
}
}
fn index_for_mouse_position(&self, position: Point<Pixels>) -> usize {
if self.content.is_empty() {
return 0;
}
let (Some(bounds), Some(line)) = (self.last_bounds.as_ref(), self.last_layout.as_ref())
else {
return 0;
};
if position.y < bounds.top() {
return 0;
}
if position.y > bounds.bottom() {
return self.content.len();
}
line.closest_index_for_x(position.x - bounds.left())
}
fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
if self.selection_reversed {
self.selected_range.start = offset
} else {
self.selected_range.end = offset
};
if self.selected_range.end < self.selected_range.start {
self.selection_reversed = !self.selection_reversed;
self.selected_range = self.selected_range.end..self.selected_range.start;
}
cx.notify()
}
fn offset_from_utf16(&self, offset: usize) -> usize {
let mut utf8_offset = 0;
let mut utf16_count = 0;
for ch in self.content.chars() {
if utf16_count >= offset {
break;
}
utf16_count += ch.len_utf16();
utf8_offset += ch.len_utf8();
}
utf8_offset
}
fn offset_to_utf16(&self, offset: usize) -> usize {
let mut utf16_offset = 0;
let mut utf8_count = 0;
for ch in self.content.chars() {
if utf8_count >= offset {
break;
}
utf8_count += ch.len_utf8();
utf16_offset += ch.len_utf16();
}
utf16_offset
}
fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end)
}
fn range_from_utf16(&self, range_utf16: &Range<usize>) -> Range<usize> {
self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end)
}
fn previous_boundary(&self, offset: usize) -> usize {
self.content
.grapheme_indices(true)
.rev()
.find_map(|(idx, _)| (idx < offset).then_some(idx))
.unwrap_or(0)
}
fn next_boundary(&self, offset: usize) -> usize {
self.content
.grapheme_indices(true)
.find_map(|(idx, _)| (idx > offset).then_some(idx))
.unwrap_or(self.content.len())
}
fn reset(&mut self) {
self.content = "".into();
self.selected_range = 0..0;
self.selection_reversed = false;
self.marked_range = None;
self.last_layout = None;
self.last_bounds = None;
self.is_selecting = false;
}
}
impl EntityInputHandler for TextInput {
fn text_for_range(
&mut self,
range_utf16: Range<usize>,
actual_range: &mut Option<Range<usize>>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<String> {
let range = self.range_from_utf16(&range_utf16);
actual_range.replace(self.range_to_utf16(&range));
Some(self.content[range].to_string())
}
fn selected_text_range(
&mut self,
_ignore_disabled_input: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<UTF16Selection> {
Some(UTF16Selection {
range: self.range_to_utf16(&self.selected_range),
reversed: self.selection_reversed,
})
}
fn marked_text_range(
&self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Range<usize>> {
self.marked_range
.as_ref()
.map(|range| self.range_to_utf16(range))
}
fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
self.marked_range = None;
}
fn replace_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
_: &mut Window,
cx: &mut Context<Self>,
) {
let range = range_utf16
.as_ref()
.map(|range_utf16| self.range_from_utf16(range_utf16))
.or(self.marked_range.clone())
.unwrap_or(self.selected_range.clone());
self.content =
(self.content[0..range.start].to_owned() + new_text + &self.content[range.end..])
.into();
self.selected_range = range.start + new_text.len()..range.start + new_text.len();
self.marked_range.take();
cx.notify();
}
fn replace_and_mark_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
new_selected_range_utf16: Option<Range<usize>>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let range = range_utf16
.as_ref()
.map(|range_utf16| self.range_from_utf16(range_utf16))
.or(self.marked_range.clone())
.unwrap_or(self.selected_range.clone());
self.content =
(self.content[0..range.start].to_owned() + new_text + &self.content[range.end..])
.into();
if !new_text.is_empty() {
self.marked_range = Some(range.start..range.start + new_text.len());
} else {
self.marked_range = None;
}
self.selected_range = new_selected_range_utf16
.as_ref()
.map(|range_utf16| self.range_from_utf16(range_utf16))
.map(|new_range| new_range.start + range.start..new_range.end + range.end)
.unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len());
cx.notify();
}
fn bounds_for_range(
&mut self,
range_utf16: Range<usize>,
bounds: Bounds<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
let last_layout = self.last_layout.as_ref()?;
let range = self.range_from_utf16(&range_utf16);
Some(Bounds::from_corners(
point(
bounds.left() + last_layout.x_for_index(range.start),
bounds.top(),
),
point(
bounds.left() + last_layout.x_for_index(range.end),
bounds.bottom(),
),
))
}
fn character_index_for_point(
&mut self,
point: gpui::Point<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<usize> {
let line_point = self.last_bounds?.localize(&point)?;
let last_layout = self.last_layout.as_ref()?;
assert_eq!(last_layout.text, self.content);
let utf8_index = last_layout.index_for_x(point.x - line_point.x)?;
Some(self.offset_to_utf16(utf8_index))
}
}
struct TextElement {
input: Entity<TextInput>,
}
struct PrepaintState {
line: Option<ShapedLine>,
cursor: Option<PaintQuad>,
selection: Option<PaintQuad>,
}
impl IntoElement for TextElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for TextElement {
type RequestLayoutState = ();
type PrepaintState = PrepaintState;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
style.size.width = relative(1.).into();
style.size.height = window.line_height().into();
(window.request_layout(style, [], cx), ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let input = self.input.read(cx);
let content = input.content.clone();
let selected_range = input.selected_range.clone();
let cursor = input.cursor_offset();
let style = window.text_style();
let (display_text, text_color) = if content.is_empty() {
(input.placeholder.clone(), hsla(0., 0., 0., 0.2))
} else {
(content, style.color)
};
let run = TextRun {
len: display_text.len(),
font: style.font(),
color: text_color,
background_color: None,
underline: None,
strikethrough: None,
};
let runs = if let Some(marked_range) = input.marked_range.as_ref() {
vec![
TextRun {
len: marked_range.start,
..run.clone()
},
TextRun {
len: marked_range.end - marked_range.start,
underline: Some(UnderlineStyle {
color: Some(run.color),
thickness: px(1.0),
wavy: false,
}),
..run.clone()
},
TextRun {
len: display_text.len() - marked_range.end,
..run
},
]
.into_iter()
.filter(|run| run.len > 0)
.collect()
} else {
vec![run]
};
let font_size = style.font_size.to_pixels(window.rem_size());
let line = window
.text_system()
.shape_line(display_text, font_size, &runs, None);
let cursor_pos = line.x_for_index(cursor);
let (selection, cursor) = if selected_range.is_empty() {
(
None,
Some(fill(
Bounds::new(
point(bounds.left() + cursor_pos, bounds.top()),
size(px(2.), bounds.bottom() - bounds.top()),
),
gpui::blue(),
)),
)
} else {
(
Some(fill(
Bounds::from_corners(
point(
bounds.left() + line.x_for_index(selected_range.start),
bounds.top(),
),
point(
bounds.left() + line.x_for_index(selected_range.end),
bounds.bottom(),
),
),
rgba(0x3311ff30),
)),
None,
)
};
PrepaintState {
line: Some(line),
cursor,
selection,
}
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let focus_handle = self.input.read(cx).focus_handle.clone();
window.handle_input(
&focus_handle,
ElementInputHandler::new(bounds, self.input.clone()),
cx,
);
if let Some(selection) = prepaint.selection.take() {
window.paint_quad(selection)
}
let line = prepaint.line.take().unwrap();
line.paint(bounds.origin, window.line_height(), window, cx)
.unwrap();
if focus_handle.is_focused(window)
&& let Some(cursor) = prepaint.cursor.take()
{
window.paint_quad(cursor);
}
self.input.update(cx, |input, _cx| {
input.last_layout = Some(line);
input.last_bounds = Some(bounds);
});
}
}
impl Render for TextInput {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.key_context("TextInput")
.track_focus(&self.focus_handle(cx))
.cursor(CursorStyle::IBeam)
.on_action(cx.listener(Self::backspace))
.on_action(cx.listener(Self::delete))
.on_action(cx.listener(Self::left))
.on_action(cx.listener(Self::right))
.on_action(cx.listener(Self::select_left))
.on_action(cx.listener(Self::select_right))
.on_action(cx.listener(Self::select_all))
.on_action(cx.listener(Self::home))
.on_action(cx.listener(Self::end))
.on_action(cx.listener(Self::show_character_palette))
.on_action(cx.listener(Self::paste))
.on_action(cx.listener(Self::cut))
.on_action(cx.listener(Self::copy))
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
.on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up))
.on_mouse_move(cx.listener(Self::on_mouse_move))
.bg(rgb(0xeeeeee))
.line_height(px(30.))
.text_size(px(24.))
.child(
div()
.h(px(30. + 4. * 2.))
.w_full()
.p(px(4.))
.bg(white())
.child(TextElement { input: cx.entity() }),
)
}
}
impl Focusable for TextInput {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
struct InputExample {
text_input: Entity<TextInput>,
recent_keystrokes: Vec<Keystroke>,
focus_handle: FocusHandle,
}
impl Focusable for InputExample {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl InputExample {
fn on_reset_click(&mut self, _: &MouseUpEvent, _window: &mut Window, cx: &mut Context<Self>) {
self.recent_keystrokes.clear();
self.text_input
.update(cx, |text_input, _cx| text_input.reset());
cx.notify();
}
}
impl Render for InputExample {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.bg(rgb(0xaaaaaa))
.track_focus(&self.focus_handle(cx))
.flex()
.flex_col()
.size_full()
.child(
div()
.bg(white())
.border_b_1()
.border_color(black())
.flex()
.flex_row()
.justify_between()
.child(format!("Keyboard {}", cx.keyboard_layout().name()))
.child(
div()
.border_1()
.border_color(black())
.px_2()
.bg(yellow())
.child("Reset")
.hover(|style| {
style
.bg(yellow().blend(opaque_grey(0.5, 0.5)))
.cursor_pointer()
})
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_reset_click)),
),
)
.child(self.text_input.clone())
.children(self.recent_keystrokes.iter().rev().map(|ks| {
format!(
"{:} {}",
ks.unparse(),
if let Some(key_char) = ks.key_char.as_ref() {
format!("-> {:?}", key_char)
} else {
"".to_owned()
}
)
}))
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx);
cx.bind_keys([
KeyBinding::new("backspace", Backspace, None),
KeyBinding::new("delete", Delete, None),
KeyBinding::new("left", Left, None),
KeyBinding::new("right", Right, None),
KeyBinding::new("shift-left", SelectLeft, None),
KeyBinding::new("shift-right", SelectRight, None),
KeyBinding::new("cmd-a", SelectAll, None),
KeyBinding::new("cmd-v", Paste, None),
KeyBinding::new("cmd-c", Copy, None),
KeyBinding::new("cmd-x", Cut, None),
KeyBinding::new("home", Home, None),
KeyBinding::new("end", End, None),
KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, None),
]);
let window = cx
.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| {
let text_input = cx.new(|cx| TextInput {
focus_handle: cx.focus_handle(),
content: "".into(),
placeholder: "Type here...".into(),
selected_range: 0..0,
selection_reversed: false,
marked_range: None,
last_layout: None,
last_bounds: None,
is_selecting: false,
});
cx.new(|cx| InputExample {
text_input,
recent_keystrokes: vec![],
focus_handle: cx.focus_handle(),
})
},
)
.unwrap();
let view = window.update(cx, |_, _, cx| cx.entity()).unwrap();
cx.observe_keystrokes(move |ev, _, cx| {
view.update(cx, |view, cx| {
view.recent_keystrokes.push(ev.keystroke.clone());
cx.notify();
})
})
.detach();
cx.on_keyboard_layout_change({
move |cx| {
window.update(cx, |_, _, cx| cx.notify()).ok();
}
})
.detach();
window
.update(cx, |view, window, cx| {
window.focus(&view.text_input.focus_handle(cx));
cx.activate(true);
})
.unwrap();
cx.on_action(|_: &Quit, cx| cx.quit());
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
});
}
+82
View File
@@ -0,0 +1,82 @@
use gpui::{
App, Application, Bounds, Context, FocusHandle, KeyBinding, Window, WindowBounds,
WindowOptions, actions, div, prelude::*, px, rgb, size,
};
actions!(example, [CloseWindow]);
struct ExampleWindow {
focus_handle: FocusHandle,
}
impl Render for ExampleWindow {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.on_action(|_: &CloseWindow, window, _| {
window.remove_window();
})
.track_focus(&self.focus_handle)
.flex()
.flex_col()
.gap_3()
.bg(rgb(0x505050))
.size(px(500.0))
.justify_center()
.items_center()
.shadow_lg()
.border_1()
.border_color(rgb(0x0000ff))
.text_xl()
.text_color(rgb(0xffffff))
.child(
"Closing this window with cmd-w or the traffic lights should quit the application!",
)
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let mut bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx);
cx.bind_keys([KeyBinding::new("cmd-w", CloseWindow, None)]);
cx.on_window_closed(|cx| {
if cx.windows().is_empty() {
cx.quit();
}
})
.detach();
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| {
cx.activate(false);
cx.new(|cx| {
let focus_handle = cx.focus_handle();
focus_handle.focus(window);
ExampleWindow { focus_handle }
})
},
)
.unwrap();
bounds.origin.x += bounds.size.width;
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| {
cx.new(|cx| {
let focus_handle = cx.focus_handle();
focus_handle.focus(window);
ExampleWindow { focus_handle }
})
},
)
.unwrap();
});
}
+175
View File
@@ -0,0 +1,175 @@
use std::{fs, path::PathBuf};
use anyhow::Result;
use gpui::{
App, Application, AssetSource, Bounds, BoxShadow, ClickEvent, Context, SharedString, Task,
Window, WindowBounds, WindowOptions, div, hsla, img, point, prelude::*, px, rgb, size, svg,
};
struct Assets {
base: PathBuf,
}
impl AssetSource for Assets {
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
fs::read(self.base.join(path))
.map(|data| Some(std::borrow::Cow::Owned(data)))
.map_err(|e| e.into())
}
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
fs::read_dir(self.base.join(path))
.map(|entries| {
entries
.filter_map(|entry| {
entry
.ok()
.and_then(|entry| entry.file_name().into_string().ok())
.map(SharedString::from)
})
.collect()
})
.map_err(|e| e.into())
}
}
struct HelloWorld {
_task: Option<Task<()>>,
opacity: f32,
animating: bool,
}
impl HelloWorld {
fn new(_window: &mut Window, _: &mut Context<Self>) -> Self {
Self {
_task: None,
opacity: 0.5,
animating: false,
}
}
fn start_animation(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
self.opacity = 0.0;
self.animating = true;
cx.notify();
}
}
impl Render for HelloWorld {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.animating {
self.opacity += 0.005;
if self.opacity >= 1.0 {
self.animating = false;
self.opacity = 1.0;
} else {
window.request_animation_frame();
}
}
div()
.flex()
.flex_row()
.size_full()
.bg(rgb(0xe0e0e0))
.text_xl()
.child(
div()
.flex()
.size_full()
.justify_center()
.items_center()
.border_1()
.text_color(gpui::blue())
.child(div().child("This is background text.")),
)
.child(
div()
.id("panel")
.on_click(cx.listener(Self::start_animation))
.absolute()
.top_8()
.left_8()
.right_8()
.bottom_8()
.opacity(self.opacity)
.flex()
.justify_center()
.items_center()
.bg(gpui::white())
.border_3()
.border_color(gpui::red())
.text_color(gpui::yellow())
.child(
div()
.flex()
.flex_col()
.gap_2()
.justify_center()
.items_center()
.size(px(300.))
.bg(gpui::blue())
.border_3()
.border_color(gpui::black())
.shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.5),
blur_radius: px(1.0),
spread_radius: px(5.0),
offset: point(px(10.0), px(10.0)),
}])
.child(img("image/app-icon.png").size_8())
.child("Opacity Panel (Click to test)")
.child(
div()
.id("deep-level-text")
.flex()
.justify_center()
.items_center()
.p_4()
.bg(gpui::black())
.text_color(gpui::white())
.text_decoration_2()
.text_decoration_wavy()
.text_decoration_color(gpui::red())
.child(format!("opacity: {:.1}", self.opacity)),
)
.child(
svg()
.path("image/arrow_circle.svg")
.text_color(gpui::black())
.text_2xl()
.size_8(),
)
.child(
div()
.flex()
.children(["🎊", "✈️", "🎉", "🎈", "🎁", "🎂"].map(|emoji| {
div()
.child(emoji.to_string())
.hover(|style| style.opacity(0.5))
})),
)
.child(img("image/black-cat-typing.gif").size_12()),
),
)
}
}
fn main() {
Application::new()
.with_assets(Assets {
base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"),
})
.run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(500.0), px(500.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| cx.new(|cx| HelloWorld::new(window, cx)),
)
.unwrap();
cx.activate(true);
});
}
+35
View File
@@ -0,0 +1,35 @@
use gpui::{App, Application, Context, Entity, EventEmitter, prelude::*};
struct Counter {
count: usize,
}
struct Change {
increment: usize,
}
impl EventEmitter<Change> for Counter {}
fn main() {
Application::new().run(|cx: &mut App| {
let counter: Entity<Counter> = cx.new(|_cx| Counter { count: 0 });
let subscriber = cx.new(|cx: &mut Context<Counter>| {
cx.subscribe(&counter, |subscriber, _emitter, event, _cx| {
subscriber.count += event.increment * 2;
})
.detach();
Counter {
count: counter.read(cx).count * 2,
}
});
counter.update(cx, |counter, cx| {
counter.count += 2;
cx.notify();
cx.emit(Change { increment: 2 });
});
assert_eq!(subscriber.read(cx).count, 4);
});
}
+463
View File
@@ -0,0 +1,463 @@
use gpui::{
Application, Background, Bounds, ColorSpace, Context, MouseDownEvent, Path, PathBuilder,
PathStyle, Pixels, Point, Render, SharedString, StrokeOptions, Window, WindowOptions, canvas,
div, linear_color_stop, linear_gradient, point, prelude::*, px, quad, rgb, size,
};
struct PaintingViewer {
default_lines: Vec<(Path<Pixels>, Background)>,
background_quads: Vec<(Bounds<Pixels>, Background)>,
lines: Vec<Vec<Point<Pixels>>>,
start: Point<Pixels>,
dashed: bool,
_painting: bool,
}
impl PaintingViewer {
fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
let mut lines = vec![];
// Black squares beneath transparent paths.
let background_quads = vec![
(
Bounds {
origin: point(px(70.), px(70.)),
size: size(px(40.), px(40.)),
},
gpui::black().into(),
),
(
Bounds {
origin: point(px(170.), px(70.)),
size: size(px(40.), px(40.)),
},
gpui::black().into(),
),
(
Bounds {
origin: point(px(270.), px(70.)),
size: size(px(40.), px(40.)),
},
gpui::black().into(),
),
(
Bounds {
origin: point(px(370.), px(70.)),
size: size(px(40.), px(40.)),
},
gpui::black().into(),
),
(
Bounds {
origin: point(px(450.), px(50.)),
size: size(px(80.), px(80.)),
},
gpui::black().into(),
),
];
// 50% opaque red path that extends across black quad.
let mut builder = PathBuilder::fill();
builder.move_to(point(px(50.), px(50.)));
builder.line_to(point(px(130.), px(50.)));
builder.line_to(point(px(130.), px(130.)));
builder.line_to(point(px(50.), px(130.)));
builder.close();
let path = builder.build().unwrap();
let mut red = rgb(0xFF0000);
red.a = 0.5;
lines.push((path, red.into()));
// 50% opaque blue path that extends across black quad.
let mut builder = PathBuilder::fill();
builder.move_to(point(px(150.), px(50.)));
builder.line_to(point(px(230.), px(50.)));
builder.line_to(point(px(230.), px(130.)));
builder.line_to(point(px(150.), px(130.)));
builder.close();
let path = builder.build().unwrap();
let mut blue = rgb(0x0000FF);
blue.a = 0.5;
lines.push((path, blue.into()));
// 50% opaque green path that extends across black quad.
let mut builder = PathBuilder::fill();
builder.move_to(point(px(250.), px(50.)));
builder.line_to(point(px(330.), px(50.)));
builder.line_to(point(px(330.), px(130.)));
builder.line_to(point(px(250.), px(130.)));
builder.close();
let path = builder.build().unwrap();
let mut green = rgb(0x00FF00);
green.a = 0.5;
lines.push((path, green.into()));
// 50% opaque black path that extends across black quad.
let mut builder = PathBuilder::fill();
builder.move_to(point(px(350.), px(50.)));
builder.line_to(point(px(430.), px(50.)));
builder.line_to(point(px(430.), px(130.)));
builder.line_to(point(px(350.), px(130.)));
builder.close();
let path = builder.build().unwrap();
let mut black = rgb(0x000000);
black.a = 0.5;
lines.push((path, black.into()));
// Two 50% opaque red circles overlapping - center should be darker red
let mut builder = PathBuilder::fill();
let center = point(px(530.), px(85.));
let radius = px(30.);
builder.move_to(point(center.x + radius, center.y));
builder.arc_to(
point(radius, radius),
px(0.),
false,
false,
point(center.x - radius, center.y),
);
builder.arc_to(
point(radius, radius),
px(0.),
false,
false,
point(center.x + radius, center.y),
);
builder.close();
let path = builder.build().unwrap();
let mut red1 = rgb(0xFF0000);
red1.a = 0.5;
lines.push((path, red1.into()));
let mut builder = PathBuilder::fill();
let center = point(px(570.), px(85.));
let radius = px(30.);
builder.move_to(point(center.x + radius, center.y));
builder.arc_to(
point(radius, radius),
px(0.),
false,
false,
point(center.x - radius, center.y),
);
builder.arc_to(
point(radius, radius),
px(0.),
false,
false,
point(center.x + radius, center.y),
);
builder.close();
let path = builder.build().unwrap();
let mut red2 = rgb(0xFF0000);
red2.a = 0.5;
lines.push((path, red2.into()));
// draw a Rust logo
let mut builder = lyon::path::Path::svg_builder();
lyon::extra::rust_logo::build_logo_path(&mut builder);
// move down the Path
let mut builder: PathBuilder = builder.into();
builder.translate(point(px(10.), px(200.)));
builder.scale(0.9);
let path = builder.build().unwrap();
lines.push((path, gpui::black().into()));
// draw a lightening bolt ⚡
let mut builder = PathBuilder::fill();
builder.add_polygon(
&[
point(px(150.), px(300.)),
point(px(200.), px(225.)),
point(px(200.), px(275.)),
point(px(250.), px(200.)),
],
false,
);
let path = builder.build().unwrap();
lines.push((path, rgb(0x1d4ed8).into()));
// draw a ⭐
let mut builder = PathBuilder::fill();
builder.move_to(point(px(350.), px(200.)));
builder.line_to(point(px(370.), px(260.)));
builder.line_to(point(px(430.), px(260.)));
builder.line_to(point(px(380.), px(300.)));
builder.line_to(point(px(400.), px(360.)));
builder.line_to(point(px(350.), px(320.)));
builder.line_to(point(px(300.), px(360.)));
builder.line_to(point(px(320.), px(300.)));
builder.line_to(point(px(270.), px(260.)));
builder.line_to(point(px(330.), px(260.)));
builder.line_to(point(px(350.), px(200.)));
let path = builder.build().unwrap();
lines.push((
path,
linear_gradient(
180.,
linear_color_stop(rgb(0xFACC15), 0.7),
linear_color_stop(rgb(0xD56D0C), 1.),
)
.color_space(ColorSpace::Oklab),
));
// draw linear gradient
let square_bounds = Bounds {
origin: point(px(450.), px(200.)),
size: size(px(200.), px(80.)),
};
let height = square_bounds.size.height;
let horizontal_offset = height;
let vertical_offset = px(30.);
let mut builder = PathBuilder::fill();
builder.move_to(square_bounds.bottom_left());
builder.curve_to(
square_bounds.origin + point(horizontal_offset, vertical_offset),
square_bounds.origin + point(px(0.0), vertical_offset),
);
builder.line_to(square_bounds.top_right() + point(-horizontal_offset, vertical_offset));
builder.curve_to(
square_bounds.bottom_right(),
square_bounds.top_right() + point(px(0.0), vertical_offset),
);
builder.line_to(square_bounds.bottom_left());
let path = builder.build().unwrap();
lines.push((
path,
linear_gradient(
180.,
linear_color_stop(gpui::blue(), 0.4),
linear_color_stop(gpui::red(), 1.),
),
));
// draw a pie chart
let center = point(px(96.), px(96.));
let pie_center = point(px(775.), px(255.));
let segments = [
(
point(px(871.), px(255.)),
point(px(747.), px(163.)),
rgb(0x1374e9),
),
(
point(px(747.), px(163.)),
point(px(679.), px(263.)),
rgb(0xe13527),
),
(
point(px(679.), px(263.)),
point(px(754.), px(349.)),
rgb(0x0751ce),
),
(
point(px(754.), px(349.)),
point(px(854.), px(310.)),
rgb(0x209742),
),
(
point(px(854.), px(310.)),
point(px(871.), px(255.)),
rgb(0xfbc10a),
),
];
for (start, end, color) in segments {
let mut builder = PathBuilder::fill();
builder.move_to(start);
builder.arc_to(center, px(0.), false, false, end);
builder.line_to(pie_center);
builder.close();
let path = builder.build().unwrap();
lines.push((path, color.into()));
}
// draw a wave
let options = StrokeOptions::default()
.with_line_width(1.)
.with_line_join(lyon::path::LineJoin::Bevel);
let mut builder = PathBuilder::stroke(px(1.)).with_style(PathStyle::Stroke(options));
builder.move_to(point(px(40.), px(420.)));
for i in 1..50 {
builder.line_to(point(
px(40.0 + i as f32 * 10.0),
px(420.0 + (i as f32 * 10.0).sin() * 40.0),
));
}
let path = builder.build().unwrap();
lines.push((path, gpui::green().into()));
Self {
default_lines: lines.clone(),
background_quads,
lines: vec![],
start: point(px(0.), px(0.)),
dashed: false,
_painting: false,
}
}
fn clear(&mut self, cx: &mut Context<Self>) {
self.lines.clear();
cx.notify();
}
}
fn button(
text: &str,
cx: &mut Context<PaintingViewer>,
on_click: impl Fn(&mut PaintingViewer, &mut Context<PaintingViewer>) + 'static,
) -> impl IntoElement {
div()
.id(SharedString::from(text.to_string()))
.child(text.to_string())
.bg(gpui::black())
.text_color(gpui::white())
.active(|this| this.opacity(0.8))
.flex()
.px_3()
.py_1()
.on_click(cx.listener(move |this, _, _, cx| on_click(this, cx)))
}
impl Render for PaintingViewer {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let default_lines = self.default_lines.clone();
let background_quads = self.background_quads.clone();
let lines = self.lines.clone();
let dashed = self.dashed;
div()
.bg(gpui::white())
.size_full()
.p_4()
.flex()
.flex_col()
.child(
div()
.flex()
.gap_2()
.justify_between()
.items_center()
.child("Mouse down any point and drag to draw lines (Hold on shift key to draw straight lines)")
.child(
div()
.flex()
.gap_x_2()
.child(button(
if dashed { "Solid" } else { "Dashed" },
cx,
move |this, _| this.dashed = !dashed,
))
.child(button("Clear", cx, |this, cx| this.clear(cx))),
),
)
.child(
div()
.size_full()
.child(
canvas(
move |_, _, _| {},
move |_, _, window, _| {
// First draw background quads
for (bounds, color) in background_quads.iter() {
window.paint_quad(quad(
*bounds,
px(0.),
*color,
px(0.),
gpui::transparent_black(),
Default::default(),
));
}
// Then draw the default paths on top
for (path, color) in default_lines {
window.paint_path(path, color);
}
for points in lines {
if points.len() < 2 {
continue;
}
let mut builder = PathBuilder::stroke(px(1.));
if dashed {
builder = builder.dash_array(&[px(4.), px(2.)]);
}
for (i, p) in points.into_iter().enumerate() {
if i == 0 {
builder.move_to(p);
} else {
builder.line_to(p);
}
}
if let Ok(path) = builder.build() {
window.paint_path(path, gpui::black());
}
}
},
)
.size_full(),
)
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|this, ev: &MouseDownEvent, _, _| {
this._painting = true;
this.start = ev.position;
let path = vec![ev.position];
this.lines.push(path);
}),
)
.on_mouse_move(cx.listener(|this, ev: &gpui::MouseMoveEvent, _, cx| {
if !this._painting {
return;
}
let is_shifted = ev.modifiers.shift;
let mut pos = ev.position;
// When holding shift, draw a straight line
if is_shifted {
let dx = pos.x - this.start.x;
let dy = pos.y - this.start.y;
if dx.abs() > dy.abs() {
pos.y = this.start.y;
} else {
pos.x = this.start.x;
}
}
if let Some(path) = this.lines.last_mut() {
path.push(pos);
}
cx.notify();
}))
.on_mouse_up(
gpui::MouseButton::Left,
cx.listener(|this, _, _, _| {
this._painting = false;
}),
),
)
}
}
fn main() {
Application::new().run(|cx| {
cx.open_window(
WindowOptions {
focus: true,
..Default::default()
},
|window, cx| cx.new(|cx| PaintingViewer::new(window, cx)),
)
.unwrap();
cx.on_window_closed(|cx| {
cx.quit();
})
.detach();
cx.activate(true);
});
}
+92
View File
@@ -0,0 +1,92 @@
use gpui::{
Application, Background, Bounds, ColorSpace, Context, Path, PathBuilder, Pixels, Render,
TitlebarOptions, Window, WindowBounds, WindowOptions, canvas, div, linear_color_stop,
linear_gradient, point, prelude::*, px, rgb, size,
};
const DEFAULT_WINDOW_WIDTH: Pixels = px(1024.0);
const DEFAULT_WINDOW_HEIGHT: Pixels = px(768.0);
struct PaintingViewer {
default_lines: Vec<(Path<Pixels>, Background)>,
_painting: bool,
}
impl PaintingViewer {
fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
let mut lines = vec![];
// draw a lightening bolt ⚡
for _ in 0..2000 {
// draw a ⭐
let mut builder = PathBuilder::fill();
builder.move_to(point(px(350.), px(100.)));
builder.line_to(point(px(370.), px(160.)));
builder.line_to(point(px(430.), px(160.)));
builder.line_to(point(px(380.), px(200.)));
builder.line_to(point(px(400.), px(260.)));
builder.line_to(point(px(350.), px(220.)));
builder.line_to(point(px(300.), px(260.)));
builder.line_to(point(px(320.), px(200.)));
builder.line_to(point(px(270.), px(160.)));
builder.line_to(point(px(330.), px(160.)));
builder.line_to(point(px(350.), px(100.)));
let path = builder.build().unwrap();
lines.push((
path,
linear_gradient(
180.,
linear_color_stop(rgb(0xFACC15), 0.7),
linear_color_stop(rgb(0xD56D0C), 1.),
)
.color_space(ColorSpace::Oklab),
));
}
Self {
default_lines: lines,
_painting: false,
}
}
}
impl Render for PaintingViewer {
fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
window.request_animation_frame();
let lines = self.default_lines.clone();
div().size_full().child(
canvas(
move |_, _, _| {},
move |_, _, window, _| {
for (path, color) in lines {
window.paint_path(path, color);
}
},
)
.size_full(),
)
}
}
fn main() {
Application::new().run(|cx| {
cx.open_window(
WindowOptions {
titlebar: Some(TitlebarOptions {
title: Some("Vulkan".into()),
..Default::default()
}),
focus: true,
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT),
cx,
))),
..Default::default()
},
|window, cx| cx.new(|cx| PaintingViewer::new(window, cx)),
)
.unwrap();
cx.activate(true);
});
}
+115
View File
@@ -0,0 +1,115 @@
use gpui::{
App, AppContext, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div,
linear_color_stop, linear_gradient, pattern_slash, prelude::*, px, rgb, size,
};
struct PatternExample;
impl Render for PatternExample {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.gap_3()
.bg(rgb(0xffffff))
.size(px(600.0))
.justify_center()
.items_center()
.shadow_lg()
.text_xl()
.text_color(rgb(0x000000))
.child("Pattern Example")
.child(
div()
.flex()
.flex_col()
.border_1()
.border_color(gpui::blue())
.child(div().w(px(54.0)).h(px(18.0)).bg(pattern_slash(
gpui::red(),
18.0 / 4.0,
18.0 / 4.0,
)))
.child(div().w(px(54.0)).h(px(18.0)).bg(pattern_slash(
gpui::red(),
18.0 / 4.0,
18.0 / 4.0,
)))
.child(div().w(px(54.0)).h(px(18.0)).bg(pattern_slash(
gpui::red(),
18.0 / 4.0,
18.0 / 4.0,
)))
.child(div().w(px(54.0)).h(px(18.0)).bg(pattern_slash(
gpui::red(),
18.0 / 4.0,
18.0 / 2.0,
))),
)
.child(
div()
.flex()
.flex_col()
.border_1()
.border_color(gpui::blue())
.bg(gpui::green().opacity(0.16))
.child("Elements the same height should align")
.child(div().w(px(256.0)).h(px(56.0)).bg(pattern_slash(
gpui::red(),
56.0 / 6.0,
56.0 / 6.0,
)))
.child(div().w(px(256.0)).h(px(56.0)).bg(pattern_slash(
gpui::green(),
56.0 / 6.0,
56.0 / 6.0,
)))
.child(div().w(px(256.0)).h(px(56.0)).bg(pattern_slash(
gpui::blue(),
56.0 / 6.0,
56.0 / 6.0,
)))
.child(div().w(px(256.0)).h(px(26.0)).bg(pattern_slash(
gpui::yellow(),
56.0 / 6.0,
56.0 / 6.0,
))),
)
.child(
div()
.border_1()
.border_color(gpui::blue())
.w(px(240.0))
.h(px(40.0))
.bg(gpui::red()),
)
.child(
div()
.border_1()
.border_color(gpui::blue())
.w(px(240.0))
.h(px(40.0))
.bg(linear_gradient(
45.,
linear_color_stop(gpui::red(), 0.),
linear_color_stop(gpui::blue(), 1.),
)),
)
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(600.0), px(600.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_window, cx| cx.new(|_cx| PatternExample),
)
.unwrap();
cx.activate(true);
});
}
+60
View File
@@ -0,0 +1,60 @@
use gpui::{
App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px,
size,
};
struct Scrollable {}
impl Render for Scrollable {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.id("vertical")
.p_4()
.overflow_scroll()
.bg(gpui::white())
.child("Example for test 2 way scroll in nested layout")
.child(
div()
.h(px(5000.))
.border_1()
.border_color(gpui::blue())
.bg(gpui::blue().opacity(0.05))
.p_4()
.child(
div()
.mb_5()
.w_full()
.id("horizontal")
.overflow_scroll()
.child(
div()
.w(px(2000.))
.h(px(150.))
.bg(gpui::green().opacity(0.1))
.hover(|this| this.bg(gpui::green().opacity(0.2)))
.border_1()
.border_color(gpui::green())
.p_4()
.child("Scroll Horizontal"),
),
)
.child("Scroll Vertical"),
)
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| cx.new(|_| Scrollable {}),
)
.unwrap();
cx.activate(true);
});
}
+49
View File
@@ -0,0 +1,49 @@
use gpui::{
App, Application, Context, Menu, MenuItem, SystemMenuType, Window, WindowOptions, actions, div,
prelude::*, rgb,
};
struct SetMenus;
impl Render for SetMenus {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.bg(rgb(0x2e7d32))
.size_full()
.justify_center()
.items_center()
.text_xl()
.text_color(rgb(0xffffff))
.child("Set Menus Example")
}
}
fn main() {
Application::new().run(|cx: &mut App| {
// Bring the menu bar to the foreground (so you can see the menu bar)
cx.activate(true);
// Register the `quit` function so it can be referenced by the `MenuItem::action` in the menu bar
cx.on_action(quit);
// Add menu items
cx.set_menus(vec![Menu {
name: "set_menus".into(),
items: vec![
MenuItem::os_submenu("Services", SystemMenuType::Services),
MenuItem::separator(),
MenuItem::action("Quit", Quit),
],
}]);
cx.open_window(WindowOptions::default(), |_, cx| cx.new(|_| SetMenus {}))
.unwrap();
});
}
// Associate actions using the `actions!` macro (or `Action` derive macro)
actions!(set_menus, [Quit]);
// Define the quit function that is registered with the App
fn quit(_: &Quit, cx: &mut App) {
println!("Gracefully quitting the application . . .");
cx.quit();
}
+585
View File
@@ -0,0 +1,585 @@
use gpui::{
App, Application, Bounds, BoxShadow, Context, Div, SharedString, Window, WindowBounds,
WindowOptions, div, hsla, point, prelude::*, px, relative, rgb, size,
};
struct Shadow {}
impl Shadow {
fn base() -> Div {
div()
.size_16()
.bg(rgb(0xffffff))
.rounded_full()
.border_1()
.border_color(hsla(0.0, 0.0, 0.0, 0.1))
}
fn square() -> Div {
div()
.size_16()
.bg(rgb(0xffffff))
.border_1()
.border_color(hsla(0.0, 0.0, 0.0, 0.1))
}
fn rounded_small() -> Div {
div()
.size_16()
.bg(rgb(0xffffff))
.rounded(px(4.))
.border_1()
.border_color(hsla(0.0, 0.0, 0.0, 0.1))
}
fn rounded_medium() -> Div {
div()
.size_16()
.bg(rgb(0xffffff))
.rounded(px(8.))
.border_1()
.border_color(hsla(0.0, 0.0, 0.0, 0.1))
}
fn rounded_large() -> Div {
div()
.size_16()
.bg(rgb(0xffffff))
.rounded(px(12.))
.border_1()
.border_color(hsla(0.0, 0.0, 0.0, 0.1))
}
}
fn example(label: impl Into<SharedString>, example: impl IntoElement) -> impl IntoElement {
let label = label.into();
div()
.flex()
.flex_col()
.justify_center()
.items_center()
.w(relative(1. / 6.))
.border_r_1()
.border_color(hsla(0.0, 0.0, 0.0, 1.0))
.child(
div()
.flex()
.items_center()
.justify_center()
.flex_1()
.py_12()
.child(example),
)
.child(
div()
.w_full()
.border_t_1()
.border_color(hsla(0.0, 0.0, 0.0, 1.0))
.p_1()
.flex()
.items_center()
.child(label),
)
}
impl Render for Shadow {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.id("shadow-example")
.overflow_y_scroll()
.bg(rgb(0xffffff))
.size_full()
.text_xs()
.child(div().flex().flex_col().w_full().children(vec![
div()
.border_b_1()
.border_color(hsla(0.0, 0.0, 0.0, 1.0))
.flex()
.flex_row()
.children(vec![
example(
"Square",
Shadow::square()
.shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Rounded 4",
Shadow::rounded_small()
.shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Rounded 8",
Shadow::rounded_medium()
.shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Rounded 16",
Shadow::rounded_large()
.shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Circle",
Shadow::base()
.shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
]),
div()
.border_b_1()
.border_color(hsla(0.0, 0.0, 0.0, 1.0))
.flex()
.w_full()
.children(vec![
example("None", Shadow::base()),
// 2Xsmall shadow
example("2X Small", Shadow::base().shadow_2xs()),
// Xsmall shadow
example("Extra Small", Shadow::base().shadow_xs()),
// Small shadow
example("Small", Shadow::base().shadow_sm()),
// Medium shadow
example("Medium", Shadow::base().shadow_md()),
// Large shadow
example("Large", Shadow::base().shadow_lg()),
example("Extra Large", Shadow::base().shadow_xl()),
example("2X Large", Shadow::base().shadow_2xl()),
]),
// Horizontal list of increasing blur radii
div()
.border_b_1()
.border_color(hsla(0.0, 0.0, 0.0, 1.0))
.flex()
.children(vec![
example(
"Blur 0",
Shadow::base().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(0.),
spread_radius: px(0.),
}]),
),
example(
"Blur 2",
Shadow::base().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(2.),
spread_radius: px(0.),
}]),
),
example(
"Blur 4",
Shadow::base().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(4.),
spread_radius: px(0.),
}]),
),
example(
"Blur 8",
Shadow::base().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Blur 16",
Shadow::base().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(16.),
spread_radius: px(0.),
}]),
),
]),
// Horizontal list of increasing spread radii
div()
.border_b_1()
.border_color(hsla(0.0, 0.0, 0.0, 1.0))
.flex()
.children(vec![
example(
"Spread 0",
Shadow::base().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Spread 2",
Shadow::base().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(2.),
}]),
),
example(
"Spread 4",
Shadow::base().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(4.),
}]),
),
example(
"Spread 8",
Shadow::base().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(8.),
}]),
),
example(
"Spread 16",
Shadow::base().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(16.),
}]),
),
]),
// Square spread examples
div()
.border_b_1()
.border_color(hsla(0.0, 0.0, 0.0, 1.0))
.flex()
.children(vec![
example(
"Square Spread 0",
Shadow::square().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Square Spread 8",
Shadow::square().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(8.),
}]),
),
example(
"Square Spread 16",
Shadow::square().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(16.),
}]),
),
]),
// Rounded large spread examples
div()
.border_b_1()
.border_color(hsla(0.0, 0.0, 0.0, 1.0))
.flex()
.children(vec![
example(
"Rounded Large Spread 0",
Shadow::rounded_large().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Rounded Large Spread 8",
Shadow::rounded_large().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(8.),
}]),
),
example(
"Rounded Large Spread 16",
Shadow::rounded_large().shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(16.),
}]),
),
]),
// Directional shadows
div()
.border_b_1()
.border_color(hsla(0.0, 0.0, 0.0, 1.0))
.flex()
.children(vec![
example(
"Left",
Shadow::base().shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(-8.), px(0.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Right",
Shadow::base().shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(8.), px(0.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Top",
Shadow::base().shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(0.), px(-8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Bottom",
Shadow::base().shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
]),
// Square directional shadows
div()
.border_b_1()
.border_color(hsla(0.0, 0.0, 0.0, 1.0))
.flex()
.children(vec![
example(
"Square Left",
Shadow::square().shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(-8.), px(0.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Square Right",
Shadow::square().shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(8.), px(0.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Square Top",
Shadow::square().shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(0.), px(-8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Square Bottom",
Shadow::square().shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
]),
// Rounded large directional shadows
div()
.border_b_1()
.border_color(hsla(0.0, 0.0, 0.0, 1.0))
.flex()
.children(vec![
example(
"Rounded Large Left",
Shadow::rounded_large().shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(-8.), px(0.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Rounded Large Right",
Shadow::rounded_large().shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(8.), px(0.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Rounded Large Top",
Shadow::rounded_large().shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(0.), px(-8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
example(
"Rounded Large Bottom",
Shadow::rounded_large().shadow(vec![BoxShadow {
color: hsla(0.0, 0.5, 0.5, 0.3),
offset: point(px(0.), px(8.)),
blur_radius: px(8.),
spread_radius: px(0.),
}]),
),
]),
// Multiple shadows for different shapes
div()
.border_b_1()
.border_color(hsla(0.0, 0.0, 0.0, 1.0))
.flex()
.children(vec![
example(
"Circle Multiple",
Shadow::base().shadow(vec![
BoxShadow {
color: hsla(0.0 / 360., 1.0, 0.5, 0.3), // Red
offset: point(px(0.), px(-12.)),
blur_radius: px(8.),
spread_radius: px(2.),
},
BoxShadow {
color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow
offset: point(px(12.), px(0.)),
blur_radius: px(8.),
spread_radius: px(2.),
},
BoxShadow {
color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green
offset: point(px(0.), px(12.)),
blur_radius: px(8.),
spread_radius: px(2.),
},
BoxShadow {
color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue
offset: point(px(-12.), px(0.)),
blur_radius: px(8.),
spread_radius: px(2.),
},
]),
),
example(
"Square Multiple",
Shadow::square().shadow(vec![
BoxShadow {
color: hsla(0.0 / 360., 1.0, 0.5, 0.3), // Red
offset: point(px(0.), px(-12.)),
blur_radius: px(8.),
spread_radius: px(2.),
},
BoxShadow {
color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow
offset: point(px(12.), px(0.)),
blur_radius: px(8.),
spread_radius: px(2.),
},
BoxShadow {
color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green
offset: point(px(0.), px(12.)),
blur_radius: px(8.),
spread_radius: px(2.),
},
BoxShadow {
color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue
offset: point(px(-12.), px(0.)),
blur_radius: px(8.),
spread_radius: px(2.),
},
]),
),
example(
"Rounded Large Multiple",
Shadow::rounded_large().shadow(vec![
BoxShadow {
color: hsla(0.0 / 360., 1.0, 0.5, 0.3), // Red
offset: point(px(0.), px(-12.)),
blur_radius: px(8.),
spread_radius: px(2.),
},
BoxShadow {
color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow
offset: point(px(12.), px(0.)),
blur_radius: px(8.),
spread_radius: px(2.),
},
BoxShadow {
color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green
offset: point(px(0.), px(12.)),
blur_radius: px(8.),
spread_radius: px(2.),
},
BoxShadow {
color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue
offset: point(px(-12.), px(0.)),
blur_radius: px(8.),
spread_radius: px(2.),
},
]),
),
]),
]))
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(1000.0), px(800.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| cx.new(|_| Shadow {}),
)
.unwrap();
cx.activate(true);
});
}
+240
View File
@@ -0,0 +1,240 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<!-- Retrieved from https://commons.wikimedia.org/wiki/Category:SVG_files#/media/File:Dragon_clip_art.svg -->
<!-- License: CC BY-SA 3.0 -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="273.55759"
height="297.81952"
id="svg2"
version="1.1"
inkscape:version="0.48.2 r9819"
sodipodi:docname="New document 1">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.78061715"
inkscape:cx="112.41895"
inkscape:cy="87.896202"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:window-width="1024"
inkscape:window-height="712"
inkscape:window-x="-4"
inkscape:window-y="-4"
inkscape:window-maximized="1" />
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(12.737105,20.750754)">
<path
style="fill:#ff0000;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="M 73.44622,98.502944 95.574248,123.45583 103.10719,106.5067 73.44622,75.904107 c -3.686689,-4.753593 -11.648074,-6.667124 -21.18641,-7.532946 2.554639,2.715713 6.322156,2.39923 6.591328,10.82861 0.824915,3.302369 -2.23752,5.956846 -8.945373,8.003754 C 38.790859,84.969312 29.325417,85.209295 20.7156,86.732716 10.937191,88.437808 3.3459273,92.876831 -1.4124273,100.85699 L 14.595082,96.148898 c 7.184903,-0.279702 11.921181,1.889221 10.3578,10.357802 -1.994238,5.38177 -5.541365,9.21065 -11.299418,10.82861 -13.89013312,3.69644 -14.20560941,10.40948 -17.4199368,16.47832 2.98162616,-2.35421 5.619737,-5.05194 12.2410366,-3.76648 C 12.049969,137.75238 6.2740844,144.2887 0,150.76275 c -8.2557969,11.86874 -11.815365,18.89318 -11.299418,29.19017 -3.600096,18.58594 5.3801474,23.59338 8.4745634,34.36906 C -3.7567158,203.36083 -1.3904441,200.64501 0,195.48962 l 7.0621365,-10.3578 c -0.8666538,6.37974 -2.2779903,12.39636 0.4708091,21.18641 12.3493154,22.89597 28.0783714,46.43569 15.0658914,64.50084 l 15.065891,-22.59883 c 5.315347,9.96887 6.398134,20.64317 25.894501,28.24854 l 26.836118,-1.41242 c 0,0 -66.854892,-105.46124 -67.79651,-107.34448 -0.941618,-1.88323 53.201428,-70.621364 53.201428,-70.621364 z"
id="path3790"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccccccccccccccccscc" />
<path
style="fill:#b4daff;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="M 142.47916,274.20076 160.07509,273.64393 96.045057,235.03758 65.913274,202.08095 58.851138,160.64975 90.866157,124.86825 68.26732,97.561326 c -12.595581,1.14506 -19.978043,4.375364 -26.36531,8.003754 -10.473991,7.25982 -17.726182,17.74144 -24.482073,28.71936 -6.479109,23.36435 -7.280245,49.16212 3.766473,80.03754 11.380348,21.89016 28.33967,38.20135 48.493337,51.31819 l 18.835517,9.59325 z"
id="path3788"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccccc" />
<path
style="fill:#ffa100;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 156.77943,275.05636 17.41994,-9.41619 0.94162,-16.47831 -25.4237,-4.70809 -12.71184,21.1864 z"
id="path2998"
inkscape:connector-curvature="0" />
<path
style="fill:#ffa100;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 149.24649,255.75318 c 0,0 6.12051,-17.41993 4.23728,-17.41993 -1.88324,0 -22.59884,-3.76648 -22.59884,-3.76648 l -17.89075,17.41994 24.95289,13.65346 z"
id="path3000"
inkscape:connector-curvature="0" />
<path
style="fill:#ffa100;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 95.103438,237.39163 21.186412,16.47832 16.00751,-9.88699 0.47081,-18.36156 -23.54046,-5.64971 0.47081,10.82861 z"
id="path3002"
inkscape:connector-curvature="0" />
<path
style="fill:#ffa100;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 90.866157,216.67603 2.824854,-11.77023 17.419939,12.71185 -1.88324,15.06589 -16.007508,5.1789 -20.244791,-18.36156 z"
id="path3004"
inkscape:connector-curvature="0" />
<path
style="fill:#ffa100;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 62.61761,202.55176 18.832364,-8.47457 13.653464,8.47457 -2.824854,16.0075 -19.303173,0 z"
id="path3006"
inkscape:connector-curvature="0" />
<path
style="fill:#ffa100;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 57.909519,180.42373 18.832364,0.47081 9.416183,10.3578 -5.178901,9.88699 -21.186409,-0.47081 z"
id="path3008"
inkscape:connector-curvature="0" />
<path
style="fill:#ffa100;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 56.497092,159.70813 18.361555,2.82485 6.591327,13.18266 -8.474563,9.88699 -16.949128,-5.1789 z"
id="path3780"
inkscape:connector-curvature="0" />
<path
style="fill:#ffa100;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 89.924538,126.28068 c -9.664322,8.18228 -14.377332,17.78125 -10.357799,33.42745 l -13.182656,8.00375 -9.886991,-8.47457 c 3.769665,-18.09309 15.869815,-31.15434 33.427446,-32.95663 z"
id="path3786"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#80afb8;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="M 133.77127,121.15445 C 102.29445,108.16921 71.200271,103.60211 38.733769,68.843895 58.122127,89.497163 77.85045,109.22179 101.42657,118.35923 L 69.081877,100.38995 c 11.499182,15.61467 23.598373,27.80948 42.327623,31.94538 -11.24421,1.21671 -20.380767,-7.051 -29.549472,-15.17405 24.733762,29.06649 40.923142,28.22765 55.105772,20.36517 -8.16571,-3.14828 -15.52424,-7.10373 -21.56313,-12.37883 z"
id="path3798"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccc" />
<path
style="fill:#ffaf29;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="M 138.56307,117.95991 128.58014,122.3524 87.051152,98.792686 C 67.454225,70.758132 70.300802,54.846161 60.696216,35.301249 68.447691,43.355436 74.77626,52.358226 85.453883,58.461647 79.819169,46.517664 80.927543,37.270916 80.262759,27.314905 c 3.166784,15.761986 9.719966,24.751173 15.972689,34.34128 -1.47816,-6.276965 -3.350278,-13.144867 -2.795221,-16.372006 5.304337,8.158007 10.295783,16.503749 20.764493,21.56313 3.59095,0.562069 10.55437,5.339728 18.36859,11.180882 7.24446,7.245148 12.72056,16.848114 16.77133,28.351519 z"
id="path3808"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccc" />
<path
style="fill:#00d7d6;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 113.8054,124.34899 15.57338,-3.59386 12.37883,-4.7918 6.78839,-10.78157 -0.79863,-6.389074 17.17064,13.976104 14.37542,7.18771 9.58361,13.9761 14.77474,-1.99659 c 4.39249,1.57017 8.78497,2.92164 13.17746,7.58703 5.76188,6.36887 11.20761,14.0023 17.96928,16.37201 4.23518,-1.76668 8.10347,-4.0837 14.37542,-2.79522 5.61406,0.5029 9.02747,3.75662 10.38225,9.58361 2.60388,11.36945 -3.06132,11.71345 -6.38908,15.17405 0.037,9.48753 -3.51318,15.38783 -8.78497,19.56654 -1.67328,-6.90259 -4.18558,-12.12712 -7.98635,-14.77473 -13.4304,-10.55772 -26.90659,-1.42587 -40.33104,-8.38566 -17.65083,-10.24469 -27.78384,-26.45774 -37.93513,-31.14674 -9.67041,-1.16902 -16.20698,-3.38266 -22.76109,-5.59045 z"
id="path3810"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccccccccc" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 154.53576,133.53328 c 7.83618,0.93458 14.84203,2.00755 16.37201,3.99317 12.61186,7.01602 21.03384,26.20016 31.54606,30.34811 -3.00497,-5.51683 -3.48439,-10.71796 1.99658,-15.17405 7.22535,-3.03546 10.55836,-0.45702 14.77474,2.3959 6.02262,6.9248 11.74338,16.2645 18.76791,15.17406"
id="path3814"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccc" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 209.24222,167.47525 c 0.33375,-1.59661 0.12171,-3.55709 1.59727,-4.39249 0.98845,-1.35075 2.83593,-0.98344 4.39249,-1.19796 4.8086,3.66257 4.22462,5.91709 5.98975,8.78498"
id="path3816"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 219.22515,144.31485 11.18088,11.18088 13.57679,8.78498"
id="path3818"
inkscape:connector-curvature="0" />
<path
style="fill:#80b7c4;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 146.94873,99.59132 c -8.40512,-14.108233 -7.32446,-24.976745 -9.98293,-38.334452 2.28959,7.959558 5.31902,15.17927 10.78157,19.96586 -5.86678,-10.515353 -3.24744,-21.030706 -1.99659,-31.546059 1.01072,9.17552 3.11766,18.229239 13.57679,26.354936 5.88809,4.549002 13.0362,8.257998 15.97269,14.774736 6.35347,8.175649 4.50901,17.892639 8.78497,23.160399 -1.99075,-8.86593 -0.24555,-12.75061 0.79864,-17.569958 0.68276,7.697158 4.15451,11.675658 7.98634,15.174058 l 0.39932,-15.174058 c 8.25592,15.027878 19.79844,10.971398 23.95903,28.750838 l -6.78839,8.38566 -9.58361,-1.19795 -6.38908,4.39249 -8.38566,-2.79522 -9.58361,-13.17747 z"
id="path3820"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccccccccc" />
<path
style="fill:#333333;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 163.10163,110.79268 c -0.23573,2.97072 13.46423,4.4681 19.60954,10.89419 2.70861,3.95943 5.16753,8.73304 8.71536,13.07303 -5.87363,0.0346 -11.0482,1.74698 -18.05324,-0.93378 -7.43617,-4.47099 -6.44315,-11.29641 -9.33788,-18.98702 z"
id="path3822"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccc" />
<path
style="fill:#ffcd71;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 169.40234,116.31052 c 0.19359,4.0262 -0.47444,12.71073 6.79118,16.41203 3.5528,1.27665 7.10559,1.14632 10.65839,0.94322 -2.15752,-3.14407 -3.90555,-6.28813 -5.565,-9.4322 -2.11396,-2.77299 -6.25107,-5.40146 -11.88457,-7.92305 z"
id="path3824"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#009d80;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 181.19259,123.29035 c -3.74814,1.60851 -4.45804,5.32041 -4.99907,9.14923 2.28898,-1.15726 4.81391,-2.06949 5.65932,-8.11169 z"
id="path3826"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:#de4000;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 137.41517,138.64674 15.74549,16.46119 -4.77136,20.03971 c -5.06394,19.66324 4.09464,25.78123 11.9284,33.16095 10.22958,3.59957 20.40736,7.66534 31.25241,5.72563 -6.95999,-6.56919 -15.51616,-8.88191 -20.75542,-20.03971 -6.49636,-7.22625 -3.74543,-16.18636 -0.23857,-25.28821 4.60728,-11.28719 1.0716,-14.4314 -2.38568,-17.65403 -7.16108,-6.52548 -8.39426,-6.27621 -11.45126,-8.11131 -6.42685,-0.0251 -14.02518,-3.48676 -19.32401,-4.29422 z"
id="path3828"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccc" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 153.87636,142.94096 c 3.90631,-1.87096 9.69299,16.23198 6.67991,19.80114 -5.63668,6.67696 -2.80544,19.95511 -0.23857,16.22262"
id="path3830"
inkscape:connector-curvature="0"
sodipodi:nodetypes="csc" />
<path
style="fill:#ececec;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 147.43503,184.21322 c 5.98074,4.73827 16.11122,1.17708 25.76534,-1.43141 -7.03019,7.0566 -13.84727,16.88359 -21.47112,16.22263 l -4.77136,-7.87275 z"
id="path3832"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#f2f2f2;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 173.67751,213.31852 8.11131,1.43141 c 6.06788,-12.85113 5.3157,-19.63998 5.9642,-27.67389 -3.126,10.99543 -11.45068,18.09185 -22.18683,23.37966 l 7.87275,4.5328 z"
id="path3834"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccc" />
<path
style="fill:#ff6c00;fill-opacity:1;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 134.31379,139.12387 5.48706,0.47714 13.12124,8.58844 -1.90854,21.70969 -3.10139,26.71962 20.51685,15.02978 24.09537,2.14712 c -7.59703,15.51076 -21.44479,13.73677 -24.33394,26.24248 1.27084,-6.44195 0.86471,-13.55469 5.00993,-18.84688 -8.47582,5.06966 -15.09323,4.5641 -22.42539,6.20277 4.22385,-2.68547 8.10205,-6.06224 11.68983,-10.01985 -5.80515,-0.60962 -11.61031,-2.01583 -17.41546,-4.5328 -8.64382,-4.18289 -9.62981,-5.93913 -11.9284,-8.11131 7.01942,0.86252 9.473,-1.31884 12.88267,-2.86281 -26.93647,-7.55398 -26.92199,-14.26574 -35.0695,-21.23256 l 20.51685,7.39561 c 4.4984,0.68777 6.63439,-1.32438 6.6799,-5.72563 -16.33876,-9.8522 -21.88964,-21.17548 -24.33393,-32.92239 0.59372,8.94131 30.81833,14.94779 28.38959,14.79122 -3.07926,-0.19851 -18.61897,-12.87576 -17.1769,-16.22262 4.13518,3.60847 8.27036,6.74347 12.40554,5.9642 4.34471,1.92284 4.85595,-1.2656 5.48706,-4.29423 -6.58133,-6.79299 -11.82628,-7.57225 -17.17689,-8.82701 z"
id="path3836"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccccccccccsccccc" />
<path
style="fill:#f2f2f2;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 203.49851,177.05618 c -1.12284,5.42482 -3.73922,9.99619 -9.30416,12.88267 6.07286,-0.34494 18.50532,-3.68719 24.09537,-12.88267 z"
id="path3838"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:#ececec;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 230.21812,178.96473 c 3.65804,1.99083 7.31609,3.07314 10.97413,9.30415 -6.98529,5.73834 -13.78235,11.6649 -26.95818,11.2127 8.7089,-1.5798 12.95652,-10.09936 15.98405,-20.51685 z"
id="path3840"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:#999999;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 186.79875,168.22917 c -1.59045,3.42787 -3.18091,5.13539 -4.77136,7.3956 4.18355,-0.76297 7.96823,-1.79187 10.25842,-3.81708 z"
id="path3842"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:#ca0000;fill-opacity:1;stroke:#130e13;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 155.78491,120.51557 c 0,0 -0.47714,-10.73556 -2.14712,-12.64411 -1.66997,-1.90854 -21.94825,-15.506918 -21.94825,-15.506918 -9.7295,-4.220532 -14.38334,-6.731213 -20.51685,-15.745488 2.90484,10.265925 7.92673,20.108438 17.1769,29.105296 -5.32802,-3.09071 -10.65604,-6.298919 -15.98406,-10.019856 2.26988,6.838946 5.33355,13.677896 15.74549,20.516846 10.6125,6.05766 18.85054,4.20042 27.67389,4.29423 z"
id="path3846"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cscccccc" />
<path
style="fill:#000000;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="M 139.45292,84.438938 125.20773,71.693241 C 119.15983,58.804456 109.97182,43.346495 95.217856,23.334566 86.780901,17.513373 80.270569,10.150883 68.226968,7.2150074 60.058839,6.6898637 52.035767,7.0350652 43.110447,1.9667792 34.99844,-5.3656363 30.6958,-11.065466 28.115509,-16.027146 34.01411,-10.45225 40.197961,-4.0216063 45.359687,-0.65733498 52.482283,2.4482615 59.604878,2.589719 66.727474,3.466273 c 14.767928,1.2266725 22.683537,9.305664 32.239116,15.744685 16.18998,15.07105 25.9637,31.746167 35.98785,48.358675 -1.05571,-9.882878 -3.6074,-20.064949 -8.62209,-30.739623 -6.0246,-9.94338 -14.00687,-17.755415 -20.7603,-26.241142 C 95.75565,5.1501111 90.473401,1.2722747 75.724437,-6.2804367 70.112554,-8.4271577 66.785373,-15.143282 62.603866,-20.150754 l 13.120571,10.121583 c 12.287926,4.1420139 32.155153,15.4397069 38.237093,21.367786 12.3045,10.369959 16.369,17.443923 20.99291,24.741648 l 8.99696,25.116521 0.37488,16.494432 c 0,0 -7.87234,-18.743673 -7.87234,-17.244179 0,1.499494 2.99898,23.991901 2.99898,23.991901 z"
id="path3848"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccccccccsc" />
<path
style="fill:#1a1a1a;stroke:#000000;stroke-width:1.20000005;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:2"
d="m 99.012729,134.48743 11.893421,6.24404 c -25.631941,9.87193 -51.204911,20.45152 -77.604572,21.11082 -8.62273,0.0124 -17.24546,-2.90897 -25.86819,-6.83871 L -8.3253946,139.83947 9.5147367,151.13822 c 7.9289473,3.73642 15.8578943,4.42414 23.7868413,5.94671 16.056118,-2.54274 32.112237,-4.68742 48.168355,-13.3801 z"
id="path3850"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

+87
View File
@@ -0,0 +1,87 @@
use std::fs;
use std::path::PathBuf;
use anyhow::Result;
use gpui::{
App, Application, AssetSource, Bounds, Context, SharedString, Window, WindowBounds,
WindowOptions, div, prelude::*, px, rgb, size, svg,
};
struct Assets {
base: PathBuf,
}
impl AssetSource for Assets {
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
fs::read(self.base.join(path))
.map(|data| Some(std::borrow::Cow::Owned(data)))
.map_err(|err| err.into())
}
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
fs::read_dir(self.base.join(path))
.map(|entries| {
entries
.filter_map(|entry| {
entry
.ok()
.and_then(|entry| entry.file_name().into_string().ok())
.map(SharedString::from)
})
.collect()
})
.map_err(|err| err.into())
}
}
struct SvgExample;
impl Render for SvgExample {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_row()
.size_full()
.justify_center()
.items_center()
.gap_8()
.bg(rgb(0xffffff))
.child(
svg()
.path("svg/dragon.svg")
.size_8()
.text_color(rgb(0xff0000)),
)
.child(
svg()
.path("svg/dragon.svg")
.size_8()
.text_color(rgb(0x00ff00)),
)
.child(
svg()
.path("svg/dragon.svg")
.size_8()
.text_color(rgb(0x0000ff)),
)
}
}
fn main() {
Application::new()
.with_assets(Assets {
base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"),
})
.run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| cx.new(|_| SvgExample),
)
.unwrap();
cx.activate(true);
});
}
+155
View File
@@ -0,0 +1,155 @@
use gpui::{
App, Application, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString,
Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size,
};
actions!(example, [Tab, TabPrev]);
struct Example {
focus_handle: FocusHandle,
items: Vec<FocusHandle>,
message: SharedString,
}
impl Example {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let items = vec![
cx.focus_handle().tab_index(1).tab_stop(true),
cx.focus_handle().tab_index(2).tab_stop(true),
cx.focus_handle().tab_index(3).tab_stop(true),
cx.focus_handle(),
cx.focus_handle().tab_index(2).tab_stop(true),
];
let focus_handle = cx.focus_handle();
window.focus(&focus_handle);
Self {
focus_handle,
items,
message: SharedString::from("Press `Tab`, `Shift-Tab` to switch focus."),
}
}
fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context<Self>) {
window.focus_next();
self.message = SharedString::from("You have pressed `Tab`.");
}
fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context<Self>) {
window.focus_prev();
self.message = SharedString::from("You have pressed `Shift-Tab`.");
}
}
impl Render for Example {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn tab_stop_style<T: Styled>(this: T) -> T {
this.border_3().border_color(gpui::blue())
}
fn button(id: impl Into<ElementId>) -> Stateful<Div> {
div()
.id(id)
.h_10()
.flex_1()
.flex()
.justify_center()
.items_center()
.border_1()
.border_color(gpui::black())
.bg(gpui::black())
.text_color(gpui::white())
.focus(tab_stop_style)
.shadow_sm()
}
div()
.id("app")
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::on_tab))
.on_action(cx.listener(Self::on_tab_prev))
.size_full()
.flex()
.flex_col()
.p_4()
.gap_3()
.bg(gpui::white())
.text_color(gpui::black())
.child(self.message.clone())
.children(
self.items
.clone()
.into_iter()
.enumerate()
.map(|(ix, item_handle)| {
div()
.id(("item", ix))
.track_focus(&item_handle)
.h_10()
.w_full()
.flex()
.justify_center()
.items_center()
.border_1()
.border_color(gpui::black())
.when(
item_handle.tab_stop && item_handle.is_focused(window),
tab_stop_style,
)
.map(|this| match item_handle.tab_stop {
true => this
.hover(|this| this.bg(gpui::black().opacity(0.1)))
.child(format!("tab_index: {}", item_handle.tab_index)),
false => this.opacity(0.4).child("tab_stop: false"),
})
}),
)
.child(
div()
.flex()
.flex_row()
.gap_3()
.items_center()
.child(
button("el1")
.tab_index(4)
.child("Button 1")
.on_click(cx.listener(|this, _, _, cx| {
this.message = "You have clicked Button 1.".into();
cx.notify();
})),
)
.child(
button("el2")
.tab_index(5)
.child("Button 2")
.on_click(cx.listener(|this, _, _, cx| {
this.message = "You have clicked Button 2.".into();
cx.notify();
})),
),
)
}
}
fn main() {
Application::new().run(|cx: &mut App| {
cx.bind_keys([
KeyBinding::new("tab", Tab, None),
KeyBinding::new("shift-tab", TabPrev, None),
]);
let bounds = Bounds::centered(None, size(px(800.), px(600.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| cx.new(|cx| Example::new(window, cx)),
)
.unwrap();
cx.activate(true);
});
}
+333
View File
@@ -0,0 +1,333 @@
use std::{
ops::{Deref, DerefMut},
sync::Arc,
};
use gpui::{
AbsoluteLength, App, Application, Context, DefiniteLength, ElementId, Global, Hsla, Menu,
SharedString, TextStyle, TitlebarOptions, Window, WindowBounds, WindowOptions, bounds,
colors::DefaultColors, div, point, prelude::*, px, relative, rgb, size,
};
use std::iter;
#[derive(Clone, Debug)]
pub struct TextContext {
font_size: f32,
line_height: f32,
type_scale: f32,
}
impl Default for TextContext {
fn default() -> Self {
TextContext {
font_size: 16.0,
line_height: 1.3,
type_scale: 1.33,
}
}
}
impl TextContext {
pub fn get_global(cx: &App) -> &Arc<TextContext> {
&cx.global::<GlobalTextContext>().0
}
}
#[derive(Clone, Debug)]
pub struct GlobalTextContext(pub Arc<TextContext>);
impl Deref for GlobalTextContext {
type Target = Arc<TextContext>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for GlobalTextContext {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl Global for GlobalTextContext {}
pub trait ActiveTextContext {
fn text_context(&self) -> &Arc<TextContext>;
}
impl ActiveTextContext for App {
fn text_context(&self) -> &Arc<TextContext> {
&self.global::<GlobalTextContext>().0
}
}
#[derive(Clone, PartialEq)]
pub struct SpecimenTheme {
pub bg: Hsla,
pub fg: Hsla,
}
impl Default for SpecimenTheme {
fn default() -> Self {
Self {
bg: gpui::white(),
fg: gpui::black(),
}
}
}
impl SpecimenTheme {
pub fn invert(&self) -> Self {
Self {
bg: self.fg,
fg: self.bg,
}
}
}
#[derive(Debug, Clone, PartialEq, IntoElement)]
struct Specimen {
id: ElementId,
scale: f32,
text_style: Option<TextStyle>,
string: SharedString,
invert: bool,
}
impl Specimen {
pub fn new(id: usize) -> Self {
let string = SharedString::new_static("The quick brown fox jumps over the lazy dog");
let id_string = format!("specimen-{}", id);
let id = ElementId::Name(id_string.into());
Self {
id,
scale: 1.0,
text_style: None,
string,
invert: false,
}
}
pub fn invert(mut self) -> Self {
self.invert = !self.invert;
self
}
pub fn scale(mut self, scale: f32) -> Self {
self.scale = scale;
self
}
}
impl RenderOnce for Specimen {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let rem_size = window.rem_size();
let scale = self.scale;
let global_style = cx.text_context();
let style_override = self.text_style;
let mut font_size = global_style.font_size;
let mut line_height = global_style.line_height;
if let Some(style_override) = style_override {
font_size = style_override.font_size.to_pixels(rem_size).into();
line_height = match style_override.line_height {
DefiniteLength::Absolute(absolute_len) => match absolute_len {
AbsoluteLength::Rems(absolute_len) => absolute_len.to_pixels(rem_size).into(),
AbsoluteLength::Pixels(absolute_len) => absolute_len.into(),
},
DefiniteLength::Fraction(value) => value,
};
}
let mut theme = SpecimenTheme::default();
if self.invert {
theme = theme.invert();
}
div()
.id(self.id)
.bg(theme.bg)
.text_color(theme.fg)
.text_size(px(font_size * scale))
.line_height(relative(line_height))
.p(px(10.0))
.child(self.string)
}
}
#[derive(Debug, Clone, PartialEq, IntoElement)]
struct CharacterGrid {
scale: f32,
invert: bool,
text_style: Option<TextStyle>,
}
impl CharacterGrid {
pub fn new() -> Self {
Self {
scale: 1.0,
invert: false,
text_style: None,
}
}
pub fn scale(mut self, scale: f32) -> Self {
self.scale = scale;
self
}
}
impl RenderOnce for CharacterGrid {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let mut theme = SpecimenTheme::default();
if self.invert {
theme = theme.invert();
}
let characters = vec![
"1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "A", "B", "C", "D", "E", "F", "G",
"H", "I", "J", "K", "L", "M", "N", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y",
"Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "p", "q",
"r", "s", "t", "u", "v", "w", "x", "y", "z", "ẞ", "ſ", "ß", "ð", "Þ", "þ", "α", "β",
"Γ", "γ", "Δ", "δ", "η", "θ", "ι", "κ", "Λ", "λ", "μ", "ν", "ξ", "π", "τ", "υ", "φ",
"χ", "ψ", "∂", "а", "в", "Ж", "ж", "З", "з", "К", "к", "л", "м", "Н", "н", "Р", "р",
"У", "у", "ф", "ч", "ь", "ы", "Э", "э", "Я", "я", "ij", "öẋ", ".,", "⣝⣑", "~", "*",
"_", "^", "`", "'", "(", "{", "«", "#", "&", "@", "$", "¢", "%", "|", "?", "", "µ",
"❮", "<=", "!=", "==", "--", "++", "=>", "->", "🏀", "🎊", "😍", "❤️", "👍", "👎",
];
let columns = 11;
let rows = characters.len().div_ceil(columns);
let grid_rows = (0..rows).map(|row_idx| {
let start_idx = row_idx * columns;
let end_idx = (start_idx + columns).min(characters.len());
div()
.w_full()
.flex()
.flex_row()
.children((start_idx..end_idx).map(|i| {
div()
.text_center()
.size(px(62.))
.bg(theme.bg)
.text_color(theme.fg)
.text_size(px(24.0))
.line_height(relative(1.0))
.child(characters[i])
}))
.when(end_idx - start_idx < columns, |d| {
d.children(
iter::repeat_with(|| div().flex_1()).take(columns - (end_idx - start_idx)),
)
})
});
div().p_4().gap_2().flex().flex_col().children(grid_rows)
}
}
struct TextExample {
next_id: usize,
}
impl TextExample {
fn next_id(&mut self) -> usize {
self.next_id += 1;
self.next_id
}
}
impl Render for TextExample {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let tcx = cx.text_context();
let colors = cx.default_colors().clone();
let type_scale = tcx.type_scale;
let step_down_2 = 1.0 / (type_scale * type_scale);
let step_down_1 = 1.0 / type_scale;
let base = 1.0;
let step_up_1 = base * type_scale;
let step_up_2 = step_up_1 * type_scale;
let step_up_3 = step_up_2 * type_scale;
let step_up_4 = step_up_3 * type_scale;
let step_up_5 = step_up_4 * type_scale;
let step_up_6 = step_up_5 * type_scale;
div()
.size_full()
.child(
div()
.id("text-example")
.overflow_y_scroll()
.overflow_x_hidden()
.bg(rgb(0xffffff))
.size_full()
.child(div().child(CharacterGrid::new().scale(base)))
.child(
div()
.child(Specimen::new(self.next_id()).scale(step_down_2))
.child(Specimen::new(self.next_id()).scale(step_down_2).invert())
.child(Specimen::new(self.next_id()).scale(step_down_1))
.child(Specimen::new(self.next_id()).scale(step_down_1).invert())
.child(Specimen::new(self.next_id()).scale(base))
.child(Specimen::new(self.next_id()).scale(base).invert())
.child(Specimen::new(self.next_id()).scale(step_up_1))
.child(Specimen::new(self.next_id()).scale(step_up_1).invert())
.child(Specimen::new(self.next_id()).scale(step_up_2))
.child(Specimen::new(self.next_id()).scale(step_up_2).invert())
.child(Specimen::new(self.next_id()).scale(step_up_3))
.child(Specimen::new(self.next_id()).scale(step_up_3).invert())
.child(Specimen::new(self.next_id()).scale(step_up_4))
.child(Specimen::new(self.next_id()).scale(step_up_4).invert())
.child(Specimen::new(self.next_id()).scale(step_up_5))
.child(Specimen::new(self.next_id()).scale(step_up_5).invert())
.child(Specimen::new(self.next_id()).scale(step_up_6))
.child(Specimen::new(self.next_id()).scale(step_up_6).invert()),
),
)
.child(div().w(px(240.)).h_full().bg(colors.container))
}
}
fn main() {
Application::new().run(|cx: &mut App| {
cx.set_menus(vec![Menu {
name: "GPUI Typography".into(),
items: vec![],
}]);
cx.init_colors();
cx.set_global(GlobalTextContext(Arc::new(TextContext::default())));
let window = cx
.open_window(
WindowOptions {
titlebar: Some(TitlebarOptions {
title: Some("GPUI Typography".into()),
..Default::default()
}),
window_bounds: Some(WindowBounds::Windowed(bounds(
point(px(0.0), px(0.0)),
size(px(920.), px(720.)),
))),
..Default::default()
},
|_window, cx| cx.new(|_cx| TextExample { next_id: 0 }),
)
.unwrap();
window
.update(cx, |_view, _window, cx| {
cx.activate(true);
})
.unwrap();
});
}
+96
View File
@@ -0,0 +1,96 @@
use gpui::{
App, Application, Bounds, Context, FontStyle, FontWeight, StyledText, Window, WindowBounds,
WindowOptions, div, prelude::*, px, size,
};
struct HelloWorld {}
impl Render for HelloWorld {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.bg(gpui::white())
.flex()
.flex_col()
.gap_2()
.p_4()
.size_full()
.child(div().child("Text left"))
.child(div().text_center().child("Text center"))
.child(div().text_right().child("Text right"))
.child(div().text_decoration_1().child("Text left (underline)"))
.child(
div()
.text_center()
.text_decoration_1()
.child("Text center (underline)"),
)
.child(
div()
.text_right()
.text_decoration_1()
.child("Text right (underline)"),
)
.child(div().line_through().child("Text left (line_through)"))
.child(
div()
.text_center()
.line_through()
.child("Text center (line_through)"),
)
.child(
div()
.text_right()
.line_through()
.child("Text right (line_through)"),
)
.child(
div()
.flex()
.gap_2()
.justify_between()
.child(
div()
.w(px(400.))
.border_1()
.border_color(gpui::blue())
.p_1()
.whitespace_nowrap()
.overflow_hidden()
.text_center()
.child("A long non-wrapping text align center"),
)
.child(
div()
.w_32()
.border_1()
.border_color(gpui::blue())
.p_1()
.whitespace_nowrap()
.overflow_hidden()
.text_right()
.child("100%"),
),
)
.child(div().flex().gap_2().justify_between().child(
StyledText::new("ABCD").with_highlights([
(0..1, FontWeight::EXTRA_BOLD.into()),
(2..3, FontStyle::Italic.into()),
]),
))
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| cx.new(|_| HelloWorld {}),
)
.unwrap();
cx.activate(true);
});
}
+123
View File
@@ -0,0 +1,123 @@
use gpui::{
App, Application, Bounds, Context, TextOverflow, Window, WindowBounds, WindowOptions, div,
prelude::*, px, size,
};
struct HelloWorld {}
impl Render for HelloWorld {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let text = "The longest word 你好世界这段是中文,こんにちはこの段落は日本語です in any of the major \
English language dictionaries is pneumonoultramicroscopicsilicovolcanoconiosis, a word that \
refers to a lung disease contracted from the inhalation of very fine silica particles, \
a url https://github.com/zed-industries/zed/pull/35724?query=foo&bar=2, \
specifically from a volcano; medically, it is the same as silicosis.";
div()
.id("page")
.size_full()
.flex()
.flex_col()
.p_2()
.gap_2()
.bg(gpui::white())
.child(
div()
.flex()
.flex_row()
.flex_shrink_0()
.gap_2()
.child(
div()
.flex()
.border_1()
.border_color(gpui::red())
.text_ellipsis()
.child("longer text in flex 1"),
)
.child(
div()
.flex()
.border_1()
.border_color(gpui::red())
.text_ellipsis()
.child("short flex"),
)
.child(
div()
.overflow_hidden()
.border_1()
.border_color(gpui::red())
.text_ellipsis()
.w_full()
.child("A short text in normal div"),
),
)
.child(
div()
.flex_shrink_0()
.text_xl()
.truncate()
.border_1()
.border_color(gpui::blue())
.child("ELLIPSIS: ".to_owned() + text),
)
.child(
div()
.flex_shrink_0()
.text_xl()
.overflow_hidden()
.text_ellipsis()
.line_clamp(2)
.border_1()
.border_color(gpui::blue())
.child("ELLIPSIS 2 lines: ".to_owned() + text),
)
.child(
div()
.flex_shrink_0()
.text_xl()
.overflow_hidden()
.text_overflow(TextOverflow::Truncate("".into()))
.border_1()
.border_color(gpui::green())
.child("TRUNCATE: ".to_owned() + text),
)
.child(
div()
.flex_shrink_0()
.text_xl()
.overflow_hidden()
.text_overflow(TextOverflow::Truncate("".into()))
.line_clamp(3)
.border_1()
.border_color(gpui::green())
.child("TRUNCATE 3 lines: ".to_owned() + text),
)
.child(
div()
.flex_shrink_0()
.text_xl()
.whitespace_nowrap()
.overflow_hidden()
.border_1()
.border_color(gpui::black())
.child("NOWRAP: ".to_owned() + text),
)
.child(div().text_xl().w_full().child(text))
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| cx.new(|_| HelloWorld {}),
)
.unwrap();
cx.activate(true);
});
}
+46
View File
@@ -0,0 +1,46 @@
//! Renders a div with deep children hierarchy. This example is useful to exemplify that Zed can
//! handle deep hierarchies (even though it cannot just yet!).
use std::sync::LazyLock;
use gpui::{
App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px,
size,
};
struct Tree {}
static DEPTH: LazyLock<u64> = LazyLock::new(|| {
std::env::var("GPUI_TREE_DEPTH")
.ok()
.and_then(|depth| depth.parse().ok())
.unwrap_or_else(|| 50)
});
impl Render for Tree {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let mut depth = *DEPTH;
static COLORS: [gpui::Hsla; 4] = [gpui::red(), gpui::blue(), gpui::green(), gpui::yellow()];
let mut colors = COLORS.iter().cycle().copied();
let mut next_div = || div().p_0p5().bg(colors.next().unwrap());
let mut innermost_node = next_div();
while depth > 0 {
innermost_node = next_div().child(innermost_node);
depth -= 1;
}
innermost_node
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| cx.new(|_| Tree {}),
)
.unwrap();
});
}
+50
View File
@@ -0,0 +1,50 @@
use gpui::{
App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px,
rgb, size, uniform_list,
};
struct UniformListExample {}
impl Render for UniformListExample {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().bg(rgb(0xffffff)).child(
uniform_list(
"entries",
50,
cx.processor(|_this, range, _window, _cx| {
let mut items = Vec::new();
for ix in range {
let item = ix + 1;
items.push(
div()
.id(ix)
.px_2()
.cursor_pointer()
.on_click(move |_event, _window, _cx| {
println!("clicked Item {item:?}");
})
.child(format!("Item {item}")),
);
}
items
}),
)
.h_full(),
)
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| cx.new(|_| UniformListExample {}),
)
.unwrap();
});
}
+269
View File
@@ -0,0 +1,269 @@
use gpui::{
App, Application, Bounds, Context, KeyBinding, PromptButton, PromptLevel, SharedString, Timer,
Window, WindowBounds, WindowKind, WindowOptions, actions, div, prelude::*, px, rgb, size,
};
struct SubWindow {
custom_titlebar: bool,
}
fn button(text: &str, on_click: impl Fn(&mut Window, &mut App) + 'static) -> impl IntoElement {
div()
.id(SharedString::from(text.to_string()))
.flex_none()
.px_2()
.bg(rgb(0xf7f7f7))
.active(|this| this.opacity(0.85))
.border_1()
.border_color(rgb(0xe0e0e0))
.rounded_sm()
.cursor_pointer()
.child(text.to_string())
.on_click(move |_, window, cx| on_click(window, cx))
}
impl Render for SubWindow {
fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.bg(rgb(0xffffff))
.size_full()
.gap_2()
.when(self.custom_titlebar, |cx| {
cx.child(
div()
.flex()
.h(px(32.))
.px_4()
.bg(gpui::blue())
.text_color(gpui::white())
.w_full()
.child(
div()
.flex()
.items_center()
.justify_center()
.size_full()
.child("Custom Titlebar"),
),
)
})
.child(
div()
.p_8()
.gap_2()
.child("SubWindow")
.child(button("Close", |window, _| {
window.remove_window();
})),
)
}
}
struct WindowDemo {}
impl Render for WindowDemo {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let window_bounds =
WindowBounds::Windowed(Bounds::centered(None, size(px(300.0), px(300.0)), cx));
div()
.p_4()
.flex()
.flex_wrap()
.bg(rgb(0xffffff))
.size_full()
.justify_center()
.content_center()
.gap_2()
.child(button("Normal", move |_, cx| {
cx.open_window(
WindowOptions {
window_bounds: Some(window_bounds),
..Default::default()
},
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
})
},
)
.unwrap();
}))
.child(button("Popup", move |_, cx| {
cx.open_window(
WindowOptions {
window_bounds: Some(window_bounds),
kind: WindowKind::PopUp,
..Default::default()
},
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
})
},
)
.unwrap();
}))
.child(button("Custom Titlebar", move |_, cx| {
cx.open_window(
WindowOptions {
titlebar: None,
window_bounds: Some(window_bounds),
..Default::default()
},
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: true,
})
},
)
.unwrap();
}))
.child(button("Invisible", move |_, cx| {
cx.open_window(
WindowOptions {
show: false,
window_bounds: Some(window_bounds),
..Default::default()
},
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
})
},
)
.unwrap();
}))
.child(button("Unmovable", move |_, cx| {
cx.open_window(
WindowOptions {
is_movable: false,
titlebar: None,
window_bounds: Some(window_bounds),
..Default::default()
},
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
})
},
)
.unwrap();
}))
.child(button("Unresizable", move |_, cx| {
cx.open_window(
WindowOptions {
is_resizable: false,
window_bounds: Some(window_bounds),
..Default::default()
},
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
})
},
)
.unwrap();
}))
.child(button("Unminimizable", move |_, cx| {
cx.open_window(
WindowOptions {
is_minimizable: false,
window_bounds: Some(window_bounds),
..Default::default()
},
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
})
},
)
.unwrap();
}))
.child(button("Hide Application", |window, cx| {
cx.hide();
// Restore the application after 3 seconds
window
.spawn(cx, async move |cx| {
Timer::after(std::time::Duration::from_secs(3)).await;
cx.update(|_, cx| {
cx.activate(false);
})
})
.detach();
}))
.child(button("Resize", |window, _| {
let content_size = window.bounds().size;
window.resize(size(content_size.height, content_size.width));
}))
.child(button("Prompt", |window, cx| {
let answer = window.prompt(
PromptLevel::Info,
"Are you sure?",
None,
&["Ok", "Cancel"],
cx,
);
cx.spawn(async move |_| {
if answer.await.unwrap() == 0 {
println!("You have clicked Ok");
} else {
println!("You have clicked Cancel");
}
})
.detach();
}))
.child(button("Prompt (non-English)", |window, cx| {
let answer = window.prompt(
PromptLevel::Info,
"Are you sure?",
None,
&[PromptButton::ok("确定"), PromptButton::cancel("取消")],
cx,
);
cx.spawn(async move |_| {
if answer.await.unwrap() == 0 {
println!("You have clicked Ok");
} else {
println!("You have clicked Cancel");
}
})
.detach();
}))
}
}
actions!(window, [Quit]);
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| {
cx.new(|cx| {
cx.observe_window_bounds(window, move |_, window, _| {
println!("Window bounds changed: {:?}", window.bounds());
})
.detach();
WindowDemo {}
})
},
)
.unwrap();
cx.activate(true);
cx.on_action(|_: &Quit, cx| cx.quit());
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
});
}
+219
View File
@@ -0,0 +1,219 @@
use gpui::{
App, Application, Bounds, Context, DisplayId, Hsla, Pixels, SharedString, Size, Window,
WindowBackgroundAppearance, WindowBounds, WindowKind, WindowOptions, div, point, prelude::*,
px, rgb,
};
struct WindowContent {
text: SharedString,
bounds: Bounds<Pixels>,
bg: Hsla,
}
impl Render for WindowContent {
fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let window_bounds = window.bounds();
div()
.flex()
.flex_col()
.bg(self.bg)
.size_full()
.items_center()
.text_color(rgb(0xffffff))
.child(self.text.clone())
.child(
div()
.flex()
.flex_col()
.text_sm()
.items_center()
.size_full()
.child(format!(
"origin: {}, {} size: {}, {}",
self.bounds.origin.x,
self.bounds.origin.y,
self.bounds.size.width,
self.bounds.size.height
))
.child(format!(
"cx.bounds() origin: {}, {} size {}, {}",
window_bounds.origin.x,
window_bounds.origin.y,
window_bounds.size.width,
window_bounds.size.height
)),
)
}
}
fn build_window_options(display_id: DisplayId, bounds: Bounds<Pixels>) -> WindowOptions {
WindowOptions {
// Set the bounds of the window in screen coordinates
window_bounds: Some(WindowBounds::Windowed(bounds)),
// Specify the display_id to ensure the window is created on the correct screen
display_id: Some(display_id),
titlebar: None,
window_background: WindowBackgroundAppearance::Transparent,
focus: false,
show: true,
kind: WindowKind::PopUp,
is_movable: false,
app_id: None,
window_min_size: None,
window_decorations: None,
tabbing_identifier: None,
..Default::default()
}
}
fn main() {
Application::new().run(|cx: &mut App| {
// Create several new windows, positioned in the top right corner of each screen
let size = Size {
width: px(350.),
height: px(75.),
};
let margin_offset = px(150.);
for screen in cx.displays() {
let bounds = Bounds {
origin: point(margin_offset, margin_offset),
size,
};
cx.open_window(build_window_options(screen.id(), bounds), |_, cx| {
cx.new(|_| WindowContent {
text: format!("Top Left {:?}", screen.id()).into(),
bg: gpui::red(),
bounds,
})
})
.unwrap();
let bounds = Bounds {
origin: screen.bounds().top_right()
- point(size.width + margin_offset, -margin_offset),
size,
};
cx.open_window(build_window_options(screen.id(), bounds), |_, cx| {
cx.new(|_| WindowContent {
text: format!("Top Right {:?}", screen.id()).into(),
bg: gpui::red(),
bounds,
})
})
.unwrap();
let bounds = Bounds {
origin: screen.bounds().bottom_left()
- point(-margin_offset, size.height + margin_offset),
size,
};
cx.open_window(build_window_options(screen.id(), bounds), |_, cx| {
cx.new(|_| WindowContent {
text: format!("Bottom Left {:?}", screen.id()).into(),
bg: gpui::blue(),
bounds,
})
})
.unwrap();
let bounds = Bounds {
origin: screen.bounds().bottom_right()
- point(size.width + margin_offset, size.height + margin_offset),
size,
};
cx.open_window(build_window_options(screen.id(), bounds), |_, cx| {
cx.new(|_| WindowContent {
text: format!("Bottom Right {:?}", screen.id()).into(),
bg: gpui::blue(),
bounds,
})
})
.unwrap();
let bounds = Bounds {
origin: point(screen.bounds().center().x - size.center().x, margin_offset),
size,
};
cx.open_window(build_window_options(screen.id(), bounds), |_, cx| {
cx.new(|_| WindowContent {
text: format!("Top Center {:?}", screen.id()).into(),
bg: gpui::black(),
bounds,
})
})
.unwrap();
let bounds = Bounds {
origin: point(margin_offset, screen.bounds().center().y - size.center().y),
size,
};
cx.open_window(build_window_options(screen.id(), bounds), |_, cx| {
cx.new(|_| WindowContent {
text: format!("Left Center {:?}", screen.id()).into(),
bg: gpui::black(),
bounds,
})
})
.unwrap();
let bounds = Bounds {
origin: point(
screen.bounds().center().x - size.center().x,
screen.bounds().center().y - size.center().y,
),
size,
};
cx.open_window(build_window_options(screen.id(), bounds), |_, cx| {
cx.new(|_| WindowContent {
text: format!("Center {:?}", screen.id()).into(),
bg: gpui::black(),
bounds,
})
})
.unwrap();
let bounds = Bounds {
origin: point(
screen.bounds().size.width - size.width - margin_offset,
screen.bounds().center().y - size.center().y,
),
size,
};
cx.open_window(build_window_options(screen.id(), bounds), |_, cx| {
cx.new(|_| WindowContent {
text: format!("Right Center {:?}", screen.id()).into(),
bg: gpui::black(),
bounds,
})
})
.unwrap();
let bounds = Bounds {
origin: point(
screen.bounds().center().x - size.center().x,
screen.bounds().size.height - size.height - margin_offset,
),
size,
};
cx.open_window(build_window_options(screen.id(), bounds), |_, cx| {
cx.new(|_| WindowContent {
text: format!("Bottom Center {:?}", screen.id()).into(),
bg: gpui::black(),
bounds,
})
})
.unwrap();
}
});
}
+227
View File
@@ -0,0 +1,227 @@
use gpui::{
App, Application, Bounds, Context, CursorStyle, Decorations, HitboxBehavior, Hsla, MouseButton,
Pixels, Point, ResizeEdge, Size, Window, WindowBackgroundAppearance, WindowBounds,
WindowDecorations, WindowOptions, black, canvas, div, green, point, prelude::*, px, rgb, size,
transparent_black, white,
};
struct WindowShadow {}
// Things to do:
// 1. We need a way of calculating which edge or corner the mouse is on,
// and then dispatch on that
// 2. We need to improve the shadow rendering significantly
// 3. We need to implement the techniques in here in Zed
impl Render for WindowShadow {
fn render(&mut self, window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let decorations = window.window_decorations();
let rounding = px(10.0);
let shadow_size = px(10.0);
let border_size = px(1.0);
let grey = rgb(0x808080);
window.set_client_inset(shadow_size);
div()
.id("window-backdrop")
.bg(transparent_black())
.map(|div| match decorations {
Decorations::Server => div,
Decorations::Client { tiling, .. } => div
.bg(gpui::transparent_black())
.child(
canvas(
|_bounds, window, _cx| {
window.insert_hitbox(
Bounds::new(
point(px(0.0), px(0.0)),
window.window_bounds().get_bounds().size,
),
HitboxBehavior::Normal,
)
},
move |_bounds, hitbox, window, _cx| {
let mouse = window.mouse_position();
let size = window.window_bounds().get_bounds().size;
let Some(edge) = resize_edge(mouse, shadow_size, size) else {
return;
};
window.set_cursor_style(
match edge {
ResizeEdge::Top | ResizeEdge::Bottom => {
CursorStyle::ResizeUpDown
}
ResizeEdge::Left | ResizeEdge::Right => {
CursorStyle::ResizeLeftRight
}
ResizeEdge::TopLeft | ResizeEdge::BottomRight => {
CursorStyle::ResizeUpLeftDownRight
}
ResizeEdge::TopRight | ResizeEdge::BottomLeft => {
CursorStyle::ResizeUpRightDownLeft
}
},
&hitbox,
);
},
)
.size_full()
.absolute(),
)
.when(!(tiling.top || tiling.right), |div| {
div.rounded_tr(rounding)
})
.when(!(tiling.top || tiling.left), |div| div.rounded_tl(rounding))
.when(!tiling.top, |div| div.pt(shadow_size))
.when(!tiling.bottom, |div| div.pb(shadow_size))
.when(!tiling.left, |div| div.pl(shadow_size))
.when(!tiling.right, |div| div.pr(shadow_size))
.on_mouse_move(|_e, window, _cx| window.refresh())
.on_mouse_down(MouseButton::Left, move |e, window, _cx| {
let size = window.window_bounds().get_bounds().size;
let pos = e.position;
match resize_edge(pos, shadow_size, size) {
Some(edge) => window.start_window_resize(edge),
None => window.start_window_move(),
};
}),
})
.size_full()
.child(
div()
.cursor(CursorStyle::Arrow)
.map(|div| match decorations {
Decorations::Server => div,
Decorations::Client { tiling } => div
.border_color(grey)
.when(!(tiling.top || tiling.right), |div| {
div.rounded_tr(rounding)
})
.when(!(tiling.top || tiling.left), |div| div.rounded_tl(rounding))
.when(!tiling.top, |div| div.border_t(border_size))
.when(!tiling.bottom, |div| div.border_b(border_size))
.when(!tiling.left, |div| div.border_l(border_size))
.when(!tiling.right, |div| div.border_r(border_size))
.when(!tiling.is_tiled(), |div| {
div.shadow(vec![gpui::BoxShadow {
color: Hsla {
h: 0.,
s: 0.,
l: 0.,
a: 0.4,
},
blur_radius: shadow_size / 2.,
spread_radius: px(0.),
offset: point(px(0.0), px(0.0)),
}])
}),
})
.on_mouse_move(|_e, _, cx| {
cx.stop_propagation();
})
.bg(gpui::rgb(0xCCCCFF))
.size_full()
.flex()
.flex_col()
.justify_around()
.child(
div().w_full().flex().flex_row().justify_around().child(
div()
.flex()
.bg(white())
.size(px(300.0))
.justify_center()
.items_center()
.shadow_lg()
.border_1()
.border_color(rgb(0x0000ff))
.text_xl()
.text_color(rgb(0xffffff))
.child(
div()
.id("hello")
.w(px(200.0))
.h(px(100.0))
.bg(green())
.shadow(vec![gpui::BoxShadow {
color: Hsla {
h: 0.,
s: 0.,
l: 0.,
a: 1.0,
},
blur_radius: px(20.0),
spread_radius: px(0.0),
offset: point(px(0.0), px(0.0)),
}])
.map(|div| match decorations {
Decorations::Server => div,
Decorations::Client { .. } => div
.on_mouse_down(
MouseButton::Left,
|_e, window, _| {
window.start_window_move();
},
)
.on_click(|e, window, _| {
if e.is_right_click() {
window.show_window_menu(e.position());
}
})
.text_color(black())
.child("this is the custom titlebar"),
}),
),
),
),
)
}
}
fn resize_edge(pos: Point<Pixels>, shadow_size: Pixels, size: Size<Pixels>) -> Option<ResizeEdge> {
let edge = if pos.y < shadow_size && pos.x < shadow_size {
ResizeEdge::TopLeft
} else if pos.y < shadow_size && pos.x > size.width - shadow_size {
ResizeEdge::TopRight
} else if pos.y < shadow_size {
ResizeEdge::Top
} else if pos.y > size.height - shadow_size && pos.x < shadow_size {
ResizeEdge::BottomLeft
} else if pos.y > size.height - shadow_size && pos.x > size.width - shadow_size {
ResizeEdge::BottomRight
} else if pos.y > size.height - shadow_size {
ResizeEdge::Bottom
} else if pos.x < shadow_size {
ResizeEdge::Left
} else if pos.x > size.width - shadow_size {
ResizeEdge::Right
} else {
return None;
};
Some(edge)
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(600.0), px(600.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
window_background: WindowBackgroundAppearance::Opaque,
window_decorations: Some(WindowDecorations::Client),
..Default::default()
},
|window, cx| {
cx.new(|cx| {
cx.observe_window_appearance(window, |_, window, _| {
window.refresh();
})
.detach();
WindowShadow {}
})
},
)
.unwrap();
});
}

Some files were not shown because too many files have changed in this diff Show More