nandi/jolt-nativepublic Fork 0
b1758f55a558c64297729c4c64d80d20e80773e7
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

lib.rs · 976 lines · 33.2 KBRust Blame HistoryRaw
Bring vidya in cfd3e36 nandi 19d ago1//! Vidya's C ABI, implemented on the Rust/egui semantic layer.
2//!
3//! This is a third backend behind the header in `raylib/include/vidya.h`,
4//! alongside the direct-raylib and cimgui ones. It exports the same symbols
5//! from a `cdylib` named `libvidya`, so Jolt — or any other FFI consumer —
6//! switches backends by shared-library search path alone, with no binding
7//! changes.
8//!
9//! Rules inherited from the ABI:
10//!
11//! * one UI context per process;
12//! * every call stays on the thread that called `vidya_open` (enforced here:
13//! the context lives in thread-local storage, so calls from other threads are
14//! inert rather than unsound);
15//! * only C integers, floats, pointers, and UTF-8 byte strings cross the
16//! boundary, and nothing on this side retains caller memory past the call.
17//!
18//! Panics are caught at the boundary: unwinding into a C or Chez caller would
19//! be undefined behaviour.
20
21#[cfg(target_os = "android")]
22mod android;
23mod app;
24mod tree;
25mod ui;
26
27use std::cell::RefCell;
28use std::ffi::{c_char, c_float, c_int, CStr, CString};
29use std::panic::AssertUnwindSafe;
30
31use app::App;
32use egui::Ui;
33use tree::{Tree, Value};
34use vidya_core::{Mode, Theme};
35
36thread_local! {
37 /// The process's UI context, owned by the thread that opened the window.
38 static APP: RefCell<Option<App>> = const { RefCell::new(None) };
39}
40
41fn guard<R>(fallback: R, f: impl FnOnce() -> R) -> R {
42 match std::panic::catch_unwind(AssertUnwindSafe(f)) {
43 Ok(value) => value,
44 Err(_) => {
45 eprintln!("vidya: panic caught at the FFI boundary");
46 fallback
47 }
48 }
49}
50
51fn with_app<R: Copy>(fallback: R, f: impl FnOnce(&mut App) -> R) -> R {
52 guard(fallback, || {
53 APP.with_borrow_mut(|slot| match slot.as_mut() {
54 Some(app) => f(app),
55 None => fallback,
56 })
57 })
58}
59
60/// Run `f` against the innermost open UI node. Inert outside a frame.
61fn with_ui<R: Copy>(fallback: R, f: impl FnOnce(&mut Ui, &Theme) -> R) -> R {
62 with_app(fallback, |app| match app.ui() {
63 Some((ui, theme)) => f(ui, theme),
64 None => fallback,
65 })
66}
67
68/// # Safety
69/// `ptr` is null or a NUL-terminated string valid for the duration of the call.
70unsafe fn borrowed_str(ptr: *const c_char) -> String {
71 if ptr.is_null() {
72 String::new()
73 } else {
74 CStr::from_ptr(ptr).to_string_lossy().into_owned()
75 }
76}
77
78/// Copy `value` into a caller buffer, truncated at a char boundary and always
79/// NUL-terminated.
80///
81/// # Safety
82/// `buf` is null or writable for `capacity` bytes.
83unsafe fn write_buffer(buf: *mut c_char, capacity: usize, value: &str) {
84 if buf.is_null() || capacity == 0 {
85 return;
86 }
87 let mut len = value.len().min(capacity - 1);
88 while len > 0 && !value.is_char_boundary(len) {
89 len -= 1;
90 }
91 std::ptr::copy_nonoverlapping(value.as_ptr().cast::<c_char>(), buf, len);
92 *buf.add(len) = 0;
93}
94
95// ── Window and frame lifecycle ──────────────────────────────────────────────
96
97/// # Safety
98/// `title` is null or a NUL-terminated UTF-8 string.
99#[no_mangle]
100pub unsafe extern "C" fn vidya_open(width: c_int, height: c_int, title: *const c_char) -> c_int {
101 let title = borrowed_str(title);
102 guard(0, || {
103 APP.with_borrow_mut(|slot| {
104 if slot.is_some() {
105 eprintln!("vidya: a window is already open");
106 return 0;
107 }
108 match App::open(width, height, &title) {
109 Ok(app) => {
110 *slot = Some(app);
111 1
112 }
113 Err(e) => {
114 eprintln!("vidya: could not open a window: {e}");
115 0
116 }
117 }
118 })
119 })
120}
121
122#[no_mangle]
123pub extern "C" fn vidya_close() {
124 guard((), || APP.with_borrow_mut(|slot| drop(slot.take())));
125}
126
127#[no_mangle]
128pub extern "C" fn vidya_should_close() -> c_int {
129 // No window is a closed window, so a caller's loop still terminates.
130 with_app(1, |app| app.should_close() as c_int)
131}
132
133#[no_mangle]
134pub extern "C" fn vidya_set_target_fps(fps: c_int) {
135 with_app((), |app| app.set_target_fps(fps));
136}
137
138#[no_mangle]
139pub extern "C" fn vidya_set_mode(mode: c_int) {
140 let mode = if mode == 1 { Mode::Light } else { Mode::Dark };
141 with_app((), |app| app.set_mode(mode));
142}
143
144#[no_mangle]
145pub extern "C" fn vidya_get_mode() -> c_int {
146 with_app(0, |app| match app.theme().mode {
147 Mode::Dark => 0,
148 Mode::Light => 1,
149 })
150}
151
152/// `atlas_size` is accepted for ABI compatibility and ignored: egui rasterizes
153/// each requested size on demand instead of from one fixed atlas.
154///
155/// # Safety
156/// `path` is null or a NUL-terminated UTF-8 string.
157#[no_mangle]
158pub unsafe extern "C" fn vidya_load_font(path: *const c_char, _atlas_size: c_int) -> c_int {
159 let path = borrowed_str(path);
160 with_app(0, |app| app.load_font(&path) as c_int)
161}
162
163#[no_mangle]
164pub extern "C" fn vidya_begin_frame() {
165 with_app((), |app| app.begin_frame());
166}
167
168#[no_mangle]
169pub extern "C" fn vidya_end_frame() {
170 with_app((), |app| app.end_frame());
171}
172
173// ── Containers ──────────────────────────────────────────────────────────────
174
175#[no_mangle]
176pub extern "C" fn vidya_page_begin(max_width: c_float) {
177 with_app((), |app| {
178 let theme = app.theme().clone();
179 app.stack.push_page(&theme, max_width);
180 });
181}
182
183#[no_mangle]
184pub extern "C" fn vidya_page_end() {
185 with_app((), |app| app.stack.pop());
186}
187
188#[no_mangle]
189pub extern "C" fn vidya_card_begin() {
190 with_app((), |app| {
191 let theme = app.theme().clone();
192 app.stack.push_card(&theme);
193 });
194}
195
196#[no_mangle]
197pub extern "C" fn vidya_card_end() {
198 with_app((), |app| app.stack.pop());
199}
200
201#[no_mangle]
202pub extern "C" fn vidya_gap(pixels: c_float) {
203 with_ui((), |ui, _| ui::gap(ui, pixels));
204}
205
206#[no_mangle]
207pub extern "C" fn vidya_separator() {
208 with_ui((), |ui, _| ui::separator(ui));
209}
210
211// ── Text roles ──────────────────────────────────────────────────────────────
212
213macro_rules! text_role {
214 ($name:ident, $call:path) => {
215 /// # Safety
216 /// `text` is null or a NUL-terminated UTF-8 string.
217 #[no_mangle]
218 pub unsafe extern "C" fn $name(text: *const c_char) {
219 let text = borrowed_str(text);
220 with_ui((), |ui, theme| $call(ui, theme, &text));
221 }
222 };
223}
224
225text_role!(vidya_title, vidya_core::title);
226text_role!(vidya_title_2, vidya_core::title_2);
227text_role!(vidya_body, vidya_core::body);
228text_role!(vidya_dim_label, vidya_core::dim_label);
229
230// ── Controls ────────────────────────────────────────────────────────────────
231
232/// # Safety
233/// `label` is null or a NUL-terminated UTF-8 string.
234#[no_mangle]
235pub unsafe extern "C" fn vidya_button(label: *const c_char, kind: c_int) -> c_int {
236 let label = borrowed_str(label);
237 with_ui(0, |ui, theme| ui::button(ui, theme, &label, kind) as c_int)
238}
239
240/// Returns 1 when the value changed this frame, writing it back through
241/// `checked`.
242///
243/// # Safety
244/// `label` is null or a NUL-terminated UTF-8 string; `checked` is null or a
245/// writable `int`.
246#[no_mangle]
247pub unsafe extern "C" fn vidya_checkbox(label: *const c_char, checked: *mut c_int) -> c_int {
248 if checked.is_null() {
249 return 0;
250 }
251 let label = borrowed_str(label);
252 let current = *checked != 0;
253 let (value, changed) = with_ui((current, false), |ui, theme| {
254 ui::checkbox(ui, theme, current, &label)
255 });
256 *checked = value as c_int;
257 changed as c_int
258}
259
260/// FFI-friendly variant: returns the value after handling input.
261///
262/// # Safety
263/// `label` is null or a NUL-terminated UTF-8 string.
264#[no_mangle]
265pub unsafe extern "C" fn vidya_checkbox_value(label: *const c_char, checked: c_int) -> c_int {
266 let label = borrowed_str(label);
267 let current = checked != 0;
268 with_ui(current, |ui, theme| ui::checkbox(ui, theme, current, &label).0) as c_int
269}
270
271/// # Safety
272/// `label` is null or a NUL-terminated UTF-8 string.
273#[no_mangle]
274pub unsafe extern "C" fn vidya_status(label: *const c_char, live: c_int) {
275 let label = borrowed_str(label);
276 with_ui((), |ui, theme| ui::status(ui, theme, &label, live != 0));
277}
278
279/// Edit `text` in place. Returns 1 when the buffer changed this frame.
280///
281/// # Safety
282/// `text` is null or a NUL-terminated buffer writable for `capacity` bytes;
283/// `placeholder` is null or a NUL-terminated UTF-8 string.
284#[no_mangle]
285pub unsafe extern "C" fn vidya_text_field(
286 text: *mut c_char,
287 capacity: usize,
288 placeholder: *const c_char,
289) -> c_int {
290 if text.is_null() || capacity == 0 {
291 return 0;
292 }
293 let placeholder = borrowed_str(placeholder);
294 let mut value = borrowed_str(text.cast_const());
295
296 let changed = with_ui(false, |ui, theme| {
297 ui::text_field(ui, theme, &mut value, &placeholder).changed()
298 });
299 if changed {
300 write_buffer(text, capacity, &value);
301 }
302 changed as c_int
303}
304
305// ── Retained node tree ──────────────────────────────────────────────────────
306//
307// The second half of this ABI, for reactive callers. See `tree.rs` for why it
308// exists and `include/vidya_tree.h` for the contract. Everything below is inert
309// until the caller builds a tree; a program using only the push/pop calls above
310// never allocates one.
311
312thread_local! {
313 /// The node tree, on the same thread as the window by the same rule as
314 /// `APP`. Created on first use — a push/pop caller never pays for it.
315 static TREE: RefCell<Tree> = RefCell::new(Tree::default());
316
317 /// Backing store for the `const char *` returns below. Rust owns every
318 /// string that crosses this boundary, so it has to outlive the call that
319 /// returns it without leaking: one slot, overwritten by the next call.
320 static SCRATCH: RefCell<CString> = RefCell::new(CString::default());
321}
322
323fn with_tree<R: Copy>(fallback: R, f: impl FnOnce(&mut Tree) -> R) -> R {
324 guard(fallback, || TREE.with_borrow_mut(f))
325}
326
327/// Copy `value` into the scratch slot and return a pointer C can read until the
328/// next string-returning call. Interior NULs truncate rather than fail.
329fn scratch(value: &str) -> *const c_char {
330 let owned = CString::new(value).unwrap_or_else(|e| {
331 let mut bytes = e.into_vec();
332 bytes.truncate(bytes.iter().position(|&b| b == 0).unwrap_or(0));
333 CString::new(bytes).expect("truncated at the first NUL")
334 });
335 SCRATCH.with_borrow_mut(|slot| {
336 *slot = owned;
337 slot.as_ptr()
338 })
339}
340
341#[no_mangle]
342pub extern "C" fn vidya_tree_root() -> c_int {
343 with_tree(0, |tree| tree.root() as c_int)
344}
345
346/// # Safety
347/// `tag` is null or a NUL-terminated UTF-8 string.
348#[no_mangle]
349pub unsafe extern "C" fn vidya_node_new(tag: *const c_char) -> c_int {
350 let tag = borrowed_str(tag);
351 with_tree(0, |tree| tree.new_node(&tag) as c_int)
352}
353
354#[no_mangle]
355pub extern "C" fn vidya_node_free(node: c_int) {
356 with_tree((), |tree| tree.free_node(node.max(0) as u32));
357}
358
359#[no_mangle]
360pub extern "C" fn vidya_node_exists(node: c_int) -> c_int {
361 with_tree(0, |tree| tree.exists(node.max(0) as u32) as c_int)
362}
363
364/// # Safety
365/// `key` and `value` are null or NUL-terminated UTF-8 strings.
366#[no_mangle]
367pub unsafe extern "C" fn vidya_node_set_str(node: c_int, key: *const c_char, value: *const c_char) {
368 let (key, value) = (borrowed_str(key), borrowed_str(value));
369 with_tree((), |tree| {
370 tree.set(node.max(0) as u32, &key, Value::Str(value))
371 });
372}
373
374/// # Safety
375/// `key` is null or a NUL-terminated UTF-8 string.
376#[no_mangle]
377pub unsafe extern "C" fn vidya_node_set_num(node: c_int, key: *const c_char, value: f64) {
378 let key = borrowed_str(key);
379 with_tree((), |tree| {
380 tree.set(node.max(0) as u32, &key, Value::Num(value))
381 });
382}
383
384/// # Safety
385/// `key` is null or a NUL-terminated UTF-8 string.
386#[no_mangle]
387pub unsafe extern "C" fn vidya_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
388 let key = borrowed_str(key);
389 with_tree((), |tree| {
390 tree.set(node.max(0) as u32, &key, Value::Bool(value != 0))
391 });
392}
393
394/// Drop every prop, so a re-render starts from a clean slate rather than
395/// inheriting props the new hiccup no longer sets.
396#[no_mangle]
397pub extern "C" fn vidya_node_clear_props(node: c_int) {
398 with_tree((), |tree| tree.clear_props(node.max(0) as u32));
399}
400
401/// The empty string for a prop that is unset or is not a string.
402///
403/// # Safety
404/// `key` is null or a NUL-terminated UTF-8 string. The returned pointer is
405/// valid until the next string-returning call on this thread.
406#[no_mangle]
407pub unsafe extern "C" fn vidya_node_get_str(node: c_int, key: *const c_char) -> *const c_char {
408 let key = borrowed_str(key);
409 let value = TREE.with_borrow(|tree| match tree.get(node.max(0) as u32, &key) {
410 Some(Value::Str(s)) => s.clone(),
411 _ => String::new(),
412 });
413 scratch(&value)
414}
415
416/// # Safety
417/// `key` is null or a NUL-terminated UTF-8 string.
418#[no_mangle]
419pub unsafe extern "C" fn vidya_node_get_num(node: c_int, key: *const c_char) -> f64 {
420 let key = borrowed_str(key);
421 with_tree(0.0, |tree| match tree.get(node.max(0) as u32, &key) {
422 Some(Value::Num(n)) => *n,
423 Some(Value::Bool(true)) => 1.0,
424 _ => 0.0,
425 })
426}
427
428/// # Safety
429/// `key` is null or a NUL-terminated UTF-8 string.
430#[no_mangle]
431pub unsafe extern "C" fn vidya_node_get_bool(node: c_int, key: *const c_char) -> c_int {
432 let key = borrowed_str(key);
433 with_tree(0, |tree| {
434 (match tree.get(node.max(0) as u32, &key) {
435 Some(Value::Bool(b)) => *b,
436 Some(Value::Num(n)) => *n != 0.0,
437 _ => false,
438 }) as c_int
439 })
440}
441
442/// The canonical tag name a node was created with; `hbox` and `vbox` both
443/// answer `box`. The empty string for a node that no longer exists.
444///
445/// # Safety
446/// The returned pointer is valid until the next string-returning call on this
447/// thread.
448#[no_mangle]
449pub extern "C" fn vidya_node_tag(node: c_int) -> *const c_char {
450 let tag = TREE.with_borrow(|tree| tree.tag_name(node.max(0) as u32).to_owned());
451 scratch(&tag)
452}
453
454/// The subtree at `node` as hiccup text, for logging and bug reports; `node` 0
455/// means the root, so `vidya_tree_dump(0)` is the whole window.
456///
457/// # Safety
458/// The returned pointer is valid until the next string-returning call on this
459/// thread.
460#[no_mangle]
461pub extern "C" fn vidya_tree_dump(node: c_int) -> *const c_char {
462 let node = node.max(0) as u32;
463 let text = TREE.with_borrow(|tree| {
464 let id = if node == 0 { tree.root() } else { node };
465 tree.dump(id)
466 });
467 scratch(&text)
468}
469
470#[no_mangle]
471pub extern "C" fn vidya_node_child_count(node: c_int) -> c_int {
472 with_tree(0, |tree| tree.child_count(node.max(0) as u32) as c_int)
473}
474
475/// The `index`th child, or 0 when there is none.
476#[no_mangle]
477pub extern "C" fn vidya_node_child_at(node: c_int, index: c_int) -> c_int {
478 with_tree(0, |tree| {
479 tree.child_at(node.max(0) as u32, index.max(0) as usize) as c_int
480 })
481}
482
483#[no_mangle]
484pub extern "C" fn vidya_node_append(parent: c_int, child: c_int) -> c_int {
485 with_tree(0, |tree| {
486 tree.append(parent.max(0) as u32, child.max(0) as u32) as c_int
487 })
488}
489
490/// Unparent `child` **and free it**, with everything under it.
491///
492/// glimmer says nothing further about a widget it has removed, so this is where
493/// a subtree's storage goes back.
494#[no_mangle]
495pub extern "C" fn vidya_node_remove(parent: c_int, child: c_int) {
496 with_tree((), |tree| {
497 tree.remove(parent.max(0) as u32, child.max(0) as u32)
498 });
499}
500
501/// Move `child` to sit immediately after `sibling`; `sibling` 0 means first.
502#[no_mangle]
503pub extern "C" fn vidya_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
504 with_tree(0, |tree| {
505 tree.insert_after(
506 parent.max(0) as u32,
507 child.max(0) as u32,
508 sibling.max(0) as u32,
509 ) as c_int
510 })
511}
512
513/// Put `new_child` where `old_child` was, and free `old_child`.
514#[no_mangle]
515pub extern "C" fn vidya_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
516 with_tree(0, |tree| {
517 tree.replace(
518 parent.max(0) as u32,
519 old_child.max(0) as u32,
520 new_child.max(0) as u32,
521 ) as c_int
522 })
523}
524
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago525/// How many times a frame is walked again because the window resized under it.
526///
527/// A drag produces a resize most frames, so one retry is the common case and
528/// two is a drag fast enough to move twice inside a single walk. Past that the
529/// frame goes out at whatever size it last measured: a cap is what keeps a
530/// continuous drag from being an unbounded loop that never presents at all,
531/// and never presenting is worse than presenting a frame one step behind.
532const RESIZE_RETRIES: u32 = 2;
533
Bring vidya in cfd3e36 nandi 19d ago534/// Paint the whole tree as one frame: a `vidya_begin_frame`, the walk, and a
535/// `vidya_end_frame`. Inert with no window open.
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago536///
537/// The walk can happen more than once. A resize arriving while the tree is
538/// being walked leaves the layout measuring the old window and the buffer
539/// sized to the new one, and everything between the two is painted with the
540/// clear colour — which is the band of bare background that follows the edge
541/// while a window is dragged. The tree is retained and carries no sizes of its
542/// own, so the answer is simply to throw the half-measured pass away and walk
543/// it again against the window as it now is, before anything is presented.
Bring vidya in cfd3e36 nandi 19d ago544#[no_mangle]
545pub extern "C" fn vidya_tree_frame() {
546 with_app((), |app| {
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago547 for attempt in 0..RESIZE_RETRIES {
548 app.begin_frame();
549 TREE.with_borrow_mut(|tree| {
550 if let Some((ui, theme)) = app.ui() {
551 tree.paint(ui, theme);
552 }
553 });
554 // The last attempt keeps whatever it measured. Discarding here
555 // instead would leave no open pass for `end_frame` to present, and
556 // a drag long enough to exhaust the retries would stop painting
557 // altogether — the one outcome worse than a frame behind.
558 if attempt + 1 == RESIZE_RETRIES || !app.resized_mid_frame() {
559 break;
Bring vidya in cfd3e36 nandi 19d ago560 }
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago561 app.discard_frame();
562 }
Bring vidya in cfd3e36 nandi 19d ago563 app.end_frame();
564 });
565}
566
567/// Dequeue one event, returning 1 while there was one. Its fields are read with
568/// the accessors below, which describe the most recently dequeued event.
569#[no_mangle]
570pub extern "C" fn vidya_tree_poll_event() -> c_int {
571 with_tree(0, |tree| tree.poll() as c_int)
572}
573
574#[no_mangle]
575pub extern "C" fn vidya_tree_event_node() -> c_int {
576 with_tree(0, |tree| tree.current().map_or(0, |e| e.node) as c_int)
577}
578
579/// The event's name — `click`, `change`, `toggled`, `activate` — or the empty
580/// string when nothing has been dequeued.
581///
582/// # Safety
583/// The returned pointer is valid until the next string-returning call on this
584/// thread.
585#[no_mangle]
586pub extern "C" fn vidya_tree_event_name() -> *const c_char {
587 let name = TREE.with_borrow(|tree| tree.current().map_or("", |e| e.name).to_owned());
588 scratch(&name)
589}
590
591/// # Safety
592/// The returned pointer is valid until the next string-returning call on this
593/// thread.
594#[no_mangle]
595pub extern "C" fn vidya_tree_event_text() -> *const c_char {
596 let text = TREE.with_borrow(|tree| tree.current().map_or(String::new(), |e| e.text.clone()));
597 scratch(&text)
598}
599
600#[no_mangle]
601pub extern "C" fn vidya_tree_event_num() -> f64 {
602 with_tree(0.0, |tree| tree.current().map_or(0.0, |e| e.num))
603}
604
Tell a caller how big the window is 42dabb0 nandi 19d ago605// ── The window ──────────────────────────────────────────────────────────────
606
607/// The window's width in points, or 0 before the first frame.
608///
609/// Points, not pixels: whoever asks is about to lay something out, and layout
610/// is in the units the widgets use. A caller that wants a tile to be a share
611/// of the window rather than a fixed number of points needs this, because the
612/// arithmetic — how many tiles, how much gap between them — is theirs and not
613/// something a single widget can work out from the space it was handed.
614///
615/// Reads what egui last saw, so it answers between frames as well as during
616/// one, and follows the window when it is dragged.
617#[no_mangle]
618pub extern "C" fn vidya_screen_width() -> c_float {
619 with_app(0.0, |app| app.screen_size().0)
620}
621
622/// The window's height in points, or 0 before the first frame.
623#[no_mangle]
624pub extern "C" fn vidya_screen_height() -> c_float {
625 with_app(0.0, |app| app.screen_size().1)
626}
627
Bring vidya in cfd3e36 nandi 19d ago628// ── Live frames ─────────────────────────────────────────────────────────────
629
630/// Hand the tree a frame of raw pixels under `key`, painted by any `:image`
631/// whose `feed` prop names it. Answers 1 when the frame was accepted.
632///
633/// This is the one thing an `:image` could not do: `src` decodes a file and
634/// caches the texture by its path forever, which is right for a picture in a
635/// message and useless for a source that produces a new picture thirty times a
636/// second. A caller that has its own pixels — a camera, a video decoder, a
637/// renderer — pushes them here instead, and the tag paints the latest.
638///
639/// `rgba` is `width * height * 4` bytes, row-major, 8 bits a channel,
640/// un-premultiplied. It is copied before this returns, so the caller may reuse
641/// the buffer immediately; nothing on this side retains it. A length that
642/// disagrees with the dimensions is refused rather than painted torn.
643///
644/// Frames are coalesced, not queued: one that arrives before the last has been
645/// painted replaces it. A source faster than the window costs no backlog.
646///
647/// Like the rest of the tree ABI this must be called on the thread that opened
648/// the window — a frame produced on a decoder thread crosses to the UI thread
649/// on the caller's side, not this one.
650///
651/// # Safety
652/// `key` is null or a NUL-terminated UTF-8 string; `rgba` is null or valid for
653/// reads of `width * height * 4` bytes for the duration of the call.
654#[no_mangle]
655pub unsafe extern "C" fn vidya_frame_rgba(
656 key: *const c_char,
657 width: c_int,
658 height: c_int,
659 rgba: *const u8,
660) -> c_int {
661 let key = borrowed_str(key);
662 if rgba.is_null() || width <= 0 || height <= 0 {
663 return 0;
664 }
665 let len = (width as usize)
666 .saturating_mul(height as usize)
667 .saturating_mul(4);
668 let pixels = std::slice::from_raw_parts(rgba, len);
669 with_tree(0, |tree| {
670 tree.set_frame(&key, width as u32, height as u32, pixels) as c_int
671 })
672}
673
674/// Forget the feed named `key` and release its texture, answering 1 when there
675/// was one. Without this the last frame of a source that has stopped keeps
676/// painting — the participant who left, still on the wall.
677///
678/// # Safety
679/// `key` is null or a NUL-terminated UTF-8 string.
680#[no_mangle]
681pub unsafe extern "C" fn vidya_frame_drop(key: *const c_char) -> c_int {
682 let key = borrowed_str(key);
683 with_tree(0, |tree| tree.drop_frame(&key) as c_int)
684}
685
686// ── Clipboard ───────────────────────────────────────────────────────────────
687
688/// Write the picture on the system clipboard to `path` as a PNG, answering 1
689/// when there was one and it was written.
690///
691/// egui carries clipboard *text* into the frame as an event and nothing else,
692/// so a pasted image has to be asked for rather than waited for: a caller
693/// binds this to whatever gesture means paste for it, and reads the file.
694/// PNG because that is what the `:image` node decodes.
695///
696/// Unlike the rest of this ABI it needs no window and no particular thread —
697/// it talks to the platform clipboard, not to egui.
698///
699/// # Safety
700/// `path` is null or a NUL-terminated UTF-8 string.
701#[no_mangle]
702pub unsafe extern "C" fn vidya_clipboard_image_png(path: *const c_char) -> c_int {
703 let path = borrowed_str(path);
704 guard(0, || {
705 if path.is_empty() {
706 return 0;
707 }
708 clipboard_image_png(&path) as c_int
709 })
710}
711
712#[cfg(not(target_os = "android"))]
713fn clipboard_image_png(path: &str) -> bool {
714 let Ok(mut clipboard) = arboard::Clipboard::new() else {
715 return false;
716 };
717 // An empty clipboard, text on it, or a format the platform will not hand
718 // over as pixels: all of them are "no picture to paste" to the caller.
719 let Ok(image) = clipboard.get_image() else {
720 return false;
721 };
722 let Ok(file) = std::fs::File::create(path) else {
723 return false;
724 };
725 let mut encoder = png::Encoder::new(
726 std::io::BufWriter::new(file),
727 image.width as u32,
728 image.height as u32,
729 );
730 encoder.set_color(png::ColorType::Rgba);
731 encoder.set_depth(png::BitDepth::Eight);
732 let written = encoder
733 .write_header()
734 .and_then(|mut writer| writer.write_image_data(&image.bytes))
735 .is_ok();
736 // A half-written file is worse than none: the caller would upload it.
737 if !written {
738 let _ = std::fs::remove_file(path);
739 }
740 written
741}
742
743/// Android has no clipboard of images to read, and arboard no backend for it.
744#[cfg(target_os = "android")]
745fn clipboard_image_png(_path: &str) -> bool {
746 false
747}
748
749/// Hand a URL to whatever shows web pages here; 1 when something took it.
750///
751/// A sign-in flow leaves the app for a browser and comes back, so the app needs
752/// a way to say "open this". What that means is the platform's business, not
753/// the caller's: an `xdg-open`/`open` on the desktop, and on Android an
754/// ACTION_VIEW intent, which is a JNI call — a shelled-out `am start` is
755/// refused there, since `am` names `com.android.shell` as its calling package
756/// and that is not the app's uid.
757///
758/// Like the clipboard call this needs no window and no particular thread.
759///
760/// # Safety
761/// `url` is null or a NUL-terminated UTF-8 string.
762#[no_mangle]
763pub unsafe extern "C" fn vidya_open_url(url: *const c_char) -> c_int {
764 let url = borrowed_str(url);
765 guard(0, || {
766 if url.is_empty() {
767 return 0;
768 }
769 open_url(&url) as c_int
770 })
771}
772
773#[cfg(not(target_os = "android"))]
774fn open_url(url: &str) -> bool {
775 let opener = if cfg!(target_os = "macos") {
776 "open"
777 } else {
778 "xdg-open"
779 };
780 // Spawned, not waited on: the browser outlives the call, and on some
781 // desktops the opener itself stays in the foreground for as long as it
782 // does.
783 std::process::Command::new(opener)
784 .arg(url)
785 .stdout(std::process::Stdio::null())
786 .stderr(std::process::Stdio::null())
787 .spawn()
788 .is_ok()
789}
790
791/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the activity the
792/// glue holds. Any JNI failure — or a device with nothing that answers the
793/// intent — is a plain false; the caller shows the URL instead.
Catch up with vidya c90f8af nandi 19d ago794/// Run `body` against the activity the glue holds, on a thread attached to the
795/// JVM for as long as it takes.
796///
797/// Every platform call below is shaped the same way: reach the activity, make
798/// some JNI calls, and read a failure — a missing handle, a refused attach, a
799/// thrown exception — as "the platform did not do it". None of them are worth
800/// a panic; the caller has something to show instead.
Bring vidya in cfd3e36 nandi 19d ago801#[cfg(target_os = "android")]
Catch up with vidya c90f8af nandi 19d ago802fn with_activity<T>(
803 what: &str,
804 body: impl FnOnce(&mut jni::Env, &jni::objects::JObject) -> jni::errors::Result<T>,
805) -> Option<T> {
Bring vidya in cfd3e36 nandi 19d ago806 use jni::objects::JObject;
807
808 let Some(app) = android::android_app() else {
Catch up with vidya c90f8af nandi 19d ago809 android::warn(&format!("{what}: no AndroidApp handle"));
810 return None;
Bring vidya in cfd3e36 nandi 19d ago811 };
812 // SAFETY: the glue owns both handles for the life of the activity, and
813 // hands them out as raw pointers for exactly this.
814 let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) };
815 let activity_ptr = app.activity_as_ptr().cast();
816
Catch up with vidya c90f8af nandi 19d ago817 let out = vm.attach_current_thread(|env| {
Bring vidya in cfd3e36 nandi 19d ago818 // SAFETY: the activity outlives this frame, and the reference is a
819 // borrow of the glue's own, not one this side owns.
820 let activity = unsafe { JObject::from_raw(env, activity_ptr) };
Catch up with vidya c90f8af nandi 19d ago821 let out = body(env, &activity);
822 // An exception is thrown, not returned. Leaving one pending would fail
823 // the next JNI call on this thread, whoever made it.
824 if env.exception_check() {
825 env.exception_clear();
826 return Err(jni::errors::Error::JavaException);
827 }
828 out
829 });
830 match out {
831 Ok(v) => Some(v),
832 Err(e) => {
833 android::warn(&format!("{what}: {e}"));
834 None
835 }
836 }
837}
838
839#[cfg(target_os = "android")]
840fn open_url(url: &str) -> bool {
841 use jni::{jni_sig, jni_str};
842
843 with_activity("vidya_open_url", |env, activity| {
Bring vidya in cfd3e36 nandi 19d ago844 let url = env.new_string(url)?;
845 let uri = env
846 .call_static_method(
847 jni_str!("android/net/Uri"),
848 jni_str!("parse"),
849 jni_sig!("(Ljava/lang/String;)Landroid/net/Uri;"),
850 &[(&url).into()],
851 )?
852 .l()?;
853 let action = env.new_string("android.intent.action.VIEW")?;
854 let intent = env.new_object(
855 jni_str!("android/content/Intent"),
856 jni_sig!("(Ljava/lang/String;Landroid/net/Uri;)V"),
857 &[(&action).into(), (&uri).into()],
858 )?;
859 env.call_method(
Catch up with vidya c90f8af nandi 19d ago860 activity,
Bring vidya in cfd3e36 nandi 19d ago861 jni_str!("startActivity"),
862 jni_sig!("(Landroid/content/Intent;)V"),
863 &[(&intent).into()],
864 )?;
Catch up with vidya c90f8af nandi 19d ago865 Ok(())
866 })
867 .is_some()
868}
869
870/// Ask the platform for a picture the reader chooses; 1 when the chooser opened.
871///
872/// This is not a file dialog and does not answer here: the reader is somewhere
873/// else now, in a screen this app does not own, and may be there for a while or
874/// never come back. What they picked arrives at `vidya_picked_image`, which the
875/// caller polls until it does.
876///
877/// Only Android answers it, and only for a host activity that offers the
878/// chooser (see `vidya_tree.h`). Everywhere else this is 0 and the caller
879/// browses the filesystem itself, which is what a desktop has anyway.
880///
881/// Needs no window and no particular thread.
882#[no_mangle]
883pub unsafe extern "C" fn vidya_pick_image() -> c_int {
884 guard(0, || pick_image() as c_int)
885}
886
887/// Take the picture chosen since the last call and put it at `path`; 1 when
888/// there was one.
889///
890/// Take, not read: the answer is handed over once, so a poll that is still
891/// running does not attach the same picture twice.
892///
893/// # Safety
894/// `path` is null or a NUL-terminated UTF-8 string.
895#[no_mangle]
896pub unsafe extern "C" fn vidya_picked_image(path: *const c_char) -> c_int {
897 let path = borrowed_str(path);
898 guard(0, || {
899 if path.is_empty() {
900 return 0;
Bring vidya in cfd3e36 nandi 19d ago901 }
Catch up with vidya c90f8af nandi 19d ago902 picked_image(&path) as c_int
903 })
904}
905
906#[cfg(not(target_os = "android"))]
907fn pick_image() -> bool {
908 false
909}
910
911#[cfg(not(target_os = "android"))]
912fn picked_image(_path: &str) -> bool {
913 false
914}
915
916/// `pickImage()` on the host activity. An activity without it — a plain
917/// `NativeActivity` — throws `NoSuchMethodError`, which reads here as "no
918/// chooser on this device", and the caller falls back to browsing.
919#[cfg(target_os = "android")]
920fn pick_image() -> bool {
921 use jni::{jni_sig, jni_str};
922
923 with_activity("vidya_pick_image", |env, activity| {
924 env.call_method(activity, jni_str!("pickImage"), jni_sig!("()V"), &[])?;
Bring vidya in cfd3e36 nandi 19d ago925 Ok(())
Catch up with vidya c90f8af nandi 19d ago926 })
927 .is_some()
928}
929
930/// `takePickedImage()` on the host activity, and then the file it names is
931/// moved to where the caller wants it.
932///
933/// Moved rather than copied: what the activity wrote is a temporary of its own,
934/// and leaving it behind would grow the app's cache by a picture per send. A
935/// rename across filesystems fails, so that case falls back to copy-and-drop.
936#[cfg(target_os = "android")]
937fn picked_image(path: &str) -> bool {
938 use jni::objects::JString;
939 use jni::{jni_sig, jni_str};
940
941 let picked = with_activity("vidya_picked_image", |env, activity| {
942 let picked = env
943 .call_method(
944 activity,
945 jni_str!("takePickedImage"),
946 jni_sig!("()Ljava/lang/String;"),
947 &[],
948 )?
949 .l()?;
950 if picked.is_null() {
951 return Ok(None);
952 }
953 // SAFETY: the method's signature says `java.lang.String`, and the
954 // reference is the one this frame just made.
955 let picked: JString = unsafe { JString::from_raw(env, picked.as_raw()) };
956 Ok(Some(picked.try_to_string(env)?))
Bring vidya in cfd3e36 nandi 19d ago957 });
958
Catch up with vidya c90f8af nandi 19d ago959 let Some(Some(src)) = picked else {
960 return false;
961 };
962 let src: String = src;
963 if std::fs::rename(&src, path).is_ok() {
964 return true;
965 }
966 match std::fs::copy(&src, path) {
967 Ok(_) => {
968 let _ = std::fs::remove_file(&src);
969 true
970 }
971 Err(e) => {
972 android::warn(&format!("vidya_picked_image: {src} -> {path}: {e}"));
973 false
974 }
Bring vidya in cfd3e36 nandi 19d ago975 }
976}