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