nandi/jolt-nativepublic Fork 0
2ab40bf193b6a39bc8d1397cc36d17572bbf59cd
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 · 982 lines · 33.6 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 });
Ask about the size only once the answer can have changed 69b5de4 nandi 19d ago554 // Wait for the compositor's go-ahead before asking whether the
555 // window moved. The resizes a drag produces arrive during that
556 // wait, so asking first would answer about a window that has not
557 // been told to change yet — and the frame would go out measured
558 // for a size the buffer no longer is.
559 app.await_present_slot();
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago560 // The last attempt keeps whatever it measured. Discarding here
561 // instead would leave no open pass for `end_frame` to present, and
562 // a drag long enough to exhaust the retries would stop painting
563 // altogether — the one outcome worse than a frame behind.
564 if attempt + 1 == RESIZE_RETRIES || !app.resized_mid_frame() {
565 break;
Bring vidya in cfd3e36 nandi 19d ago566 }
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago567 app.discard_frame();
568 }
Bring vidya in cfd3e36 nandi 19d ago569 app.end_frame();
570 });
571}
572
573/// Dequeue one event, returning 1 while there was one. Its fields are read with
574/// the accessors below, which describe the most recently dequeued event.
575#[no_mangle]
576pub extern "C" fn vidya_tree_poll_event() -> c_int {
577 with_tree(0, |tree| tree.poll() as c_int)
578}
579
580#[no_mangle]
581pub extern "C" fn vidya_tree_event_node() -> c_int {
582 with_tree(0, |tree| tree.current().map_or(0, |e| e.node) as c_int)
583}
584
585/// The event's name — `click`, `change`, `toggled`, `activate` — or the empty
586/// string when nothing has been dequeued.
587///
588/// # Safety
589/// The returned pointer is valid until the next string-returning call on this
590/// thread.
591#[no_mangle]
592pub extern "C" fn vidya_tree_event_name() -> *const c_char {
593 let name = TREE.with_borrow(|tree| tree.current().map_or("", |e| e.name).to_owned());
594 scratch(&name)
595}
596
597/// # Safety
598/// The returned pointer is valid until the next string-returning call on this
599/// thread.
600#[no_mangle]
601pub extern "C" fn vidya_tree_event_text() -> *const c_char {
602 let text = TREE.with_borrow(|tree| tree.current().map_or(String::new(), |e| e.text.clone()));
603 scratch(&text)
604}
605
606#[no_mangle]
607pub extern "C" fn vidya_tree_event_num() -> f64 {
608 with_tree(0.0, |tree| tree.current().map_or(0.0, |e| e.num))
609}
610
Tell a caller how big the window is 42dabb0 nandi 19d ago611// ── The window ──────────────────────────────────────────────────────────────
612
613/// The window's width in points, or 0 before the first frame.
614///
615/// Points, not pixels: whoever asks is about to lay something out, and layout
616/// is in the units the widgets use. A caller that wants a tile to be a share
617/// of the window rather than a fixed number of points needs this, because the
618/// arithmetic — how many tiles, how much gap between them — is theirs and not
619/// something a single widget can work out from the space it was handed.
620///
621/// Reads what egui last saw, so it answers between frames as well as during
622/// one, and follows the window when it is dragged.
623#[no_mangle]
624pub extern "C" fn vidya_screen_width() -> c_float {
625 with_app(0.0, |app| app.screen_size().0)
626}
627
628/// The window's height in points, or 0 before the first frame.
629#[no_mangle]
630pub extern "C" fn vidya_screen_height() -> c_float {
631 with_app(0.0, |app| app.screen_size().1)
632}
633
Bring vidya in cfd3e36 nandi 19d ago634// ── Live frames ─────────────────────────────────────────────────────────────
635
636/// Hand the tree a frame of raw pixels under `key`, painted by any `:image`
637/// whose `feed` prop names it. Answers 1 when the frame was accepted.
638///
639/// This is the one thing an `:image` could not do: `src` decodes a file and
640/// caches the texture by its path forever, which is right for a picture in a
641/// message and useless for a source that produces a new picture thirty times a
642/// second. A caller that has its own pixels — a camera, a video decoder, a
643/// renderer — pushes them here instead, and the tag paints the latest.
644///
645/// `rgba` is `width * height * 4` bytes, row-major, 8 bits a channel,
646/// un-premultiplied. It is copied before this returns, so the caller may reuse
647/// the buffer immediately; nothing on this side retains it. A length that
648/// disagrees with the dimensions is refused rather than painted torn.
649///
650/// Frames are coalesced, not queued: one that arrives before the last has been
651/// painted replaces it. A source faster than the window costs no backlog.
652///
653/// Like the rest of the tree ABI this must be called on the thread that opened
654/// the window — a frame produced on a decoder thread crosses to the UI thread
655/// on the caller's side, not this one.
656///
657/// # Safety
658/// `key` is null or a NUL-terminated UTF-8 string; `rgba` is null or valid for
659/// reads of `width * height * 4` bytes for the duration of the call.
660#[no_mangle]
661pub unsafe extern "C" fn vidya_frame_rgba(
662 key: *const c_char,
663 width: c_int,
664 height: c_int,
665 rgba: *const u8,
666) -> c_int {
667 let key = borrowed_str(key);
668 if rgba.is_null() || width <= 0 || height <= 0 {
669 return 0;
670 }
671 let len = (width as usize)
672 .saturating_mul(height as usize)
673 .saturating_mul(4);
674 let pixels = std::slice::from_raw_parts(rgba, len);
675 with_tree(0, |tree| {
676 tree.set_frame(&key, width as u32, height as u32, pixels) as c_int
677 })
678}
679
680/// Forget the feed named `key` and release its texture, answering 1 when there
681/// was one. Without this the last frame of a source that has stopped keeps
682/// painting — the participant who left, still on the wall.
683///
684/// # Safety
685/// `key` is null or a NUL-terminated UTF-8 string.
686#[no_mangle]
687pub unsafe extern "C" fn vidya_frame_drop(key: *const c_char) -> c_int {
688 let key = borrowed_str(key);
689 with_tree(0, |tree| tree.drop_frame(&key) as c_int)
690}
691
692// ── Clipboard ───────────────────────────────────────────────────────────────
693
694/// Write the picture on the system clipboard to `path` as a PNG, answering 1
695/// when there was one and it was written.
696///
697/// egui carries clipboard *text* into the frame as an event and nothing else,
698/// so a pasted image has to be asked for rather than waited for: a caller
699/// binds this to whatever gesture means paste for it, and reads the file.
700/// PNG because that is what the `:image` node decodes.
701///
702/// Unlike the rest of this ABI it needs no window and no particular thread —
703/// it talks to the platform clipboard, not to egui.
704///
705/// # Safety
706/// `path` is null or a NUL-terminated UTF-8 string.
707#[no_mangle]
708pub unsafe extern "C" fn vidya_clipboard_image_png(path: *const c_char) -> c_int {
709 let path = borrowed_str(path);
710 guard(0, || {
711 if path.is_empty() {
712 return 0;
713 }
714 clipboard_image_png(&path) as c_int
715 })
716}
717
718#[cfg(not(target_os = "android"))]
719fn clipboard_image_png(path: &str) -> bool {
720 let Ok(mut clipboard) = arboard::Clipboard::new() else {
721 return false;
722 };
723 // An empty clipboard, text on it, or a format the platform will not hand
724 // over as pixels: all of them are "no picture to paste" to the caller.
725 let Ok(image) = clipboard.get_image() else {
726 return false;
727 };
728 let Ok(file) = std::fs::File::create(path) else {
729 return false;
730 };
731 let mut encoder = png::Encoder::new(
732 std::io::BufWriter::new(file),
733 image.width as u32,
734 image.height as u32,
735 );
736 encoder.set_color(png::ColorType::Rgba);
737 encoder.set_depth(png::BitDepth::Eight);
738 let written = encoder
739 .write_header()
740 .and_then(|mut writer| writer.write_image_data(&image.bytes))
741 .is_ok();
742 // A half-written file is worse than none: the caller would upload it.
743 if !written {
744 let _ = std::fs::remove_file(path);
745 }
746 written
747}
748
749/// Android has no clipboard of images to read, and arboard no backend for it.
750#[cfg(target_os = "android")]
751fn clipboard_image_png(_path: &str) -> bool {
752 false
753}
754
755/// Hand a URL to whatever shows web pages here; 1 when something took it.
756///
757/// A sign-in flow leaves the app for a browser and comes back, so the app needs
758/// a way to say "open this". What that means is the platform's business, not
759/// the caller's: an `xdg-open`/`open` on the desktop, and on Android an
760/// ACTION_VIEW intent, which is a JNI call — a shelled-out `am start` is
761/// refused there, since `am` names `com.android.shell` as its calling package
762/// and that is not the app's uid.
763///
764/// Like the clipboard call this needs no window and no particular thread.
765///
766/// # Safety
767/// `url` is null or a NUL-terminated UTF-8 string.
768#[no_mangle]
769pub unsafe extern "C" fn vidya_open_url(url: *const c_char) -> c_int {
770 let url = borrowed_str(url);
771 guard(0, || {
772 if url.is_empty() {
773 return 0;
774 }
775 open_url(&url) as c_int
776 })
777}
778
779#[cfg(not(target_os = "android"))]
780fn open_url(url: &str) -> bool {
781 let opener = if cfg!(target_os = "macos") {
782 "open"
783 } else {
784 "xdg-open"
785 };
786 // Spawned, not waited on: the browser outlives the call, and on some
787 // desktops the opener itself stays in the foreground for as long as it
788 // does.
789 std::process::Command::new(opener)
790 .arg(url)
791 .stdout(std::process::Stdio::null())
792 .stderr(std::process::Stdio::null())
793 .spawn()
794 .is_ok()
795}
796
797/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the activity the
798/// glue holds. Any JNI failure — or a device with nothing that answers the
799/// intent — is a plain false; the caller shows the URL instead.
Catch up with vidya c90f8af nandi 19d ago800/// Run `body` against the activity the glue holds, on a thread attached to the
801/// JVM for as long as it takes.
802///
803/// Every platform call below is shaped the same way: reach the activity, make
804/// some JNI calls, and read a failure — a missing handle, a refused attach, a
805/// thrown exception — as "the platform did not do it". None of them are worth
806/// a panic; the caller has something to show instead.
Bring vidya in cfd3e36 nandi 19d ago807#[cfg(target_os = "android")]
Catch up with vidya c90f8af nandi 19d ago808fn with_activity<T>(
809 what: &str,
810 body: impl FnOnce(&mut jni::Env, &jni::objects::JObject) -> jni::errors::Result<T>,
811) -> Option<T> {
Bring vidya in cfd3e36 nandi 19d ago812 use jni::objects::JObject;
813
814 let Some(app) = android::android_app() else {
Catch up with vidya c90f8af nandi 19d ago815 android::warn(&format!("{what}: no AndroidApp handle"));
816 return None;
Bring vidya in cfd3e36 nandi 19d ago817 };
818 // SAFETY: the glue owns both handles for the life of the activity, and
819 // hands them out as raw pointers for exactly this.
820 let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) };
821 let activity_ptr = app.activity_as_ptr().cast();
822
Catch up with vidya c90f8af nandi 19d ago823 let out = vm.attach_current_thread(|env| {
Bring vidya in cfd3e36 nandi 19d ago824 // SAFETY: the activity outlives this frame, and the reference is a
825 // borrow of the glue's own, not one this side owns.
826 let activity = unsafe { JObject::from_raw(env, activity_ptr) };
Catch up with vidya c90f8af nandi 19d ago827 let out = body(env, &activity);
828 // An exception is thrown, not returned. Leaving one pending would fail
829 // the next JNI call on this thread, whoever made it.
830 if env.exception_check() {
831 env.exception_clear();
832 return Err(jni::errors::Error::JavaException);
833 }
834 out
835 });
836 match out {
837 Ok(v) => Some(v),
838 Err(e) => {
839 android::warn(&format!("{what}: {e}"));
840 None
841 }
842 }
843}
844
845#[cfg(target_os = "android")]
846fn open_url(url: &str) -> bool {
847 use jni::{jni_sig, jni_str};
848
849 with_activity("vidya_open_url", |env, activity| {
Bring vidya in cfd3e36 nandi 19d ago850 let url = env.new_string(url)?;
851 let uri = env
852 .call_static_method(
853 jni_str!("android/net/Uri"),
854 jni_str!("parse"),
855 jni_sig!("(Ljava/lang/String;)Landroid/net/Uri;"),
856 &[(&url).into()],
857 )?
858 .l()?;
859 let action = env.new_string("android.intent.action.VIEW")?;
860 let intent = env.new_object(
861 jni_str!("android/content/Intent"),
862 jni_sig!("(Ljava/lang/String;Landroid/net/Uri;)V"),
863 &[(&action).into(), (&uri).into()],
864 )?;
865 env.call_method(
Catch up with vidya c90f8af nandi 19d ago866 activity,
Bring vidya in cfd3e36 nandi 19d ago867 jni_str!("startActivity"),
868 jni_sig!("(Landroid/content/Intent;)V"),
869 &[(&intent).into()],
870 )?;
Catch up with vidya c90f8af nandi 19d ago871 Ok(())
872 })
873 .is_some()
874}
875
876/// Ask the platform for a picture the reader chooses; 1 when the chooser opened.
877///
878/// This is not a file dialog and does not answer here: the reader is somewhere
879/// else now, in a screen this app does not own, and may be there for a while or
880/// never come back. What they picked arrives at `vidya_picked_image`, which the
881/// caller polls until it does.
882///
883/// Only Android answers it, and only for a host activity that offers the
884/// chooser (see `vidya_tree.h`). Everywhere else this is 0 and the caller
885/// browses the filesystem itself, which is what a desktop has anyway.
886///
887/// Needs no window and no particular thread.
888#[no_mangle]
889pub unsafe extern "C" fn vidya_pick_image() -> c_int {
890 guard(0, || pick_image() as c_int)
891}
892
893/// Take the picture chosen since the last call and put it at `path`; 1 when
894/// there was one.
895///
896/// Take, not read: the answer is handed over once, so a poll that is still
897/// running does not attach the same picture twice.
898///
899/// # Safety
900/// `path` is null or a NUL-terminated UTF-8 string.
901#[no_mangle]
902pub unsafe extern "C" fn vidya_picked_image(path: *const c_char) -> c_int {
903 let path = borrowed_str(path);
904 guard(0, || {
905 if path.is_empty() {
906 return 0;
Bring vidya in cfd3e36 nandi 19d ago907 }
Catch up with vidya c90f8af nandi 19d ago908 picked_image(&path) as c_int
909 })
910}
911
912#[cfg(not(target_os = "android"))]
913fn pick_image() -> bool {
914 false
915}
916
917#[cfg(not(target_os = "android"))]
918fn picked_image(_path: &str) -> bool {
919 false
920}
921
922/// `pickImage()` on the host activity. An activity without it — a plain
923/// `NativeActivity` — throws `NoSuchMethodError`, which reads here as "no
924/// chooser on this device", and the caller falls back to browsing.
925#[cfg(target_os = "android")]
926fn pick_image() -> bool {
927 use jni::{jni_sig, jni_str};
928
929 with_activity("vidya_pick_image", |env, activity| {
930 env.call_method(activity, jni_str!("pickImage"), jni_sig!("()V"), &[])?;
Bring vidya in cfd3e36 nandi 19d ago931 Ok(())
Catch up with vidya c90f8af nandi 19d ago932 })
933 .is_some()
934}
935
936/// `takePickedImage()` on the host activity, and then the file it names is
937/// moved to where the caller wants it.
938///
939/// Moved rather than copied: what the activity wrote is a temporary of its own,
940/// and leaving it behind would grow the app's cache by a picture per send. A
941/// rename across filesystems fails, so that case falls back to copy-and-drop.
942#[cfg(target_os = "android")]
943fn picked_image(path: &str) -> bool {
944 use jni::objects::JString;
945 use jni::{jni_sig, jni_str};
946
947 let picked = with_activity("vidya_picked_image", |env, activity| {
948 let picked = env
949 .call_method(
950 activity,
951 jni_str!("takePickedImage"),
952 jni_sig!("()Ljava/lang/String;"),
953 &[],
954 )?
955 .l()?;
956 if picked.is_null() {
957 return Ok(None);
958 }
959 // SAFETY: the method's signature says `java.lang.String`, and the
960 // reference is the one this frame just made.
961 let picked: JString = unsafe { JString::from_raw(env, picked.as_raw()) };
962 Ok(Some(picked.try_to_string(env)?))
Bring vidya in cfd3e36 nandi 19d ago963 });
964
Catch up with vidya c90f8af nandi 19d ago965 let Some(Some(src)) = picked else {
966 return false;
967 };
968 let src: String = src;
969 if std::fs::rename(&src, path).is_ok() {
970 return true;
971 }
972 match std::fs::copy(&src, path) {
973 Ok(_) => {
974 let _ = std::fs::remove_file(&src);
975 true
976 }
977 Err(e) => {
978 android::warn(&format!("vidya_picked_image: {src} -> {path}: {e}"));
979 false
980 }
Bring vidya in cfd3e36 nandi 19d ago981 }
982}