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