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