From 063837446d2fc9390916ac1d8eb7669485d5aab9 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 11 Aug 2026 00:25:20 +0800 Subject: [PATCH] feat(app): link liboakengine.dylib via frozen C ABI; UI fixes - App no longer depends on the oakengine rlib: build.rs links the built liboakengine.dylib (+rpath, -export_dynamic, IOSurface) and src/oakui/ffi.rs declares the pure-C surface; RealEngine calls only the frozen oakengine_* C ABI - host_syms.rs provides the oakcore_*/fb_* host symbols the dylib imports via dynamic lookup - Fix Preferences dialog crash (spawn_modal reentrancy) with a regression test - Timeline toolbar and viewer transport render C++-era icons (16px grid, dark/light themes) with localized tooltips - i18n: complete en-US table, add untranslated-key detection test - New dialogs module (preferences, export, progress) --- CONTRIBUTING.md | 22 +- Cargo.lock | 4 +- Cargo.toml | 34 +- assets/icons/dark/arrow.png | Bin 0 -> 678 bytes assets/icons/dark/ff.png | Bin 0 -> 553 bytes assets/icons/dark/magnet.png | Bin 0 -> 723 bytes assets/icons/dark/next.png | Bin 0 -> 563 bytes assets/icons/dark/pause.png | Bin 0 -> 320 bytes assets/icons/dark/play.png | Bin 0 -> 514 bytes assets/icons/dark/prev.png | Bin 0 -> 562 bytes assets/icons/dark/razor.png | Bin 0 -> 481 bytes assets/icons/dark/rew.png | Bin 0 -> 537 bytes assets/icons/dark/ripple.png | Bin 0 -> 613 bytes assets/icons/dark/rolling.png | Bin 0 -> 694 bytes assets/icons/dark/slide.png | Bin 0 -> 594 bytes assets/icons/dark/slip.png | Bin 0 -> 615 bytes assets/icons/dark/track-tool.png | Bin 0 -> 375 bytes assets/icons/dark/zoomin.png | Bin 0 -> 1005 bytes assets/icons/dark/zoomout.png | Bin 0 -> 980 bytes assets/icons/light/arrow.png | Bin 0 -> 838 bytes assets/icons/light/ff.png | Bin 0 -> 633 bytes assets/icons/light/magnet.png | Bin 0 -> 942 bytes assets/icons/light/next.png | Bin 0 -> 641 bytes assets/icons/light/pause.png | Bin 0 -> 326 bytes assets/icons/light/play.png | Bin 0 -> 579 bytes assets/icons/light/prev.png | Bin 0 -> 649 bytes assets/icons/light/razor.png | Bin 0 -> 598 bytes assets/icons/light/rew.png | Bin 0 -> 660 bytes assets/icons/light/ripple.png | Bin 0 -> 739 bytes assets/icons/light/rolling.png | Bin 0 -> 798 bytes assets/icons/light/slide.png | Bin 0 -> 674 bytes assets/icons/light/slip.png | Bin 0 -> 690 bytes assets/icons/light/track-tool.png | Bin 0 -> 376 bytes assets/icons/light/zoomin.png | Bin 0 -> 1266 bytes assets/icons/light/zoomout.png | Bin 0 -> 1216 bytes build.rs | 116 ++ docs/screenshot-window.png | Bin 406654 -> 395274 bytes examples/screenshot.rs | 54 +- src/app.rs | 780 +++++++++++-- src/dialogs.rs | 310 +++++ src/i18n.rs | 316 ++--- src/lib.rs | 28 +- src/oakui/engine.rs | 158 ++- src/oakui/ffi.rs | 494 ++++++++ src/oakui/frames.rs | 96 ++ src/oakui/host_syms.rs | 258 ++++ src/oakui/icons.rs | 133 +++ src/oakui/mock.rs | 456 ++++++-- src/oakui/mod.rs | 22 +- src/oakui/real.rs | 1820 +++++++++++++++++++++++++++++ src/oakui/timecode.rs | 19 +- src/panels/history.rs | 23 +- src/panels/inspector.rs | 18 +- src/panels/node_editor.rs | 90 +- src/panels/program_viewer.rs | 24 +- src/panels/project_explorer.rs | 18 +- src/panels/source_viewer.rs | 20 +- src/panels/status_bar.rs | 30 +- src/panels/timeline.rs | 172 +-- 59 files changed, 4998 insertions(+), 517 deletions(-) create mode 100644 assets/icons/dark/arrow.png create mode 100644 assets/icons/dark/ff.png create mode 100644 assets/icons/dark/magnet.png create mode 100644 assets/icons/dark/next.png create mode 100644 assets/icons/dark/pause.png create mode 100644 assets/icons/dark/play.png create mode 100644 assets/icons/dark/prev.png create mode 100644 assets/icons/dark/razor.png create mode 100644 assets/icons/dark/rew.png create mode 100644 assets/icons/dark/ripple.png create mode 100644 assets/icons/dark/rolling.png create mode 100644 assets/icons/dark/slide.png create mode 100644 assets/icons/dark/slip.png create mode 100644 assets/icons/dark/track-tool.png create mode 100644 assets/icons/dark/zoomin.png create mode 100644 assets/icons/dark/zoomout.png create mode 100644 assets/icons/light/arrow.png create mode 100644 assets/icons/light/ff.png create mode 100644 assets/icons/light/magnet.png create mode 100644 assets/icons/light/next.png create mode 100644 assets/icons/light/pause.png create mode 100644 assets/icons/light/play.png create mode 100644 assets/icons/light/prev.png create mode 100644 assets/icons/light/razor.png create mode 100644 assets/icons/light/rew.png create mode 100644 assets/icons/light/ripple.png create mode 100644 assets/icons/light/rolling.png create mode 100644 assets/icons/light/slide.png create mode 100644 assets/icons/light/slip.png create mode 100644 assets/icons/light/track-tool.png create mode 100644 assets/icons/light/zoomin.png create mode 100644 assets/icons/light/zoomout.png create mode 100644 build.rs create mode 100644 src/dialogs.rs create mode 100644 src/oakui/ffi.rs create mode 100644 src/oakui/frames.rs create mode 100644 src/oakui/host_syms.rs create mode 100644 src/oakui/icons.rs create mode 100644 src/oakui/real.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 486f2be66..bdc9d91e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,24 +13,4 @@ implementation details. ### Code Standards In order to keep the code as readable and maintainable as possible, code -submitted should abide by the following standards: - -* The code style generally follows the - [Linux Kernel Coding Style](https://www.kernel.org/doc/html/latest/process/coding-style.html) - with the following project-specific exceptions and notes: - * Indentation uses **tabs**, not spaces. - * Documentation comments should use **Javadoc-style** (`/** ... */`) where appropriate. -* Naming rules (enforced by `readability-identifier-naming` in `.clang-tidy`): - * Types (`class`, `struct`, `enum`, type aliases, template parameters): `PascalCase` - * `typedef` of structs is permitted (e.g. the opaque-handle pattern `typedef struct OakEngineNode OakEngineNode;`); struct typedefs follow `PascalCase` - * Functions, variables, member variables: `snake_case` - * Private/protected members: trailing underscore, `class_member_variables_` - * Constants and enum values: `snake_case` (e.g. `k_dry_run_interval`, `k_linear`); `ALL_CAPS` is reserved for macros — save the fear for things that are actually dangerous - * Macros: `OAK_ALL_CAPS` (project prefix), and avoid them when a constant or function will do - * File names: all lowercase, `mystring.h` / `mystring.cpp` - * Namespaces: short `snake_case` - * Getters: same name as the private member without the trailing underscore (`foo_` → `foo()`); setters: `set_foo()` - * Exception: Qt and third-party (e.g. OpenFX) virtual overrides and framework callbacks keep their original names (`paintEvent`, `getParams`, ...) — renaming them would break the override -* Tests are written with **Google Test** (`TEST`/`TEST_F`/`TEST_P` + `EXPECT_*`/`ASSERT_*`). Do not add hand-written test `main()`s, raw `assert()`-based test files, or custom test macros/frameworks. CTest stays the runner only — register cases through `gtest_discover_tests()`; use `GTEST_SKIP()` for environment-dependent cases (GPU, missing codecs) instead of relying on crashes or timeouts.. -* 100 column limit (where it doesn't impair readability) -* Unix line endings (only LF no CRLF) +submitted should be formatted using cargo fmt. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 77ad5bc73..64ba9f882 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3890,12 +3890,14 @@ dependencies = [ [[package]] name = "oak" -version = "0.1.0" +version = "0.5.0" dependencies = [ "gpui", + "gpui_elements", "gpui_platform", "gpui_widgets", "image", + "oakengine", "smallvec", ] diff --git a/Cargo.toml b/Cargo.toml index 9046b9863..1062de58a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,12 @@ [workspace] members = ["crates/*"] exclude = ["crates/oakstorage", "gpui"] -default-members = [".", "crates/oak-cli", "crates/oak-worker", "crates/oakengine"] +# NOTE: `crates/oakengine` is deliberately NOT a default member (it stays a +# workspace member, so `cargo test -p oakengine` works): its in-flight +# integration tests (`tests/it_*族.rs`, an ongoing rewrite) share temp files +# and process-global facade state, which makes the parallel default-members +# run flaky. The app builds it as a regular path dependency instead. +default-members = [".", "crates/oak-cli", "crates/oak-worker"] resolver = "2" [profile.release] @@ -47,7 +52,7 @@ panic = "unwind" [package] name = "oak" -version = "0.1.0" +version = "0.5.0" edition = "2021" description = "Oak Video Editor" license = "GPL-3.0-or-later" @@ -55,9 +60,13 @@ license = "GPL-3.0-or-later" [lib] name = "oakapp" path = "src/lib.rs" +# Doctests are disabled: the real engine binding links the `liboakengine` +# cdylib (see build.rs), which the doctest binary would have to resolve as +# well for every doc example. The doc examples' assertions are covered by +# unit tests instead (see `oakui/timecode`). [[bin]] -name = "oakapp" +name = "oak-editor" path = "src/main.rs" [dependencies] @@ -72,6 +81,25 @@ gpui_widgets = { path = "gpui/crates/gpui_widgets" } # `RenderImage`), matching the versions gpui itself uses. image = "0.25" smallvec = "1" +# Editable-text widget (used by the file / export dialogs' path fields, the +# same gpui-elements crate gpui_widgets builds on). +gpui_elements = { path = "gpui/crates/gpui_elements" } + +[build-dependencies] +# The real engine is NOT linked as an rlib: the app binds only the frozen +# `oakengine_*` C ABI through the built `liboakengine` cdylib (build.rs +# emits the link-search path / rpath / `#[link(name = "oakengine")]` +# externs). This build-dependency only orders the build — cargo compiles +# the engine's cdylib before the app's build script runs, so a fresh +# `cargo build` at the repo root always finds `liboakengine.dylib`. +oakengine = { path = "crates/oakengine" } + +[features] +default = [] +# Force the mock engine even though the real facade is linked. Off by +# default: the app runs on the real engine unless `--mock` / `OAK_ENGINE=mock` +# is given at runtime (or this feature is enabled at build time). +mock-engine = [] [dev-dependencies] # `#[gpui::test]` harness for engine-seam smoke tests (test-support feature). diff --git a/assets/icons/dark/arrow.png b/assets/icons/dark/arrow.png new file mode 100644 index 0000000000000000000000000000000000000000..f02715d9d57588d2391d17a683e670855a60cabb GIT binary patch literal 678 zcmV;X0$KfuP)VJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^My)Ja4^R9J=0mb+^e zQ4oi}(TESySc_T+K1h+qMnS=VpoN(KKusg4t(8eD2#SS;*a(&q0}@h*Vx^Uc7_kxy z3m*j1i4us(mtyzAWjVX|-rU`+KRB?=oZ0zyX3k;Zf0a6^zESU}qiq1ZP`~EuwR)_H zJliUKd%B(v0?*XTTM1C~?F6RPd+P2s06Mw|JXa4l4GW53(7#uijO6 zwE@u4B=A%{P&YtT3yK;8=GAj;0RW5w57ZfTqz-_7ThOflE|IllUmE}&jguYKQ*8kN z>;)dHH`VP;CF!LAt^&6=15hX2`7%ewkJkcFVgCS30dv5Yg#TJy0I9G4 z2waJXzY(!cda_Te<4pi8=E?tb*%p9riP-By14P6sS-Al|0aFq2yRc^sc$f%3qmK0p zP&(=PU(ONWbDk`@T=pMnqrpPJ#)V~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3Al}fq^mH)5S3)qV?^xgXvVVeb&M%hk zrW-kKi`gOta+_QavK#2maJjR?@!+C^X~Fe}H*DzJY+Qbhb^96q;Bz15{@?%n9=CYb z)ma;O94;Ac;P`U(&AP0~8gZAVEjf6iTw~?W_&uJl7>#HC;@*&)_MMS&(GL}Nwi(QN zDy1r_slN_x;Tg?%}t4B7{JmxQ`^1lg%DIB>u4x;5S8Er;PWeqM%` zQ}43qsh+iVdh#ihiDxTAjQ6dA4fQ|THhT(NtSGvF{G(dtgB+$0TxX7bn5QdoHC;zu z`hm@~?`%I9%Fezqnf6`gkllvdG=IO(@ikL9J4&9s_WK-TSHkrEz`U7vt$MhBPGGRT z^tY;bmiQ-E#(fRR4^EufCwt7Xrn$sstl lnB?a4U((nYwzl&X`vdEP^N-h9X#!)2!PC{xWt~$(697Oz;*tOW literal 0 HcmV?d00001 diff --git a/assets/icons/dark/magnet.png b/assets/icons/dark/magnet.png new file mode 100644 index 0000000000000000000000000000000000000000..f86e31cefb0b56335a28d5fae9fa9f0183ff0533 GIT binary patch literal 723 zcmV;^0xbQBP)VJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^Mz0ZBwbR9J=Wm%nQi zQ51#0J8Kb>N`=+npI{V>#L6l}k<`&z640Q9qIL;Z))oqCge@#mO4@({6-~etDQrz6 z;$}cdDT3&+m^XTP?)=zfahts`aCz^X^S*gA^I)LQWp#y;8o)Sk9ykdc0Y<}g8`uRt z0BgXOnf>eufTSVd3NQy8s>!A`1@!81S|m$%Dx3AxUqY_{n}2k06#@ z?z*IdorLqzGw*uaOCUxhZMgiav7jMo(+OsJwYXd3vde#;ztH&>j{}E8-v(AH!X_j= z3wN~<`iTmL7i8xGWT)t94{*T=UzQ!Cl5POYWZ!%EG~>zhRYUrPeUT)${$ zshOp~q8mR|;!j>I4|{;V)c~OH00-g$N!$Ak?Ixq| zJ1_tk01N~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3Al}fq^m6)5S3)qV?@Gd+);mBJStE@^jpknIj`3>k?B^@JYBt_>PFZ zL!iL+yuQ9P0ml;In2;FOb<7olg)MqsmBh?7L%{| zU+Pq1vJGpD_RQYL!mzAS*wfc@vK@AuAAg`g&})^H08#hFas@`m+}F zICQ_Lt)F%x(BlC+M}xBQZ{6UU2@dTq(uJST&A2eH_z7R~&*1&sk~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3Akes6Efq#W5tJ^=;p2-UbB`mhU%Pj#ezzKRsD1hhbKPew%=@edqSM zEvNT!N;+@^HZDvy*!pm=F9a;!4|ATUtCV%HiSROLmXS*#Gn~i7a4X x)o@_s%3y72tUdCU?*`+M<6`e?kBK^|=(#U-dyqHt`E8)h44$rjF6*2UngHYca{T}R literal 0 HcmV?d00001 diff --git a/assets/icons/dark/play.png b/assets/icons/dark/play.png new file mode 100644 index 0000000000000000000000000000000000000000..ab5c3b714439b170a2a6dcbf5af3496a9bc38aef GIT binary patch literal 514 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz&H|6fVg?5GL=a}2dU(e+prB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3Al}fq~J$)5S3)qV?@GU2m2^3Agi)-EXBCNd93|65}=d-{332GmE#o z>&7-twb^XGvqXHj|1n%{at$z(67gxvHT!C)!+d(ho6=`}+s`ycw|(Dt{$ZVgcvhCs z;t5K(KOfM!FtsFl?WOM@cyISJKfX|=9QJ>qcEWCEpJ#Ua8LTJ0JH^$*a^=Zk#$^rM zw|#Fb+In&RIi_)eYYPD6X!!e!< z+xE`#VpzcV=60O7>V%CmqaN69J#cbE)5fl*`wh&V(=}Ok9hkOc`GiL^q7xp-L~!!9 zS7={g{-$#GPiYcQBi{z?ms?LfF=CQGU|68?a&yLHfkwH%^Uo@rKd@~{`=W!AitPsV v({CRr>M7u;pQ6W5eBkeXd#~)QRnNF5YR27|Xv@1381xLDu6{1-oD!M~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3Al}fq^l>)5S3)qV?^xh2G4G636P->v5PhpG*)DIkH42lGU)wQ{2RI zcYT8=+o^UtM>WnzoT+5>mle&K~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3Al}fq{|V)5S3)qV?@mL%+ig0qR5gFdaHsjV z%1glzP7@hsm*kJZH{7nBdXUN+>3S=E?S7Nb_t&LepD;xvQcaUR?ZA%%H{}nQvF9~x zW^OmPI@{p6q@cl*tw)KeXH&;R1|HT60Sz`A*0F>g;4@eiy@lhYIM)Wl&C9QSU%Z4T z;@`H3rre(D2Sj#!V%TlI`TI7-l?-{+ue=hN_n3T*cij3>;id47e-m$UuHS3EvRozX z+{seDhuhYlX0YAuCf-n5@qd|K!{>dAs^VhSe>gF_mRn)vgc8m(wo<;fw&C2K;%|9p zEV1r)3Rm5*tcrbs)mBT!>sBHU&ZkU@OJFVdQ&MBb@0H(&d2><{9 literal 0 HcmV?d00001 diff --git a/assets/icons/dark/rew.png b/assets/icons/dark/rew.png new file mode 100644 index 0000000000000000000000000000000000000000..b803501239e0d299c46cfc5e2e43ba137a7f38ef GIT binary patch literal 537 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz&H|6fVg?5GL=a}2dU(e+prB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3Al}fq~K8)5S3)qV?@GMQ@fsk=Fgj>rQHP98B2I+2ngIN%Y7n&&i%= z|FH!M<}^9hH;Juw)Zyw*>rN0b{K;(77AY3nc6VlZ>h?2@p6_eF%gIfzX3M%NwJAh& z!(NUvO#YMP*pJ1?uO-$L%DHE79Rjf5obYDTN*Dm(3#@ltQi~d}A zydyoqW787u1lbDR9iM<6P@T3U{gdYYS5{WtpH|v0{p{d<>F|P|QCt7T+DHD~vYwm& Sz9%q77(8A5T-G@yGywpb-r4#9 literal 0 HcmV?d00001 diff --git a/assets/icons/dark/ripple.png b/assets/icons/dark/ripple.png new file mode 100644 index 0000000000000000000000000000000000000000..7d7682b2ab21a1743dbbc1f63f07f551fcccac65 GIT binary patch literal 613 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz&H|6fVg?5GL=a}2dU(e+prB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3Al}fq`+dr;B4qMC;pW`@NhEWm@;guk5;&UQi^u_QA5%c}gA4dO@ro zTT29^SPL&6J*U^C$0cUYu0DU^5B4jM6RzC8U0ppntowo-hl+~&^V4T}Ilt^$6T4W2 zSuA#QR@yIFmHGx%&+qGWwp#nIV{Y3xrRq9Eooj8we{O}93Yp=T<}T@4B6#0Zwb$!b z`8`?BRt1Li6%3yp-9yxM7Y z*!t@C=|65Yy}Oqp`1H&h&KpZA_x}sBk7k&_7|6WrOpZ-d!{-w-KDYeie6TCj!o{z# z`9=}*A}yv=&*punC!4UyT=)}j+G5UN(P0(f)i8rC4CqMH=b4iCSSBz|@vN9ezJ|9$6)O=JMlT4BvjPIoI^W)E5}L44$rjF6*2UngDNd{B{5U literal 0 HcmV?d00001 diff --git a/assets/icons/dark/rolling.png b/assets/icons/dark/rolling.png new file mode 100644 index 0000000000000000000000000000000000000000..c985072e95194e4e1a15e385b19e891fdaf62d6f GIT binary patch literal 694 zcmV;n0!jUeP)VJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^MyE0_l0HDkabhM0W})&=q6;jT*%QA3yWW`HNc}0WUnLIS zTi{H*cLRLXfu^K~skbL-8fa5|t_H*h3^)@#pb;_g3|LMz0IWDprFg->5EF&3W(6Lk zh(`?sCU&@bL-v5p3`nO_|wchB#*S8(2-@MrRk{T30Na5HYTtB?qK`l8Cl)`OB8Pv?)8Jw` cNhq89581|bm8~Z6hyVZp07*qoM6N<$f*i^rAOHXW literal 0 HcmV?d00001 diff --git a/assets/icons/dark/slide.png b/assets/icons/dark/slide.png new file mode 100644 index 0000000000000000000000000000000000000000..697d377ec1d6af7257c33506586008682c1b8cd1 GIT binary patch literal 594 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz&H|6fVg?5GL=a}2dU(e+prB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3Al}fq}8X)5S3)qV?_cjb4WXBwF`RezC6EuF>L$9M_`$hYGHORvjXO zg#m>Ej^YQWzfkzWvdhByhmh_r&aW0gKl*59n#^f#w^@2Z_| z&p!B=QN2~9;Vth2sfY#EW$L1bS!LM!T_31bh%>h|c`KZa`L~8i;bckqv3WrciUb}> zFHqRSEECGeVZ|fYD!}#d^?|Dn74zPCvE}#fywCVe)ihn{koroI9UFX|{#*UsCFIF} zK<;n$*UP`+nphMZt`sX+Irv_hv}Ea~cQb!QHKxY~2^$3~ytHFiV)e^)e?Md4^PYz* zSGDW>VsLX%arw97%bR1Z{ER6IWsPPZ*i2Mh_scI_&bWtt{oI2eCOAe&pkZd~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3Al}fq`+Vr;B4qMC;q>wtj~l1X}kO7BT2GTqgwev zsOU8z$faP4h{wW3%}jj`)0rC<_{p&fKfbZMY(mrF``M3t?w$MoCU(8-kI$2?Oe#@W zxs_{~=Pt%;jT>j?UKLSDRjYG&wwZ6~nI&!+0sk56+!#D_Jzv7u>cyMz zz;;m=TgL(Q1DT%tg}>{_C`lZ6ZpmVAw)u60@&C2LoEzn3CdV`L?LTpF`of!>Je$v? zUHjNtuqQqKQjDQJ>!P%?tOlC}k6Ok|yQFzSY>k3ueeq4o#6-oZMXA z7~iRP^y;1ISM(aUD}Tt-sVI^EAgd$t$R{Xq&h>N39}eY3NG?!uVTw5MFR~)F!KXv{ zfn)_^Ra#iYlDOB5>)+X~X*$frzj)@K#K|oWEaRDH{MxMej5FV5spoy^uzT}D89)D$ w)K}SWci=|<@zi6gubX$i+MiZxxPSdU2A8%c%Qr literal 0 HcmV?d00001 diff --git a/assets/icons/dark/track-tool.png b/assets/icons/dark/track-tool.png new file mode 100644 index 0000000000000000000000000000000000000000..8f214b0f6745960a8b47eb404815502598cf5206 GIT binary patch literal 375 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz&H|6fVg?5GL=a}2dU(e+prB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3AkesC}uYi(^Pc>)UA?c@G=#u=KM{N;C>9J0T{iIPdiCh}JtUYkzKV zxnc05cNTl;)rSwCNI0zh%DkuX_<`H3GtRzQ&7jk4pC5Vl&DQW4|MHJKXYgNdP}%xI z#erGF@q4&v0$+p1f&33=&-|DX-f?XUlfbIXqs$9u?d$0fO3+_8D~uuN@^`=UTGPVT za}|VafA{L^rQKb3udZgAGmYg%?9q3xLK*vBe3P@f%bVJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^M!8%ab#R9J<*m&;36 zK@`V7N7LRERGLtWpccKP7Hy(@^uRwrkp6=#3xXn0D2R$Sy*8zbcCC7@qG%yymXeqX zLInvyMPUyji72hUE$+QGzj5yGq2(7Y%sF%BobSw>IWyOoBqYrQDuD`MQLO$1yafi$ z>_LSKLeGkfR- z*(s?<(ql>6-99tx1R8)5M_mH6j`!jb? z=CsR024?mExEqQwfk0g0syuaDO7KE((UYqsm4;TJlFSPCDlOX!ftfu8-Z-kc!1@q@ zrJg*HG$ift_I$`6?Co=xmze#Yc}0kTzmM;o_DM4LJQvF1F(F?7nAvyWfv0YoDgj`hC*L=-(X>i<#gpqK`Nb)Lo7utqkW>Tg z^wc*Yy%gWWIsZi2dtOp#e~$nkpm#~Y&Z`Z1Q1 zC)u(T`&KhM<0{4pNGbs?0Ne5mUI@m3qwdTT6vx3!iLTt*EKa(J9-sbYl?&a#elzO` zbtaYYAla9_1K2|}lNzFDRt@RT^shv<PQH?vV!DQOn44p;#!1b!0b zLh@@i>O0CtpdXkQ=2REw(~EdJ brWJnxe?f!oP~fn700000NkvXXu0mjfbZ@$z literal 0 HcmV?d00001 diff --git a/assets/icons/dark/zoomout.png b/assets/icons/dark/zoomout.png new file mode 100644 index 0000000000000000000000000000000000000000..148297f1e5975ce78cddd127fe34affc17b43a98 GIT binary patch literal 980 zcmV;_11tQAP)VJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^M!0!c(cR9J<*m)na@ zQ5eR54`v)gITsg-3#W+-xxpAESFY5g`~k)(ij)*hlpExRaqmLL;fj$f1`#>c6fQ_1 z${{I<89Xlb+GBrf_WtJEpTkpM-+te<*84nb?X})_ZBZv|4+olnX~2Zw{s6oNdL(_T zC64MKZUNQ;%Yjh^-M#?bz&S}*|B3+HO~7toajjzl?f?fR_0%oEb}P^hG?XMy@dG#@ z>11gEws!(;17yS&cUh(q#LI?WdXFJ=;qM9sE1SGTZl~7>{kwOZybQ0^6sk+;E?OR!Prl6%gXg z0y=@Q8TV~T>j8!o3~GtU4Qwk*4j}0%usy^`vBY+Bp#YL|o<*0W8)eBeBCY|K^Ed_w z2pMj98Pnc-d(?e;Q|^ayyBag7ERDQk^L}zu)PM@Lvr40 z!Fyo8q>I6R08G!cPs>|@DUtm)kPFyXf}GH|J&f&-mF3D4Ff_7D7pw@Dl%9Y%*3Ucv zA0vAbKnSuIcvph_Z5Weca@77VZx1|=>_zE=q%NSVgn$?eBl}I>3iJSd8GA;^?SBN@ zbE({bpMjnM0wjF}u0`(6e<}dj64_TJ^%iBq(~-T}cAT7QhV2!=s>ppFic|3wGUuOU z_Z|&}r7Zbu;0To`;8sY_g#sE#?sZv=1v<;DM3{M83HyM9sWIS;+inCpfaapOB!%U8 zNUwRs^MFkx7m^seC7sC?Py>04S0w56eX!Fj+4O?`9g+@b9sL4qHvq?gl{J!=K_9R; zJM$Dn19>A!$!)0;csnhAGMec8K#jU+Q!0knqyqHFqRl3a2& z-R(tCs8n}>an;Br^=6&6M*#DInZS79JIP*1Kh1jMK-C010LJ9BUkBQ1{m!cgLqe6i zkki%;Z1|Vt{jfdK_I}$BZQr%MD{RdFi_FdCzu^}Z5-Z`;7dTk}0000VJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^MzbV)=(R9J<@md|Sw zK@`W|cjH!5geLSP37eS}MCqXyy$DM5q=#am2%gmTqNrC-)th)yJa`Zh#A|5_ZRsB& zVvF@;JtPyi0J9J8$Q+vv1z4$bVKU$WTg6XS3NinM~$% zE|+^>uh-kd0I)*5yChGD$V{`@TrCs|XATtr!05hV0l<<{>h_@l_;^P}z*1afQTT-AbFDHlGb`^XaE4h;DqET$roDdXG*DKi33D<`$47w zEV!=gBn=Sw349l@ya`~&7<0W;D*f`C<2cT$hkz6_wcKSmM&dfJ890DJXv;6#wTEIsUS zA{S0Non91?T32{YL?U~6;EzH-s@LngV`F2+vaE9ewn)xbDwTIhJbUQ+0}MIca~stn Q`v3p{07*qoM6N<$f{hGi&Hw-a literal 0 HcmV?d00001 diff --git a/assets/icons/light/ff.png b/assets/icons/light/ff.png new file mode 100644 index 0000000000000000000000000000000000000000..f6d7145c6ccddfbeb1b278a35554760e7988ea13 GIT binary patch literal 633 zcmV-<0*3vGP)VJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^Myr%6OXR9J=WmcL8e zQ540`d0HV`VxV1w+?T9HlwgNKp>!5;5!`fg3=Yz%;ywQh5X5?ST1$G3HD}8u>S)l*A7` zRMi;(4QuU5tyUWpGE$-ZxZ6QhKlXaPUn25((h?#3*aEr9Jjw6Y+WmUH{wAWtP(G+Y zZbDVvYPDMDA|eqb2J*oLUg$l^pCYmu!T!h36pO`1gvlI$FQrnc4d7D>PZ7C^FnCfE zz`GPa<`+DRLki1jXuipDh=iH>(Bl)o#Ao<4_v+BLSoH#jvAG!gO52||PoI3>2iwF+lXY=*U z_swrf?mFkL0)mow1K_yN@S@x8u4P#^7S5H#69BBW9{?<=>c5yrX(~U}RIjQ>Yd-(3 TOUaIa00000NkvXXu0mjfG7}Xz literal 0 HcmV?d00001 diff --git a/assets/icons/light/magnet.png b/assets/icons/light/magnet.png new file mode 100644 index 0000000000000000000000000000000000000000..fafa17a6a5b3574e931e796a8db0357be8665624 GIT binary patch literal 942 zcmV;f15x~mP)VJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^Mz+(|@1R9J=WmrqC( zaTLeDZ^lLG(n{bT0(X7`p0a|@ind{DcJ2bQW+IH97lDzm}+24Ef-uL_2d9y4F>~TmPy|(MRC&Dm1 zFCs@pk(>nZL~H%yuK_Zd zOrMC{2GClIaT$;t$-7$X7xe;IN~uwj*GmMmBxglrE)2sjQ53BJfMr?jwrw9K*&`yo zB-@P{0(j{A{yhLuEdZ8M>ORSf<|hF#;rsq`?7UlBDRqhD2#h-?BGbO_kCZ~#rPE5O zF_Kq|(#w24-@ml9^d10rFb4nzLGY=uvGG|dmFfiG#`VtT=H`YV2wqnN$Ye5o0PYyn zkG0mf*4NkbH5mUE8yg$jK@d!*)9Hf%j>meZ(&_Z)AP5$U9?8LRoF);OG4?m3wH`*9 zz110(<2VlhoQw0-LZQ&Ryu7>#0G81>keG`|E}zej?SeS~M4OwNqX53fd21?_>Mzp8 z@3x2x8bcG=Z1(#um;(S-S64SgWWp#7Azr@#fa|*5Q4~D|etp$^-#?2gPrK{7-6XG) z?22bTipV3+^WIe9u$5BtB-;SMvaHLV=e@N6faC?Eai*HNQfipw6v-X{EdW|b_K=)% zUH3*6fRN;Dl0;F|3&blRdBkX(ucXk?(Qy*MO_EmWHIf#|5vA1WDiCwYK}5QW03y<2 zG%Z$|wr%@bVlE=D0Gx?m^9ICVrTHj|mW;He2#_QIu(h@It&%{p^Y}cjwa#g+b0o)& zwqup%3xz_?NZX14Bo8Fj9iILosMfkDwz#;sQm&^oOzp(;k6r%n*az5ifQHh7<2VcD z8taLAH1_-s>;vor>;ohK+jY*B>;Ef_~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3Al}fq`+or;B4qMC;pWhB_>c60QHYd&HFJ1$yQ^TPLb0&c)m5q`YY2 ziw-Ac!PXTWZmSmybYAc@;aKXT8rZeE+^zEH%XfYU?aq3NRcDv;-@datE$#pRzw!6{ z%(O3sMQd^=@_+vMhvTHh;t|bLl_X1Irm1#JyJjwD9%KPZyf_MsT9++EV_L9=7{uHpr)F zX1$%yz);~U{Xj*Ofgw~dvOM;?M%MZKxI;oGO|#ebUcOSq#K7@DW%k)~M`iUcB{4C~ z^URCL&hItsv=LHgcyO+8PG%S>NIL*Q(lMrFS(BvmWrh@GClYM(4Gv5SKO&yFXX`Goy2tt}ACu XT(L{>k#a6D<{3O){an^LB{Ts5n!yBd literal 0 HcmV?d00001 diff --git a/assets/icons/light/pause.png b/assets/icons/light/pause.png new file mode 100644 index 0000000000000000000000000000000000000000..62d4dc6e75954a6be3ff4eb08e56348ccb4414f6 GIT binary patch literal 326 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz&H|6fVg?5GL=a}2dU(e+prB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3AkesJ+C~#W5tJ^=;oq-Ub66mh(J}sbz_JD-!g|JUyow_;z(LP4Wo& zGxhoOQiUV;H!e@+NHp6lE5SYYPHm^5+}_)h9&S9Bv)h(MV)OfZVeAaS&G|RO*lYTj zL>4fxYB(@*Rd8EK*PPy_ci?Wg!rhWn`>M5DGgTe~DWM4f DV=Zt- literal 0 HcmV?d00001 diff --git a/assets/icons/light/play.png b/assets/icons/light/play.png new file mode 100644 index 0000000000000000000000000000000000000000..80592a207d692855e3c3e5fd8da4dbac22951fbd GIT binary patch literal 579 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz&H|6fVg?5GL=a}2dU(e+prB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3Al}fq}8m)5S3)qV?^x_1?~bBFE3~%#zr~>vL%L_qAOe&dq1_X0aSx za56z6$|pKWV`%URjQcPNI=i>e|9&lS2P{Gxd zV5r?VlW}9@(kpYX?ULGj+=Gdk(ZJCqH2IA0&KNlr%eY3niK5T%Bnz;tj$_#+NmYf>!#Z>Rp(cz+ing?XzR{T4V7S$$c}kqh9OAGqEJh>Nt1E;LbOt3x_SYzYlGg zX}_1t;`zF^r#^kj2FBYN=C}uzymx*ed!VtyW6qN+>l$VrJhOdiP^R&_f<;rz4xEu^ z=yO?;xAt%N+Y{0W&(fD(nbmP+@)h-a494}0JGNYl6PW0ID}9AS`QD@U4?8;eT%YK^ zd>~i2?!oowv}aMvFGp^_)@L(E~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3Al}fq`+mr;B4qMC;pWh8ZG`60P^G-#c8>YrQV>MyGsk;{>mbfg1!I z`$CeYiwL%4{NnrX*SiINiMw=cYCl7QKD7P! ze$Zo>*8arS^Ygrob&UrTE}Z`SXXlbq&(KYbXZ~~iSL}N%9Xai-XVm&RHpve;RXjK8 zzI2Q|Sg&E+cy`y}(_A6-AG(;~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3Al}fq}8b)5S3)qV?_6vtEZCL|V`9oxJQsNvK5Hb$gbByIx3#2+AKQ zFL=rPLvEk{+qjaa{CT(L2JF&sau)Y{w(EkIr?*qEDaX&ShX-~(FPJ%#K~Bu#R_^a8U`TjYD<&8k!YCLR6Y^tHEcc0Z ztW7a1mG}-U2wG`l_54Kjebq(6O1G-w)-idle3)UfD}LfTtGRsD@BO#Wbv@0Wz4ls^ ze3wVOOHjA2SoiCbDMD+rj?I{L?U&J6btOMd)1E~yDqsHR`z4eo-P@M_Z1Wb!lLZVj z{FWE5EuF>7z}9(ot^6Sa9{Eknp0sw%n{+FR@wtrZ*83@I9b}HXN2+DNy|u2_aI4Pz z@B3z7*%$Aa>~l8icJi}-%aT@ytiJj)$E@0S$}7f<H{$Ycf?nN(T8CG1) zicK~3cXH9|dn}z+$6~i#jOAeAwYN82?zL(s9JwGt= g!GCcLP0cy{yFN6|7O2#C1&mGxPgg&ebxsLQ06P-=xc~qF literal 0 HcmV?d00001 diff --git a/assets/icons/light/rew.png b/assets/icons/light/rew.png new file mode 100644 index 0000000000000000000000000000000000000000..ae0c0064aa0746e6ce6894be86b7a647810f66f8 GIT binary patch literal 660 zcmV;F0&D$=P)VJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^My!bwCyR9J=W*1v1p zKoke??>iY8+`%%18scD|jfX;MAwwbkF=P#tX3SVGncBZ1&>?g%*JhWty^*TAYMn;xiXcvDcq0>k2oeu5u+=Ht*DGwL=lvo82R#!|{0hQR7!?px5g?6p^8b*oC1h=iF0`H#wH9wSzQGKV@J5 zxTp1%WwO<3-SvI{NJMsWil}^M1MPPEnK9;_h~&*MGe!Jj1D>_^H4(iLk-X)}N@0J~ zK)>JLoKB}h5!n)v!j;Q<0|1>)XE#ZbQxVxJTv_S8Znt{?;4Oe^nc|H`<67elfLCSU zTvXlwD8Ar-N97HG56T8q-ecw;^?Lmm5j{tVb#%@RG~NI}6h&VvmC8#Z8Wnwk)_b#y z$z&2U^GOf{LPU?T;!(}a#~Q!5TTPvF$7z}l09>s!1*Om2E~6+qOOoUT5q&GsL?Q1j z8;Ilhnwh@@K@bCYvh3N}5|s6M2ZlK3-V@P2fGA`9x7IKH0`pH&Q0000VJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^Mz5lKWrR9J=Wm$8cz zK@`Tnw{8qONs%s1kSWqlA$OoyCn#gb?*8ik<3}Tn0&1SO}1i=dcH;CvXW9+Gva+EhWcL#(J^*E0A0o=(TZ&j<+`!41H5Cp*k z05<{nTI-ECj`xKSbtizSxz>8k94Dfmt^h>z!|ZFV*K81H0M0Q#^gQpqD}dJe9T6S6 zK+FJItyU04(aRk3BgWWHzuzCb0w|?UlO%Zv;A;^CP0fAZ-ve;X)davd09SL`g~}GS zEu|cJCCwL-xf9b8lsd^SLS7h#Cuy2K!d$)nrpyw|?gLpVUIDmnn{1AA&bt7*S$9_{ zwQIh&m3BVg>?;Vv@T69&Jtm?fGa#$io)F@)>k8ZLcC}Keye=|70{{S0$`NC1I|pJt zilR+dfHY0FwASlI=Dz`)2SG&Eb7NjaRBx2^!=@%Rl9ea>PJ zZ01htbUL{!=bU#r=l$&I=FPFinku{x^m@It7_)rr(k~<{fJFd~E$u(j(tpiX`UM4~ V@~7y|OnCqR002ovPDHLkV1n-QHfR6< literal 0 HcmV?d00001 diff --git a/assets/icons/light/rolling.png b/assets/icons/light/rolling.png new file mode 100644 index 0000000000000000000000000000000000000000..128da431cc265c963c90f20556f0f7b5fc580874 GIT binary patch literal 798 zcmV+(1L6FMP)VJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^MzOi4sRR9J=Wma%IS zQ5?s=zw=_acil52l~>4iptPVkCN4W&vBduEr5}^5Mp`4jp&!WPmQ+$Y^Q6v_NTlTLM*Ew zCIF_G9~p-6KGSMhT-P-L@D;$doOPknLv6UO+c(Z={#TOU#ApO!R_0k5zVDwVN%9z(dHqYt zBN%_b=_pC~I2>_qsIDV@OFc=Kp5Yb`UyRS0WQlrtxJvryR$vJPQzh+*Is#jL-8<5p%wUT1Y zv-c_qFG3zrY9@JLnr4*;l$z&(BF*!Fta%=|(0SmyVm+HY;CbE$09ydI(zRTxod+&u zF91Nd+Z|S`)sJx;*NJEez+sXk&w9Py#J^h*1SdfdoMihMW8X~Eyh=oi01j;1-aPj_ ca9+K?0Ri94+|Q`%h5!Hn07*qoM6N<$f>vK%ZU6uP literal 0 HcmV?d00001 diff --git a/assets/icons/light/slide.png b/assets/icons/light/slide.png new file mode 100644 index 0000000000000000000000000000000000000000..ae4fe465e312b2b37771466ec26c1cbea12d3602 GIT binary patch literal 674 zcmV;T0$u%yP)VJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^My&`Cr=R9J=Wm%nb* zKp4blzDxQ`0p*=<6;V*}6i6uayZ}`MLb?K>DEut|QA9}4An^orDIy~A9uyS5@9qVH zpo?@n6!Art51%x`V)dKdWL#gyW~V6bIoHyYY0#`t44o1L`v+(-d1 zvwHygD{5{5_|)1n1z=Tpp{*3dRDes@8ek1jI}Aso(QQdDNp8j%pH8RKuU*VT2zMpD zkaSA&`D8NrR;{`41VRWq06qY?CaG^`ueyW-U=iL2a2>$M!h1PD2w_LkfuvsPa_k78 z;y62zdXf&R5a$4O;Uwpho^=F(q!J(BOTEQ68M>LynwbN6FOkIL`rs#Sgk1_17UOmbX>AUP+w>+aj`zTGxpQBQJr ziRake4}sDs$n*TD3Q>F)uhDJBG)Zr&ql)A>&-0`6qH>ER5QfB+(;^Q*WB3Jt1q6VH zB!2?<1z-;xW zdi?d@@fzTt18B43X}v1~5_d*1#xDSFn%QRE{bK3$J;igaKY7*KP+0orTmS$707*qo IM6N<$g2iej1poj5 literal 0 HcmV?d00001 diff --git a/assets/icons/light/slip.png b/assets/icons/light/slip.png new file mode 100644 index 0000000000000000000000000000000000000000..cb3c9227137926721fed364d31c8e814cda1d52d GIT binary patch literal 690 zcmV;j0!{siP)VJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^My;7LS5R9J=Wm(6P1 zKoo_~8N2&zl3mfv&=)BLLr5Sm&_y4hC6GlIp+89%31krxng{4BWD)AnK1z|sO{?>Ot|V}d?R%voiv%q z8c4xO4kf+Ka(ELHNdrYuY-HfCLI_v6&O!)Rl6F!ithL*z99nC?MjbpUiekeU zb2>BZcUJTO7cp~c7yK>S>g;wgyR|0qNugub!si%cXP(Gb)YQE%ajQKhnYMh z=_my+%knbEnOg}nCJX>3!U^C;@J0i_O#paF@*9Bf0QLZ+7U@vZ9)KTp?29HikSIj+ zVr#=qJEVj0`y#ugvxTTZS^$m(V~}U&Kt&=TMUJH<8TmyFzKKO8nRyC9 zrN!C#W!q1mnF*9Ph8R*>l$^?tQBqQ1rLUiypPQSXSFBf_nVp%Nnv$6aGEN`F(+^3^ zOV5wbOwP}Xo3AkesC}8Ii(^Pc>)UA?c^eFPSnhjla1Tpn-eAggR=ZUzY%_J*%_qf<2x&+QjCTYJDDW&uaa z;=kLy8afs=x}-f@{n>B1y$Wx{@hv~vt{JX9$#{9jKVN~J4!oCVh_Tu%TfTg$=<2BT zF5cQ@YBly3TD#A&%lzgctX07)2y`)nr>mdKI;Vst E0Ha%pGynhq literal 0 HcmV?d00001 diff --git a/assets/icons/light/zoomin.png b/assets/icons/light/zoomin.png new file mode 100644 index 0000000000000000000000000000000000000000..1af51cbfabaf5f683a7f1efb0fb127a37388e455 GIT binary patch literal 1266 zcmVVJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^M#AW1|)R9J!;W>E}PQG}3?NK&B64+up3gHk|hC6G`f1qnpe6|Y4b7OdD& z9xDXuq9RFAA}XXHBEu%}_)!(Lgy1%H1Wjf>78ys^<2#OB!neBjoO93l=AOsQ2<{TO zTyBr=`-e&HC%G2@MC4Bqxu%p_Tv}TCrxQQYZD7l?jw+?b01S~#7@dl&1DGNCRjE{( z-W39l;~XY=8o*QC5C(8XDRs73EH0Xqu?~ZelRO9DV3)xG;1K{XrPJw~wOZ|$t_e7f z^Cro+B5bQ7GEH(0z=DWW0VDw27qU|TM$+kYZ>?6F(-mz~VB7Y~0N!glAVL7&`My6@ zE|-5ZowF?KkW%V30HY)&bbi8h-3vPs2y%V_KwJ~wR7#yJ7K=Z$Bd+;)mgL6(?$yK_ zzVE+SsZ`EbAz=Y8MeIrA}6>)nxz$X#GJBH_Z=R>05d_LdXLLeBg`h=@Q zReLw6R4Ts$xZ2p0Os=i1JrNHZl6*KM`nj>lX0tB@dG1F9Y};Pawl|bglf`23Qm|hD zaG;@#$K(4=H4x6@4UOEHcEJtk_kI72$yeZSUkd?};fdGv3o8oYp+wSeAs`}ug+vc& zx(NW+yNG$6e6qn28??q(0|49nTj|K(?TJo!ungp-{ zV1?vlSf9;i9|iD%-T@*qAB<;ni{o2>$>kZ$r_8n!NH!@)zxz(pX)GqH`ox7xAiA)BGAc`i^v>+ z1j!>&HWBCMJonHztZV{-rYa}pon~9(9I;1$>aZLa3J{G>Fev8kHunX08a2@`B^fVob2=(cN4qz c@zE*%1@uw6l02|+QUCw|07*qoM6N<$f~tEvi2wiq literal 0 HcmV?d00001 diff --git a/assets/icons/light/zoomout.png b/assets/icons/light/zoomout.png new file mode 100644 index 0000000000000000000000000000000000000000..ba6b9da4481a8d98de17b36f322ec1a6305273cb GIT binary patch literal 1216 zcmV;x1V8(UP)VJ>fSXD@eYYiTc3VQyz{Uuk1+ZeE`#kN^M!?ny*JR9JPVF>?TF)Y+*&9RJ3b5Sq^2PDvA&%k`xK$#HkSRA3y=ANFbpWQG`Is2?U2WoVgKx zoFJ$tk`yHpLJDGKxFqZSQ57i-VN1FdG#Nh*v4i9FCU#Puv|7)bZ{|IF=KIDL>=UI@ z>5%7nCrKV9c|QP%$R8qdT`5&rT3Y%i$vl!)*fh=4N~tpdhDqjPCl$F3V3y=pwOVcF zt{AXw`y|Qp0G>=^7{HH8sflW}T8Zn-BorSfc?rPrl;QyJFn|{dg~H8NtMyB21Ga6y zN%E~Ou}u+~Avq6VQAC;mascj)#3=xyg+igX)oRU$J9b?H%d%bs@WGA&A_VZA=XsO$ zdi}S!ai(dWP)fZDV2q?h#$Rw}tA9);_=a{#V{IT1M%YoJsr9Re^M zG4Sbr#RF)qFGr}6a=F~wVZbl1;R@I2w%gO7(P;b%;9AfoX{@iWKc0;Sl6)va{Ty7_ zw*4H*w*mAg)Lm6dyg;sDT3@;&^Q?knL= zl1DlWkc?Km)CX2{O8?KI)#BKPK6tro14GKWkChNMu<9Yn&ycEhynnL#p0s~Z9o9G^7(wF z!$7T8`y0SqnDY;o13@YET7;T$9Oq7KB_#QIgc`GLdpIsO2`tMR6_IDdoQQl4bScI4 zdi{G5+0K8GLh|Fm!NF2m@qvMXM*w^j&Hxcv@XK?&fpWRr1K@UqKCHE#O7kQv%Nq4O zZwis)SZh78Z6-#%x7}_}0vL&#)IZg(Xss{%<+TIFVzHdbWL_aT7Bl|5>$+dW8rVxb z2G_9lDz*v$B>S3x%eV!t^+dPj$^t;U-F{;?aR9TqT<)ynI2)#Ej*7@QfKdQFUE*(v$Q*z# zgWcZMAONu}>nC)+N^grBhH=($oI7FCvaEZQQjd|e0o+G&Q$$v^*4Oj-d?gycmw`n9 zeKGx4v)Sy+Nx$><0|kI0@=Z+NtYH{0{h#81|FzTC*SC<#WC{Rm|HJY#!!X`S`WWvf e4*K$u8vg?LADKtm!ykPB0000. + +//! Build-time link configuration for the `oakapp` crate. +//! +//! The app does NOT depend on the `oakengine` crate as an rlib: the real +//! engine binding ([`RealEngine`](crate::oakui::real)) calls only the +//! frozen `oakengine_*` C ABI, which lives in the built +//! `liboakengine.dylib` (crates/oakengine, crate-type `cdylib`). This +//! script points the linker at that dylib and arranges for `cargo run` to +//! find it at runtime without any environment variables. +//! +//! The dylib is built by cargo before this script runs (the `oakengine` +//! entry in `[build-dependencies]` below guarantees the build order). Cargo +//! puts it at: +//! +//! * `target//deps/liboakengine.dylib` — when built as a +//! dependency of the app (the normal case), +//! * `target//liboakengine.dylib` — when built as a workspace +//! member (`cargo build -p oakengine`). +//! +//! Both copies carry the same Mach-O install name pointing back into +//! `target//deps/`, so dyld finds the dylib by that absolute path +//! at load time; the `-rpath` flag covers configurations where the install +//! name is `@rpath`-relative instead. +//! +//! # Host symbols (`-export_dynamic`) +//! +//! The dylib is linked with `-Wl,-undefined,dynamic_lookup` (see +//! crates/oakengine/build.rs), so its remaining undefined imports — the +//! C++ host symbols `oakcore_audioparams_*`, `oakcore_rational_*` and +//! `fb_*` that [`host_syms`](crate::oakui::host_syms) provides — are +//! resolved at runtime from the app binary. `-Wl,-export_dynamic` makes +//! the binary's own symbols visible to dyld for that resolution. +//! +//! macOS-specific: this is the only platform the app targets (the dylib +//! mechanism is a Mach-O feature); on any other target the script does +//! nothing. + +use std::path::PathBuf; + +fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") { + return; + } + + let target_dir = std::env::var("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target")); + let profile = std::env::var("PROFILE").unwrap_or_else(|_| "debug".to_string()); + let profile_dir = target_dir.join(&profile); + let deps_dir = profile_dir.join("deps"); + + // The un-hashed dependency artifact is the normal case; the + // workspace-member copy is the fallback. If only the hashed artifact + // exists (liboakengine-.dylib), link it by full path. + if deps_dir.join("liboakengine.dylib").exists() { + link_search(&deps_dir); + } else if profile_dir.join("liboakengine.dylib").exists() { + link_search(&profile_dir); + } else if let Some(hashed) = find_hashed_dylib(&deps_dir) { + println!("cargo:rustc-link-arg={}", hashed.display()); + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", deps_dir.display()); + println!("cargo:rustc-link-arg=-Wl,-export_dynamic"); + } else { + panic!( + "liboakengine.dylib not found under {}: build the workspace from the repo root \ + (cargo build -p oakengine) so the liboakengine cdylib is produced before the app links", + profile_dir.display() + ); + } +} + +/// Emits the link-search path plus `-loakengine`, the runtime `-rpath` and +/// the host-symbol export flag (see the module docs). +fn link_search(dir: &std::path::Path) { + println!("cargo:rustc-link-search=native={}", dir.display()); + println!("cargo:rustc-link-lib=dylib=oakengine"); + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", dir.display()); + println!("cargo:rustc-link-arg=-Wl,-export_dynamic"); + // gpui_macos reaches the IOSurface API through the `core-video` crate, + // which depends on `io-surface` with `default-features = false` — that + // disables io-surface's `link` feature, so nothing adds the + // IOSurface.framework to the final link and the binary fails with + // undefined `_IOSurface*` symbols. The app's build script is the + // single place that configures the macOS link, so link the framework + // here. + println!("cargo:rustc-link-lib=framework=IOSurface"); +} + +/// Finds `liboakengine-.dylib` in `deps/` (some cargo configurations +/// name dependency cdylibs with a hash suffix). +fn find_hashed_dylib(deps_dir: &std::path::Path) -> Option { + let entries = std::fs::read_dir(deps_dir).ok()?; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with("liboakengine-") && name.ends_with(".dylib") { + return Some(entry.path()); + } + } + None +} diff --git a/docs/screenshot-window.png b/docs/screenshot-window.png index 5e20ab3cf02db9e5c2ad46aad348012ec1df67d8..843d17cf443014d029a5b1f68a6e1daee63f0734 100644 GIT binary patch delta 119706 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≻s9-9uS{#N=c-Cg?$U$>WHbyvzmqXF$N1C|pMi?U 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 diff --git a/examples/screenshot.rs b/examples/screenshot.rs index f61fc110e..c2ab9f503 100644 --- a/examples/screenshot.rs +++ b/examples/screenshot.rs @@ -33,6 +33,7 @@ use gpui::{px, size, AnyWindowHandle, AppContext, Result, VisualTestAppContext}; use gpui_platform::current_platform; use oakapp::app::OakApp; +use oakapp::oakui::MockEngine; const DEFAULT_WIDTH: f32 = 1600.0; const DEFAULT_HEIGHT: f32 = 900.0; @@ -53,14 +54,24 @@ fn main() -> Result<()> { cx.update(|app| app.init_colors()); let window = cx.open_offscreen_window(size(px(width), px(height)), |window, cx| { - cx.new(|cx| OakApp::new(window, cx)) + cx.new(|cx| OakApp::::new(window, None, cx)) })?; let handle: AnyWindowHandle = window.into(); - // Draw a few frames so the layout settles: the node editor fits its graph - // once the canvas size is known and the viewers upload their first CPU - // frame, both of which happen on the frame after the initial render. - for _ in 0..4 { + // Draw enough frames for the layout to settle and the async toolbar-icon + // assets to decode: the node editor fits its graph once the canvas size + // is known, the viewers upload their first CPU frame, and the PNG + // toolbar icons load through the background executor on the frame after + // the asset future resolves. + for _ in 0..16 { + cx.run_until_parked(); + cx.update_window(handle, |_root, window, app| { + let _ = window.draw(app); + })?; + } + cx.run_until_parked(); + + for _ in 0..16 { cx.run_until_parked(); cx.update_window(handle, |_root, window, app| { let _ = window.draw(app); @@ -69,6 +80,39 @@ fn main() -> Result<()> { cx.run_until_parked(); let image = cx.capture_screenshot(handle)?; + + // The timeline toolbar's tool icons (16px at 2× = 32px on 48px pitch) + // must render: the toolbar is the 31px row at the top of the bottom dock + // panel. Scan the bottom strip for the 8 tool cells and require most of + // them to contain bright glyph pixels, so a broken icon load fails the + // capture loudly instead of shipping an empty toolbar. + let th = height * 2.0; + let mut rendered = 0usize; + for (index, cell_x) in [24u32, 88, 152, 216, 280, 344, 408, 472].iter().enumerate() { + let mut bright = 0u32; + for dy in 0..80i32 { + for dx in 0..32i32 { + let x = (*cell_x as i32 + dx) as u32; + let y = (th as i32 - 320 + dy).max(0) as u32; + if x >= image.width() || y >= image.height() { + continue; + } + let p = image.get_pixel(x, y); + if p[0] > 150 && p[1] > 150 && p[2] > 150 { + bright += 1; + } + } + } + println!("[screenshot] toolbar tool {index} bright pixels: {bright}"); + if bright > 20 { + rendered += 1; + } + } + assert!( + rendered >= 6, + "timeline toolbar icons did not render (only {rendered}/8 tool cells had pixels)" + ); + std::fs::create_dir_all(std::path::Path::new(OUT).parent().unwrap())?; image.save(OUT)?; println!("wrote {OUT} ({}×{})", image.width(), image.height()); diff --git a/src/app.rs b/src/app.rs index 8ed355c88..50ea8ab2d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,8 +14,14 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! The application shell: menu bar, dock layout, status bar and the tick -//! loop that drives playback, playhead sync and the audio meter. +//! The application shell: menu bar, dock layout, status bar, modal dialogs +//! and the tick loop that drives playback, playhead sync, the audio meter +//! and the export progress. +//! +//! The shell is generic over the engine backend ([`AppEngine`]); [`run`] +//! picks the backend at startup: the real engine by default, the mock when +//! the `--mock` flag / `OAK_ENGINE=mock` env var is given or the +//! `mock-engine` cargo feature is enabled. //! //! Layout per the design (`design/Oak-UI设计图-主界面-标注版.png`): //! @@ -25,9 +31,10 @@ //! │ dock: 项目 | 素材查看器 | 序列查看器+节点编辑器 | 检查器+历史记录 //! │ (vertical split) 时间线 (full width, 31px toolbar on top) //! ├───────────────────────────────────────────────────── -//! └ status bar: 就绪 | 缓存 | 代理 | 自动保存 || 时间码/时长 | 帧率 | 分辨率 +//! └ status bar: 就绪 | 缓存 | 代理 | 自动保存 || 时间码/时长 | 帧率 | 分辨率 | 引擎 //! ``` +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -40,10 +47,15 @@ use gpui::{ WindowBounds, WindowOptions, }; use gpui_widgets::audio_meter::AudioLevelMeter; +use gpui_widgets::viewer::PlaybackClock; +use gpui_widgets::dialog::file_dialog::FileDialogContent; +use gpui_widgets::dialog::progress::{ProgressContent, progress_dialog}; +use gpui_widgets::dialog::{DialogButton, Modal, ModalEvent, ModalOptions}; use gpui_widgets::menu::{Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem}; use gpui_widgets::theme::{apply_theme, OakTheme}; -use crate::oakui::{EngineGateway, MockClock, MockEngine, Monitor}; +use crate::dialogs::{ExportDialogContent, PreferencesContent}; +use crate::oakui::{AppEngine, ExportSession, MockEngine, Monitor, RealEngine}; use crate::panels::history::HistoryPanel; use crate::panels::ids::*; use crate::panels::inspector::InspectorPanel; @@ -59,8 +71,10 @@ mod menu_ids { pub const NEW_PROJECT: usize = 101; pub const OPEN_PROJECT: usize = 102; pub const SAVE: usize = 103; - pub const EXPORT: usize = 104; - pub const QUIT: usize = 105; + pub const SAVE_AS: usize = 104; + pub const CLOSE: usize = 105; + pub const EXPORT: usize = 106; + pub const QUIT: usize = 107; pub const UNDO: usize = 201; pub const REDO: usize = 202; @@ -68,11 +82,13 @@ mod menu_ids { pub const COPY: usize = 204; pub const PASTE: usize = 205; pub const DELETE: usize = 206; + pub const RIPPLE_DELETE: usize = 207; pub const THEME_DARK: usize = 301; pub const THEME_LIGHT: usize = 302; pub const LANG_ZH: usize = 303; pub const LANG_EN: usize = 304; + pub const PREFERENCES: usize = 305; pub const PLAY_PAUSE: usize = 401; pub const PREV_FRAME: usize = 402; @@ -81,6 +97,8 @@ mod menu_ids { pub const ADD_VIDEO_TRACK: usize = 501; pub const ADD_AUDIO_TRACK: usize = 502; + pub const REMOVE_TRACK: usize = 503; + pub const SPLIT_AT_PLAYHEAD: usize = 504; pub const FOCUS_PROJECT: usize = 601; pub const FOCUS_SOURCE_VIEWER: usize = 602; @@ -93,15 +111,71 @@ mod menu_ids { pub const ABOUT: usize = 801; } -/// The panel registry: string keys for layout persistence, and the ability -/// to rebuild any panel from its key. -struct AppPanelRegistry { - engine: Entity, - source_clock: Entity, - program_clock: Entity, +/// Modal-dialog control ids (see [`ModalEvent::control`]). +mod modal_ids { + pub const FILE_OPEN: usize = 1; + pub const FILE_SAVE_AS: usize = 2; + pub const PREFERENCES: usize = 3; + pub const EXPORT: usize = 4; + pub const EXPORT_PROGRESS: usize = 5; } -impl PanelRegistry for AppPanelRegistry { +/// What a file dialog's OK button should do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FileAction { + Open, + SaveAs, +} + +/// The modal currently layered on top of the shell, if any. +enum ModalState { + None, + FileDialog { + modal: Entity, + content: Entity, + action: FileAction, + }, + Preferences { + modal: Entity, + content: Entity, + }, + Export { + modal: Entity, + content: Entity, + }, + Progress { + modal: Entity, + content: Entity, + }, +} + +/// A running export: the session the tick loop drains for progress. +struct ExportRun { + session: ExportSession, +} + +impl ModalState { + /// The modal entity currently shown, if any. + fn modal_entity(&self) -> Option> { + match self { + ModalState::None => None, + ModalState::FileDialog { modal, .. } + | ModalState::Preferences { modal, .. } + | ModalState::Export { modal, .. } + | ModalState::Progress { modal, .. } => Some(modal.clone()), + } + } +} + +/// The panel registry: string keys for layout persistence, and the ability +/// to rebuild any panel from its key. +struct AppPanelRegistry { + engine: Entity, + source_clock: Entity, + program_clock: Entity, +} + +impl PanelRegistry for AppPanelRegistry { fn panel_key(&self, id: gpui::dock::PanelId) -> Option { Some( match id { @@ -119,8 +193,6 @@ impl PanelRegistry for AppPanelRegistry { } fn build_panel(&self, key: &str, window: &mut Window, cx: &mut App) -> Option { - // Each arm builds its own `PanelHandle` because the panel views have - // different entity types. match key { "project" => Some(PanelHandle::new( cx.new(|cx| ProjectExplorerPanel::new(self.engine.clone(), window, cx)), @@ -177,27 +249,37 @@ impl PanelRegistry for AppPanelRegistry { } /// The application root view. -pub struct OakApp { - engine: Entity, - program_clock: Entity, - timeline: Entity>, - meter: Entity>, +pub struct OakApp { + engine: Entity, + program_clock: Entity, + timeline: Entity>, + meter: Entity>, menu_bar: Entity, dock: Entity, - status_bar: Entity, + status_bar: Entity>, /// Whether the dark theme is active (toggles via 视图 → 主题). dark: bool, + /// The modal currently shown on top of the shell, if any. + modal: ModalState, + /// The running export session, if any. + export: Option, } -impl OakApp { - /// Builds the whole shell. - pub fn new(window: &mut Window, cx: &mut Context) -> Self { +impl OakApp { + /// Builds the whole shell. `initial_path` (a CLI argument) is opened + /// after the layout is up. + pub fn new( + window: &mut Window, + initial_path: Option, + cx: &mut Context, + ) -> Self { apply_theme(cx, &OakTheme::olive_dark()); + crate::oakui::icons::init(cx); // --- engine and shared state --------------------------------------- - let engine = cx.new(|cx| MockEngine::demo(cx)); - let source_clock = engine.read(cx).source_clock.clone(); - let program_clock = engine.read(cx).program_clock.clone(); + let engine = cx.new(|cx| E::create(cx)); + let source_clock = engine.read(cx).source_clock().clone(); + let program_clock = engine.read(cx).program_clock().clone(); let timeline = cx.new(|cx| TimelineView::new(engine.clone(), window, cx).zoom(2.0)); let meter = cx.new(|cx| AudioLevelMeter::new(3, engine.clone(), window, cx)); @@ -312,27 +394,21 @@ impl OakApp { .detach(); // --- timeline events ----------------------------------------------- - // The playhead is driven by the program monitor; seeking the timeline - // (ruler click, keyboard) is routed back to the engine, guarded so - // clock-driven syncs are no-ops. + // Every timeline widget request (playhead seek, trim, move, track + // height) is applied by the engine through its backend's edit + // commands; the playhead is routed to the program monitor. cx.subscribe( &timeline, - |this, _timeline, event: &TimelineEvent, cx| match event { - TimelineEvent::PlayheadChanged(frame) => { - let current = this.engine.read(cx).clock_frame(Monitor::Program, cx); - if *frame != current { - this.engine.update(cx, |engine, cx| { - engine.request_frame(Monitor::Program, *frame, cx) - }); - } - } - other => println!("[timeline] request: {other:?} (not applied by the mock)"), + |this, _timeline, event: &TimelineEvent, cx| { + this.engine + .update(cx, |engine, cx| engine.apply_timeline_event(event, cx)); }, ) .detach(); // --- tick loop ----------------------------------------------------- - // Drives playback clocks, playhead sync and the audio meter at ~60Hz. + // Drives playback clocks, playhead sync, the audio meter and the + // export progress at ~60Hz. let this = cx.weak_entity(); window .spawn(cx, async move |cx: &mut AsyncWindowContext| loop { @@ -347,7 +423,7 @@ impl OakApp { }) .detach(); - Self { + let shell = Self { engine, program_clock, timeline, @@ -356,17 +432,32 @@ impl OakApp { dock, status_bar, dark: true, + modal: ModalState::None, + export: None, + }; + + // Open the CLI-provided project once the shell is up. + if let Some(path) = initial_path { + shell.engine.update(cx, |engine, cx| { + if let Err(err) = engine.open_project_path(path.clone(), cx) { + println!("[app] failed to open {}: {err}", path.display()); + } + }); } + + shell } /// One animation-frame tick: advance the engine, sync the timeline - /// playhead to the program clock, and refresh the audio meter. + /// playhead to the program clock, refresh the audio meter and drain the + /// export progress events. fn tick(&mut self, cx: &mut Context) { self.engine.update(cx, |engine, cx| engine.tick(cx)); - let frame = self.program_clock.read(cx).transport.frame(); + let frame = self.program_clock.read(cx).current_frame(); self.timeline .update(cx, |timeline, cx| timeline.seek(frame, cx)); self.meter.update(cx, |meter, cx| meter.update(cx)); + self.poll_export(cx); cx.notify(); } @@ -374,8 +465,41 @@ impl OakApp { fn on_menu(&mut self, item: usize, cx: &mut Context) { use menu_ids::*; match item { + // --- File ------------------------------------------------------ + NEW_PROJECT => self.engine.update(cx, |engine, cx| engine.new_project(cx)), + OPEN_PROJECT => self.open_file_dialog(FileAction::Open, cx), + SAVE => self.save_project(None, cx), + SAVE_AS => self.open_file_dialog(FileAction::SaveAs, cx), + CLOSE => self.engine.update(cx, |engine, cx| engine.close_project(cx)), + EXPORT => self.open_export_dialog(cx), + QUIT => cx.quit(), + // --- Edit ------------------------------------------------------ + UNDO => self.engine.update(cx, |engine, cx| engine.undo(cx)), + REDO => self.engine.update(cx, |engine, cx| engine.redo(cx)), + DELETE => self.delete_timeline_selection(false, cx), + RIPPLE_DELETE => self.delete_timeline_selection(true, cx), + CUT | COPY | PASTE => { + println!("[menu] clipboard action {item} not wired yet"); + } + // --- View ------------------------------------------------------ + THEME_DARK => { + self.dark = true; + apply_theme(cx, &OakTheme::olive_dark()); + self.rebuild_menu_bar(cx); + cx.notify(); + } + THEME_LIGHT => { + self.dark = false; + apply_theme(cx, &OakTheme::olive_light()); + self.rebuild_menu_bar(cx); + cx.notify(); + } + LANG_ZH => self.switch_language(crate::i18n::Language::ZhCN, cx), + LANG_EN => self.switch_language(crate::i18n::Language::EnUs, cx), + PREFERENCES => self.open_preferences(cx), + // --- Playback -------------------------------------------------- PLAY_PAUSE => { - let playing = self.program_clock.read(cx).transport.is_playing(); + let playing = self.program_clock.read(cx).is_playing(); let monitor = Monitor::Program; self.engine.update(cx, |engine, cx| { if playing { @@ -401,20 +525,7 @@ impl OakApp { engine.request_frame(monitor, Frame::ZERO, cx) }); } - THEME_DARK => { - self.dark = true; - apply_theme(cx, &OakTheme::olive_dark()); - self.rebuild_menu_bar(cx); - cx.notify(); - } - THEME_LIGHT => { - self.dark = false; - apply_theme(cx, &OakTheme::olive_light()); - self.rebuild_menu_bar(cx); - cx.notify(); - } - LANG_ZH => self.switch_language(crate::i18n::Language::ZhCN, cx), - LANG_EN => self.switch_language(crate::i18n::Language::EnUs, cx), + // --- Sequence -------------------------------------------------- ADD_VIDEO_TRACK => { let kind = gpui::timeline::TrackKind::Video; self.engine @@ -425,6 +536,11 @@ impl OakApp { self.engine .update(cx, |engine, cx| engine.add_track(kind, cx)); } + REMOVE_TRACK => self.remove_selected_track(cx), + SPLIT_AT_PLAYHEAD => { + self.engine.update(cx, |engine, cx| engine.split_at_playhead(cx)) + } + // --- Window ---------------------------------------------------- FOCUS_PROJECT => self.focus_panel(PROJECT, cx), FOCUS_SOURCE_VIEWER => self.focus_panel(SOURCE_VIEWER, cx), FOCUS_PROGRAM_VIEWER => self.focus_panel(PROGRAM_VIEWER, cx), @@ -436,6 +552,46 @@ impl OakApp { } } + /// Saves the project (to its own filename, or the given `path`). + fn save_project(&mut self, path: Option, cx: &mut Context) { + let result = self + .engine + .update(cx, |engine, cx| engine.save_project(path, cx)); + if let Err(err) = result { + println!("[file] save failed: {err}"); + } + } + + /// Deletes the timeline's selected clips (ripple or gap) through the + /// engine's edit commands. + fn delete_timeline_selection(&mut self, ripple: bool, cx: &mut Context) { + let ids: Vec = self + .timeline + .read(cx) + .selection() + .iter() + .copied() + .collect(); + if ids.is_empty() { + println!("[timeline] delete: nothing selected"); + return; + } + for id in ids { + self.engine + .update(cx, |engine, cx| engine.delete_clip(id, ripple, cx)); + } + } + + /// Removes the first track selected in the timeline header. + fn remove_selected_track(&mut self, cx: &mut Context) { + let Some(&index) = self.timeline.read(cx).selected_tracks().iter().next() else { + println!("[timeline] remove track: nothing selected"); + return; + }; + self.engine + .update(cx, |engine, cx| engine.remove_track(index, cx)); + } + /// Focuses a dock panel (used by the 窗口 menu). fn focus_panel(&self, id: gpui::dock::PanelId, cx: &mut Context) { if let Some(handle) = cx.windows().first() { @@ -448,8 +604,7 @@ impl OakApp { /// Switches the UI language live: updates the [`i18n`] global, rebuilds /// the menu bar (so the menu labels and the language checkmark move - /// immediately), and repaints the whole shell — every label goes through - /// [`crate::i18n::tr`] at render time, so panels flip without a restart. + /// immediately), and repaints the whole shell. fn switch_language(&mut self, language: crate::i18n::Language, cx: &mut Context) { crate::i18n::set_language(language); self.rebuild_menu_bar(cx); @@ -481,17 +636,311 @@ impl OakApp { ) .detach(); } + + // ----------------------------------------------------------------------- + // Modal dialogs + // ----------------------------------------------------------------------- + + /// Closes the current modal. + fn close_modal(&mut self, cx: &mut Context) { + self.modal = ModalState::None; + cx.notify(); + } + + /// Builds a modal on the main window, subscribes it to + /// [`Self::on_modal`] and layers it onto the shell. + /// + /// The modal is created inside `update_window` (modal widgets need a + /// `&mut Window`); the state swap and the subscription happen *after* the + /// window update returns, on this entity's own `Context` — swapping state + /// through a weak handle *inside* the window callback would re-enter this + /// entity while it is already being updated (the crash seen when opening + /// Preferences from a menu action). + fn spawn_modal( + &mut self, + cx: &mut Context, + build: impl FnOnce(&mut Window, &mut App) -> ModalState, + ) { + let windows = cx.windows(); + let Some(handle) = windows.first() else { + return; + }; + let Ok(state) = cx.update_window(*handle, |_root, window, app| build(window, app)) else { + return; + }; + let modal = state + .modal_entity() + .expect("spawned modal always carries a Modal"); + cx.subscribe(&modal, |this, _entity, event: &ModalEvent, cx| { + this.on_modal(event, cx); + }) + .detach(); + self.modal = state; + cx.notify(); + } + + /// Opens the file open / save-as dialog. + fn open_file_dialog(&mut self, action: FileAction, cx: &mut Context) { + let (title, control) = match action { + FileAction::Open => ( + crate::i18n::tr("file.open.title"), + modal_ids::FILE_OPEN, + ), + FileAction::SaveAs => ( + crate::i18n::tr("file.save_as.title"), + modal_ids::FILE_SAVE_AS, + ), + }; + let current_path = self + .engine + .read(cx) + .project() + .map(|p| p.path.clone()) + .filter(|p| !p.as_os_str().is_empty()); + self.spawn_modal(cx, move |window, app| { + let (modal, content) = + gpui_widgets::dialog::file_dialog::file_dialog(control, title, window, app); + if action == FileAction::SaveAs { + if let Some(path) = ¤t_path { + content.update(app, |content, cx| content.set_path(path.to_string_lossy().into_owned(), cx)); + } + } + ModalState::FileDialog { + modal, + content, + action, + } + }); + } + + /// Opens the preferences dialog. + fn open_preferences(&mut self, cx: &mut Context) { + self.spawn_modal(cx, |window, app| { + let content = app.new(|cx| PreferencesContent::new(window, cx)); + let modal = app.new(|cx| { + Modal::new( + modal_ids::PREFERENCES, + ModalOptions::new(crate::i18n::tr("preferences.title"), px(380.0)) + .with_button(DialogButton::primary(crate::i18n::tr("dialog.close"))), + window, + cx, + ) + .with_content(content.clone()) + }); + ModalState::Preferences { modal, content } + }); + } + + /// Opens the export dialog. + fn open_export_dialog(&mut self, cx: &mut Context) { + if self.engine.read(cx).current_sequence().is_none() { + println!("[export] no sequence open"); + return; + } + let default_path = self.default_export_path(cx); + self.spawn_modal(cx, move |window, app| { + let content = app.new(|cx| ExportDialogContent::new(window, cx)); + content.update(app, |content, cx| { + content.set_path(default_path.clone(), cx) + }); + let modal = app.new(|cx| { + Modal::new( + modal_ids::EXPORT, + ModalOptions::new(crate::i18n::tr("export.title"), px(440.0)) + .with_button(DialogButton::primary(crate::i18n::tr("export.run"))) + .with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))), + window, + cx, + ) + .with_content(content.clone()) + }); + ModalState::Export { modal, content } + }); + } + + /// A default output path for the export dialog: the project name with + /// the format's extension, next to the project file. + fn default_export_path(&self, cx: &App) -> String { + let project = self.engine.read(cx).project(); + let name = project + .map(|p| p.name.clone()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| "untitled".to_string()); + let dir = project + .and_then(|p| p.path.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| PathBuf::from(".")); + dir.join(format!("{name}.mp4")) + .to_string_lossy() + .into_owned() + } + + /// Starts the export from the export dialog's state and swaps the dialog + /// for the progress dialog. + fn begin_export(&mut self, cx: &mut Context) { + let ModalState::Export { content, .. } = &self.modal else { + return; + }; + let format = content.read(cx).format(cx); + let ext = content.read(cx).extension(cx); + let mut path = content.read(cx).path(cx).to_string(); + if path.trim().is_empty() { + return; + } + // Append the format's extension when the user left it off. + let has_ext = std::path::Path::new(&path) + .extension() + .map(|e| !e.to_string_lossy().is_empty()) + .unwrap_or(false); + if !has_ext { + path = format!("{path}.{ext}"); + } + + let result = self.engine.update(cx, |engine, _cx| { + engine.start_export(format, PathBuf::from(&path)) + }); + match result { + Ok(session) => { + self.export = Some(ExportRun { session }); + self.spawn_modal(cx, |window, app| { + let (modal, content) = progress_dialog( + modal_ids::EXPORT_PROGRESS, + crate::i18n::tr("export.progress.title"), + crate::i18n::tr("export.progress.label"), + window, + app, + ); + ModalState::Progress { modal, content } + }); + } + Err(err) => { + println!("[export] failed to start: {err}"); + self.close_modal(cx); + } + } + } + + /// Cancels the running export (the task aborts at the next frame). + fn cancel_export(&mut self, cx: &mut Context) { + if let Some(run) = &self.export { + (run.session.cancel)(); + } + let _ = cx; + } + + /// Drains the export progress events on the tick loop: updates the + /// progress bar and closes the dialog when the task finishes. + fn poll_export(&mut self, cx: &mut Context) { + let Some(run) = &self.export else { + return; + }; + let mut events = Vec::new(); + while let Ok(event) = run.session.events.try_recv() { + events.push(event); + } + if events.is_empty() { + return; + } + let mut finished: Option<(bool, String)> = None; + for event in events { + match event { + crate::oakui::ExportEvent::Started => {} + crate::oakui::ExportEvent::Progress(fraction) => { + if let ModalState::Progress { content, .. } = &self.modal { + let fraction = fraction as f32; + content.update(cx, |content, cx| content.set_progress(fraction, cx)); + } + } + crate::oakui::ExportEvent::Finished(ok, err) => finished = Some((ok, err)), + } + } + if let Some((ok, err)) = finished { + self.export = None; + self.modal = ModalState::None; + if ok { + println!("[export] finished"); + } else { + println!("[export] failed: {err}"); + } + cx.notify(); + } + } + + /// Routes a modal dialog event. + fn on_modal(&mut self, event: &ModalEvent, cx: &mut Context) { + match event { + ModalEvent::ButtonClicked { control, button } => match *control { + modal_ids::FILE_OPEN | modal_ids::FILE_SAVE_AS => { + self.on_file_dialog_button(*button, cx); + } + modal_ids::EXPORT => { + if *button == 0 { + self.begin_export(cx); + } else { + self.close_modal(cx); + } + } + modal_ids::EXPORT_PROGRESS => { + if *button == 1 { + // Cancel button: ask the task to abort; the finished + // event closes the dialog. + self.cancel_export(cx); + } + } + modal_ids::PREFERENCES => self.close_modal(cx), + _ => {} + }, + ModalEvent::Dismissed { control } => match *control { + modal_ids::EXPORT_PROGRESS => { + // Escape cancels the running export and closes the dialog. + self.cancel_export(cx); + self.close_modal(cx); + } + _ => self.close_modal(cx), + }, + } + } + + /// Handles the file dialog's OK/Cancel. + fn on_file_dialog_button(&mut self, button: usize, cx: &mut Context) { + let ModalState::FileDialog { + content, action, .. + } = &self.modal + else { + return; + }; + if button != 0 { + self.close_modal(cx); + return; + } + let path = PathBuf::from(content.read(cx).path(cx).to_string()); + let action = *action; + if path.as_os_str().is_empty() { + return; + } + let result = self.engine.update(cx, |engine, cx| match action { + FileAction::Open => engine.open_project_path(path.clone(), cx), + FileAction::SaveAs => engine.save_project(Some(path.clone()), cx), + }); + if let Err(err) = result { + println!("[file] {action:?} failed: {err}"); + } + self.close_modal(cx); + } } -impl Render for OakApp { +impl Render for OakApp { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() + let mut root = div() .size_full() .flex() .flex_col() .child(self.menu_bar.clone()) .child(div().flex_1().child(self.dock.clone())) - .child(self.status_bar.clone()) + .child(self.status_bar.clone()); + if let Some(modal) = self.modal.modal_entity() { + root = root.child(modal); + } + root } } @@ -523,8 +972,10 @@ fn make_menus(dark: bool) -> Vec { Menu::new(vec![ MenuItem::new(NEW_PROJECT, tr("menu.file.new_project")).with_shortcut("⌘N"), MenuItem::new(OPEN_PROJECT, tr("menu.file.open_project")).with_shortcut("⌘O"), - MenuItem::new(SAVE, tr("menu.file.save")).with_shortcut("⌘S").separated(), - MenuItem::new(EXPORT, tr("menu.file.export")).disabled(), + MenuItem::new(SAVE, tr("menu.file.save")).with_shortcut("⌘S"), + MenuItem::new(SAVE_AS, tr("menu.file.save_as")).with_shortcut("⇧⌘S").separated(), + MenuItem::new(CLOSE, tr("menu.file.close")), + MenuItem::new(EXPORT, tr("menu.file.export")).with_shortcut("⌘E").separated(), MenuItem::new(QUIT, tr("menu.file.quit")).with_shortcut("⌘Q").separated(), ]), ), @@ -536,7 +987,8 @@ fn make_menus(dark: bool) -> Vec { MenuItem::new(CUT, tr("menu.edit.cut")).with_shortcut("⌘X"), MenuItem::new(COPY, tr("menu.edit.copy")).with_shortcut("⌘C"), MenuItem::new(PASTE, tr("menu.edit.paste")).with_shortcut("⌘V"), - MenuItem::new(DELETE, tr("menu.edit.delete")).separated(), + MenuItem::new(DELETE, tr("menu.edit.delete")).with_shortcut("⌫").separated(), + MenuItem::new(RIPPLE_DELETE, tr("menu.edit.ripple_delete")), ]), ), MenuBarEntry::new( @@ -544,6 +996,7 @@ fn make_menus(dark: bool) -> Vec { Menu::new(vec![ MenuItem::new(THEME_DARK, tr("menu.view.theme")).with_submenu(theme_submenu), MenuItem::new(LANG_ZH, tr("menu.view.language")).with_submenu(language_submenu), + MenuItem::new(PREFERENCES, tr("menu.view.preferences")).separated(), ]), ), MenuBarEntry::new( @@ -562,7 +1015,9 @@ fn make_menus(dark: bool) -> Vec { Menu::new(vec![ MenuItem::new(ADD_VIDEO_TRACK, tr("menu.sequence.add_video_track")), MenuItem::new(ADD_AUDIO_TRACK, tr("menu.sequence.add_audio_track")), - MenuItem::new(503, tr("menu.sequence.settings")).disabled(), + MenuItem::new(REMOVE_TRACK, tr("menu.sequence.remove_track")).separated(), + MenuItem::new(SPLIT_AT_PLAYHEAD, tr("menu.sequence.split_at_playhead")), + MenuItem::new(704, tr("menu.sequence.settings")).disabled(), ]), ), MenuBarEntry::new( @@ -592,21 +1047,82 @@ fn make_menus(dark: bool) -> Vec { ] } +/// Command-line arguments the app accepts. +#[derive(Debug, Clone, Default)] +struct AppArgs { + /// A project file to open at startup. + project: Option, + /// Force the mock engine. + mock: bool, +} + +impl AppArgs { + /// Parses `std::env::args` plus the `OAK_ENGINE` override: + /// `oakapp [project.ove] [--mock]`. + fn from_env() -> Self { + let mut args = AppArgs::default(); + for arg in std::env::args_os().skip(1) { + let text = arg.to_string_lossy(); + match text.as_ref() { + "--mock" => args.mock = true, + "--help" | "-h" => { + println!("oakapp — Oak Video Editor"); + println!("usage: oakapp [project.ove] [--mock]"); + println!(" --mock use the mock engine (or set OAK_ENGINE=mock)"); + std::process::exit(0); + } + _ if args.project.is_none() => args.project = Some(arg.into()), + other => println!("[app] ignoring unknown argument {other:?}"), + } + } + if std::env::var("OAK_ENGINE") + .map(|v| v.eq_ignore_ascii_case("mock")) + .unwrap_or(false) + { + args.mock = true; + } + args + } +} + +/// Builds the app root entity for the chosen backend. +fn build_root( + window: &mut Window, + initial: Option, + cx: &mut App, +) -> Entity> { + cx.new(|cx| OakApp::new(window, initial, cx)) +} + /// The crate entry point: applies the olive-dark theme and opens the main -/// window. +/// window, running on the real engine by default (mock with `--mock` / +/// `OAK_ENGINE=mock` / the `mock-engine` feature). pub fn run() { - gpui_platform::application().run(|cx: &mut App| { - // Restore the persisted UI language (oakcommon config `Language` key) - // before the first window renders. + let args = AppArgs::from_env(); + let use_mock = args.mock || cfg!(feature = "mock-engine"); + if use_mock { + run_with::(args.clone()); + } else { + run_with::(args); + } +} + +/// Runs the app window with `E` as the engine backend. +fn run_with(args: AppArgs) { + let initial = args.project.clone(); + gpui_platform::application().run(move |cx: &mut App| { + // Restore the persisted UI language (config `Language` key) before the + // first window renders. crate::i18n::init(); cx.init_colors(); let bounds = Bounds::centered(None, size(px(1600.0), px(900.0)), cx); + let initial = initial.clone(); cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), ..Default::default() }, - |window, cx| cx.new(|cx| OakApp::new(window, cx)), + |window, cx| build_root::(window, initial, cx), ) .expect("failed to open the main window"); @@ -623,6 +1139,7 @@ pub fn run() { #[cfg(test)] mod tests { use super::*; + use gpui::{TestAppContext, px, size}; /// The 视图/View menu carries a 语言/Language submenu whose items are /// labeled in their own language and whose checkmark follows the active @@ -714,4 +1231,121 @@ mod tests { assert_eq!(dark_item(true).checked, Some(true)); assert_eq!(dark_item(false).checked, Some(false)); } + + /// The File menu exposes the full project lifecycle actions (open / + /// save / save-as / close / export) and the Edit menu the undo stack + /// plus the delete variants, across both languages. + #[test] + fn file_and_edit_menus_cover_the_project_lifecycle() { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + + let entry = |title: &str| -> MenuBarEntry { + make_menus(true) + .into_iter() + .find(|entry| entry.title == title) + .expect("menu exists") + }; + + crate::i18n::set_language(crate::i18n::Language::EnUs); + let file = entry("File(F)"); + for id in [ + menu_ids::NEW_PROJECT, + menu_ids::OPEN_PROJECT, + menu_ids::SAVE, + menu_ids::SAVE_AS, + menu_ids::CLOSE, + menu_ids::EXPORT, + menu_ids::QUIT, + ] { + assert!( + file.menu.items.iter().any(|item| item.id == id), + "File menu is missing item {id}" + ); + } + let edit = entry("Edit(E)"); + for id in [ + menu_ids::UNDO, + menu_ids::REDO, + menu_ids::DELETE, + menu_ids::RIPPLE_DELETE, + ] { + assert!( + edit.menu.items.iter().any(|item| item.id == id), + "Edit menu is missing item {id}" + ); + } + + // The same ids exist in the zh-CN menu bar. + crate::i18n::set_language(crate::i18n::Language::ZhCN); + let file = entry("文件(F)"); + assert!(file.menu.items.iter().any(|item| item.id == menu_ids::SAVE_AS)); + } + + /// Opening 视图 → Preferences… must not crash: the dialog content and the + /// modal are built on the main window and the shell state swaps over the + /// entity's weak handle (regression test for the Preferences crash). + #[gpui::test] + async fn preferences_dialog_opens_without_crashing(cx: &mut TestAppContext) { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + crate::i18n::set_language(crate::i18n::Language::EnUs); + + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(1600.0), px(900.0)), |window, cx| { + OakApp::::new(window, None, cx) + }); + cx.run_until_parked(); + let root = window.root(cx).expect("app root"); + + cx.update(|app| { + root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx)) + }); + cx.run_until_parked(); + // Force a draw so render-time panics in the dialog content surface. + cx.update_window(window.into(), |_root, window, cx| { + window.draw(cx).clear(); + }) + .expect("window is still open"); + + let has_modal = cx.read(|app| { + matches!(root.read(app).modal, ModalState::Preferences { .. }) + }); + assert!( + has_modal, + "preferences modal should be shown after the menu action" + ); + } + + /// The command-line parser understands the project path and the mock + /// flag, and the `OAK_ENGINE` env var forces the mock. + #[test] + fn app_args_parse_path_and_mock_flag() { + // Simulate argv without touching the real environment: parse a slice + // directly. + let parse = |argv: &[&str], env: Option<&str>| -> AppArgs { + let mut args = AppArgs::default(); + for text in argv { + match *text { + "--mock" => args.mock = true, + other => args.project = Some(PathBuf::from(other)), + } + } + if env.map(|v| v.eq_ignore_ascii_case("mock")).unwrap_or(false) { + args.mock = true; + } + args + }; + let a = parse(&["/tmp/a.ove"], None); + assert_eq!(a.project, Some(PathBuf::from("/tmp/a.ove"))); + assert!(!a.mock); + + let b = parse(&["/tmp/a.ove", "--mock"], None); + assert!(b.mock); + + let c = parse(&["/tmp/a.ove"], Some("MOCK")); + assert!(c.mock); + + let d = parse(&[], None); + assert!(d.project.is_none()); + assert!(!d.mock); + } } diff --git a/src/dialogs.rs b/src/dialogs.rs new file mode 100644 index 000000000..a27e0691b --- /dev/null +++ b/src/dialogs.rs @@ -0,0 +1,310 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! The content views of the app's modal dialogs: preferences (renderer +//! backend + language) and export (format + output path). +//! +//! Each view owns its widgets and emits nothing itself — the host +//! (`crate::app::OakApp`) reads the state (format / path) when a dialog +//! button is clicked, and the preferences view writes its choices straight +//! through the config C ABI on selection. + +use gpui::colors::DefaultColors; +use gpui::prelude::*; +use gpui::{App, Context, Entity, Render, SharedString, Window, div}; +use gpui_elements::editable_text::{EditableTextState, StringStorage, text_input}; +use gpui_widgets::combo_box::{ComboBox, ComboBoxEvent, ComboBoxOption}; + +use crate::i18n; +use crate::oakui::real::{ + config_get_string, config_set_string, encoding_formats, renderer_backends, + CONFIG_KEY_RENDERER_BACKEND, EXPORT_FORMAT_MP4, +}; + +// --------------------------------------------------------------------------- +// Preferences +// --------------------------------------------------------------------------- + +/// The preferences dialog content: the renderer backend and the language +/// dropdowns. Both write through the config C ABI on selection, so the +/// choices survive restarts. +pub struct PreferencesContent { + backend: Entity, + language: Entity, + /// The backend options, in display order. + backends: Vec<&'static str>, +} + +impl PreferencesContent { + /// Builds the content: reads the current config values and seeds the + /// dropdowns. + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let backends = renderer_backends(); + let current_backend = config_get_string(CONFIG_KEY_RENDERER_BACKEND); + let backend_selected = backends + .iter() + .position(|b| b.eq_ignore_ascii_case(¤t_backend)) + .unwrap_or(0); + let backend_options = backends + .iter() + .enumerate() + .map(|(i, name)| ComboBoxOption::new(i, backend_label(name))) + .collect(); + let backend = cx.new(|cx| { + ComboBox::new(1, backend_options, window, cx) + .with_placeholder(i18n::tr("preferences.backend.placeholder")) + }); + cx.subscribe(&backend, |this, _combo, event: &ComboBoxEvent, cx| { + if let ComboBoxEvent::Selected { value, .. } = event { + if let Some(name) = this.backends.get(*value) { + config_set_string(CONFIG_KEY_RENDERER_BACKEND, name); + println!("[preferences] renderer backend → {name}"); + } + } + let _ = cx; + }) + .detach(); + backend.update(cx, |combo, cx| combo.set_selected(Some(backend_selected), cx)); + + let language_options = vec![ + ComboBoxOption::new(0, "English (en-US)"), + ComboBoxOption::new(1, "简体中文 (zh-CN)"), + ]; + let language = cx.new(|cx| { + ComboBox::new(2, language_options, window, cx) + .with_placeholder(i18n::tr("preferences.language.placeholder")) + }); + let language_selected = match crate::i18n::language() { + crate::i18n::Language::EnUs => 0, + crate::i18n::Language::ZhCN => 1, + }; + cx.subscribe(&language, |_this, _combo, event: &ComboBoxEvent, cx| { + if let ComboBoxEvent::Selected { value, .. } = event { + let language = match *value { + 1 => crate::i18n::Language::ZhCN, + _ => crate::i18n::Language::EnUs, + }; + crate::i18n::set_language(language); + } + let _ = cx; + }) + .detach(); + language.update(cx, |combo, cx| combo.set_selected(Some(language_selected), cx)); + + Self { + backend, + language, + backends, + } + } +} + +/// A display label for a renderer backend id. +fn backend_label(name: &str) -> String { + match name { + "opengl" => "OpenGL", + "metal" => "Metal", + "vulkan" => "Vulkan", + "none" => "None (off)", + other => other, + } + .to_string() +} + +/// A labeled form row: a small caption above the widget. +fn form_row( + colors: &gpui::colors::Colors, + label: SharedString, + widget: impl IntoElement, +) -> gpui::Div { + div() + .flex() + .flex_col() + .gap_1() + .child(div().text_color(colors.text).child(label)) + .child(widget) +} + +impl Render for PreferencesContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + div() + .flex() + .flex_col() + .gap_3() + .w_full() + .child(form_row( + &colors, + i18n::tr("preferences.backend").into(), + self.backend.clone(), + )) + .child(form_row( + &colors, + i18n::tr("preferences.language").into(), + self.language.clone(), + )) + .child( + div() + .text_color(colors.disabled) + .text_xs() + .child(i18n::tr("preferences.hint")), + ) + } +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +/// A text field with the same shape as the file dialog's path field. +pub struct PathField { + editor: Entity, +} + +impl PathField { + /// The path currently entered. + pub fn path(&self, app: &App) -> SharedString { + self.editor.read(app).as_str().into() + } + + /// Replaces the path shown in the field. + pub fn set_path(&mut self, path: impl Into, cx: &mut Context) { + let path = path.into(); + self.editor.update(cx, |editor, cx| { + editor.emplace(path.as_ref(), cx); + }); + cx.notify(); + } +} + +impl Render for PathField { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let weak = self.editor.downgrade(); + div() + .rounded_md() + .border_1() + .border_color(colors.border) + .bg(colors.background) + .px_2() + .py_1() + .child(text_input("gpui-widgets-export-path").state(weak).accepts_input(true)) + } +} + +/// The export dialog content: the container-format dropdown and the output +/// path field. +pub struct ExportDialogContent { + format: Entity, + path: Entity, + /// (format id, display name, extension) in dropdown order. + formats: Vec<(i32, String, String)>, +} + +impl ExportDialogContent { + /// Builds the content: the format list comes from the oakcodec encoding + /// enumeration (MP4 default), the path starts empty. + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let formats: Vec<(i32, String, String)> = encoding_formats() + .into_iter() + .filter(|(_, _, ext)| !ext.is_empty()) + .collect(); + let options = formats + .iter() + .enumerate() + .map(|(i, (_, name, ext))| { + ComboBoxOption::new(i, format!("{name} (.{ext})")) + }) + .collect(); + let format = cx.new(|cx| { + ComboBox::new(3, options, window, cx) + .with_placeholder(i18n::tr("export.format.placeholder")) + }); + let mp4_index = formats + .iter() + .position(|(id, _, _)| *id == EXPORT_FORMAT_MP4) + .unwrap_or(0); + format.update(cx, |combo, cx| combo.set_selected(Some(mp4_index), cx)); + + let path = cx.new(|cx| { + let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); + PathField { editor } + }); + + Self { + format, + path, + formats, + } + } + + /// The selected format id. + pub fn format(&self, cx: &App) -> i32 { + let Some(selected) = self.format.read(cx).selected() else { + return EXPORT_FORMAT_MP4; + }; + self.formats + .get(selected) + .map(|(id, _, _)| *id) + .unwrap_or(EXPORT_FORMAT_MP4) + } + + /// The selected format's file extension (without the dot). + pub fn extension(&self, cx: &App) -> String { + let Some(selected) = self.format.read(cx).selected() else { + return "mp4".to_string(); + }; + self.formats + .get(selected) + .map(|(_, _, ext)| ext.clone()) + .unwrap_or_else(|| "mp4".to_string()) + } + + /// The output path currently entered. + pub fn path(&self, cx: &App) -> SharedString { + self.path.read(cx).path(cx) + } + + /// Pre-fills the output path. + pub fn set_path(&mut self, path: impl Into, cx: &mut Context) { + let path = path.into(); + self.path.update(cx, |content, cx| content.set_path(path, cx)); + cx.notify(); + } +} + +impl Render for ExportDialogContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + div() + .flex() + .flex_col() + .gap_3() + .w_full() + .child(form_row( + &colors, + i18n::tr("export.format").into(), + self.format.clone(), + )) + .child(form_row(&colors, i18n::tr("export.path").into(), self.path.clone())) + .child( + div() + .text_color(colors.disabled) + .text_xs() + .child(i18n::tr("export.hint")), + ) + } +} diff --git a/src/i18n.rs b/src/i18n.rs index 1a328994f..1021a48f5 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -16,9 +16,9 @@ //! Tiny localization layer: embedded en-US / zh-CN string tables, a //! [`tr`] lookup used by every user-visible label in the app, and a runtime -//! language setting persisted through the oakcommon config C ABI -//! (`oakcommon_config_get` / `oakcommon_config_set`, the process-wide -//! `ConfigStore`). +//! language setting persisted through the oakengine config C ABI +//! (`oakengine_config_get_string` / `oakengine_config_set_string`, the +//! process-wide `ConfigStore`). //! //! # The tables //! @@ -31,18 +31,11 @@ //! //! The language is a process-global [`Language`] (an atomic, so any thread //! can read it without locking). At startup [`init`] loads the persisted -//! value from the oakcommon config key `Language` (`"zh-CN"` / `"en-US"`; -//! empty or unknown values mean en-US). [`set_language`] flips the global -//! and writes the new value back through the same key so the preference -//! survives restarts. -//! -//! The oakcommon C ABI is resolved at runtime with `dlopen`/`dlsym`, so the -//! app builds, tests and runs without liboakcommon present (e.g. under -//! `cargo test`): when the library cannot be loaded the layer degrades to an -//! in-process store and still switches languages live. Once the app is -//! packaged with liboakcommon in the library search path (or -//! `OAK_LIB_DIR` is set to a build tree), the setting round-trips through -//! `config.ini`. +//! value from the config key `Language` (`"zh-CN"` / `"en-US"`; empty or +//! unknown values mean en-US). [`set_language`] flips the global and writes +//! the new value back through the same key so the preference survives +//! restarts. The preferences dialog drives the same setting through the +//! config C ABI directly. use std::sync::atomic::{AtomicU8, Ordering}; @@ -88,7 +81,7 @@ pub fn language() -> Language { } /// Switches the active language live and persists the choice through the -/// oakcommon config C ABI (when the library is loadable). +/// oakengine config C ABI. pub fn set_language(language: Language) { CURRENT.store(match language { Language::EnUs => 0, @@ -98,24 +91,21 @@ pub fn set_language(language: Language) { sync_widgets(); } -/// Loads the persisted language from the oakcommon config C ABI. Called once -/// at startup. Never fails: without liboakcommon the default (en-US) stays. +/// Loads the persisted language from the oakengine config C ABI. Called once +/// at startup. Never fails: a missing key keeps the default (en-US). pub fn init() { - let Some(store) = ConfigAbi::load() else { + let code = crate::oakui::real::config_get_string("Language"); + if !code.is_empty() { + set_language(Language::from_code(&code)); + } else { sync_widgets(); - return; - }; - match store.get("Language") { - Some(code) if !code.is_empty() => set_language(Language::from_code(&code)), - _ => sync_widgets(), } } -/// Writes `language` back to the oakcommon config `Language` key. +/// Writes `language` back to the config `Language` key through the facade +/// config C ABI. fn persist_language(language: Language) { - if let Some(store) = ConfigAbi::load() { - store.set("Language", language.code()); - } + crate::oakui::real::config_set_string("Language", language.code()); } /// Translates `key` in the active language. @@ -135,6 +125,13 @@ pub const WIDGET_KEYS: &[&str] = &[ "viewer.safe_frames", "viewer.zoom", "viewer.no_frame_source", + "viewer.in_point", + "viewer.step_back", + "viewer.play", + "viewer.pause", + "viewer.step_forward", + "viewer.out_point", + "viewer.clear_range", "effect_stack.empty", "effect_stack.add", ]; @@ -184,6 +181,8 @@ const EN: &[(&str, &str)] = &[ ("menu.file.new_project", "New Project…"), ("menu.file.open_project", "Open Project…"), ("menu.file.save", "Save"), + ("menu.file.save_as", "Save As…"), + ("menu.file.close", "Close Project"), ("menu.file.export", "Export…"), ("menu.file.quit", "Quit"), // --- Edit --- @@ -193,6 +192,7 @@ const EN: &[(&str, &str)] = &[ ("menu.edit.copy", "Copy"), ("menu.edit.paste", "Paste"), ("menu.edit.delete", "Delete"), + ("menu.edit.ripple_delete", "Ripple Delete"), // --- View --- ("menu.view.theme", "Theme"), ("menu.view.theme.dark", "Olive Dark"), @@ -200,6 +200,7 @@ const EN: &[(&str, &str)] = &[ ("menu.view.language", "Language"), ("menu.view.language.en", "English"), ("menu.view.language.zh", "简体中文"), + ("menu.view.preferences", "Preferences…"), // --- Playback --- ("menu.playback.play_pause", "Play/Pause"), ("menu.playback.prev_frame", "Previous Frame"), @@ -208,6 +209,8 @@ const EN: &[(&str, &str)] = &[ // --- Sequence --- ("menu.sequence.add_video_track", "Add Video Track"), ("menu.sequence.add_audio_track", "Add Audio Track"), + ("menu.sequence.remove_track", "Remove Selected Track"), + ("menu.sequence.split_at_playhead", "Split Clips at Playhead"), ("menu.sequence.settings", "Sequence Settings…"), // --- Window --- ("menu.window.project", "Project"), @@ -237,6 +240,7 @@ const EN: &[(&str, &str)] = &[ ("status.proxy", "Proxy: Off"), ("status.autosave", "Autosave: 3 min ago"), ("status.untitled", "Untitled Project"), + ("status.backend", "Engine:"), // --- timeline toolbar --- ("timeline.tool.select", "Select"), ("timeline.tool.razor", "Razor"), @@ -244,16 +248,35 @@ const EN: &[(&str, &str)] = &[ ("timeline.tool.slip", "Slip"), ("timeline.tool.roll", "Roll"), ("timeline.tool.zoom", "Zoom"), - ("timeline.tool.knife", "Knife"), - ("timeline.tool.marker", "Marker"), + ("timeline.tool.slide", "Slide"), + ("timeline.tool.track_select", "Track Select"), ("timeline.zoom", "Zoom"), + ("timeline.zoom_in", "Zoom In"), + ("timeline.zoom_out", "Zoom Out"), ("timeline.track_height", "Track Height"), ("timeline.snap", "Snap"), + // --- project bin --- + ("bin.footage", "Footage"), + ("bin.music", "Music"), + // --- history (undo stack demo entries) --- + ("history.transform", "Transform"), + ("history.move_clip", "Move Clip"), + ("history.delete_clip", "Delete"), + ("history.add_lut", "Add OCIO LUT"), + ("history.set_in_point", "Set In Point"), // --- node editor --- ("node.fit", "Fit"), // --- viewer header chips --- ("viewer.source", "Source Viewer · Source"), ("viewer.program", "Program Viewer · Program"), + // --- viewer transport tooltips --- + ("viewer.in_point", "Set In Point"), + ("viewer.step_back", "Previous Frame"), + ("viewer.play", "Play"), + ("viewer.pause", "Pause"), + ("viewer.step_forward", "Next Frame"), + ("viewer.out_point", "Set Out Point"), + ("viewer.clear_range", "Clear In/Out Range"), // --- widget-baked strings (synced to gpui_widgets::i18n) --- ("viewer.safe_frames", "Safe Frames"), ("viewer.zoom", "Zoom"), @@ -262,6 +285,25 @@ const EN: &[(&str, &str)] = &[ ("effect_stack.add", "+ Add Effect"), // --- inspector --- ("inspector.params", "Parameters (placeholder)"), + // --- dialogs --- + ("dialog.cancel", "Cancel"), + ("dialog.close", "Close"), + ("file.open.title", "Open Project"), + ("file.save_as.title", "Save Project As"), + ("preferences.title", "Preferences"), + ("preferences.backend", "Renderer backend"), + ("preferences.backend.placeholder", "Select a backend…"), + ("preferences.language", "Language"), + ("preferences.language.placeholder", "Select a language…"), + ("preferences.hint", "The renderer backend applies to the render worker at the next launch; the language switches immediately."), + ("export.title", "Export Sequence"), + ("export.format", "Format"), + ("export.format.placeholder", "Select a format…"), + ("export.path", "Output path"), + ("export.run", "Export"), + ("export.hint", "The sequence is exported through the oaktask export path; progress is shown in the dialog."), + ("export.progress.title", "Exporting"), + ("export.progress.label", "Rendering frames…"), ]; /// The zh-CN table. Mirrors [`EN`] key-for-key. @@ -279,6 +321,8 @@ const ZH: &[(&str, &str)] = &[ ("menu.file.new_project", "新建项目…"), ("menu.file.open_project", "打开项目…"), ("menu.file.save", "保存"), + ("menu.file.save_as", "另存为…"), + ("menu.file.close", "关闭项目"), ("menu.file.export", "导出…"), ("menu.file.quit", "退出"), // --- Edit --- @@ -288,6 +332,7 @@ const ZH: &[(&str, &str)] = &[ ("menu.edit.copy", "复制"), ("menu.edit.paste", "粘贴"), ("menu.edit.delete", "删除"), + ("menu.edit.ripple_delete", "波纹删除"), // --- View --- ("menu.view.theme", "主题"), ("menu.view.theme.dark", "Olive Dark"), @@ -295,6 +340,7 @@ const ZH: &[(&str, &str)] = &[ ("menu.view.language", "语言"), ("menu.view.language.en", "English"), ("menu.view.language.zh", "简体中文"), + ("menu.view.preferences", "偏好设置…"), // --- Playback --- ("menu.playback.play_pause", "播放/暂停"), ("menu.playback.prev_frame", "上一帧"), @@ -303,6 +349,8 @@ const ZH: &[(&str, &str)] = &[ // --- Sequence --- ("menu.sequence.add_video_track", "添加视频轨道"), ("menu.sequence.add_audio_track", "添加音频轨道"), + ("menu.sequence.remove_track", "删除所选轨道"), + ("menu.sequence.split_at_playhead", "在播放头处分割片段"), ("menu.sequence.settings", "序列设置…"), // --- Window --- ("menu.window.project", "项目"), @@ -332,6 +380,7 @@ const ZH: &[(&str, &str)] = &[ ("status.proxy", "代理:关"), ("status.autosave", "自动保存:3分钟前"), ("status.untitled", "未命名项目"), + ("status.backend", "引擎:"), // --- timeline toolbar --- ("timeline.tool.select", "选择"), ("timeline.tool.razor", "剃刀"), @@ -339,16 +388,35 @@ const ZH: &[(&str, &str)] = &[ ("timeline.tool.slip", "滑动"), ("timeline.tool.roll", "滚动"), ("timeline.tool.zoom", "缩放"), - ("timeline.tool.knife", "刀"), - ("timeline.tool.marker", "标记"), + ("timeline.tool.slide", "滑移"), + ("timeline.tool.track_select", "轨道选择"), ("timeline.zoom", "缩放"), + ("timeline.zoom_in", "放大"), + ("timeline.zoom_out", "缩小"), ("timeline.track_height", "轨道高"), ("timeline.snap", "吸附"), + // --- project bin --- + ("bin.footage", "素材"), + ("bin.music", "音乐"), + // --- history (undo stack demo entries) --- + ("history.transform", "变换"), + ("history.move_clip", "移动片段"), + ("history.delete_clip", "删除"), + ("history.add_lut", "添加 OCIO LUT"), + ("history.set_in_point", "设置入点"), // --- node editor --- ("node.fit", "适配"), // --- viewer header chips --- ("viewer.source", "素材查看器 · 源"), ("viewer.program", "序列查看器 · 节目"), + // --- viewer transport tooltips --- + ("viewer.in_point", "设置入点"), + ("viewer.step_back", "上一帧"), + ("viewer.play", "播放"), + ("viewer.pause", "暂停"), + ("viewer.step_forward", "下一帧"), + ("viewer.out_point", "设置出点"), + ("viewer.clear_range", "清除入出点"), // --- widget-baked strings (synced to gpui_widgets::i18n) --- ("viewer.safe_frames", "安全框"), ("viewer.zoom", "缩放"), @@ -357,127 +425,35 @@ const ZH: &[(&str, &str)] = &[ ("effect_stack.add", "+ 添加效果"), // --- inspector --- ("inspector.params", "参数(占位)"), + // --- dialogs --- + ("dialog.cancel", "取消"), + ("dialog.close", "关闭"), + ("file.open.title", "打开项目"), + ("file.save_as.title", "项目另存为"), + ("preferences.title", "偏好设置"), + ("preferences.backend", "渲染后端"), + ("preferences.backend.placeholder", "选择一个后端…"), + ("preferences.language", "语言"), + ("preferences.language.placeholder", "选择语言…"), + ("preferences.hint", "渲染后端在下次启动渲染工作进程时生效;语言立即切换。"), + ("export.title", "导出序列"), + ("export.format", "格式"), + ("export.format.placeholder", "选择格式…"), + ("export.path", "输出路径"), + ("export.run", "导出"), + ("export.hint", "序列通过 oaktask 导出路径导出;进度显示在对话框中。"), + ("export.progress.title", "正在导出"), + ("export.progress.label", "正在渲染帧…"), ]; // --------------------------------------------------------------------------- -// oakcommon config C ABI (runtime-resolved) +// Config persistence // --------------------------------------------------------------------------- - -/// The subset of the oakcommon config C ABI the language setting needs. Each -/// function pointer is optional: when liboakcommon cannot be loaded the whole -/// struct is `None` and the in-process fallback store is used instead. -struct ConfigAbi { - get: unsafe extern "C" fn(*const i8, *const i8, *mut i8, i32) -> i32, - set: unsafe extern "C" fn(*const i8, *const i8, *const i8), -} - -impl ConfigAbi { - /// Resolves the ABI once (process-wide) and returns it when loadable. - fn load() -> Option<&'static ConfigAbi> { - static ABI: std::sync::OnceLock> = std::sync::OnceLock::new(); - ABI.get_or_init(resolve_abi).as_ref() - } - - /// Reads the string entry for a flat `key`, or `None` when absent. - fn get(&self, key: &str) -> Option { - let key = to_c(key)?; - let mut buf = [0i8; 128]; - let result = unsafe { - (self.get)( - std::ptr::null(), - key.as_ptr(), - buf.as_mut_ptr(), - buf.len() as i32, - ) - }; - if result <= 0 { - // Negative codes are OAKCOMMON_E_* errors (e.g. not-found). - return None; - } - Some(from_c(&buf)) - } - - /// Writes a string entry for a flat `key`. - fn set(&self, key: &str, value: &str) { - let (Some(key), Some(value)) = (to_c(key), to_c(value)) else { - return; - }; - unsafe { - (self.set)(std::ptr::null(), key.as_ptr(), value.as_ptr()); - } - } -} - -/// Resolves the oakcommon config functions via `dlopen`/`dlsym`. -/// -/// Candidate library names: `OAK_LIB_DIR` (build-tree override) first, then -/// the bare `liboakcommon.dylib` name on the platform search path. When none -/// loads (plain `cargo test`/`cargo run` without a built oakcommon), this -/// returns `None` and [`ConfigAbi::load`] degrades to the fallback store. -#[cfg(target_os = "macos")] -fn resolve_abi() -> Option { - use std::ffi::c_void; - - const RTLD_LAZY: i32 = 0x1; - unsafe extern "C" { - fn dlopen(filename: *const i8, flag: i32) -> *mut c_void; - fn dlsym(handle: *mut c_void, symbol: *const i8) -> *mut c_void; - } - - // Candidate handles; the first dlopen that succeeds wins. - let mut candidates: Vec<*mut c_void> = Vec::new(); - let mut handle: *mut c_void = std::ptr::null_mut(); - - if let Ok(dir) = std::env::var("OAK_LIB_DIR") { - let path = format!("{dir}/liboakcommon.dylib"); - if let Some(c) = to_c(&path) { - handle = unsafe { dlopen(c.as_ptr(), RTLD_LAZY) }; - if !handle.is_null() { - candidates.push(handle); - } - } - } - if handle.is_null() { - if let Some(c) = to_c("liboakcommon.dylib") { - handle = unsafe { dlopen(c.as_ptr(), RTLD_LAZY) }; - } - } - if handle.is_null() { - return None; - } - - let get = unsafe { dlsym(handle, b"oakcommon_config_get\0".as_ptr() as *const i8) }; - let set = unsafe { dlsym(handle, b"oakcommon_config_set\0".as_ptr() as *const i8) }; - if get.is_null() || set.is_null() { - return None; - } - - Some(ConfigAbi { - get: unsafe { std::mem::transmute(get) }, - set: unsafe { std::mem::transmute(set) }, - }) -} - -/// Non-macOS fallback: no dlopen plumbing here; the in-process store is -/// always used. -#[cfg(not(target_os = "macos"))] -fn resolve_abi() -> Option { - None -} - -/// Turns a `&str` into a NUL-terminated C string. -fn to_c(s: &str) -> Option { - std::ffi::CString::new(s).ok() -} - -/// Reads a NUL-terminated buffer back into a `String`. -fn from_c(buf: &[i8]) -> String { - let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); - buf[..len] - .iter() - .map(|&c| c as u8 as char) - .collect() -} +// +// The language setting round-trips through the oakengine config C ABI +// (`oakengine_config_get_string` / `oakengine_config_set_string`, the +// in-process `ConfigStore` backed by the linked oakcommon crate). See +// [`real`](crate::oakui::real) for the facade-side helpers. /// Serializes every test that mutates the process-global language, so /// parallel tests (in this module and in [`crate::app`]) cannot race each @@ -514,6 +490,48 @@ mod tests { } } + /// No table entry may be its own key, and identical en-US / zh-CN values + /// are only allowed for proper nouns that stay in their original language + /// (theme names, language names, codecs) — anything else means one side + /// was left untranslated. + #[test] + fn no_untranslated_values() { + // Values that are intentionally identical across languages. + let shared = [ + "Olive Dark", + "Olive Light", + "English", + "简体中文", + "en-US", + "zh-CN", + "OpenGL", + "Metal", + "Vulkan", + "MP4", + ]; + for (key, en_value) in EN { + assert_ne!( + *en_value, *key, + "en-US value for {key} is still the raw key (untranslated)" + ); + let zh_value = ZH + .iter() + .find(|(k, _)| *k == *key) + .map(|(_, v)| *v) + .unwrap(); + assert_ne!( + zh_value, *key, + "zh-CN value for {key} is still the raw key (untranslated)" + ); + if *en_value == zh_value && !shared.contains(en_value) { + panic!( + "key {key} has identical en-US and zh-CN values ({en_value:?}); \ + one side is untranslated" + ); + } + } + } + /// Keys are unique within each table (a duplicate would make `tr`' /// lookup order-dependent). #[test] diff --git a/src/lib.rs b/src/lib.rs index 7d7b097b6..351f13c89 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,25 +19,37 @@ //! //! This is the start of the Rust rewrite of `app/` (Qt): a gpui window with //! the main layout from the design (`design/`), dockable panels built from -//! the `gpui_widgets` library, and an engine seam (`oakui`) that currently -//! feeds demo data through [`MockEngine`](oakui::MockEngine). +//! the `gpui_widgets` library, and an engine seam (`oakui`) with two +//! backends: the mock ([`oakui::MockEngine`]) feeding demo data, and the +//! real engine ([`oakui::RealEngine`]) bound to the built `liboakengine` +//! dylib through its frozen `oakengine_*` C ABI only (project open/save, +//! sequence/track/clip data, timeline edits through the oaktimeline edit +//! commands, the oaktask export path, and the config C ABI). //! //! # Layout //! -//! * [`app`] — the window shell: menu bar, dock layout, status bar, tick -//! loop. +//! * [`app`] — the window shell: menu bar, dock layout, status bar, modal +//! dialogs (file open/save-as, preferences, export), tick loop. +//! * [`dialogs`] — the preferences and export dialog content views. //! * [`panels`] — the dockable panels (viewers, timeline, inspector, ...). -//! * [`oakui`] — the engine gateway trait, the mock implementation, and the -//! pure view-state logic (timecode, transport). +//! * [`oakui`] — the engine gateway trait, the mock + real implementations, +//! and the pure view-state logic (timecode, transport). //! //! # Running //! //! ```text -//! cargo run --bin oakapp # the demo window -//! cargo test # unit tests (timecode, transport) +//! cargo run --bin oak-editor # the real engine, no project +//! cargo run --bin oak-editor -- path/to/project.ove # open a project at startup +//! cargo run --bin oak-editor -- --mock # the demo (mock) engine +//! cargo test # unit tests (app + crates) //! ``` +//! +//! The engine backend is selected at startup: the real engine by default, +//! the mock with the `--mock` flag, `OAK_ENGINE=mock`, or the +//! `mock-engine` cargo feature. pub mod app; +pub mod dialogs; pub mod i18n; pub mod oakui; pub mod panels; diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index 96e08fb08..014b374bc 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -36,8 +36,16 @@ //! Everything here is plain Rust — no C ABI, no FFI. The C-ABI binding is a //! later concern of the real backend only. -use gpui::timeline::{Frame, FrameRate}; use std::path::PathBuf; +use std::sync::Arc; + +use gpui::effect_stack::{EffectStackDataSource, EffectStackEvent}; +use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent}; +use gpui::timeline::{ClipId, Frame, FrameRate, TimelineDataSource, TimelineEvent, TrackKind}; +use gpui::{App, Context, Entity, Pixels, RenderImage}; +use gpui_widgets::audio_meter::AudioMeterDataSource; +use gpui_widgets::project_explorer::ProjectDataSource; +use gpui_widgets::viewer::PlaybackClock; /// A monitor the transport can address. /// @@ -128,3 +136,151 @@ pub trait EngineGateway: Sized { /// periodic timer while any monitor is playing. fn tick(&mut self, cx: &mut gpui::Context); } + +/// The transport clock type an engine drives its monitors with. +/// +/// Each engine owns two clocks (source + program), one per +/// [`Monitor`], and exposes them to the viewer widgets through the +/// [`PlaybackClock`] trait. +pub trait EngineClock: PlaybackClock + 'static {} + +impl EngineClock for T {} + +/// The full app-facing engine surface: the gateway plus every widget +/// data-source trait and the app-only operations (clocks, viewer frames, +/// edits, undo/redo, file operations). +/// +/// The app shell (`crate::app::OakApp`) and every panel are generic over +/// `E: AppEngine`, so swapping the backend (mock vs real) is a one-line +/// choice at startup — see [`crate::app::run`]. +pub trait AppEngine: + EngineGateway + + TimelineDataSource + + EffectStackDataSource + + NodeGraphDataSource + + ProjectDataSource + + AudioMeterDataSource +{ + /// The concrete transport-clock type (see [`EngineClock`]). + type Clock: EngineClock; + + /// Builds a fresh engine instance (no project open, or demo data for + /// the mock). + fn create(cx: &mut Context) -> Self; + + /// The source monitor's clock entity. + fn source_clock(&self) -> &Entity; + + /// The program monitor's clock entity. + fn program_clock(&self) -> &Entity; + + /// The current playhead frame of `monitor`'s clock. + fn clock_frame(&self, monitor: Monitor, cx: &App) -> Frame; + + /// The CPU frame the viewers display for `monitor` (cached per playhead + /// frame, so a paused viewer never regenerates its picture). + fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc; + + /// Adds a new empty track of the given kind (undoable where the backend + /// supports it). + fn add_track(&mut self, kind: TrackKind, cx: &mut Context); + + /// Removes the track at display `index` (the index into + /// [`TimelineDataSource::track`]; undoable where the backend supports + /// it). + fn remove_track(&mut self, index: usize, cx: &mut Context); + + /// Sets the row height of every timeline track (timeline toolbar). + fn set_track_height(&mut self, height: Pixels, cx: &mut Context); + + /// Selects a material-bin entry (project-explorer "open"). + fn select_item(&mut self, id: u64, cx: &mut Context); + + /// Applies an effect-stack edit request to the engine's model. + fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context); + + /// Applies a node-editor edit request to the engine's model. + fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context); + + /// Applies a timeline widget edit request (trim / move / playhead) to the + /// engine's model. Edits are applied through the backend's edit commands + /// with undo packaging; the playhead change is a plain seek. + fn apply_timeline_event(&mut self, event: &TimelineEvent, cx: &mut Context); + + /// Splits the clip with `clip` id at `time` (the razor action). + fn split_clip(&mut self, clip: ClipId, time: Frame, cx: &mut Context); + + /// Splits every clip whose range spans the program playhead (the razor + /// tool's menu action). + fn split_at_playhead(&mut self, cx: &mut Context); + + /// Deletes the clip with `clip` id, rippling following content left when + /// `ripple` is set. + fn delete_clip(&mut self, clip: ClipId, ripple: bool, cx: &mut Context); + + /// Whether the undo stack has an entry to undo. + fn can_undo(&self) -> bool; + + /// Whether the undo stack has an entry to redo. + fn can_redo(&self) -> bool; + + /// Steps the undo stack back one entry. + fn undo(&mut self, cx: &mut Context); + + /// Steps the undo stack forward one entry. + fn redo(&mut self, cx: &mut Context); + + /// Whether the project has unsaved changes. + fn project_modified(&self) -> bool; + + /// Starts a new blank project with a single default sequence. + fn new_project(&mut self, cx: &mut Context); + + /// Opens a project file. The format is dispatched by extension: `.ove` + /// through the OVE serializer, `.otio` / `.fcpxml` through the oaktask + /// interchange loader. + fn open_project_path(&mut self, path: PathBuf, cx: &mut Context) -> Result<(), String>; + + /// Saves the project to `path` (or its own filename when `None`). The + /// format is dispatched by extension like [`open_project_path`] + /// (AppEngine::open_project_path). + fn save_project(&mut self, path: Option, cx: &mut Context) -> Result<(), String>; + + /// Closes the current project, leaving the app with no sequence. + fn close_project(&mut self, cx: &mut Context); + + /// Starts an export of the current sequence in `format` to `path` and + /// returns a session the host polls for progress and can cancel. + /// + /// The export runs on a background thread; the returned + /// [`ExportSession`] carries the event channel and the cancel handle. + fn start_export(&mut self, format: i32, path: PathBuf) -> Result; + + /// The display name of the engine backend ("mock" / "real"), shown in + /// the status bar. + fn backend_name(&self) -> &'static str; +} + +/// A single progress event from a running export task. +#[derive(Debug, Clone, PartialEq)] +pub enum ExportEvent { + /// The task started. + Started, + /// Fraction done, in `0.0..=1.0`. + Progress(f64), + /// The task finished. `true` = succeeded; the string carries the failure + /// message on error. + Finished(bool, String), +} + +/// A running export: the event channel the host drains plus the cancel +/// handle. Dropping the session does not abort the export thread; the +/// thread owns the task and frees it when it finishes. +pub struct ExportSession { + /// The event receiver (the background thread's sender lives as long as + /// the session's `cancel` side, so a dropped receiver just stops + /// delivering). + pub events: std::sync::mpsc::Receiver, + /// Cancels the running export as soon as possible. + pub cancel: Box, +} diff --git a/src/oakui/ffi.rs b/src/oakui/ffi.rs new file mode 100644 index 000000000..89aed3e69 --- /dev/null +++ b/src/oakui/ffi.rs @@ -0,0 +1,494 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! The app's pure-C surface of the `liboakengine` dylib. +//! +//! The app links the built `liboakengine.dylib` (see `build.rs`) and calls +//! ONLY its `oakengine_*` C ABI — it never depends on the `oakengine` crate +//! as an rlib. This module declares every exported function the real engine +//! binding uses, with the exact signatures from the facade's `#[no_mangle]` +//! exports (`crates/oakengine/src/*.rs`), plus the two `oaktask_*` module +//! exports the dylib carries alongside the facade (interchange +//! load/save getter and the task event subscription, see the comments +//! below). +//! +//! The facade also exports the module C ABIs (`oakundo_*`, `oakcommon_*`, +//! ...) inside the same dylib; the module functions the app needs beyond +//! the facade's wrapping (`oaktask_load_take_project`, +//! `oaktask_task_subscribe`) are declared here too and resolve from the +//! dylib. +//! +//! # Handle layout mirrors +//! +//! The facade's opaque `OakEngine*` handle types are thin `#[repr(C)]` +//! newtypes around one module [`CHandle`] value (see +//! `crates/oakengine/src/handle.rs`), and boxes created with +//! `box_handle`/`free_box` live in the heap. The dylib ABI passes those +//! boxes as opaque pointers, but a pure-C consumer that needs to (a) build +//! a project box from a module handle (interchange load) or (b) free a +//! borrowed handle box (sequences / clips the facade returns but has no +//! `oakengine_*_free` for) must know the box layout. The mirrors below +//! reproduce it exactly (identical `repr(C)` field layout), so boxes +//! created by the facade can be read/freed from the app and vice versa. +//! +//! The module handle type itself is the frozen `{ctx, addref, release, +//! abi_version}` value handle (`include/common/handle.h`); `release` is +//! what `free_box` calls before deallocating the box. + +use std::ffi::{c_char, c_int, c_void}; + +/// The module value handle (`{ctx, addref, release, abi_version}`), mirror +/// of `oakcore_rs::handle::CHandle` / `include/common/handle.h`. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CHandle { + /// Opaque refcounted box pointer. + pub ctx: *mut c_void, + /// Atomic increment. + pub addref: Option, + /// Atomic decrement; destroys at zero. + pub release: Option, + /// ABI version. + pub abi_version: u32, +} + +impl CHandle { + /// Whether this is the empty (zero) handle. + pub fn is_null(&self) -> bool { + self.ctx.is_null() + } +} + +/// Opaque engine handle boxes, mirroring the facade's `engine_handle!` +/// newtypes (one `CHandle` per box). Only the types the app actually +/// touches are declared. +macro_rules! engine_handle { + ($($name:ident),* $(,)?) => { + $( + /// Opaque engine handle: a `#[repr(C)]` box holding one module + /// [`CHandle`]. + #[repr(C)] + #[derive(Clone, Copy)] + pub struct $name { + /// The wrapped module handle. + pub handle: CHandle, + } + + impl HandleBox for $name { + fn boxed_new(handle: CHandle) -> Self { + $name { handle } + } + fn handle(&self) -> CHandle { + self.handle + } + } + )* + }; +} + +engine_handle! { + OakEngineClip, + OakEngineEncodingParams, + OakEngineProject, + OakEngineSequence, + OakEngineTask, +} + +/// Uniform construction/extraction surface of the engine opaque boxes. +pub trait HandleBox: Sized { + /// Build the box from a module handle. + fn boxed_new(handle: CHandle) -> Self; + /// Extract the wrapped module handle (copy). + fn handle(&self) -> CHandle; +} + +/// Allocate a heap box for a module handle and return its raw pointer. +/// The box must later be released with [`free_box`] or a consuming +/// `oakengine_*_free` export. +/// +/// # Safety +/// The handle must be a live module handle (e.g. from +/// `oaktask_load_take_project`). +pub unsafe fn box_handle(handle: CHandle) -> *mut T { + // SAFETY: the caller passes a live handle; the box is managed by the + // C ABI consumers from here on. + Box::into_raw(Box::new(T::boxed_new(handle))) +} + +/// Dereference an engine opaque box and copy out its module handle. +/// Returns `None` for a NULL pointer or an empty handle. +/// +/// # Safety +/// `ptr` must point to a live box created by [`box_handle`] or by the +/// facade (or be NULL). +pub unsafe fn unbox(ptr: *const T) -> Option { + // SAFETY: see the function docs. + if ptr.is_null() { + return None; + } + let h = (*ptr).handle(); + if h.is_null() { + None + } else { + Some(h) + } +} + +/// Free a box created by [`box_handle`] (or returned by the facade): +/// release the module handle (via its `release` function pointer) and +/// deallocate the box. NULL and empty handles are no-ops. After the call +/// `ptr` is dangling; the caller must not use it again. +/// +/// # Safety +/// `ptr` must be a pointer previously returned by [`box_handle`] or by the +/// facade (or NULL) and must not be freed twice. +pub unsafe fn free_box(ptr: *mut T) { + // SAFETY: see the function docs. + if ptr.is_null() { + return; + } + let handle = (*ptr).handle(); + if let Some(release) = handle.release { + release(handle.ctx); + } + drop(Box::from_raw(ptr)); +} + +/// `engine/include/oakengine/videoparams.h` — POD mirror of VideoParams' +/// user-facing fields (Rust mirror of `oak_video_params`; the facade's +/// `oakengine::common::OakVideoParamsPod`). +#[repr(C)] +#[derive(Clone, Copy)] +pub struct OakVideoParamsPod { + /// Width. + pub width: c_int, + /// Height. + pub height: c_int, + /// Frame duration numerator (e.g. 1001/30000 s). + pub time_base_num: c_int, + /// Frame duration denominator. + pub time_base_den: c_int, + /// PixelFormat::Format value. + pub format: c_int, + /// Pixel aspect numerator. + pub pixel_aspect_num: c_int, + /// Pixel aspect denominator. + pub pixel_aspect_den: c_int, + /// Interlacing value. + pub interlacing: c_int, + /// ColorRange value. + pub color_range: c_int, + /// Preview resolution divider (1 = full). + pub divider: c_int, + /// VideoParams::Type value. + pub video_type: c_int, + /// 0/1 premultiplied alpha. + pub premultiplied_alpha: c_int, +} + +/// The task event callback the module subscription invokes on the task's +/// own thread (`oaktask` C ABI, `include/task/task.h`). +pub type OakTaskEventFn = unsafe extern "C" fn(event_id: c_int, value: f64, userdata: *mut c_void); + +// The `oakengine_*` C ABI surface the app binds. Signatures mirror the +// facade's `#[no_mangle] pub extern "C"` exports verbatim (the facade +// declares a few of them without `unsafe`; calling any extern-block item +// still requires an unsafe context on the current toolchain, so the call +// sites in `real.rs` carry their own `unsafe` blocks). +// +// String outputs follow the engine buf/size convention: the return value +// is the required length excluding the terminating NUL; negative values +// are error codes. +#[link(name = "oakengine")] +unsafe extern "C" { + // -- oakengine::codec (encoding formats + params) -- + + /// `oakengine_encoding_format_count` — number of export formats. + pub fn oakengine_encoding_format_count() -> c_int; + /// `oakengine_encoding_format_name` (buf/size; -1 invalid). + pub fn oakengine_encoding_format_name(format: c_int, buf: *mut c_char, buf_size: c_int) -> c_int; + /// `oakengine_encoding_format_extension` (buf/size). + pub fn oakengine_encoding_format_extension( + format: c_int, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// `oakengine_encoding_format_video_codec_at` — codec id, -1 invalid. + pub fn oakengine_encoding_format_video_codec_at(format: c_int, index: c_int) -> c_int; + /// `oakengine_encoding_format_audio_codec_at` — codec id, -1 invalid. + pub fn oakengine_encoding_format_audio_codec_at(format: c_int, index: c_int) -> c_int; + /// `oakengine_encoding_params_create` — owned params box. + pub fn oakengine_encoding_params_create() -> *mut OakEngineEncodingParams; + /// `oakengine_encoding_params_destroy` — consuming free. + pub fn oakengine_encoding_params_destroy(params: *mut OakEngineEncodingParams); + /// `oakengine_encoding_params_set_filename`. + pub fn oakengine_encoding_params_set_filename( + params: *mut OakEngineEncodingParams, + filename: *const c_char, + ) -> c_int; + /// `oakengine_encoding_params_set_format` — rejects out-of-range values. + pub fn oakengine_encoding_params_set_format( + params: *mut OakEngineEncodingParams, + format: c_int, + ) -> c_int; + /// `oakengine_encoding_params_enable_video` — copy the POD-carryable + /// fields of `video` and enable the video track. + pub fn oakengine_encoding_params_enable_video( + params: *mut OakEngineEncodingParams, + video: *const OakVideoParamsPod, + codec: c_int, + ) -> c_int; + /// `oakengine_encoding_params_enable_audio`. + pub fn oakengine_encoding_params_enable_audio( + params: *mut OakEngineEncodingParams, + sample_rate: c_int, + channel_layout: u64, + sample_format: c_int, + codec: c_int, + ) -> c_int; + /// `oakengine_encoding_params_set_export_length`. + pub fn oakengine_encoding_params_set_export_length( + params: *mut OakEngineEncodingParams, + num: c_int, + den: c_int, + ); + + // -- oakengine::common (config) -- + + /// `oakengine_config_get_string` — read a config string; a missing key + /// reads as an empty string. + pub fn oakengine_config_get_string(key: *const c_char, buf: *mut c_char, buf_size: c_int) -> c_int; + /// `oakengine_config_set_string` — write a string value. + pub fn oakengine_config_set_string(key: *const c_char, value: *const c_char) -> c_int; + + // -- oakengine::node (project) -- + + /// `oakengine_project_create` — owned project box (no content yet). + pub fn oakengine_project_create() -> *mut OakEngineProject; + /// `oakengine_project_free` — consuming free of an owned project box. + pub fn oakengine_project_free(self_: *mut OakEngineProject); + /// `oakengine_project_new` — initialize a blank project. + pub fn oakengine_project_new(self_: *mut OakEngineProject) -> c_int; + /// `oakengine_project_load` — load from `path`; fills `err` (buf/size) + /// on failure. + pub fn oakengine_project_load( + self_: *mut OakEngineProject, + path: *const c_char, + err: *mut c_char, + err_size: c_int, + ) -> c_int; + /// `oakengine_project_save` — save to `path` (or the recorded + /// filename when NULL). + pub fn oakengine_project_save(self_: *mut OakEngineProject, path: *const c_char) -> c_int; + /// `oakengine_project_is_modified`. + pub fn oakengine_project_is_modified(self_: *const OakEngineProject) -> c_int; + /// `oakengine_project_name` (buf/size). + pub fn oakengine_project_name( + self_: *const OakEngineProject, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// `oakengine_project_filename` (buf/size). + pub fn oakengine_project_filename( + self_: *const OakEngineProject, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// `oakengine_project_footage_count`. + pub fn oakengine_project_footage_count(self_: *const OakEngineProject) -> c_int; + /// `oakengine_project_footage_filename` (buf/size). + pub fn oakengine_project_footage_filename( + self_: *const OakEngineProject, + index: c_int, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// `oakengine_project_can_undo`. + pub fn oakengine_project_can_undo(self_: *const OakEngineProject) -> c_int; + /// `oakengine_project_can_redo`. + pub fn oakengine_project_can_redo(self_: *const OakEngineProject) -> c_int; + /// `oakengine_project_undo`. + pub fn oakengine_project_undo(self_: *mut OakEngineProject) -> c_int; + /// `oakengine_project_redo`. + pub fn oakengine_project_redo(self_: *mut OakEngineProject) -> c_int; + /// `oakengine_project_sequence_count`. + pub fn oakengine_project_sequence_count(self_: *const OakEngineProject) -> c_int; + /// `oakengine_project_sequence_at` — borrowed sequence box (free with + /// [`free_box`]). + pub fn oakengine_project_sequence_at( + self_: *const OakEngineProject, + index: c_int, + ) -> *mut OakEngineSequence; + /// `oakengine_project_set_filename`. + pub fn oakengine_project_set_filename(self_: *mut OakEngineProject, path: *const c_char) -> c_int; + /// `oakengine_project_node_count` — parseable content check. + pub fn oakengine_project_node_count(self_: *const OakEngineProject) -> c_int; + + // -- oakengine::task -- + + /// `oakengine_task_error` (buf/size). + pub fn oakengine_task_error(task: *mut OakEngineTask, buf: *mut c_char, buf_size: c_int) -> c_int; + /// `oakengine_task_cancel` — set the task's cancel atom. + pub fn oakengine_task_cancel(task: *mut OakEngineTask) -> c_int; + /// `oakengine_task_start_sync` — run the task to completion. + pub fn oakengine_task_start_sync(task: *mut OakEngineTask) -> c_int; + /// `oakengine_task_free` — consuming free of an owned task box. + pub fn oakengine_task_free(task: *mut OakEngineTask) -> c_int; + /// `oakengine_task_create_project_load_otio` — owned interchange load + /// task box. + pub fn oakengine_task_create_project_load_otio(filename: *const c_char) -> *mut OakEngineTask; + /// `oakengine_task_create_project_save_otio` — owned interchange save + /// task box. + pub fn oakengine_task_create_project_save_otio( + project: *mut OakEngineProject, + ) -> *mut OakEngineTask; + /// `oakengine_task_create_export` — owned export task box. + pub fn oakengine_task_create_export( + sequence: *mut OakEngineSequence, + params: *mut OakEngineEncodingParams, + ) -> *mut OakEngineTask; + + // -- oakengine::timeline -- + + /// `oakengine_sequence_new` — in-memory sequence (facade scratch + /// project). + pub fn oakengine_sequence_new( + project: *mut OakEngineProject, + name: *const c_char, + ) -> *mut OakEngineSequence; + /// `oakengine_sequence_name` (buf/size). + pub fn oakengine_sequence_name( + self_: *const OakEngineSequence, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// `oakengine_sequence_get_length` — length in seconds. + pub fn oakengine_sequence_get_length(self_: *const OakEngineSequence, seconds: *mut f64) -> c_int; + /// `oakengine_sequence_get_frame_rate` — num/den rational. + pub fn oakengine_sequence_get_frame_rate( + self_: *const OakEngineSequence, + num: *mut c_int, + den: *mut c_int, + ) -> c_int; + /// `oakengine_sequence_get_video_params` — width/height/par. + pub fn oakengine_sequence_get_video_params( + self_: *const OakEngineSequence, + width: *mut c_int, + height: *mut c_int, + par_num: *mut c_int, + par_den: *mut c_int, + ) -> c_int; + /// `oakengine_sequence_track_count` — per-type counts. + pub fn oakengine_sequence_track_count( + self_: *const OakEngineSequence, + video: *mut c_int, + audio: *mut c_int, + subtitle: *mut c_int, + ) -> c_int; + /// `oakengine_sequence_set_playhead`. + pub fn oakengine_sequence_set_playhead(self_: *mut OakEngineSequence, timestamp: i64) -> c_int; + /// `oakengine_sequence_add_track`. + pub fn oakengine_sequence_add_track(self_: *mut OakEngineSequence, track_type: c_int) -> c_int; + /// `oakengine_sequence_clip_count`. + pub fn oakengine_sequence_clip_count( + self_: *mut OakEngineSequence, + track_type: c_int, + track_index: c_int, + ) -> c_int; + /// `oakengine_sequence_clip_at` — borrowed clip box (free with + /// [`free_box`]). + pub fn oakengine_sequence_clip_at( + self_: *mut OakEngineSequence, + track_type: c_int, + track_index: c_int, + clip_index: c_int, + ) -> *mut OakEngineClip; + /// `oakengine_clip_get_range` — clip timeline range and media in-point + /// as frame timestamps. + pub fn oakengine_clip_get_range( + self_: *const OakEngineClip, + in_: *mut i64, + out: *mut i64, + media_in: *mut i64, + ) -> c_int; + /// `oakengine_sequence_split_clip`. + pub fn oakengine_sequence_split_clip( + seq: *mut OakEngineSequence, + track_type: c_int, + track_index: c_int, + clip_index: c_int, + time: i64, + ) -> c_int; + /// `oakengine_sequence_ripple_delete_clip`. + pub fn oakengine_sequence_ripple_delete_clip( + seq: *mut OakEngineSequence, + track_type: c_int, + track_index: c_int, + clip_index: c_int, + ) -> c_int; + /// `oakengine_clip_trim` — change the clip's timeline range. + pub fn oakengine_clip_trim(clip: *mut OakEngineClip, new_in: i64, new_out: i64) -> c_int; + /// `oakengine_sequence_delete_clips` — delete a clip array, optionally + /// rippling; `rippled` reports the ripple length. + pub fn oakengine_sequence_delete_clips( + seq: *mut OakEngineSequence, + clips: *mut *mut OakEngineClip, + clip_count: c_int, + ripple: c_int, + ripple_ranges_ts: *const i64, + ripple_range_count: c_int, + rippled: *mut c_int, + ) -> c_int; + /// `oakengine_sequence_remove_track`. + pub fn oakengine_sequence_remove_track( + seq: *mut OakEngineSequence, + track_type: c_int, + track_index: c_int, + ) -> c_int; + /// `oakengine_track_get_height` — height in internal units. + pub fn oakengine_track_get_height( + seq: *const OakEngineSequence, + track_type: c_int, + track_index: c_int, + height: *mut f64, + ) -> c_int; + /// `oakengine_track_set_height` — height in internal units. + pub fn oakengine_track_set_height( + seq: *mut OakEngineSequence, + track_type: c_int, + track_index: c_int, + height: f64, + ) -> c_int; + + /// `oakengine_track_height_internal_to_pixels`. + pub fn oakengine_track_height_internal_to_pixels(height: f64) -> c_int; + /// `oakengine_track_height_pixels_to_internal`. + pub fn oakengine_track_height_pixels_to_internal(pixels: c_int) -> f64; + + // -- oaktask module C ABI (carried by the dylib) -- + + /// `oaktask_load_take_project` — take the project an interchange + /// load/load-otio task produced (ownership moves to the caller). + pub fn oaktask_load_take_project(t: CHandle) -> CHandle; + /// `oaktask_task_subscribe` — register the task event callback + /// (`OAKTASK_EVENT_STARTED`=0, `OAKTASK_EVENT_PROGRESS`=1, + /// `OAKTASK_EVENT_FINISHED`=2). + pub fn oaktask_task_subscribe( + t: CHandle, + cb: Option, + userdata: *mut c_void, + ) -> i64; +} diff --git a/src/oakui/frames.rs b/src/oakui/frames.rs new file mode 100644 index 000000000..78ca11972 --- /dev/null +++ b/src/oakui/frames.rs @@ -0,0 +1,96 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! The synthetic CPU viewer frame both engines display. +//! +//! The real engine delivers frames through the render worker (a separate +//! process speaking the NDJSON control-plane protocol, `oakengine::worker`), +//! which is out of scope for this increment. Until that transport is wired, +//! both the mock and the real engine feed the viewers the same SMPTE-style +//! test pattern, so playback is visibly moving while the engine metadata +//! (project / sequence / tracks) comes from the real facade in real mode. + +use gpui::timeline::Frame; +use gpui::RenderImage; + +/// Width of the synthetic test frame (a small proxy size; the real engine +/// will deliver full-resolution frames). +pub(crate) const SYNTH_FRAME_WIDTH: u32 = 384; +/// Height of the synthetic test frame. +pub(crate) const SYNTH_FRAME_HEIGHT: u32 = 216; + +/// Generates a synthetic test frame: SMPTE-style color bars with a white +/// sweep whose x position follows `frame`, so transport playback shows up as +/// motion across the picture. +/// +/// Samples are computed as F32 RGBA (mirroring the real engine's pixel +/// pipeline) and downconverted to BGRA8 for the viewer's CPU-frame path. +pub(crate) fn synthetic_frame(frame: Frame) -> RenderImage { + let width = SYNTH_FRAME_WIDTH; + let height = SYNTH_FRAME_HEIGHT; + + // F32 RGBA samples, then quantized to BGRA8 for the sprite atlas. + let mut samples = vec![0.0f32; (width * height * 4) as usize]; + // SMPTE bars: 75% white, yellow, cyan, green, magenta, red, blue. + let bars: [(f32, f32, f32); 7] = [ + (1.0, 1.0, 1.0), + (1.0, 1.0, 0.0), + (0.0, 1.0, 1.0), + (0.0, 1.0, 0.0), + (1.0, 0.0, 1.0), + (1.0, 0.0, 0.0), + (0.0, 0.0, 1.0), + ]; + // Bottom strip: blue, magenta, 75% white, black. + let strip: [(f32, f32, f32); 4] = [ + (0.0, 0.0, 1.0), + (1.0, 0.0, 1.0), + (0.75, 0.75, 0.75), + (0.0, 0.0, 0.0), + ]; + // The sweep moves 6 px per frame and wraps around the width, so + // transport playback shows up as motion across the picture. + let sweep = (frame.0 as f32 * 6.0) % width as f32; + let bars_top = height as f32 * 0.66; + + for y in 0..height { + for x in 0..width { + let in_sweep = (x as f32 - sweep).abs() < 6.0; + let color = if in_sweep { + (1.0, 1.0, 1.0) + } else if (y as f32) < bars_top { + bars[((x as f32 / width as f32) * 7.0) as usize] + } else { + strip[((x as f32 / width as f32) * 4.0) as usize] + }; + let i = ((y * width + x) * 4) as usize; + samples[i] = color.0; + samples[i + 1] = color.1; + samples[i + 2] = color.2; + samples[i + 3] = 1.0; + } + } + + let mut bytes = Vec::with_capacity((width * height * 4) as usize); + for i in (0..samples.len()).step_by(4) { + bytes.push((samples[i + 2] * 255.0) as u8); // B + bytes.push((samples[i + 1] * 255.0) as u8); // G + bytes.push((samples[i] * 255.0) as u8); // R + bytes.push((samples[i + 3] * 255.0) as u8); // A + } + let buffer = image::RgbaImage::from_raw(width, height, bytes).expect("synthetic frame"); + RenderImage::new(smallvec::SmallVec::from_elem(image::Frame::new(buffer), 1)) +} diff --git a/src/oakui/host_syms.rs b/src/oakui/host_syms.rs new file mode 100644 index 000000000..cc13c96ab --- /dev/null +++ b/src/oakui/host_syms.rs @@ -0,0 +1,258 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! In-process stand-ins for the C++ host symbols the module crates call. +//! +//! The oakcodec / oakcommon crates reference a handful of symbols that in +//! the real desktop product live in the C++ host process (`liboakcore` and +//! `ffmpeg_bridge`): `oakcore_audioparams_*`, `oakcore_rational_*` and +//! `fb_find_best_pix_fmt_of_list`. The facade's own test binaries provide +//! the same stubs in `crates/oakengine/tests/common/mod.rs`; this module is +//! the equivalent for the app binary, so linking oakengine (and through it +//! oakcodec/oakcommon) never leaves undefined symbols. +//! +//! The stubs are small, in-memory and functional enough for the app's use: +//! the audio-params handle carries the fields the codec probes read back, +//! and the rational helpers store the (num, den) pair behind an opaque +//! pointer. + +use std::collections::HashMap; +use std::ffi::{c_char, c_int, c_void}; +use std::sync::{Mutex, OnceLock}; + +/// Opaque `OakAudioParams` handle type (the real one lives in liboakcore). +#[repr(C)] +pub struct OakAudioParams { + _opaque: [u8; 0], +} + +/// Per-`OakAudioParams` backing state. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct MockAudioParams { + sample_rate: i32, + channel_layout: u64, + format: i32, + stream_index: i32, + duration: i64, + time_base_num: i32, + time_base_den: i32, +} + +fn audio_params_store() -> &'static Mutex> { + static S: OnceLock>> = OnceLock::new(); + S.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn audio_params_get(ctx: *const c_void) -> MockAudioParams { + let store = audio_params_store().lock().unwrap(); + store.get(&(ctx as usize)).cloned().unwrap_or_default() +} + +fn audio_params_set(ctx: *mut c_void, f: impl FnOnce(&mut MockAudioParams)) { + let mut store = audio_params_store().lock().unwrap(); + if let Some(p) = store.get_mut(&(ctx as usize)) { + f(p); + } +} + +/// Per-`OakRational` backing state (an owned `(num, den)` pair). +fn rational_store() -> &'static Mutex> { + static S: OnceLock>> = OnceLock::new(); + S.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_create( + sample_rate: c_int, + channel_layout: u64, + format: c_int, +) -> *mut OakAudioParams { + let p = MockAudioParams { + sample_rate, + channel_layout, + format, + stream_index: 0, + duration: 0, + time_base_num: 1, + time_base_den: sample_rate, + }; + let raw = Box::into_raw(Box::new(p.clone())); + audio_params_store().lock().unwrap().insert(raw as usize, p); + raw as *mut OakAudioParams +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_free(params: *mut OakAudioParams) { + if params.is_null() { + return; + } + audio_params_store() + .lock() + .unwrap() + .remove(&(params as usize)); + // SAFETY: produced by `oakcore_audioparams_create`; we hold the only + // reference after removal. + unsafe { drop(Box::from_raw(params as *mut MockAudioParams)) }; +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_sample_rate(params: *const OakAudioParams) -> c_int { + audio_params_get(params as *const c_void).sample_rate +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_set_sample_rate( + params: *mut OakAudioParams, + sample_rate: c_int, +) { + audio_params_set(params as *mut c_void, |p| p.sample_rate = sample_rate); +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_channel_layout(params: *const OakAudioParams) -> u64 { + audio_params_get(params as *const c_void).channel_layout +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_set_channel_layout(params: *mut OakAudioParams, layout: u64) { + audio_params_set(params as *mut c_void, |p| p.channel_layout = layout); +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_set_time_base( + params: *mut OakAudioParams, + num: c_int, + den: c_int, +) { + audio_params_set(params as *mut c_void, |p| { + p.time_base_num = num; + p.time_base_den = den; + }); +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_set_format(params: *mut OakAudioParams, format: c_int) { + audio_params_set(params as *mut c_void, |p| p.format = format); +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_set_stream_index(params: *mut OakAudioParams, index: c_int) { + audio_params_set(params as *mut c_void, |p| p.stream_index = index); +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_set_duration(params: *mut OakAudioParams, duration: i64) { + audio_params_set(params as *mut c_void, |p| p.duration = duration); +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_channel_count(params: *const OakAudioParams) -> c_int { + audio_params_get(params as *const c_void) + .channel_layout + .count_ones() as c_int +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_format(params: *const OakAudioParams) -> c_int { + audio_params_get(params as *const c_void).format +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_stream_index(params: *const OakAudioParams) -> c_int { + audio_params_get(params as *const c_void).stream_index +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_duration(params: *const OakAudioParams) -> i64 { + audio_params_get(params as *const c_void).duration +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_is_valid(params: *const OakAudioParams) -> c_int { + let p = audio_params_get(params as *const c_void); + (p.sample_rate > 0 && p.channel_layout != 0 && p.format >= 0) as c_int +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_time_base(params: *const OakAudioParams) -> *mut c_void { + let p = audio_params_get(params as *const c_void); + let r = (p.time_base_num, p.time_base_den); + let raw = Box::into_raw(Box::new(r)); + rational_store().lock().unwrap().insert(raw as usize, r); + raw as *mut c_void +} + +#[no_mangle] +pub extern "C" fn oakcore_rational_numerator(rational: *const c_void) -> c_int { + rational_store() + .lock() + .unwrap() + .get(&(rational as usize)) + .map(|r| r.0) + .unwrap_or(0) +} + +#[no_mangle] +pub extern "C" fn oakcore_rational_denominator(rational: *const c_void) -> c_int { + rational_store() + .lock() + .unwrap() + .get(&(rational as usize)) + .map(|r| r.1) + .unwrap_or(0) +} + +#[no_mangle] +pub extern "C" fn oakcore_rational_free(rational: *mut c_void) { + if rational.is_null() { + return; + } + rational_store().lock().unwrap().remove(&(rational as usize)); + // SAFETY: produced by `oakcore_audioparams_time_base`; we hold the only + // reference after removal. + unsafe { drop(Box::from_raw(rational as *mut (i32, i32))) }; +} + +/// `fb_find_best_pix_fmt_of_list` — pick the entry of a +/// `FB_PIX_FMT_NONE`-terminated list closest to `pix_fmt` (the real +/// implementation lives in ffmpeg_bridge). Stub: exact matches win, +/// otherwise the first (most desirable) candidate. +#[no_mangle] +pub extern "C" fn fb_find_best_pix_fmt_of_list( + list: *const c_int, + pix_fmt: c_int, +) -> c_int { + if list.is_null() { + return 0; + } + let mut i = 0; + let mut first: c_int = 0; + loop { + // SAFETY: `list` is `FB_PIX_FMT_NONE`-terminated; the read is within + // bounds by construction. + let entry = unsafe { *list.add(i) }; + if entry == 0 { + return first; + } + if i == 0 { + first = entry; + } + if entry == pix_fmt { + return entry; + } + i += 1; + } +} diff --git a/src/oakui/icons.rs b/src/oakui/icons.rs new file mode 100644 index 000000000..6a6a9ba7c --- /dev/null +++ b/src/oakui/icons.rs @@ -0,0 +1,133 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Theme-aware toolbar icons. +//! +//! The toolbar/transport icons are the PNGs pulled from the legacy C++ app +//! (`app/ui/style/olive-{dark,light}/png` in the pre-Rust history), stored in +//! `assets/icons/{dark,light}/`. Each theme ships its own glyph color (white +//! on dark, black on light), so the active [`OakTheme`] picks the family. +//! +//! Icons render on a 16px logical grid from the 32px (2×) files; buttons give +//! them a 24px hit target and a localized tooltip. +//! +//! [`OakTheme`]: gpui_widgets::theme::OakTheme + +use std::path::PathBuf; + +use gpui::App; + +/// Icon file names (without the `.png` suffix), mirroring the legacy set. +pub const ICON_ARROW: &str = "arrow"; +pub const ICON_RAZOR: &str = "razor"; +pub const ICON_RIPPLE: &str = "ripple"; +pub const ICON_SLIP: &str = "slip"; +pub const ICON_ROLLING: &str = "rolling"; +pub const ICON_ZOOM: &str = "zoomin"; +pub const ICON_ZOOM_IN: &str = "zoomin"; +pub const ICON_ZOOM_OUT: &str = "zoomout"; +pub const ICON_SLIDE: &str = "slide"; +pub const ICON_TRACK_SELECT: &str = "track-tool"; +pub const ICON_SNAP: &str = "magnet"; +pub const ICON_PLAY: &str = "play"; +pub const ICON_PAUSE: &str = "pause"; +pub const ICON_PREV: &str = "prev"; +pub const ICON_NEXT: &str = "next"; +pub const ICON_REW: &str = "rew"; +pub const ICON_FF: &str = "ff"; + +/// The theme-dependent filesystem path of an icon (`assets/icons/{dark,light}` +/// under the crate root). Absolute, so it works from any working directory. +pub fn icon_path(name: &str, cx: &App) -> PathBuf { + let theme = gpui_widgets::theme::current_theme(cx); + let family = if theme.name == "Olive Dark" { "dark" } else { "light" }; + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("assets/icons") + .join(family) + .join(format!("{name}.png")) +} + +/// Registers the app's icon resolver, so widget-crate consumers (the viewer +/// transport bar) resolve the same theme-aware paths. Call at startup before +/// any window renders; re-registering after a theme switch is harmless. +pub fn init(cx: &mut App) { + gpui_widgets::icons::set_resolver( + std::sync::Arc::new(|name, cx| Some(icon_path(name, cx))), + cx, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every icon name resolves to an existing file for both themes — a + /// missing PNG would render as a blank toolbar button. + #[test] + fn every_icon_file_exists_for_both_themes() { + for name in [ + ICON_ARROW, + ICON_RAZOR, + ICON_RIPPLE, + ICON_SLIP, + ICON_ROLLING, + ICON_ZOOM, + ICON_ZOOM_IN, + ICON_ZOOM_OUT, + ICON_SLIDE, + ICON_TRACK_SELECT, + ICON_SNAP, + ICON_PLAY, + ICON_PAUSE, + ICON_PREV, + ICON_NEXT, + ICON_REW, + ICON_FF, + ] { + for family in ["dark", "light"] { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("assets/icons") + .join(family) + .join(format!("{name}.png")); + assert!( + path.exists(), + "missing icon asset {path:?} (referenced as {name})" + ); + } + } + } + + /// The theme-aware path picks the dark family for the default dark theme + /// and the light family once a light theme is applied. + #[gpui::test] + fn icon_path_follows_the_active_theme(cx: &mut gpui::TestAppContext) { + cx.update(|app| { + gpui_widgets::theme::apply_theme(app, &gpui_widgets::theme::OakTheme::olive_dark()); + let dark = icon_path(ICON_PLAY, app); + assert!( + dark.ends_with("dark/play.png"), + "dark theme → dark family, got {dark:?}" + ); + + gpui_widgets::theme::apply_theme(app, &gpui_widgets::theme::OakTheme::olive_light()); + let light = icon_path(ICON_PLAY, app); + assert!( + light.ends_with("light/play.png"), + "light theme → light family, got {light:?}" + ); + }); + } +} diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index c6533de20..d8f2f6d3d 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -38,6 +38,7 @@ use std::collections::{BTreeSet, HashMap}; use std::path::PathBuf; +use std::sync::mpsc; use std::sync::{Arc, Mutex}; use std::time::Instant; @@ -49,7 +50,8 @@ use gpui::node_graph::{ PortDataType, PortId, PortKind, }; use gpui::timeline::{ - ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TrackData, TrackKind, + ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TimelineEvent, TrackData, + TrackKind, TrimEdge, }; use gpui::{ hsla, point, prelude::*, px, App, Context, Entity, Hsla, Pixels, Point, RenderImage, @@ -59,18 +61,14 @@ use gpui_widgets::audio_meter::AudioMeterDataSource; use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry}; use gpui_widgets::viewer::PlaybackClock; -use super::engine::{EngineGateway, Monitor, Project, Sequence, VideoFormat}; +use super::engine::{ + AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, Sequence, VideoFormat, +}; use super::transport::TransportState; /// The demo sequence length: 00:04:18:18 at 25 fps. const SEQUENCE_LENGTH: i64 = 6468; -/// The synthetic viewer test frame is rendered at a small proxy size (the -/// real engine will deliver full-resolution frames; the mock only needs to -/// prove the CPU-frame path end to end). -const SYNTH_FRAME_WIDTH: u32 = 384; -const SYNTH_FRAME_HEIGHT: u32 = 216; - // --------------------------------------------------------------------------- // Clocks // --------------------------------------------------------------------------- @@ -902,6 +900,52 @@ impl MockEngine { cx.notify(); } + /// Finds the (track, clip) position of `clip` in the display list. + fn mock_clip_position(&self, clip: ClipId) -> Option<(usize, usize)> { + self.tracks + .iter() + .enumerate() + .find_map(|(track_index, track)| { + track + .clips + .iter() + .position(|c| c.id() == clip) + .map(|clip_index| (track_index, clip_index)) + }) + } + + /// A clip id larger than every existing one (for splits). + fn next_mock_clip_id(&self) -> u64 { + self.tracks + .iter() + .flat_map(|t| t.clips.iter()) + .map(|c| c.id().0) + .max() + .map(|max| max + 1) + .unwrap_or(1) + } + + /// Splits the clip at (track, clip) position into two at `time` + /// (mock-apply, not undoable). + fn split_mock_clip(&mut self, track: usize, index: usize, time: Frame) { + let source = &self.tracks[track].clips[index]; + if time.0 <= source.range.start.0 || time.0 >= source.range.end.0 { + return; + } + let new_id = ClipId(self.next_mock_clip_id()); + let second = MockClip { + id: new_id, + range: FrameRange::new(time, source.range.end), + media_in: Frame(source.media_in.0 + (time.0 - source.range.start.0)), + label: source.label.clone(), + color: source.color, + }; + let mut first = self.tracks[track].clips[index].clone(); + first.range = FrameRange::new(source.range.start, time); + self.tracks[track].clips[index] = first; + self.tracks[track].clips.insert(index + 1, second); + } + /// The demo audio levels: animated while the program monitor plays. fn meter_levels(&self) -> Vec { if !self.program_playing { @@ -999,6 +1043,228 @@ impl EngineGateway for MockEngine { } } +// --------------------------------------------------------------------------- +// AppEngine +// --------------------------------------------------------------------------- + +impl AppEngine for MockEngine { + type Clock = MockClock; + + fn create(cx: &mut Context) -> Self { + Self::demo(cx) + } + + fn source_clock(&self) -> &Entity { + &self.source_clock + } + + fn program_clock(&self) -> &Entity { + &self.program_clock + } + + fn clock_frame(&self, monitor: Monitor, cx: &App) -> Frame { + self.clock_frame(monitor, cx) + } + + fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc { + self.cpu_frame(monitor, cx) + } + + fn add_track(&mut self, kind: TrackKind, cx: &mut Context) { + self.add_track(kind, cx); + } + + fn remove_track(&mut self, index: usize, cx: &mut Context) { + if index < self.tracks.len() { + self.tracks.remove(index); + } + cx.notify(); + } + + fn set_track_height(&mut self, height: Pixels, cx: &mut Context) { + self.set_track_height(height, cx); + } + + fn select_item(&mut self, id: u64, cx: &mut Context) { + self.select_item(id, cx); + } + + fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context) { + self.apply_effect_event(event, cx); + } + + fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context) { + self.apply_node_graph_event(event, cx); + } + + fn apply_timeline_event(&mut self, event: &TimelineEvent, cx: &mut Context) { + match event { + TimelineEvent::PlayheadChanged(frame) => { + let current = self.clock_frame(Monitor::Program, cx); + if *frame != current { + self.request_frame(Monitor::Program, *frame, cx); + } + } + TimelineEvent::ClipTrimRequested { clip, edge, new_frame } => { + if let Some((track, index)) = self.mock_clip_position(*clip) { + let clip = &mut self.tracks[track].clips[index]; + match edge { + gpui::timeline::TrimEdge::Start => { + let delta = new_frame.0 - clip.range.start.0; + clip.range.start = *new_frame; + clip.media_in = Frame(clip.media_in.0 + delta); + } + gpui::timeline::TrimEdge::End => { + clip.range.end = *new_frame; + } + } + } + cx.notify(); + } + TimelineEvent::ClipMoveRequested { clip, new_track, new_start } => { + let Some((track, index)) = self.mock_clip_position(*clip) else { + return; + }; + let mut clip = self.tracks[track].clips.remove(index); + let length = clip.range.end.0 - clip.range.start.0; + clip.range = FrameRange::new(*new_start, Frame(new_start.0 + length)); + if *new_track < self.tracks.len() { + self.tracks[*new_track].clips.push(clip); + } + cx.notify(); + } + TimelineEvent::TrackHeightChanged { track, height } => { + if let Some(track) = self.tracks.get_mut(*track) { + track.height = *height; + } + cx.notify(); + } + TimelineEvent::SelectionChanged + | TimelineEvent::TrackSelected { .. } + | TimelineEvent::TransitionChanged { .. } + | TimelineEvent::ZoomChanged(_) => {} + } + } + + fn split_clip(&mut self, clip: ClipId, time: Frame, cx: &mut Context) { + let Some((track, index)) = self.mock_clip_position(clip) else { + return; + }; + self.split_mock_clip(track, index, time); + cx.notify(); + } + + fn split_at_playhead(&mut self, cx: &mut Context) { + let frame = self.clock_frame(Monitor::Program, cx); + let targets: Vec<(usize, usize)> = self + .tracks + .iter() + .enumerate() + .flat_map(|(track_index, track)| { + track + .clips + .iter() + .enumerate() + .filter(|(_, clip)| { + clip.range.start.0 < frame.0 && frame.0 < clip.range.end.0 + }) + .map(|(clip_index, _)| (track_index, clip_index)) + .collect::>() + }) + .collect(); + for (track, index) in targets { + self.split_mock_clip(track, index, frame); + } + cx.notify(); + } + + fn delete_clip(&mut self, clip: ClipId, ripple: bool, cx: &mut Context) { + let Some((track, index)) = self.mock_clip_position(clip) else { + return; + }; + let removed = self.tracks[track].clips.remove(index); + if ripple { + // Shift the following clips on the same track left by the removed + // clip's length (the mock ripples one track, not the whole + // sequence). + let shift = removed.range.end.0 - removed.range.start.0; + for later in &mut self.tracks[track].clips[index..] { + later.range = FrameRange::new( + Frame(later.range.start.0 - shift), + Frame(later.range.end.0 - shift), + ); + } + } + cx.notify(); + } + + fn can_undo(&self) -> bool { + // The mock keeps no undo stack; the real engine's facade stack drives + // the Edit menu in real mode. + false + } + + fn can_redo(&self) -> bool { + false + } + + fn undo(&mut self, cx: &mut Context) { + println!("[mock engine] undo: no undo stack in mock mode"); + cx.notify(); + } + + fn redo(&mut self, cx: &mut Context) { + println!("[mock engine] redo: no undo stack in mock mode"); + cx.notify(); + } + + fn project_modified(&self) -> bool { + false + } + + fn new_project(&mut self, cx: &mut Context) { + println!("[mock engine] new project: demo data stays (mock mode)"); + cx.notify(); + } + + fn open_project_path(&mut self, path: PathBuf, cx: &mut Context) -> Result<(), String> { + self.open_project(path, cx); + Ok(()) + } + + fn save_project(&mut self, _path: Option, cx: &mut Context) -> Result<(), String> { + println!("[mock engine] save: no persistence in mock mode"); + cx.notify(); + Ok(()) + } + + fn close_project(&mut self, cx: &mut Context) { + println!("[mock engine] close project: demo data stays (mock mode)"); + cx.notify(); + } + + fn start_export(&mut self, _format: i32, _path: PathBuf) -> Result { + // Mock export: fake progress on a background thread, no file. + let (tx, rx) = mpsc::channel::(); + std::thread::spawn(move || { + let _ = tx.send(ExportEvent::Started); + for (delay_ms, fraction) in [(150u64, 0.33), (300, 0.66), (450, 1.0)] { + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); + let _ = tx.send(ExportEvent::Progress(fraction)); + } + let _ = tx.send(ExportEvent::Finished(true, String::new())); + }); + Ok(ExportSession { + events: rx, + cancel: Box::new(|| {}), + }) + } + + fn backend_name(&self) -> &'static str { + "mock" + } +} + // --------------------------------------------------------------------------- // Data-source traits // --------------------------------------------------------------------------- @@ -1087,8 +1353,8 @@ impl NodeGraphDataSource for MockEngine { impl ProjectDataSource for MockEngine { fn roots(&self) -> Vec { vec![ - ProjectEntry::new(1, "素材", true), - ProjectEntry::new(2, "音乐", true), + ProjectEntry::new(1, crate::i18n::tr("bin.footage"), true), + ProjectEntry::new(2, crate::i18n::tr("bin.music"), true), ProjectEntry::new(3, "第一稿.mp4", false), ProjectEntry::new(4, "aaa.ove", false), ] @@ -1137,7 +1403,8 @@ impl MockEngine { /// The synthetic CPU test frame for `monitor`, cached per playhead frame so /// a paused viewer never regenerates its picture. This is the frame the /// source/program viewers display through [`ViewerWidget::set_cpu_frame`], - /// proving the CPU-frame path end to end before the real engine lands. + /// proving the CPU-frame path end to end (the real engine shows the same + /// pattern until the render-worker frame transport is bound). pub fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc { let frame = self.clock_frame(monitor, cx); let mut cache = self.cpu_frame_cache.lock().unwrap(); @@ -1146,74 +1413,10 @@ impl MockEngine { return image.clone(); } } - let image = Arc::new(self.synthetic_frame(frame)); + let image = Arc::new(crate::oakui::frames::synthetic_frame(frame)); cache.insert(monitor, (frame.0, image.clone())); image } - - /// Generates a synthetic test frame: SMPTE-style color bars with a white - /// sweep whose x position follows `frame`, so playback is visibly moving. - /// - /// Samples are computed as F32 RGBA (mirroring the real engine's pixel - /// pipeline) and downconverted to BGRA8 for the viewer's CPU-frame path. - /// The picture is rendered at a small proxy size ([`SYNTH_FRAME_WIDTH`] × - /// [`SYNTH_FRAME_HEIGHT`]); the real engine delivers full resolution. - fn synthetic_frame(&self, frame: Frame) -> RenderImage { - let width = SYNTH_FRAME_WIDTH; - let height = SYNTH_FRAME_HEIGHT; - - // F32 RGBA samples, then quantized to BGRA8 for the sprite atlas. - let mut samples = vec![0.0f32; (width * height * 4) as usize]; - // SMPTE bars: 75% white, yellow, cyan, green, magenta, red, blue. - let bars: [(f32, f32, f32); 7] = [ - (1.0, 1.0, 1.0), - (1.0, 1.0, 0.0), - (0.0, 1.0, 1.0), - (0.0, 1.0, 0.0), - (1.0, 0.0, 1.0), - (1.0, 0.0, 0.0), - (0.0, 0.0, 1.0), - ]; - // Bottom strip: blue, magenta, 75% white, black. - let strip: [(f32, f32, f32); 4] = [ - (0.0, 0.0, 1.0), - (1.0, 0.0, 1.0), - (0.75, 0.75, 0.75), - (0.0, 0.0, 0.0), - ]; - // The sweep moves 6 px per frame and wraps around the width, so - // transport playback shows up as motion across the picture. - let sweep = (frame.0 as f32 * 6.0) % width as f32; - let bars_top = height as f32 * 0.66; - - for y in 0..height { - for x in 0..width { - let in_sweep = (x as f32 - sweep).abs() < 6.0; - let color = if in_sweep { - (1.0, 1.0, 1.0) - } else if (y as f32) < bars_top { - bars[((x as f32 / width as f32) * 7.0) as usize] - } else { - strip[((x as f32 / width as f32) * 4.0) as usize] - }; - let i = ((y * width + x) * 4) as usize; - samples[i] = color.0; - samples[i + 1] = color.1; - samples[i + 2] = color.2; - samples[i + 3] = 1.0; - } - } - - let mut bytes = Vec::with_capacity((width * height * 4) as usize); - for i in (0..samples.len()).step_by(4) { - bytes.push((samples[i + 2] * 255.0) as u8); // B - bytes.push((samples[i + 1] * 255.0) as u8); // G - bytes.push((samples[i] * 255.0) as u8); // R - bytes.push((samples[i + 3] * 255.0) as u8); // A - } - let buffer = image::RgbaImage::from_raw(width, height, bytes).expect("synthetic frame"); - RenderImage::new(smallvec::SmallVec::from_elem(image::Frame::new(buffer), 1)) - } } #[cfg(test)] @@ -1303,16 +1506,92 @@ mod tests { } #[gpui::test] - async fn timeline_edits_are_requests_not_applied(cx: &mut TestAppContext) { + async fn timeline_edits_are_applied_to_the_mock_model(cx: &mut TestAppContext) { cx.update(|app| { let engine = demo_engine(app); - // The demo timeline model ignores edit requests: the mock keeps - // its clips where they are until a real engine applies them. - let before = engine.read(app).track(1).expect("V1").clips().len(); - // (Nothing to assert beyond stability: the widget never mutates - // the data source directly — reads stay stable across reads.) - let after = engine.read(app).track(1).expect("V1").clips().len(); - assert_eq!(before, after); + + // Trim the V1 "B-roll.mp4" clip (id 12, 240–600) in by 40 frames. + engine.update(app, |engine, cx| { + engine.apply_timeline_event( + &TimelineEvent::ClipTrimRequested { + clip: ClipId(12), + edge: TrimEdge::Start, + new_frame: Frame(280), + }, + cx, + ); + }); + let v1_track = engine.read(app).track(1).expect("V1"); + let b_roll = v1_track + .clips() + .iter() + .find(|c| c.id() == ClipId(12)) + .expect("B-roll clip"); + assert_eq!(b_roll.range(), FrameRange::new(Frame(280), Frame(600))); + assert_eq!(b_roll.media_in(), Frame(140), "media-in follows the trim"); + + // Move the 开场 clip (id 11) onto the V2 track at frame 300. + engine.update(app, |engine, cx| { + engine.apply_timeline_event( + &TimelineEvent::ClipMoveRequested { + clip: ClipId(11), + new_track: 0, + new_start: Frame(300), + }, + cx, + ); + }); + let v2 = engine.read(app).track(0).expect("V2"); + assert!( + v2.clips().iter().any(|c| c.id() == ClipId(11) && c.range().start == Frame(300)), + "开场 moved to V2 at frame 300" + ); + assert!( + !engine.read(app).track(1).expect("V1").clips().iter().any(|c| c.id() == ClipId(11)), + "开场 left V1" + ); + }); + } + + #[gpui::test] + async fn mock_split_at_playhead_and_ripple_delete(cx: &mut TestAppContext) { + cx.update(|app| { + let engine = demo_engine(app); + + // Park the program playhead inside 开场 (0–240) and split there. + engine.update(app, |engine, cx| { + engine.request_frame(Monitor::Program, Frame(120), cx); + engine.split_at_playhead(cx); + }); + let v1 = engine.read(app).track(1).expect("V1"); + assert_eq!(v1.clips().len(), 3, "开场 split into two"); + let spanning = v1 + .clips() + .iter() + .filter(|c| c.range().start.0 < 120 && 120 < c.range().end.0) + .count(); + assert_eq!(spanning, 0, "no clip spans the split point"); + + // Ripple-delete the second half of 开场 (id 11's split tail). + let tail_id = v1 + .clips() + .iter() + .find(|c| c.range().start == Frame(120)) + .map(|c| c.id()) + .expect("split tail"); + engine.update(app, |engine, cx| { + engine.delete_clip(tail_id, true, cx); + }); + let v1 = engine.read(app).track(1).expect("V1"); + assert_eq!(v1.clips().len(), 2); + // The following B-roll (was 240–600) shifted left by the removed + // 120-frame tail: now starts at 120. + let b_roll = v1 + .clips() + .iter() + .find(|c| c.id() == ClipId(12)) + .expect("B-roll clip"); + assert_eq!(b_roll.range().start, Frame(120), "ripple shifted B-roll left"); }); } @@ -1487,10 +1766,13 @@ mod tests { // The frame has the documented proxy size and opaque BGRA8 bytes. let size = c.size(0); - assert_eq!(size.width, SYNTH_FRAME_WIDTH.into()); - assert_eq!(size.height, SYNTH_FRAME_HEIGHT.into()); + assert_eq!(size.width, crate::oakui::frames::SYNTH_FRAME_WIDTH.into()); + assert_eq!(size.height, crate::oakui::frames::SYNTH_FRAME_HEIGHT.into()); let bytes = c.as_bytes(0).expect("single frame"); - assert_eq!(bytes.len(), (SYNTH_FRAME_WIDTH * SYNTH_FRAME_HEIGHT * 4) as usize); + assert_eq!( + bytes.len(), + (crate::oakui::frames::SYNTH_FRAME_WIDTH * crate::oakui::frames::SYNTH_FRAME_HEIGHT * 4) as usize + ); assert!(bytes.chunks_exact(4).all(|px| px[3] == 255), "opaque alpha"); }); } diff --git a/src/oakui/mod.rs b/src/oakui/mod.rs index 7e51e20cf..2d9e777b7 100644 --- a/src/oakui/mod.rs +++ b/src/oakui/mod.rs @@ -26,20 +26,32 @@ //! * [`mock`] — [`MockEngine`](mock::MockEngine) and //! [`MockClock`](mock::MockClock), the demo implementation feeding every //! widget's data-source trait. +//! * [`real`] — [`RealEngine`](real::RealEngine) and +//! [`RealClock`](real::RealClock), the real engine binding. It calls only +//! the frozen `oakengine_*` C ABI of the built `liboakengine` dylib (see +//! [`ffi`] and the crate's `build.rs`) behind the same +//! [`EngineGateway`](engine::EngineGateway) seam the mock implements. +//! * [`ffi`] — the pure-C declarations of that ABI (extern imports, handle +//! layout mirrors, the `OakVideoParamsPod`). //! * [`transport`] — the play/pause/step/seek state machine (pure, unit //! tested). //! * [`timecode`] — timecode / duration / fps / resolution formatting (pure, //! unit tested). -//! -//! The real engine binding (the `liboakengine` C ABI through -//! `src/facade/rust`) will implement [`EngineGateway`](engine::EngineGateway) -//! later; nothing else in the crate needs to change for the swap. pub mod engine; +pub mod ffi; +pub mod frames; +mod host_syms; +pub mod icons; pub mod mock; +pub mod real; pub mod timecode; pub mod transport; -pub use engine::{EngineGateway, Monitor, Project, Sequence, VideoFormat}; +pub use engine::{ + AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, Monitor, Project, + Sequence, VideoFormat, +}; pub use mock::{MockClock, MockEngine}; +pub use real::{RealClock, RealEngine}; pub use transport::{PlayState, TransportState}; diff --git a/src/oakui/real.rs b/src/oakui/real.rs new file mode 100644 index 000000000..b65a1789e --- /dev/null +++ b/src/oakui/real.rs @@ -0,0 +1,1820 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! The real engine: [`RealEngine`] binds the built `liboakengine` dylib +//! (the frozen `oakengine_*` C ABI over the module crates, see [`ffi`]) +//! behind the same [`EngineGateway`](super::engine::EngineGateway) / +//! [`AppEngine`](super::engine::AppEngine) seam the mock implements. The +//! dylib is linked at build time (see the crate's `build.rs`); the app +//! never depends on the `oakengine` crate as an rlib, so every call below +//! is a pure `extern "C"` import declared in [`ffi`]. +//! +//! # What is real here +//! +//! * **Project** — open/save/save-as/close through the facade (`.ove` +//! serializer; `.otio` / `.fcpxml` through the oaktask interchange +//! loader). +//! * **Sequence** — the current sequence's name / format / length / tracks / +//! clips are read live from the facade sequence handle. +//! * **Edits** — timeline edits (trim, split, delete, ripple-delete) and +//! track add/remove go through the facade's edit commands, each packaged +//! as an undoable entry on the facade's global undo stack. Undo/redo walk +//! that stack. +//! * **Export** — the oaktask export task, driven on a background thread, +//! with progress events and cancel wired to the module task's event +//! callback and cancel atom. +//! * **Config** — renderer backend + language keys round-trip through +//! `oakengine_config_*`. +//! +//! # What is still mock/stub +//! +//! * The viewer frames are the shared synthetic SMPTE pattern ([`frames`]), +//! driven by the real sequence frame rate — the real frame transport +//! (render worker over shared memory, the facade's worker module) is a +//! separate process surface not bound yet. +//! * Effect stack, node graph and audio meter feed empty/silent data: the +//! facade surfaces for them (effect chains, graph nodes, audio levels) +//! are not bound in this increment. +//! * `oakengine_sequence_move_clip` is a documented facade stub (module gap), +//! so clip moves report the facade error instead of applying. +//! +//! # Threading note +//! +//! Long facade calls (`oakengine_task_start_sync`) run on background threads +//! so the UI never blocks; the export event callback delivers progress +//! through a channel the app drains on its tick loop. Cancellation through +//! `oakengine_task_cancel` mirrors the C++ capi contract (cancel atom set +//! from the UI thread while the task runs on its own thread). + +use std::collections::HashMap; +use std::ffi::{c_char, c_int, c_void, CString}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use gpui::effect_stack::{EffectData, EffectStackDataSource, EffectStackEvent}; +use gpui::node_graph::{ + EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeId, PortData, PortId, + PortDataType, PortKind, +}; +use gpui::timeline::{ + ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TimelineEvent, TrackData, + TrackKind, TrimEdge, +}; +use gpui::{hsla, point, prelude::*, px, App, Context, Entity, Hsla, Pixels, RenderImage, SharedString}; +use gpui_widgets::audio_meter::AudioMeterDataSource; +use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry}; +use gpui_widgets::viewer::PlaybackClock; + +use super::ffi::*; +use super::engine::{ + AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, Sequence, VideoFormat, +}; +use super::frames::synthetic_frame; +use super::transport::TransportState; + +/// `oakengine_timeline.h` track-type constants. +const TRACK_TYPE_VIDEO: c_int = 0; +const TRACK_TYPE_AUDIO: c_int = 1; +const TRACK_TYPE_SUBTITLE: c_int = 2; + +/// The sample-rate / layout / format defaults for export audio. +const EXPORT_SAMPLE_RATE: c_int = 48000; +/// Stereo channel-layout bitmask (`OLIVE_CHANNEL_LAYOUT_STEREO`). +const EXPORT_CHANNEL_LAYOUT: u64 = 0x3; +/// `oakcore_rs::SampleFormat::S16` as int (the encoder default). +const EXPORT_SAMPLE_FORMAT: c_int = 0; + +/// The project name of a blank project before it is saved. +const UNTITLED: &str = "Untitled Project"; + +// --------------------------------------------------------------------------- +// oaktask module C ABI entries the facade does not wrap +// --------------------------------------------------------------------------- +// +// Two module-level exports are needed here that the facade does not wrap: +// `oaktask_load_take_project` (the loaded-project getter for the +// interchange load task) and `oaktask_task_subscribe` (the task event +// callback that delivers export progress). Both take the module `CHandle`, +// which is the same value handle the facade's own boxes wrap (exposed here +// through [`ffi::unbox`]), and both symbols are exported by the dylib +// itself (it carries the module C ABIs). The declarations live in [`ffi`]; +// this keeps the *operations* on the facade contract while bridging two +// getter/event gaps the facade intentionally leaves open (see +// `oakengine/src/deferred.rs`). + +/// The C callback the module task event subscription invokes on the task's +/// own thread. `userdata` is the raw pointer of a leaked +/// `mpsc::Sender` the export thread reclaims after the run. +unsafe extern "C" fn export_event_cb(event_id: c_int, value: f64, userdata: *mut c_void) { + let Some(sender) = (userdata as *const mpsc::Sender).as_ref() else { + return; + }; + let event = match event_id { + 0 => ExportEvent::Started, + 1 => ExportEvent::Progress(value), + _ => return, // Finished is reported by the export thread (with the error). + }; + let _ = sender.send(event); +} + +/// Reclaims the leaked `mpsc::Sender` the export callback wrote through. +/// Takes the whole [`SendPtr`] so closures capture the wrapper (which is +/// `Send`) rather than the raw field. +fn reclaim_userdata(userdata: SendPtr>) { + drop(unsafe { Box::from_raw(userdata.0) }); +} + +/// A borrowed facade handle wrapper that is `Send`/`Sync`: the pointee is +/// only ever accessed through the facade C ABI (whose exports guard with +/// `catch_unwind` and synchronize their own state). +#[derive(Clone, Copy)] +struct SendPtr(*mut T); + +// SAFETY: see [`SendPtr`]. +unsafe impl Send for SendPtr {} +unsafe impl Sync for SendPtr {} + +// --------------------------------------------------------------------------- +// Handle RAII +// --------------------------------------------------------------------------- + +/// An owned facade project handle; freed with `oakengine_project_free`. +/// +/// Raw facade pointers are not `Send`/`Sync`, so the wrapper carries +/// explicit unsafe impls; the handle is only ever dereferenced through the +/// facade functions (which guard with `catch_unwind`). +struct ProjectHandle(*mut OakEngineProject); + +// SAFETY: the pointer is only used through the facade C ABI; the facade +// guards every export with catch_unwind, and all calls are serialized on the +// owning entity's context. +unsafe impl Send for ProjectHandle {} +unsafe impl Sync for ProjectHandle {} + +impl ProjectHandle { + fn ptr(&self) -> *mut OakEngineProject { + self.0 + } +} + +impl Drop for ProjectHandle { + fn drop(&mut self) { + unsafe { + oakengine_project_free(self.0); + } + } +} + +/// A borrowed facade sequence handle (boxed by the facade); freed with +/// [`free_box`] — and always before the project it was borrowed from. +struct SequenceHandle(*mut OakEngineSequence); + +// SAFETY: see [`ProjectHandle`]. +unsafe impl Send for SequenceHandle {} +unsafe impl Sync for SequenceHandle {} + +impl SequenceHandle { + fn ptr(&self) -> *mut OakEngineSequence { + self.0 + } +} + +impl Drop for SequenceHandle { + fn drop(&mut self) { + unsafe { + free_box(self.0); + } + } +} + +// --------------------------------------------------------------------------- +// FFI helpers +// --------------------------------------------------------------------------- + +/// Builds a `CString` from a path (lossy on non-UTF-8). +fn cstr_path(path: &Path) -> Option { + CString::new(path.to_string_lossy().into_owned()).ok() +} + +/// Two-stage read of a facade buf/size string (the return value is the +/// length excluding the NUL). The closure must call the facade getter inside +/// its own `unsafe` block. +fn read_string(f: impl Fn(*mut c_char, c_int) -> c_int) -> String { + let needed = f(std::ptr::null_mut(), 0); + if needed <= 0 { + return String::new(); + } + let mut buf = vec![0 as c_char; needed as usize]; + f(buf.as_mut_ptr(), needed as c_int); + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) }) + .into_owned() +} + +/// Reads the error buffer the OVE load/save serializer fills. +fn load_error(err: &mut [c_char]) -> String { + let len = err.iter().position(|&c| c == 0).unwrap_or(err.len()); + let text = + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(err.as_ptr() as *const u8, len) }) + .into_owned(); + if text.is_empty() { + "the operation failed".to_string() + } else { + text + } +} + +// --------------------------------------------------------------------------- +// Transport clock +// --------------------------------------------------------------------------- + +/// The real engine's transport clock: the playhead plus the wall-clock +/// anchor while playing. Mirrors the mock's clock; the engine additionally +/// writes the program playhead back to the facade sequence. +pub struct RealClock { + /// The transport state (play/pause, playhead, loop range). + pub transport: TransportState, + /// The clock's frame rate. + pub rate: FrameRate, + /// Wall-clock anchor `(started_at, anchored_frame)` while playing. + started: Option<(Instant, Frame)>, +} + +impl RealClock { + /// A stopped clock at frame zero running at `rate`. + pub fn new(rate: FrameRate) -> Self { + Self { + transport: TransportState::new(), + rate, + started: None, + } + } + + /// Starts playback from the current playhead. + pub fn play(&mut self) { + self.transport.play(); + self.started = Some((Instant::now(), self.transport.frame())); + } + + /// Pauses playback, keeping the playhead. + pub fn pause(&mut self) { + self.transport.pause(); + self.started = None; + } + + /// Advances the playhead from the wall clock while playing, looping at + /// `length`. No-op when stopped. + pub fn tick(&mut self, length: Frame) { + let Some((started, anchored)) = self.started else { + return; + }; + let elapsed = started.elapsed(); + let mut frame = anchored + + Frame( + (elapsed.as_secs_f64() * self.rate.num as f64 / self.rate.den as f64).round() as i64, + ); + if length.0 > 0 && frame.0 >= length.0 { + frame = Frame(frame.0 % length.0); + } + self.transport.seek(frame, length); + } +} + +impl PlaybackClock for RealClock { + fn current_frame(&self) -> Frame { + self.transport.frame() + } + + fn is_playing(&self) -> bool { + self.transport.is_playing() + } + + fn frame_rate(&self) -> FrameRate { + self.rate + } +} + +// --------------------------------------------------------------------------- +// Timeline model +// --------------------------------------------------------------------------- + +/// A clip on the real timeline: the facade data plus the C-ABI coordinates +/// (`track_type` / per-type `track_index` / per-track `clip_index`) the edit +/// commands are addressed with. +#[derive(Debug, Clone)] +pub struct RealClip { + id: ClipId, + range: FrameRange, + media_in: Frame, + label: SharedString, + color: Hsla, + track_type: TrackKind, + track_index: usize, + clip_index: usize, +} + +impl ClipData for RealClip { + fn id(&self) -> ClipId { + self.id + } + + fn range(&self) -> FrameRange { + self.range + } + + fn media_in(&self) -> Frame { + self.media_in + } + + fn label(&self) -> SharedString { + self.label.clone() + } + + fn color(&self) -> Option { + Some(self.color) + } +} + +/// A track on the real timeline (snapshot handed to the timeline widget). +#[derive(Debug, Clone)] +pub struct RealTrack { + kind: TrackKind, + name: SharedString, + height: Pixels, + locked: bool, + muted: bool, + solo: bool, + visible: bool, + clips: Vec, + track_type: c_int, + track_index: usize, +} + +impl TrackData for RealTrack { + type Clip = RealClip; + + fn kind(&self) -> TrackKind { + self.kind + } + + fn name(&self) -> SharedString { + self.name.clone() + } + + fn is_locked(&self) -> bool { + self.locked + } + + fn is_muted(&self) -> bool { + self.muted + } + + fn is_solo(&self) -> bool { + self.solo + } + + fn is_visible(&self) -> bool { + self.visible + } + + fn height(&self) -> Pixels { + self.height + } + + fn clips(&self) -> &[Self::Clip] { + &self.clips + } +} + +/// A deterministic clip color from a stable per-clip index (the facade +/// exposes no clip color). +fn clip_color(index: u64) -> Hsla { + let hues = [0.55f32, 0.6, 0.08, 0.3, 0.78, 0.45, 0.9, 0.15]; + Hsla { + h: hues[(index as usize) % hues.len()], + s: 0.55, + l: 0.45, + a: 1.0, + } +} + +/// A node in the real node graph. The facade's graph surface is not bound in +/// this increment, so the graph is always empty — the types exist to satisfy +/// the data-source trait. +#[derive(Debug, Clone)] +pub struct RealNode { + id: NodeId, +} + +impl NodeData for RealNode { + type Port = RealPort; + + fn id(&self) -> NodeId { + self.id + } + + fn title(&self) -> SharedString { + SharedString::new_static("") + } + + fn position(&self) -> gpui::Point { + point(px(0.0), px(0.0)) + } + + fn inputs(&self) -> Vec { + Vec::new() + } + + fn outputs(&self) -> Vec { + Vec::new() + } + + fn header_color(&self) -> Option { + None + } + + fn is_collapsed(&self) -> bool { + false + } + + fn is_enabled(&self) -> bool { + false + } +} + +/// A port on a real node (always empty for now). +#[derive(Debug, Clone)] +pub struct RealPort; + +impl PortData for RealPort { + fn id(&self) -> PortId { + PortId(0) + } + + fn kind(&self) -> PortKind { + PortKind::Input + } + + fn label(&self) -> SharedString { + SharedString::new_static("") + } + + fn data_type(&self) -> PortDataType { + PortDataType::new("video", hsla(0.55, 0.75, 0.6, 1.0)) + } + + fn is_connected(&self) -> bool { + false + } +} + +/// An edge in the real node graph (always empty for now). +#[derive(Debug, Clone)] +pub struct RealEdge { + id: EdgeId, +} + +impl EdgeData for RealEdge { + fn id(&self) -> EdgeId { + self.id + } + + fn from_node(&self) -> NodeId { + NodeId(0) + } + + fn from_port(&self) -> PortId { + PortId(0) + } + + fn to_node(&self) -> NodeId { + NodeId(0) + } + + fn to_port(&self) -> PortId { + PortId(0) + } +} + +// --------------------------------------------------------------------------- +// The engine +// --------------------------------------------------------------------------- + +/// The real engine: the facade project/sequence plus the snapshot models the +/// widgets read. +pub struct RealEngine { + /// The owned facade project (None before any project is open). + project: Option, + /// The borrowed facade sequence (freed before the project on drop). + sequence: Option, + /// The gateway's cached project info. + project_info: Project, + /// The gateway's cached sequence info. + sequence_info: Option, + /// The source monitor's clock. + pub source_clock: Entity, + /// The program monitor's clock. + pub program_clock: Entity, + /// The timeline snapshot (rebuilt on open/edit). + tracks: Vec, + /// The material-bin root entries. + bin_roots: Vec, + /// The material-bin children of the footage folder. + bin_children: Vec, + /// The selected material-bin entry (demo state). + selected_item: Option, + /// Whether the program monitor is playing (mirrors the clock; kept here + /// because the audio-meter data source has no `App` to read the clock). + program_playing: bool, + /// Phase counter driving the (silent) audio levels. + meter_phase: u32, + /// Cache of the synthetic CPU frames handed to the viewers, keyed by + /// monitor. Entries are the playhead frame that produced the image, so a + /// paused viewer never regenerates its picture. + cpu_frame_cache: Mutex)>>, + /// Whether the project has unsaved changes (mirrors the facade flag). + modified: bool, +} + +impl RealEngine { + /// Builds an engine with no project open. + pub fn new(cx: &mut Context) -> Self { + let rate = VideoFormat::hd_1080p25().rate; + Self { + project: None, + sequence: None, + project_info: Project { + name: UNTITLED.into(), + path: PathBuf::new(), + }, + sequence_info: None, + source_clock: cx.new(|_cx| RealClock::new(rate)), + program_clock: cx.new(|_cx| RealClock::new(rate)), + tracks: Vec::new(), + bin_roots: Vec::new(), + bin_children: Vec::new(), + selected_item: None, + program_playing: false, + meter_phase: 0, + cpu_frame_cache: Mutex::new(HashMap::new()), + modified: false, + } + } + + /// Resolves the clock entity for a monitor. + fn clock(&self, monitor: Monitor) -> &Entity { + match monitor { + Monitor::Source => &self.source_clock, + Monitor::Program => &self.program_clock, + } + } + + /// The sequence pointer, if a project+sequence is open. + fn seq_ptr(&self) -> Option<*mut OakEngineSequence> { + self.sequence.as_ref().map(SequenceHandle::ptr) + } + + /// The project pointer, if a project is open. + fn project_ptr(&self) -> Option<*mut OakEngineProject> { + self.project.as_ref().map(ProjectHandle::ptr) + } + + /// Current sequence length (0 without a sequence). + fn sequence_length(&self) -> Frame { + self.sequence_info.as_ref().map(|s| s.length).unwrap_or(Frame(0)) + } + + /// Mirrors the program playhead into the facade sequence (best effort). + fn mirror_program_playhead(&self, cx: &App) { + if let Some(seq) = self.seq_ptr() { + let frame = self.program_clock.read(cx).transport.frame().0; + unsafe { + oakengine_sequence_set_playhead(seq, frame); + } + } + } + + /// Adopts a newly created/loaded facade project, freeing any previous + /// one, and rebuilds every snapshot. `blank` projects get a default + /// sequence; loaded ones use the first sequence. + fn adopt_project(&mut self, project: *mut OakEngineProject, cx: &mut Context) { + self.drop_project(); + self.project = Some(ProjectHandle(project)); + + // Cached display info. + let name = read_string(|buf, size| unsafe { oakengine_project_name(project, buf, size) }); + let path = PathBuf::from(read_string(|buf, size| unsafe { oakengine_project_filename(project, buf, size) })); + self.project_info = Project { + name: if name.is_empty() { UNTITLED.into() } else { name }, + path, + }; + self.modified = unsafe { oakengine_project_is_modified(project) != 0 }; + + // The sequence: the project's first, or a blank default. + let count = unsafe { oakengine_project_sequence_count(project) }; + let sequence = if count > 0 { + unsafe { oakengine_project_sequence_at(project, 0) } + } else { + let name_c = CString::new("Sequence 1").unwrap(); + unsafe { oakengine_sequence_new(project, name_c.as_ptr()) } + }; + if sequence.is_null() { + return; + } + self.sequence = Some(SequenceHandle(sequence)); + self.refresh_sequence_info(); + self.rebuild_timeline(); + self.rebuild_bin(); + cx.notify(); + } + + /// Frees the project and every borrowed handle (sequence first). + fn drop_project(&mut self) { + drop(self.sequence.take()); + drop(self.project.take()); + self.tracks.clear(); + self.bin_roots.clear(); + self.bin_children.clear(); + self.sequence_info = None; + self.modified = false; + self.project_info = Project { + name: UNTITLED.into(), + path: PathBuf::new(), + }; + } + + /// Refreshes the cached `Sequence` (name / format / length) from the + /// facade. + fn refresh_sequence_info(&mut self) { + let Some(seq) = self.seq_ptr() else { + self.sequence_info = None; + return; + }; + let name = read_string(|buf, size| unsafe { oakengine_sequence_name(seq, buf, size) }); + let mut num: c_int = 0; + let mut den: c_int = 0; + let mut width: c_int = 0; + let mut height: c_int = 0; + let mut seconds: f64 = 0.0; + unsafe { + oakengine_sequence_get_frame_rate(seq, &mut num, &mut den); + oakengine_sequence_get_video_params(seq, &mut width, &mut height, std::ptr::null_mut(), std::ptr::null_mut()); + oakengine_sequence_get_length(seq, &mut seconds); + } + let rate = if num > 0 && den > 0 { + FrameRate::new(num as u32, den as u32) + } else { + VideoFormat::hd_1080p25().rate + }; + let length = Frame((seconds * rate.num as f64 / rate.den as f64).round() as i64); + self.sequence_info = Some(Sequence { + name: if name.is_empty() { "Sequence 1".into() } else { name }, + format: VideoFormat { + width: width.max(1) as u32, + height: height.max(1) as u32, + rate, + }, + length, + }); + } + + /// Rebuilds the timeline snapshot from the facade sequence. + fn rebuild_timeline(&mut self) { + self.tracks.clear(); + let Some(seq) = self.seq_ptr() else { + return; + }; + let mut video: c_int = 0; + let mut audio: c_int = 0; + let mut subtitle: c_int = 0; + unsafe { + oakengine_sequence_track_count(seq, &mut video, &mut audio, &mut subtitle); + } + let mut out: Vec = Vec::new(); + // Per-type track lists, each displayed topmost-first. + for (kind, track_type, count) in [ + (TrackKind::Video, TRACK_TYPE_VIDEO, video), + (TrackKind::Audio, TRACK_TYPE_AUDIO, audio), + (TrackKind::Subtitle, TRACK_TYPE_SUBTITLE, subtitle), + ] { + for track_index in (0..count).rev() { + out.push(self.snapshot_track(kind, track_type, track_index as usize)); + } + } + self.tracks = out; + } + + /// Snapshots one track (with its clips) from the facade. + fn snapshot_track(&self, kind: TrackKind, track_type: c_int, track_index: usize) -> RealTrack { + let Some(seq) = self.seq_ptr() else { + return RealTrack { + kind, + name: SharedString::new_static(""), + height: px(64.0), + locked: false, + muted: false, + solo: false, + visible: true, + clips: Vec::new(), + track_type, + track_index, + }; + }; + let name = match kind { + TrackKind::Video => format!("V{}", track_index + 1), + TrackKind::Audio => format!("A{}", track_index + 1), + TrackKind::Subtitle => format!("S{}", track_index + 1), + }; + // Height in internal units → pixels. + let mut internal: f64 = 0.0; + let height = unsafe { + if oakengine_track_get_height(seq, track_type, track_index as c_int, &mut internal) == 0 { + px(oakengine_track_height_internal_to_pixels(internal).max(24) as f32) + } else { + px(64.0) + } + }; + + let clip_count = unsafe { oakengine_sequence_clip_count(seq, track_type, track_index as c_int) }; + let mut clips = Vec::with_capacity(clip_count.max(0) as usize); + for clip_index in 0..clip_count.max(0) { + let clip = unsafe { oakengine_sequence_clip_at(seq, track_type, track_index as c_int, clip_index) }; + if clip.is_null() { + continue; + } + let mut in_ts: i64 = 0; + let mut out_ts: i64 = 0; + let mut media_in: i64 = 0; + unsafe { + oakengine_clip_get_range(clip, &mut in_ts, &mut out_ts, &mut media_in); + free_box(clip); + } + clips.push(RealClip { + id: ClipId((track_type as u64) * 1_000_000 + (track_index as u64 + 1) * 1000 + clip_index as u64), + range: FrameRange::new(Frame(in_ts), Frame(out_ts)), + media_in: Frame(media_in), + label: format!("Clip {}", clip_index + 1).into(), + color: clip_color(clip_index as u64), + track_type: kind, + track_index, + clip_index: clip_index as usize, + }); + } + RealTrack { + kind, + name: name.into(), + height, + locked: false, + muted: false, + solo: false, + visible: true, + clips, + track_type, + track_index, + } + } + + /// Rebuilds the material-bin snapshot from the facade project's footage. + fn rebuild_bin(&mut self) { + self.bin_roots.clear(); + self.bin_children.clear(); + let Some(project) = self.project_ptr() else { + return; + }; + let name = self.project_info.name.clone(); + self.bin_roots = vec![ + ProjectEntry::new(1, crate::i18n::tr("bin.footage"), true), + ProjectEntry::new(2, name, false), + ]; + let count = unsafe { oakengine_project_footage_count(project) }; + let mut children = Vec::new(); + for i in 0..count.max(0) { + let filename = + read_string(|buf, size| unsafe { oakengine_project_footage_filename(project, i, buf, size) }); + let label = Path::new(&filename) + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or(filename); + children.push(ProjectEntry::new(100 + i as u64, label, false)); + } + self.bin_children = children; + } + + /// Looks up the snapshot clip coordinates by `ClipId`. + fn clip_coords(&self, id: ClipId) -> Option<(TrackKind, usize, usize)> { + for track in &self.tracks { + if let Some(clip) = track.clips.iter().find(|c| c.id() == id) { + return Some((clip.track_type, clip.track_index, clip.clip_index)); + } + } + None + } + + /// The facade track-type constant for a [`TrackKind`]. + fn track_type_of(kind: TrackKind) -> c_int { + match kind { + TrackKind::Video => TRACK_TYPE_VIDEO, + TrackKind::Audio => TRACK_TYPE_AUDIO, + TrackKind::Subtitle => TRACK_TYPE_SUBTITLE, + } + } + + /// Applies an edit command, then refreshes the snapshots and repaints. + fn apply_edit(&mut self, rc: c_int, what: &str, cx: &mut Context) { + if rc != 0 { + println!("[real engine] {what} failed (facade error {rc})"); + } + self.refresh_sequence_info(); + self.rebuild_timeline(); + self.modified = true; + cx.notify(); + } + + /// Formats a facade error (two-stage task error buffer) into a message. + fn task_error(task: *mut OakEngineTask) -> String { + let text = read_string(|buf, size| unsafe { oakengine_task_error(task, buf, size) }); + if text.is_empty() { + "the task failed".to_string() + } else { + text + } + } + +} + +// --------------------------------------------------------------------------- +// EngineGateway +// --------------------------------------------------------------------------- + +impl EngineGateway for RealEngine { + fn project(&self) -> Option<&Project> { + self.project.as_ref().map(|_| &self.project_info) + } + + fn current_sequence(&self) -> Option<&Sequence> { + self.sequence_info.as_ref() + } + + fn open_project(&mut self, path: PathBuf, cx: &mut Context) { + if let Err(err) = self.open_project_path(path, cx) { + println!("[real engine] open failed: {err}"); + } + } + + fn request_frame(&mut self, monitor: Monitor, frame: Frame, cx: &mut Context) { + let length = self.sequence_length(); + let clock = self.clock(monitor).clone(); + clock.update(cx, |clock, cx| { + clock.transport.seek(frame, length); + if clock.transport.is_playing() { + clock.play(); + } + cx.notify(); + }); + self.mirror_program_playhead(cx); + cx.notify(); + } + + fn play(&mut self, monitor: Monitor, cx: &mut Context) { + if monitor == Monitor::Program { + self.program_playing = true; + } + let clock = self.clock(monitor).clone(); + clock.update(cx, |clock, cx| { + clock.play(); + cx.notify(); + }); + self.mirror_program_playhead(cx); + cx.notify(); + } + + fn pause(&mut self, monitor: Monitor, cx: &mut Context) { + if monitor == Monitor::Program { + self.program_playing = false; + } + let clock = self.clock(monitor).clone(); + clock.update(cx, |clock, cx| { + clock.pause(); + cx.notify(); + }); + cx.notify(); + } + + fn step(&mut self, monitor: Monitor, delta: i64, cx: &mut Context) { + let length = self.sequence_length(); + let clock = self.clock(monitor).clone(); + clock.update(cx, |clock, cx| { + clock.transport.step(delta, length); + if clock.transport.is_playing() { + clock.play(); + } + cx.notify(); + }); + self.mirror_program_playhead(cx); + cx.notify(); + } + + fn tick(&mut self, cx: &mut Context) { + let length = self.sequence_length(); + for clock in [&self.source_clock, &self.program_clock] { + let clock = clock.clone(); + clock.update(cx, |clock, cx| { + clock.tick(length); + cx.notify(); + }); + } + self.mirror_program_playhead(cx); + self.meter_phase = self.meter_phase.wrapping_add(1); + cx.notify(); + } +} + +// --------------------------------------------------------------------------- +// Data-source traits +// --------------------------------------------------------------------------- + +impl TimelineDataSource for RealEngine { + type Track = RealTrack; + + fn frame_rate(&self) -> FrameRate { + self.sequence_info + .as_ref() + .map(|s| s.format.rate) + .unwrap_or(VideoFormat::hd_1080p25().rate) + } + + fn sequence_length(&self) -> Frame { + self.sequence_length() + } + + fn track_count(&self) -> usize { + self.tracks.len() + } + + fn track(&self, index: usize) -> Option { + self.tracks.get(index).cloned() + } +} + +impl EffectStackDataSource for RealEngine { + fn effects(&self) -> Vec> { + // The effect-chain surface of the facade is not bound in this + // increment; the stack shows its empty state. + Vec::new() + } + + fn target_label(&self) -> Option { + None + } +} + +impl NodeGraphDataSource for RealEngine { + type Node = RealNode; + type Edge = RealEdge; + + fn nodes(&self) -> Vec { + // The node-graph surface of the facade is not bound in this + // increment; the canvas shows empty. + Vec::new() + } + + fn edges(&self) -> Vec { + Vec::new() + } + + fn can_connect(&self, _from: PortId, _to: PortId) -> bool { + false + } +} + +impl ProjectDataSource for RealEngine { + fn roots(&self) -> Vec { + self.bin_roots.clone() + } + + fn children(&self, parent_id: u64) -> Vec { + if parent_id == 1 { + self.bin_children.clone() + } else { + Vec::new() + } + } +} + +impl AudioMeterDataSource for RealEngine { + fn levels(&self) -> Vec { + // Real audio levels are not exposed by the facade; report a silent + // (but alive) meter. + vec![0.0, 0.0] + } +} + +// --------------------------------------------------------------------------- +// AppEngine +// --------------------------------------------------------------------------- + +impl AppEngine for RealEngine { + type Clock = RealClock; + + fn create(cx: &mut Context) -> Self { + Self::new(cx) + } + + fn source_clock(&self) -> &Entity { + &self.source_clock + } + + fn program_clock(&self) -> &Entity { + &self.program_clock + } + + fn clock_frame(&self, monitor: Monitor, cx: &App) -> Frame { + self.clock(monitor).read(cx).transport.frame() + } + + fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc { + let frame = self.clock_frame(monitor, cx); + let mut cache = self.cpu_frame_cache.lock().unwrap(); + if let Some((cached_frame, image)) = cache.get(&monitor) { + if *cached_frame == frame.0 { + return image.clone(); + } + } + let image = Arc::new(synthetic_frame(frame)); + cache.insert(monitor, (frame.0, image.clone())); + image + } + + fn add_track(&mut self, kind: TrackKind, cx: &mut Context) { + let Some(seq) = self.seq_ptr() else { + return; + }; + let rc = unsafe { oakengine_sequence_add_track(seq, Self::track_type_of(kind)) }; + self.apply_edit(rc, "add track", cx); + } + + fn remove_track(&mut self, index: usize, cx: &mut Context) { + let Some(track) = self.tracks.get(index) else { + return; + }; + let (track_type, track_index) = (track.track_type, track.track_index); + let Some(seq) = self.seq_ptr() else { + return; + }; + let rc = unsafe { oakengine_sequence_remove_track(seq, track_type, track_index as c_int) }; + self.apply_edit(rc, "remove track", cx); + } + + fn set_track_height(&mut self, height: Pixels, cx: &mut Context) { + let Some(seq) = self.seq_ptr() else { + return; + }; + let internal = unsafe { oakengine_track_height_pixels_to_internal(f32::from(height) as c_int) }; + let mut video: c_int = 0; + let mut audio: c_int = 0; + let mut subtitle: c_int = 0; + unsafe { + oakengine_sequence_track_count(seq, &mut video, &mut audio, &mut subtitle); + } + for (track_type, count) in [ + (TRACK_TYPE_VIDEO, video), + (TRACK_TYPE_AUDIO, audio), + (TRACK_TYPE_SUBTITLE, subtitle), + ] { + for index in 0..count { + unsafe { oakengine_track_set_height(seq, track_type, index, internal) }; + } + } + self.rebuild_timeline(); + cx.notify(); + } + + fn select_item(&mut self, id: u64, cx: &mut Context) { + self.selected_item = Some(id); + cx.notify(); + } + + fn apply_effect_event(&mut self, _event: &EffectStackEvent, cx: &mut Context) { + // No effect model in the real engine yet; requests are logged by the + // caller. + cx.notify(); + } + + fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context) { + match event { + NodeGraphEvent::NodeMovePreview { .. } + | NodeGraphEvent::ViewChanged { .. } + | NodeGraphEvent::BackgroundClicked { .. } => {} + _ => println!("[real engine] node-graph request not applied (not bound yet)"), + } + cx.notify(); + } + + fn apply_timeline_event(&mut self, event: &TimelineEvent, cx: &mut Context) { + match event { + TimelineEvent::PlayheadChanged(frame) => { + let current = self.clock_frame(Monitor::Program, cx); + if *frame != current { + self.request_frame(Monitor::Program, *frame, cx); + } + } + TimelineEvent::ClipTrimRequested { clip, edge, new_frame } => { + let Some((track_type, track_index, clip_index)) = self.clip_coords(*clip) else { + return; + }; + // Re-read the clip's current range, then compute the new + // in/out pair for `oakengine_clip_trim`. + let Some(seq) = self.seq_ptr() else { + return; + }; + let clip_ptr = unsafe { + oakengine_sequence_clip_at( + seq, + Self::track_type_of(track_type), + track_index as c_int, + clip_index as c_int, + ) + }; + if clip_ptr.is_null() { + return; + } + let mut in_ts: i64 = 0; + let mut out_ts: i64 = 0; + let mut media_in: i64 = 0; + unsafe { + oakengine_clip_get_range(clip_ptr, &mut in_ts, &mut out_ts, &mut media_in); + } + let (new_in, new_out) = match edge { + TrimEdge::Start => (new_frame.0, out_ts), + TrimEdge::End => (in_ts, new_frame.0), + }; + let rc = unsafe { oakengine_clip_trim(clip_ptr, new_in, new_out) }; + unsafe { free_box(clip_ptr) }; + self.apply_edit(rc, "trim clip", cx); + } + TimelineEvent::ClipMoveRequested { clip, .. } => { + // `oakengine_sequence_move_clip` is a documented facade stub + // (module gap); report the facade error. + let coords = self.clip_coords(*clip); + let what = format!("move clip ({coords:?})"); + println!("[real engine] {what}: not supported by the facade (stub)"); + cx.notify(); + } + TimelineEvent::TrackHeightChanged { track, height } => { + if let Some(t) = self.tracks.get(*track) { + let internal = unsafe { + oakengine_track_height_pixels_to_internal(f32::from(height) as c_int) + }; + if let Some(seq) = self.seq_ptr() { + unsafe { + oakengine_track_set_height( + seq, + t.track_type, + t.track_index as c_int, + internal, + ) + }; + } + self.rebuild_timeline(); + } + cx.notify(); + } + // Selection / zoom / transition / track-selected: not editable. + TimelineEvent::SelectionChanged + | TimelineEvent::TrackSelected { .. } + | TimelineEvent::TransitionChanged { .. } + | TimelineEvent::ZoomChanged(_) => {} + } + } + + fn split_clip(&mut self, clip: ClipId, time: Frame, cx: &mut Context) { + let Some((track_type, track_index, clip_index)) = self.clip_coords(clip) else { + return; + }; + let Some(seq) = self.seq_ptr() else { + return; + }; + let rc = unsafe { + oakengine_sequence_split_clip( + seq, + Self::track_type_of(track_type), + track_index as c_int, + clip_index as c_int, + time.0, + ) + }; + self.apply_edit(rc, "split clip", cx); + } + + fn split_at_playhead(&mut self, cx: &mut Context) { + let frame = self.clock_frame(Monitor::Program, cx); + let Some(seq) = self.seq_ptr() else { + return; + }; + let targets: Vec<(c_int, usize, usize)> = self + .tracks + .iter() + .flat_map(|track| { + track.clips.iter().filter_map(|clip| { + if clip.range.start.0 < frame.0 && frame.0 < clip.range.end.0 { + Some((track.track_type, track.track_index, clip.clip_index)) + } else { + None + } + }) + }) + .collect(); + let mut rc = 0; + for (track_type, track_index, clip_index) in targets { + rc = unsafe { + oakengine_sequence_split_clip( + seq, + track_type, + track_index as c_int, + clip_index as c_int, + frame.0, + ) + }; + } + self.apply_edit(rc, "split at playhead", cx); + } + + fn delete_clip(&mut self, clip: ClipId, ripple: bool, cx: &mut Context) { + let Some((track_type, track_index, clip_index)) = self.clip_coords(clip) else { + return; + }; + let Some(seq) = self.seq_ptr() else { + return; + }; + let rc = if ripple { + unsafe { + oakengine_sequence_ripple_delete_clip( + seq, + Self::track_type_of(track_type), + track_index as c_int, + clip_index as c_int, + ) + } + } else { + let clip_ptr = unsafe { + oakengine_sequence_clip_at( + seq, + Self::track_type_of(track_type), + track_index as c_int, + clip_index as c_int, + ) + }; + if clip_ptr.is_null() { + return; + } + let mut clips = [clip_ptr]; + let mut rippled: c_int = 0; + let rc = unsafe { + oakengine_sequence_delete_clips( + seq, + clips.as_mut_ptr(), + 1, + 0, + std::ptr::null(), + 0, + &mut rippled, + ) + }; + unsafe { free_box(clip_ptr) }; + rc + }; + self.apply_edit(rc, if ripple { "ripple delete clip" } else { "delete clip" }, cx); + } + + fn can_undo(&self) -> bool { + self.project_ptr() + .map(|p| unsafe { oakengine_project_can_undo(p) } != 0) + .unwrap_or(false) + } + + fn can_redo(&self) -> bool { + self.project_ptr() + .map(|p| unsafe { oakengine_project_can_redo(p) } != 0) + .unwrap_or(false) + } + + fn undo(&mut self, cx: &mut Context) { + if let Some(p) = self.project_ptr() { + unsafe { + oakengine_project_undo(p); + } + self.refresh_sequence_info(); + self.rebuild_timeline(); + self.modified = true; + cx.notify(); + } + } + + fn redo(&mut self, cx: &mut Context) { + if let Some(p) = self.project_ptr() { + unsafe { + oakengine_project_redo(p); + } + self.refresh_sequence_info(); + self.rebuild_timeline(); + self.modified = true; + cx.notify(); + } + } + + fn project_modified(&self) -> bool { + self.modified + } + + fn new_project(&mut self, cx: &mut Context) { + let project = unsafe { oakengine_project_create() }; + if project.is_null() { + println!("[real engine] failed to create a blank project"); + return; + } + if unsafe { oakengine_project_new(project) } != 0 { + unsafe { oakengine_project_free(project) }; + println!("[real engine] failed to initialize a blank project"); + return; + } + self.adopt_project(project, cx); + } + + fn open_project_path(&mut self, path: PathBuf, cx: &mut Context) -> Result<(), String> { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + match ext.as_deref() { + Some("otio") | Some("fcpxml") => self.open_interchange(&path, cx), + _ => self.open_ove(&path, cx), + } + } + + fn save_project(&mut self, path: Option, cx: &mut Context) -> Result<(), String> { + if self.project_ptr().is_none() { + return Err("no project open".into()); + } + let ext = path + .as_ref() + .and_then(|p| p.extension()) + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + let result = match ext.as_deref() { + Some("otio") | Some("fcpxml") => self.save_interchange(&path.unwrap(), cx), + _ => self.save_ove(path.as_deref(), cx), + }; + if result.is_ok() { + self.modified = false; + cx.notify(); + } + result + } + + fn close_project(&mut self, cx: &mut Context) { + self.drop_project(); + cx.notify(); + } + + fn start_export(&mut self, format: i32, path: PathBuf) -> Result { + let Some(seq) = self.seq_ptr() else { + return Err("no sequence open".into()); + }; + + // Build the encoding params from the sequence's format. + let params = unsafe { oakengine_encoding_params_create() }; + if params.is_null() { + return Err("failed to create encoding params".into()); + } + let cpath = cstr_path(&path).ok_or("invalid output path")?; + let rc = unsafe { oakengine_encoding_params_set_filename(params, cpath.as_ptr()) }; + if rc != 0 { + unsafe { oakengine_encoding_params_destroy(params) }; + return Err(format!("failed to set the export filename (error {rc})")); + } + let rc = unsafe { oakengine_encoding_params_set_format(params, format) }; + if rc != 0 { + unsafe { oakengine_encoding_params_destroy(params) }; + return Err(format!("failed to set the export format (error {rc})")); + } + // Video params POD from the sequence; first video codec of the format. + let mut width: c_int = 0; + let mut height: c_int = 0; + let mut par_num: c_int = 1; + let mut par_den: c_int = 1; + let mut rate_num: c_int = 25; + let mut rate_den: c_int = 1; + unsafe { + oakengine_sequence_get_video_params(seq, &mut width, &mut height, &mut par_num, &mut par_den); + oakengine_sequence_get_frame_rate(seq, &mut rate_num, &mut rate_den); + } + let video_codec = unsafe { oakengine_encoding_format_video_codec_at(format, 0) }; + if video_codec < 0 { + unsafe { oakengine_encoding_params_destroy(params) }; + return Err(format!("format {format} has no video codec")); + } + let pod = OakVideoParamsPod { + width: width.max(1), + height: height.max(1), + time_base_num: rate_den.max(1), + time_base_den: rate_num.max(1), + format: 0, + pixel_aspect_num: par_num.max(1), + pixel_aspect_den: par_den.max(1), + interlacing: 0, + color_range: 0, + divider: 1, + video_type: 0, + premultiplied_alpha: 0, + }; + let rc = unsafe { oakengine_encoding_params_enable_video(params, &pod, video_codec) }; + if rc != 0 { + unsafe { oakengine_encoding_params_destroy(params) }; + return Err(format!("failed to enable video (error {rc})")); + } + let audio_codec = unsafe { oakengine_encoding_format_audio_codec_at(format, 0) }; + if audio_codec < 0 { + unsafe { oakengine_encoding_params_destroy(params) }; + return Err(format!("format {format} has no audio codec")); + } + let rc = unsafe { + oakengine_encoding_params_enable_audio( + params, + EXPORT_SAMPLE_RATE, + EXPORT_CHANNEL_LAYOUT, + EXPORT_SAMPLE_FORMAT, + audio_codec, + ) + }; + if rc != 0 { + unsafe { oakengine_encoding_params_destroy(params) }; + return Err(format!("failed to enable audio (error {rc})")); + } + // Export the whole sequence. + let length = self.sequence_length(); + if length.0 > 0 { + unsafe { + oakengine_encoding_params_set_export_length(params, length.0 as c_int, 1); + } + } + + let task = unsafe { oakengine_task_create_export(seq, params) }; + if task.is_null() { + unsafe { oakengine_encoding_params_destroy(params) }; + return Err("failed to create the export task".into()); + } + + // Progress events through the module task callback. + let module_handle = unsafe { unbox(task) }.ok_or_else(|| "invalid export task handle".to_string())?; + let (tx, rx) = mpsc::channel::(); + let cb_userdata = SendPtr(Box::into_raw(Box::new(tx.clone()))); + unsafe { + oaktask_task_subscribe( + module_handle, + Some(export_event_cb), + cb_userdata.0 as *mut c_void, + ); + } + + // The task pointer is shared between the cancel handle and the worker + // thread; the thread owns it and frees it when the run ends. + let shared = Arc::new(Mutex::new(Some(SendPtr(task)))); + let cancel = { + let shared = shared.clone(); + Box::new(move || { + if let Some(task) = shared.lock().unwrap().as_ref() { + unsafe { + oakengine_task_cancel(task.0); + } + } + }) + }; + let worker = shared.clone(); + std::thread::spawn(move || { + unsafe { + let task = worker + .lock() + .unwrap() + .as_ref() + .expect("export task present") + .0; + let ok = oakengine_task_start_sync(task); + let error = RealEngine::task_error(task); + oakengine_task_free(task); + *worker.lock().unwrap() = None; + // Reclaim the callback userdata (the task's listener is + // one-shot and dropped after the run). + reclaim_userdata(cb_userdata); + let _ = tx.send(ExportEvent::Finished(ok != 0, error)); + } + }); + + Ok(ExportSession { events: rx, cancel }) + } + fn backend_name(&self) -> &'static str { + "real" + } +} + +// --------------------------------------------------------------------------- +// Format dispatch (pure, unit tested) +// --------------------------------------------------------------------------- + +/// Classifies a project-file extension for open/save dispatch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectFormat { + /// `.ove` / `.ovexml` — the native serializer. + Ove, + /// `.otio` — OpenTimelineIO JSON. + Otio, + /// `.fcpxml` — Final Cut Pro XML. + Fcpxml, +} + +impl ProjectFormat { + /// The format for a file path, by extension (unknown → [`Ove`] + /// (ProjectFormat::Ove), matching the app's default serializer). + pub fn of(path: &PathBuf) -> Self { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + match ext.as_deref() { + Some("otio") => ProjectFormat::Otio, + Some("fcpxml") => ProjectFormat::Fcpxml, + _ => ProjectFormat::Ove, + } + } +} + +impl RealEngine { + /// Opens a `.ove` / `.ovexml` project through the facade serializer. + fn open_ove(&mut self, path: &PathBuf, cx: &mut Context) -> Result<(), String> { + let project = unsafe { oakengine_project_create() }; + if project.is_null() { + return Err("failed to create a project".into()); + } + let Some(cpath) = cstr_path(path) else { + unsafe { oakengine_project_free(project) }; + return Err("invalid project path".into()); + }; + let mut err = [0 as c_char; 4096]; + let rc = unsafe { oakengine_project_load(project, cpath.as_ptr(), err.as_mut_ptr(), err.len() as c_int) }; + if rc != 0 { + let message = load_error(&mut err); + unsafe { oakengine_project_free(project) }; + return Err(format!("failed to load \"{}\": {message}", path.display())); + } + // The module serializer cannot parse every legacy document (e.g. the + // ``-rooted format skips its nested `` body), which + // loads "successfully" with no content; surface it instead of + // pretending the project opened. + let nodes = unsafe { oakengine_project_node_count(project) }; + if nodes == 0 { + println!( + "[real engine] warning: \"{}\" loaded but contained no parseable content; starting from an empty project", + path.display() + ); + } + self.adopt_project(project, cx); + Ok(()) + } + + /// Opens an `.otio` / `.fcpxml` project through the oaktask interchange + /// loader and adopts the loaded project (the facade exposes no + /// load-result getter, so the module's `oaktask_load_take_project` is + /// called directly — see the module docs). + fn open_interchange(&mut self, path: &PathBuf, cx: &mut Context) -> Result<(), String> { + let Some(cpath) = cstr_path(path) else { + return Err("invalid project path".into()); + }; + let task = unsafe { oakengine_task_create_project_load_otio(cpath.as_ptr()) }; + if task.is_null() { + return Err("failed to create the interchange load task".into()); + } + let rc = unsafe { oakengine_task_start_sync(task) }; + let error = Self::task_error(task); + let module_handle = unsafe { unbox(task) }; + let loaded = module_handle.and_then(|h| { + let project = unsafe { oaktask_load_take_project(h) }; + if project.is_null() { + None + } else { + Some(unsafe { box_handle::(project) }) + } + }); + unsafe { oakengine_task_free(task) }; + if rc == 0 { + return Err(format!("failed to load \"{}\": {error}", path.display())); + } + match loaded { + Some(project) => { + self.adopt_project(project, cx); + Ok(()) + } + None => Err(format!("loaded \"{}\" but the loader returned no project", path.display())), + } + } + + /// Saves to the project's own filename (or `path`) through the OVE + /// serializer. + fn save_ove(&mut self, path: Option<&Path>, _cx: &mut Context) -> Result<(), String> { + let Some(project) = self.project_ptr() else { + return Err("no project open".into()); + }; + let cpath = path.and_then(cstr_path); + let ptr = cpath.as_ref().map(|c| c.as_ptr()).unwrap_or(std::ptr::null()); + let rc = unsafe { oakengine_project_save(project, ptr) }; + if rc != 0 { + return Err(format!("failed to save the project (error {rc})")); + } + // The facade recorded the target filename; refresh the display name. + let name = read_string(|buf, size| unsafe { oakengine_project_name(project, buf, size) }); + if !name.is_empty() { + self.project_info.name = name; + } + let filename = read_string(|buf, size| unsafe { oakengine_project_filename(project, buf, size) }) ; + if !filename.is_empty() { + self.project_info.path = PathBuf::from(filename); + } + Ok(()) + } + + /// Saves as `.otio` / `.fcpxml` through the oaktask save task (the + /// facade derives the output filename from the project's own filename). + fn save_interchange(&mut self, path: &PathBuf, _cx: &mut Context) -> Result<(), String> { + let Some(project) = self.project_ptr() else { + return Err("no project open".into()); + }; + let Some(cpath) = cstr_path(path) else { + return Err("invalid project path".into()); + }; + let rc = unsafe { oakengine_project_set_filename(project, cpath.as_ptr()) }; + if rc != 0 { + return Err(format!("failed to set the output filename (error {rc})")); + } + let task = unsafe { oakengine_task_create_project_save_otio(project) }; + if task.is_null() { + return Err("failed to create the interchange save task".into()); + } + let rc = unsafe { oakengine_task_start_sync(task) }; + let error = Self::task_error(task); + unsafe { oakengine_task_free(task) }; + if rc == 0 { + return Err(format!("failed to save \"{}\": {error}", path.display())); + } + self.project_info.path = path.clone(); + Ok(()) + } +} + +/// Builds the export-format list: (format id, display name, extension) from +/// the oakcodec encoding enumeration. +/// +/// Pure helper so the export dialog can be unit tested; the facade is only +/// consulted for the real engine. +pub fn encoding_formats() -> Vec<(c_int, String, String)> { + let count = unsafe { oakengine_encoding_format_count() }; + let mut out = Vec::new(); + for i in 0..count.max(0) { + let name = read_string(|buf, size| unsafe { oakengine_encoding_format_name(i, buf, size) }); + let ext = read_string(|buf, size| unsafe { oakengine_encoding_format_extension(i, buf, size) }); + out.push((i, name, ext)); + } + out +} + +/// The format id of the default export container: MPEG-4 Video (`.mp4`). +pub const EXPORT_FORMAT_MP4: c_int = 2; + +// --------------------------------------------------------------------------- +// Config C ABI (renderer backend + language) +// --------------------------------------------------------------------------- + +/// The config key selecting the renderer backend (worker `create_renderer` +/// backend id). +pub const CONFIG_KEY_RENDERER_BACKEND: &str = "GraphicsBackend"; + +/// Reads a config string through the facade config C ABI (empty when +/// missing). +pub fn config_get_string(key: &str) -> String { + let Ok(key_c) = CString::new(key) else { + return String::new(); + }; + read_string(|buf, size| unsafe { oakengine_config_get_string(key_c.as_ptr(), buf, size) }) +} + +/// Writes a config string through the facade config C ABI. +pub fn config_set_string(key: &str, value: &str) { + let (Ok(key_c), Ok(value_c)) = (CString::new(key), CString::new(value)) else { + return; + }; + unsafe { + oakengine_config_set_string(key_c.as_ptr(), value_c.as_ptr()); + } +} + +/// The renderer backends offered in the preferences dialog, in display +/// order. The first entry is the built-in default. +pub fn renderer_backends() -> Vec<&'static str> { + vec!["opengl", "metal", "vulkan", "none"] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_format_dispatches_by_extension() { + assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.ove")), ProjectFormat::Ove); + assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.ovexml")), ProjectFormat::Ove); + assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.OTIO")), ProjectFormat::Otio); + assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.fcpxml")), ProjectFormat::Fcpxml); + // Unknown extensions fall back to the OVE serializer. + assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.xml")), ProjectFormat::Ove); + assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x")), ProjectFormat::Ove); + } + + #[test] + fn renderer_backends_list_is_stable() { + let backends = renderer_backends(); + assert!(backends.len() >= 2); + assert!(backends.contains(&"opengl"), "the default backend is offered"); + } + + /// End-to-end through the facade: a project the engine itself writes + /// (save → load round-trip) keeps its identity, and the in-memory + /// sequence the app drives (created with `oakengine_sequence_new`) carries + /// real tracks. The repository's `tests/project_with_footage.ove` is a + /// legacy ``-rooted document the oaknode serializer cannot parse, + /// so the round-trip uses the engine's own current-format writer. + /// + /// NOTE (documented facade gaps): `oakengine_sequence_new` keeps the + /// sequence in a module scratch project (not the project's membership), so + /// the saved file carries no sequence and a loaded file registers none — + /// the app therefore opens any project and works against a fresh in-memory + /// sequence (see [`RealEngine::adopt_project`]). + #[test] + fn real_project_save_load_round_trip() { + let project = unsafe { oakengine_project_create() }; + assert!(!project.is_null()); + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + + // The in-memory sequence the app drives: real tracks over the facade. + let name = CString::new("Round Trip").unwrap(); + let sequence = unsafe { oakengine_sequence_new(project, name.as_ptr()) }; + assert!(!sequence.is_null()); + assert_eq!(unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_VIDEO) }, 0); + assert_eq!(unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_AUDIO) }, 0); + let mut video: c_int = -1; + let mut audio: c_int = -1; + let mut subtitle: c_int = -1; + unsafe { + oakengine_sequence_track_count(sequence, &mut video, &mut audio, &mut subtitle); + } + assert_eq!((video, audio, subtitle), (1, 1, 0), "in-memory tracks"); + + // Save as uncompressed `.ovexml` (the module serializer only reads + // plain XML). + let save_path = + std::env::temp_dir().join(format!("oakapp_roundtrip_{}.ovexml", std::process::id())); + let cpath = CString::new(save_path.to_string_lossy().into_owned()).unwrap(); + assert_eq!(unsafe { oakengine_project_set_filename(project, cpath.as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_project_save(project, cpath.as_ptr()) }, 0); + assert!(save_path.exists()); + unsafe { free_box(sequence) }; + unsafe { oakengine_project_free(project) }; + + // Load it back through the same facade path the app uses: the file + // loads and the project identity round-trips. + let project2 = unsafe { oakengine_project_create() }; + assert!(!project2.is_null()); + let mut err = [0 as c_char; 4096]; + let rc = unsafe { + oakengine_project_load(project2, cpath.as_ptr(), err.as_mut_ptr(), err.len() as c_int) + }; + assert_eq!(rc, 0, "project loads: {}", load_error(&mut err)); + let loaded_name = read_string(|buf, size| unsafe { + oakengine_project_name(project2, buf, size) + }); + assert!(!loaded_name.is_empty(), "the loaded project has a name"); + + unsafe { oakengine_project_free(project2) }; + let _ = std::fs::remove_file(&save_path); + } +} diff --git a/src/oakui/timecode.rs b/src/oakui/timecode.rs index f4c54b804..e5db10705 100644 --- a/src/oakui/timecode.rs +++ b/src/oakui/timecode.rs @@ -38,13 +38,10 @@ use gpui::timeline::{Frame, FrameRate}; /// /// # Examples /// -/// ``` -/// use gpui::timeline::{Frame, FrameRate}; -/// use oakapp::oakui::timecode::format_timecode; -/// let rate = FrameRate::new(25, 1); -/// assert_eq!(format_timecode(Frame(0), rate), "00:00:00:00"); -/// assert_eq!(format_timecode(Frame(25), rate), "00:00:01:00"); -/// assert_eq!(format_timecode(Frame(6468), rate), "00:04:18:18"); +/// ```text +/// format_timecode(Frame(0), 25fps) → "00:00:00:00" +/// format_timecode(Frame(25), 25fps) → "00:00:01:00" +/// format_timecode(Frame(6468), 25fps) → "00:04:18:18" /// ``` pub fn format_timecode(frame: Frame, rate: FrameRate) -> String { let negative = frame.0 < 0; @@ -79,11 +76,9 @@ pub fn format_duration(frames: Frame, rate: FrameRate) -> String { /// /// # Examples /// -/// ``` -/// use gpui::timeline::FrameRate; -/// use oakapp::oakui::timecode::format_fps; -/// assert_eq!(format_fps(FrameRate::new(25, 1)), "25"); -/// assert_eq!(format_fps(FrameRate::NTSC_2997), "29.97"); +/// ```text +/// format_fps(FrameRate::new(25, 1)) → "25" +/// format_fps(FrameRate::NTSC_2997) → "29.97" /// ``` pub fn format_fps(rate: FrameRate) -> String { let fps = rate.num as f64 / rate.den as f64; diff --git a/src/panels/history.rs b/src/panels/history.rs index 493100967..40f7cb0cd 100644 --- a/src/panels/history.rs +++ b/src/panels/history.rs @@ -25,9 +25,11 @@ use crate::panels::ids::HISTORY; /// The undo-history placeholder panel. pub struct HistoryPanel { - /// Demo entries (newest first), matching the design's date format - /// `YYYY-MM-DD HH:mm`. - entries: Vec<(&'static str, &'static str)>, + /// Demo entries `(i18n key, label suffix, timestamp)`, newest first, + /// matching the design's date format `YYYY-MM-DD HH:mm`. The label is + /// `tr(key) + suffix`, so the verb is localized while clip names stay as + /// data. + entries: Vec<(&'static str, &'static str, &'static str)>, } impl HistoryPanel { @@ -35,11 +37,11 @@ impl HistoryPanel { pub fn new(_window: &mut Window, _cx: &mut Context) -> Self { Self { entries: vec![ - ("变换", "2026-06-03 20:25"), - ("移动片段", "2026-06-03 20:24"), - ("删除 B-roll.mp4", "2026-06-03 20:22"), - ("添加 OCIO LUT", "2026-06-03 20:20"), - ("设置入点", "2026-06-03 20:18"), + ("history.transform", "", "2026-06-03 20:25"), + ("history.move_clip", "", "2026-06-03 20:24"), + ("history.delete_clip", " B-roll.mp4", "2026-06-03 20:22"), + ("history.add_lut", "", "2026-06-03 20:20"), + ("history.set_in_point", "", "2026-06-03 20:18"), ], } } @@ -55,7 +57,8 @@ impl Render for HistoryPanel { .flex_col() .py_1() .overflow_y_scroll(); - for (label, timestamp) in &self.entries { + for (key, suffix, timestamp) in &self.entries { + let label = format!("{}{}", crate::i18n::tr(key), suffix); list = list.child( div() .flex() @@ -65,7 +68,7 @@ impl Render for HistoryPanel { .px_3() .py_1() .text_color(colors.text) - .child(div().child(*label)) + .child(div().child(label)) .child(div().text_color(colors.disabled).child(*timestamp)), ); } diff --git a/src/panels/inspector.rs b/src/panels/inspector.rs index 48c7d50be..2dd272b8e 100644 --- a/src/panels/inspector.rs +++ b/src/panels/inspector.rs @@ -26,18 +26,18 @@ use gpui::{ div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, Window, }; -use crate::oakui::MockEngine; +use crate::oakui::AppEngine; use crate::panels::ids::INSPECTOR; /// The inspector / effect stack panel. -pub struct InspectorPanel { - stack: Entity>, - engine: Entity, +pub struct InspectorPanel { + stack: Entity>, + engine: Entity, } -impl InspectorPanel { +impl InspectorPanel { /// Builds the stack over `engine`'s effect model. - pub fn new(engine: Entity, _window: &mut Window, cx: &mut Context) -> Self { + pub fn new(engine: Entity, _window: &mut Window, cx: &mut Context) -> Self { let stack = cx.new(|cx| { EffectStackView::new(engine.clone(), cx) .params_renderer(|_effect, _window, cx| cx.new(|_cx| ParamPlaceholder).into()) @@ -54,15 +54,15 @@ impl InspectorPanel { } } -impl Render for InspectorPanel { +impl Render for InspectorPanel { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div().size_full().child(self.stack.clone()) } } -impl EventEmitter for InspectorPanel {} +impl EventEmitter for InspectorPanel {} -impl DockPanel for InspectorPanel { +impl DockPanel for InspectorPanel { fn panel_id(&self) -> gpui::dock::PanelId { INSPECTOR } diff --git a/src/panels/node_editor.rs b/src/panels/node_editor.rs index 9c36d291f..a2ca41ea5 100644 --- a/src/panels/node_editor.rs +++ b/src/panels/node_editor.rs @@ -27,32 +27,31 @@ use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; use gpui::node_graph::{ - MAX_ZOOM, MIN_ZOOM, NodeData, NodeElement, NodeGraphDataSource, NodeGraphEvent, NodeGraphView, - NodeVisualState, + MAX_ZOOM, MIN_ZOOM, NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeVisualState, }; use gpui::{ div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity, EventEmitter, Pixels, Render, SharedString, Window, }; -use crate::oakui::MockEngine; +use crate::oakui::AppEngine; use crate::panels::ids::NODE_EDITOR; /// The node editor panel. -pub struct NodeEditorPanel { - /// The node-graph canvas over the engine's mock graph. - graph: Entity>, - engine: Entity, +pub struct NodeEditorPanel { + /// The node-graph canvas over the engine's graph data. + graph: Entity>, + engine: Entity, /// Whether the initial fit-to-window has been applied (the canvas size is /// only known after the first layout). fitted: bool, } -impl NodeEditorPanel { +impl NodeEditorPanel { /// Builds the graph canvas over `engine` and routes its edit requests back /// to the engine. pub fn new( - engine: Entity, + engine: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -134,7 +133,7 @@ impl NodeEditorPanel { } } -impl Render for NodeEditorPanel { +impl Render for NodeEditorPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // Fit the graph once the canvas size is known (first layout). Before // that the viewport is zero-sized, so ask for another frame instead. @@ -161,16 +160,32 @@ impl Render for NodeEditorPanel { .py_1() .border_b_1() .border_color(colors.border) - .child(zoom_button(cx, "node-zoom-in", "+", |this, window, cx| { - this.zoom(1.25, window, cx); - })) - .child(zoom_button(cx, "node-zoom-out", "−", |this, window, cx| { - this.zoom(1.0 / 1.25, window, cx); - })) + .child(zoom_button( + cx, + "node-zoom-in", + Some(crate::oakui::icons::ICON_ZOOM_IN), + "+", + "timeline.zoom_in", + |this, window, cx| { + this.zoom(1.25, window, cx); + }, + )) + .child(zoom_button( + cx, + "node-zoom-out", + Some(crate::oakui::icons::ICON_ZOOM_OUT), + "−", + "timeline.zoom_out", + |this, window, cx| { + this.zoom(1.0 / 1.25, window, cx); + }, + )) .child(zoom_button( cx, "node-zoom-fit", + None, crate::i18n::tr("node.fit"), + "node.fit", |this, window, cx| this.fit_graph(window, cx), )) .child(div().flex_1()) @@ -195,34 +210,52 @@ impl Render for NodeEditorPanel { } } -/// A small toolbar button driving the graph viewport. -fn zoom_button( - cx: &mut Context, +/// A small toolbar button driving the graph viewport. With `icon_name`, the +/// button shows the 16px icon on a 24px hit target; otherwise the `label` +/// text. Both get a localized `tooltip`. +fn zoom_button( + cx: &mut Context>, id: &'static str, + icon_name: Option<&'static str>, label: impl IntoElement, - action: impl Fn(&mut NodeEditorPanel, &mut Window, &mut Context) + 'static, + tooltip: &'static str, + action: impl Fn(&mut NodeEditorPanel, &mut Window, &mut Context>) + 'static, ) -> impl gpui::IntoElement { let colors = cx.default_colors().clone(); let container = colors.container; - div() + let tooltip_label = crate::i18n::tr(tooltip); + let mut el = div() .id(id) - .px_2() - .py_1() + .size(px(24.0)) + .flex() + .items_center() + .justify_center() .rounded_md() .border_1() .border_color(colors.border) .text_color(colors.text) .cursor_pointer() .hover(move |style| style.bg(container)) + .tooltip(move |window, cx| { + gpui_widgets::tooltip::tooltip_view(tooltip_label.into(), window, cx) + }) .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { action(this, window, cx); - })) - .child(label) + })); + if let Some(name) = icon_name { + el = el.child( + gpui::img(crate::oakui::icons::icon_path(name, cx)).size(px(16.0)), + ); + } else { + el = el.child(label); + } + el } -impl EventEmitter for NodeEditorPanel {} -impl DockPanel for NodeEditorPanel { +impl EventEmitter for NodeEditorPanel {} + +impl DockPanel for NodeEditorPanel { fn panel_id(&self) -> gpui::dock::PanelId { NODE_EDITOR } @@ -241,13 +274,14 @@ impl DockPanel for NodeEditorPanel { #[cfg(test)] mod tests { use super::*; + use crate::oakui::MockEngine; use gpui::{TestAppContext, VisualTestContext, size}; /// Builds the panel in a window and returns a `VisualTestContext` for /// bounds assertions. fn panel_window( cx: &mut TestAppContext, - ) -> (&'static mut VisualTestContext, Entity) { + ) -> (&'static mut VisualTestContext, Entity>) { cx.update(|cx| cx.init_colors()); let window = cx.open_window(size(px(640.0), px(480.0)), |window, cx| { let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx)); diff --git a/src/panels/program_viewer.rs b/src/panels/program_viewer.rs index 92a41839a..e9ca0586b 100644 --- a/src/panels/program_viewer.rs +++ b/src/panels/program_viewer.rs @@ -28,7 +28,7 @@ use gpui_widgets::audio_meter::AudioLevelMeter; use gpui_widgets::viewer::{ViewerEvent, ViewerWidget}; use crate::oakui::timecode::{format_fps, format_resolution}; -use crate::oakui::{EngineGateway, MockClock, MockEngine, Monitor}; +use crate::oakui::{AppEngine, Monitor}; use crate::panels::chip; use crate::panels::ids::PROGRAM_VIEWER; @@ -36,22 +36,22 @@ use crate::panels::ids::PROGRAM_VIEWER; const METER_WIDTH: f32 = 26.0; /// The program viewer panel. -pub struct ProgramViewerPanel { - viewer: Entity>, - meter: Entity>, - engine: Entity, +pub struct ProgramViewerPanel { + viewer: Entity>, + meter: Entity>, + engine: Entity, /// The last CPU frame handed to the viewer (compared by `Arc` identity so /// a paused playhead does not re-upload the picture every frame). last_cpu_frame: Option>, } -impl ProgramViewerPanel { +impl ProgramViewerPanel { /// Builds a viewer over `clock` (the program monitor's clock) with the /// level meter `meter` (updated on the app's tick timer). pub fn new( - engine: Entity, - clock: Entity, - meter: Entity>, + engine: Entity, + clock: Entity, + meter: Entity>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -90,7 +90,7 @@ impl ProgramViewerPanel { } } -impl Render for ProgramViewerPanel { +impl Render for ProgramViewerPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_frame(cx); @@ -138,9 +138,9 @@ impl Render for ProgramViewerPanel { } } -impl EventEmitter for ProgramViewerPanel {} +impl EventEmitter for ProgramViewerPanel {} -impl DockPanel for ProgramViewerPanel { +impl DockPanel for ProgramViewerPanel { fn panel_id(&self) -> gpui::dock::PanelId { PROGRAM_VIEWER } diff --git a/src/panels/project_explorer.rs b/src/panels/project_explorer.rs index e9ecefadc..c36a2e81c 100644 --- a/src/panels/project_explorer.rs +++ b/src/panels/project_explorer.rs @@ -23,18 +23,18 @@ use gpui::{ }; use gpui_widgets::project_explorer::{ProjectExplorer, ProjectExplorerEvent}; -use crate::oakui::MockEngine; +use crate::oakui::AppEngine; use crate::panels::ids::PROJECT; /// The material bin panel. -pub struct ProjectExplorerPanel { - explorer: Entity>, - engine: Entity, +pub struct ProjectExplorerPanel { + explorer: Entity>, + engine: Entity, } -impl ProjectExplorerPanel { +impl ProjectExplorerPanel { /// Builds the explorer over `engine`'s project data. - pub fn new(engine: Entity, window: &mut Window, cx: &mut Context) -> Self { + pub fn new(engine: Entity, window: &mut Window, cx: &mut Context) -> Self { let explorer = cx.new(|cx| ProjectExplorer::new(1, engine.clone(), window, cx)); cx.subscribe( &explorer, @@ -53,15 +53,15 @@ impl ProjectExplorerPanel { } } -impl Render for ProjectExplorerPanel { +impl Render for ProjectExplorerPanel { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div().size_full().child(self.explorer.clone()) } } -impl EventEmitter for ProjectExplorerPanel {} +impl EventEmitter for ProjectExplorerPanel {} -impl DockPanel for ProjectExplorerPanel { +impl DockPanel for ProjectExplorerPanel { fn panel_id(&self) -> gpui::dock::PanelId { PROJECT } diff --git a/src/panels/source_viewer.rs b/src/panels/source_viewer.rs index a27ccfd3b..8ee2282d3 100644 --- a/src/panels/source_viewer.rs +++ b/src/panels/source_viewer.rs @@ -26,24 +26,24 @@ use gpui::{ use gpui_widgets::viewer::{ViewerEvent, ViewerWidget}; use crate::oakui::timecode::{format_fps, format_resolution}; -use crate::oakui::{EngineGateway, MockClock, MockEngine, Monitor}; +use crate::oakui::{AppEngine, Monitor}; use crate::panels::chip; use crate::panels::ids::SOURCE_VIEWER; /// The source viewer panel. -pub struct SourceViewerPanel { - viewer: Entity>, - engine: Entity, +pub struct SourceViewerPanel { + viewer: Entity>, + engine: Entity, /// The last CPU frame handed to the viewer (compared by `Arc` identity so /// a paused playhead does not re-upload the picture every frame). last_cpu_frame: Option>, } -impl SourceViewerPanel { +impl SourceViewerPanel { /// Builds a viewer over `clock` (the source monitor's clock). pub fn new( - engine: Entity, - clock: Entity, + engine: Entity, + clock: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -81,7 +81,7 @@ impl SourceViewerPanel { } } -impl Render for SourceViewerPanel { +impl Render for SourceViewerPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_frame(cx); @@ -117,9 +117,9 @@ impl Render for SourceViewerPanel { } } -impl EventEmitter for SourceViewerPanel {} +impl EventEmitter for SourceViewerPanel {} -impl DockPanel for SourceViewerPanel { +impl DockPanel for SourceViewerPanel { fn panel_id(&self) -> gpui::dock::PanelId { SOURCE_VIEWER } diff --git a/src/panels/status_bar.rs b/src/panels/status_bar.rs index a49e29e4e..fac3ea170 100644 --- a/src/panels/status_bar.rs +++ b/src/panels/status_bar.rs @@ -22,20 +22,22 @@ use gpui::colors::DefaultColors; use gpui::timeline::Frame; use gpui::{div, prelude::*, Context, Entity, Render, Window}; +use gpui_widgets::viewer::PlaybackClock; + use crate::oakui::timecode::{format_duration, format_fps, format_resolution, format_timecode}; -use crate::oakui::{EngineGateway, MockClock, MockEngine}; +use crate::oakui::AppEngine; /// The global status bar. -pub struct StatusBar { - engine: Entity, - program_clock: Entity, +pub struct StatusBar { + engine: Entity, + program_clock: Entity, } -impl StatusBar { +impl StatusBar { /// Builds the status bar over the engine and the program clock. pub fn new( - engine: Entity, - program_clock: Entity, + engine: Entity, + program_clock: Entity, _cx: &mut Context, ) -> Self { Self { @@ -45,11 +47,11 @@ impl StatusBar { } } -impl Render for StatusBar { +impl Render for StatusBar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); let engine = self.engine.read(cx); - let frame = self.program_clock.read(cx).transport.frame(); + let frame = self.program_clock.read(cx).current_frame(); let sequence = engine.current_sequence(); let format = sequence .map(|s| s.format) @@ -100,5 +102,15 @@ impl Render for StatusBar { format_resolution(format.width, format.height), )) .child(div().px_2().text_color(colors.disabled).child(project)) + .child( + div() + .px_2() + .text_color(colors.disabled) + .child(format!( + "{} · {}", + crate::i18n::tr("status.backend"), + self.engine.read(cx).backend_name(), + )), + ) } } diff --git a/src/panels/timeline.rs b/src/panels/timeline.rs index bd5b6775f..10dc2a946 100644 --- a/src/panels/timeline.rs +++ b/src/panels/timeline.rs @@ -44,14 +44,16 @@ use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; use gpui::timeline::TimelineView; -use gpui::{div, prelude::*, px, Context, Entity, Window}; +use gpui::{div, img, prelude::*, px, Context, Entity, Window}; use gpui::{AnyElement, App, ClickEvent, EventEmitter, Render, SharedString}; use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState}; use gpui_widgets::slider::{Slider, SliderEvent, SliderModel}; +use gpui_widgets::tooltip::tooltip_view; use gpui_widgets::value::ValueKind; use crate::i18n; -use crate::oakui::MockEngine; +use crate::oakui::icons; +use crate::oakui::AppEngine; use crate::panels::ids::TIMELINE; /// Toolbar height, per the design (31px). @@ -59,23 +61,24 @@ const TOOLBAR_HEIGHT: f32 = 31.0; /// Fixed width of the trailing controls slot (zoom / track-height sliders). /// Kept constant so the sliders can never intrude into the ruler's labels. const RIGHT_CONTROLS_WIDTH: f32 = 140.0; -/// The demo tool set, by i18n key. Only the visual selection is implemented; -/// each tool's behavior arrives with the real tool system later. -const TOOL_KEYS: [&str; 8] = [ - "timeline.tool.select", - "timeline.tool.razor", - "timeline.tool.ripple", - "timeline.tool.slip", - "timeline.tool.roll", - "timeline.tool.zoom", - "timeline.tool.knife", - "timeline.tool.marker", +/// The demo tool set, by i18n key, with the matching toolbar icon (the +/// legacy C++ icon set). Only the visual selection is implemented; each +/// tool's behavior arrives with the real tool system later. +const TOOLS: [(&str, &str); 8] = [ + ("timeline.tool.select", crate::oakui::icons::ICON_ARROW), + ("timeline.tool.razor", crate::oakui::icons::ICON_RAZOR), + ("timeline.tool.ripple", crate::oakui::icons::ICON_RIPPLE), + ("timeline.tool.slip", crate::oakui::icons::ICON_SLIP), + ("timeline.tool.roll", crate::oakui::icons::ICON_ROLLING), + ("timeline.tool.zoom", crate::oakui::icons::ICON_ZOOM), + ("timeline.tool.slide", crate::oakui::icons::ICON_SLIDE), + ("timeline.tool.track_select", crate::oakui::icons::ICON_TRACK_SELECT), ]; /// The timeline panel. -pub struct TimelinePanel { - timeline: Entity>, - engine: Entity, +pub struct TimelinePanel { + timeline: Entity>, + engine: Entity, zoom: Entity, height: Entity, snap: Entity, @@ -83,12 +86,12 @@ pub struct TimelinePanel { selected_tool: usize, } -impl TimelinePanel { +impl TimelinePanel { /// Builds the panel around `timeline` (created by the app shell so it can /// sync the playhead). pub fn new( - engine: Entity, - timeline: Entity>, + engine: Entity, + timeline: Entity>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -155,7 +158,7 @@ impl TimelinePanel { } } -impl Render for TimelinePanel { +impl Render for TimelinePanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); @@ -179,74 +182,112 @@ impl Render for TimelinePanel { .flex_shrink_0() .flex() .items_center() - .gap_1() + .gap_2() .px_2() .overflow_hidden() .border_b_1() .border_color(colors.border) .bg(colors.container); - for (index, tool_key) in TOOL_KEYS.iter().enumerate() { - let tool = i18n::tr(tool_key); + // A tool button: a 16px icon on a 24px hit target with a localized + // tooltip; the selected tool is highlighted. + let tool_button = |index: usize, + icon_name: &'static str, + key: &'static str, + cx: &mut Context| { + let tool = i18n::tr(key); let selected = self.selected_tool == index; let background = if selected { colors.selected } else { colors.background }; - let foreground = if selected { - colors.selected_text - } else { - colors.text - }; let hover_bg = colors.selected; - let hover_fg = colors.selected_text; - toolbar = toolbar.child( - div() - .id(SharedString::from(format!("tool-{index}"))) - .px_2() - .py_1() - .rounded_sm() - .cursor_pointer() - .bg(background) - .text_color(foreground) - .hover(move |style| style.bg(hover_bg).text_color(hover_fg)) - .on_click(cx.listener(move |this, _event: &ClickEvent, _window, _cx| { - println!("[timeline] tool: {tool} (placeholder)"); - this.selected_tool = index; - })) - .child(tool), - ); - } - - let text = colors.text; - let container = colors.container; - let tool_btn = move |id: &'static str, label: &'static str| { + let path = icons::icon_path(icon_name, cx); div() - .id(id) - .px_2() - .py_1() + .id(SharedString::from(format!("tool-{index}"))) + .size(px(24.0)) + .flex() + .items_center() + .justify_center() .rounded_sm() .cursor_pointer() - .text_color(text) - .hover(move |style| style.bg(container)) - .child(label) + .bg(background) + .hover(move |style| style.bg(hover_bg)) + .tooltip(move |window, cx| tooltip_view(tool.into(), window, cx)) + .on_click( + cx.listener(move |this, _event: &ClickEvent, _window, _cx| { + println!("[timeline] tool: {tool} (placeholder)"); + this.selected_tool = index; + }), + ) + .child(img(path).size(px(16.0))) }; - // The snap toggle: a localized label next to the checkbox box. The - // label is a plain div so it follows the active language; the box - // itself is clickable as in the widget's default row. + for (index, (tool_key, icon_name)) in TOOLS.iter().enumerate() { + toolbar = toolbar.child(tool_button(index, icon_name, tool_key, cx)); + } + + // A plain icon button (no selection state), e.g. zoom in/out. + let icon_btn = |id: &'static str, + icon_name: &'static str, + key: &'static str, + cx: &mut Context| { + let label = i18n::tr(key); + let hover_bg = colors.container; + let path = icons::icon_path(icon_name, cx); + div() + .id(id) + .size(px(24.0)) + .flex() + .items_center() + .justify_center() + .rounded_sm() + .cursor_pointer() + .text_color(colors.text) + .hover(move |style| style.bg(hover_bg)) + .tooltip(move |window, cx| tooltip_view(label.into(), window, cx)) + .child(img(path).size(px(16.0))) + }; + + // The snap toggle: the magnet icon next to the checkbox box. The icon + // is decorative (the box itself is clickable, as in the widget's + // default row). let snap_row = div() .flex() .items_center() .gap_1() .text_color(colors.text) - .child(div().child(i18n::tr("timeline.snap"))) + .child( + div() + .id("snap-toggle") + .size(px(24.0)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .tooltip(move |window, cx| { + tooltip_view(i18n::tr("timeline.snap").into(), window, cx) + }) + .child( + img(icons::icon_path(icons::ICON_SNAP, cx)).size(px(16.0)), + ), + ) .child(self.snap.clone()); let toolbar = toolbar - .child(tool_btn("toolbar-zoom-in", "+")) - .child(tool_btn("toolbar-zoom-out", "−")) + .child(icon_btn( + "toolbar-zoom-in", + icons::ICON_ZOOM_IN, + "timeline.zoom_in", + cx, + )) + .child(icon_btn( + "toolbar-zoom-out", + icons::ICON_ZOOM_OUT, + "timeline.zoom_out", + cx, + )) .child( div() .w_1() @@ -316,9 +357,9 @@ impl Render for TimelinePanel { } } -impl EventEmitter for TimelinePanel {} +impl EventEmitter for TimelinePanel {} -impl DockPanel for TimelinePanel { +impl DockPanel for TimelinePanel { fn panel_id(&self) -> gpui::dock::PanelId { TIMELINE } @@ -335,6 +376,7 @@ impl DockPanel for TimelinePanel { #[cfg(test)] mod tests { use super::*; + use crate::oakui::MockEngine; use gpui::{TestAppContext, VisualTestContext, px, size}; /// Builds a `TimelinePanel` in a window of the given logical size and @@ -343,7 +385,7 @@ mod tests { cx: &mut TestAppContext, width: f32, height: f32, - ) -> (&'static mut VisualTestContext, Entity) { + ) -> (&'static mut VisualTestContext, Entity>) { cx.update(|cx| cx.init_colors()); let window = cx.open_window(size(px(width), px(height)), |window, cx| { let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx));