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