nandi/jolt-nativepublic Fork 0
c90f8af
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.

Catch up with vidya

The import was made from a working tree that kept moving: the picture
chooser, the with_activity refactor, the synthetic clicks and the
time-based capture all landed in ~/code/vidya afterwards, and none of it
was here.

Carried across by hand, which is what the import bought us. Until that repo
is retired or made a mirror, every change there has to be walked over like
this, and this is the second time in an afternoon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T17:17:51-07:00 Browse files
c90f8af parent: fc16d8e
modified crates/jolt-vidya/include/vidya_tree.h +18 -0
@@ -181,6 +181,24 @@ VIDYA_API int vidya_clipboard_image_png(const char *path);
181181 */
182182 VIDYA_API int vidya_open_url(const char *url);
183183
184+/* Choose a picture
185+ *
186+ * `vidya_pick_image` asks the platform for its own picture chooser and returns
187+ * 1 when one opened. It does not answer with the picture: the reader is in
188+ * another screen by then, and may be for a while. Poll `vidya_picked_image`
189+ * with the path to put it at — 1 once there is one, and the answer is handed
190+ * over only once.
191+ *
192+ * Android only, and only where the host activity offers the chooser: it is
193+ * `void pickImage()` and `String takePickedImage()` on the activity named by
194+ * the manifest, which a plain NativeActivity does not have. 0 everywhere else,
195+ * which is a caller's cue to browse the filesystem itself.
196+ *
197+ * Both need no window and no particular thread.
198+ */
199+VIDYA_API int vidya_pick_image(void);
200+VIDYA_API int vidya_picked_image(const char *path);
201+
184202 #ifdef __cplusplus
185203 }
186204 #endif
@@ -181,6 +181,24 @@ VIDYA_API int vidya_clipboard_image_png(const char *path);
181 */181 */
182 VIDYA_API int vidya_open_url(const char *url);182 VIDYA_API int vidya_open_url(const char *url);
183 183
184+/* Choose a picture
185+ *
186+ * `vidya_pick_image` asks the platform for its own picture chooser and returns
187+ * 1 when one opened. It does not answer with the picture: the reader is in
188+ * another screen by then, and may be for a while. Poll `vidya_picked_image`
189+ * with the path to put it at — 1 once there is one, and the answer is handed
190+ * over only once.
191+ *
192+ * Android only, and only where the host activity offers the chooser: it is
193+ * `void pickImage()` and `String takePickedImage()` on the activity named by
194+ * the manifest, which a plain NativeActivity does not have. 0 everywhere else,
195+ * which is a caller's cue to browse the filesystem itself.
196+ *
197+ * Both need no window and no particular thread.
198+ */
199+VIDYA_API int vidya_pick_image(void);
200+VIDYA_API int vidya_picked_image(const char *path);
201+
184 #ifdef __cplusplus202 #ifdef __cplusplus
185 }203 }
186 #endif204 #endif
modified crates/jolt-vidya/src/android.rs +31 -0
@@ -27,6 +27,35 @@ const APP_LIBRARY: &str = "libjoltapp.so";
2727 /// Set once, before any application code runs, and read on the loop thread.
2828 static ANDROID_APP: Mutex<Option<AndroidApp>> = Mutex::new(None);
2929
30+/// Point `HOME` (and the XDG directories under it) at the app's own storage.
31+///
32+/// An Android process starts with none of them set, and an application written
33+/// against a Unix — which is every application this library loads — reads a
34+/// missing `HOME` as the empty string and writes to `/.cache`, `/.config`, or
35+/// worse. Nothing there is writable, so caches, saved sessions and settings all
36+/// fail quietly on the phone and nowhere else.
37+///
38+/// The app's internal data directory is what a desktop's home directory is
39+/// here: private to the app, writable without a permission, and removed with
40+/// it. Set only when unset, so an application that has its own idea keeps it.
41+fn set_home_from(app: &AndroidApp) {
42+ let Some(home) = app.internal_data_path() else {
43+ log(ANDROID_LOG_ERROR, "vidya: no internal data path for HOME");
44+ return;
45+ };
46+ let mut set = |key: &str, value: &std::path::Path| {
47+ if std::env::var_os(key).is_none() {
48+ // SAFETY: this runs before the application starts, on the only
49+ // thread there is; nothing else can be reading the environment.
50+ unsafe { std::env::set_var(key, value) };
51+ }
52+ };
53+ set("HOME", &home);
54+ set("XDG_CACHE_HOME", &home.join(".cache"));
55+ set("XDG_CONFIG_HOME", &home.join(".config"));
56+ set("XDG_DATA_HOME", &home.join(".local/share"));
57+}
58+
3059 /// The handle winit needs to build an event loop. `None` off Android's own
3160 /// thread, or before the glue has started.
3261 pub fn android_app() -> Option<AndroidApp> {
@@ -74,6 +103,8 @@ fn last_dl_error() -> String {
74103 /// flash, so a failure to reach the application is logged rather than silent.
75104 #[no_mangle]
76105 pub extern "C" fn android_main(app: AndroidApp) {
106+ set_home_from(&app);
107+
77108 if let Ok(mut slot) = ANDROID_APP.lock() {
78109 *slot = Some(app);
79110 }
@@ -27,6 +27,35 @@ const APP_LIBRARY: &str = "libjoltapp.so";
27 /// Set once, before any application code runs, and read on the loop thread.27 /// Set once, before any application code runs, and read on the loop thread.
28 static ANDROID_APP: Mutex<Option<AndroidApp>> = Mutex::new(None);28 static ANDROID_APP: Mutex<Option<AndroidApp>> = Mutex::new(None);
29 29
30+/// Point `HOME` (and the XDG directories under it) at the app's own storage.
31+///
32+/// An Android process starts with none of them set, and an application written
33+/// against a Unix — which is every application this library loads — reads a
34+/// missing `HOME` as the empty string and writes to `/.cache`, `/.config`, or
35+/// worse. Nothing there is writable, so caches, saved sessions and settings all
36+/// fail quietly on the phone and nowhere else.
37+///
38+/// The app's internal data directory is what a desktop's home directory is
39+/// here: private to the app, writable without a permission, and removed with
40+/// it. Set only when unset, so an application that has its own idea keeps it.
41+fn set_home_from(app: &AndroidApp) {
42+ let Some(home) = app.internal_data_path() else {
43+ log(ANDROID_LOG_ERROR, "vidya: no internal data path for HOME");
44+ return;
45+ };
46+ let mut set = |key: &str, value: &std::path::Path| {
47+ if std::env::var_os(key).is_none() {
48+ // SAFETY: this runs before the application starts, on the only
49+ // thread there is; nothing else can be reading the environment.
50+ unsafe { std::env::set_var(key, value) };
51+ }
52+ };
53+ set("HOME", &home);
54+ set("XDG_CACHE_HOME", &home.join(".cache"));
55+ set("XDG_CONFIG_HOME", &home.join(".config"));
56+ set("XDG_DATA_HOME", &home.join(".local/share"));
57+}
58+
30 /// The handle winit needs to build an event loop. `None` off Android's own59 /// The handle winit needs to build an event loop. `None` off Android's own
31 /// thread, or before the glue has started.60 /// thread, or before the glue has started.
32 pub fn android_app() -> Option<AndroidApp> {61 pub fn android_app() -> Option<AndroidApp> {
@@ -74,6 +103,8 @@ fn last_dl_error() -> String {
74 /// flash, so a failure to reach the application is logged rather than silent.103 /// flash, so a failure to reach the application is logged rather than silent.
75 #[no_mangle]104 #[no_mangle]
76 pub extern "C" fn android_main(app: AndroidApp) {105 pub extern "C" fn android_main(app: AndroidApp) {
106+ set_home_from(&app);
107+
77 if let Ok(mut slot) = ANDROID_APP.lock() {108 if let Ok(mut slot) = ANDROID_APP.lock() {
78 *slot = Some(app);109 *slot = Some(app);
79 }110 }
modified crates/jolt-vidya/src/app.rs +44 -2
@@ -294,8 +294,14 @@ struct Handler {
294294 /// `VIDYA_RESIZE_AT` parsed: (frame, width, height), in frame order.
295295 resize_at: Vec<(u32, f64, f64)>,
296296 /// Which frame to dump. `VIDYA_CAPTURE_AT` moves it later than the third,
297- /// for a screen that only exists once something has loaded.
297+ /// for a screen that only exists once something has loaded. `ms:2500` says
298+ /// when rather than which — a window nobody is compositing skips frames, so
299+ /// a frame number can be a wait with no end on an unfocused desktop.
298300 capture_at: u32,
301+ capture_after: Option<Duration>,
302+ /// `VIDYA_CLICK_AT` parsed: (frame, x, y), in frame order.
303+ click_at: Vec<(u32, f32, f32)>,
304+ started: Instant,
299305 }
300306
301307 impl Handler {
@@ -333,7 +339,29 @@ impl Handler {
333339 }
334340 }
335341 // Third frame: fonts and layout have settled by then.
336- if self.frames == self.capture_at {
342+ // A click the desktop never sent: `VIDYA_CLICK_AT=frame:x,y` presses and
343+ // releases the left button at a point, so an interaction can be tested
344+ // where there is nobody to do the clicking.
345+ for &(at, x, y) in &self.click_at {
346+ if self.frames == at {
347+ let pos = egui::pos2(x, y);
348+ let events = &mut gl.winit_state.egui_input_mut().events;
349+ events.push(egui::Event::PointerMoved(pos));
350+ for pressed in [true, false] {
351+ events.push(egui::Event::PointerButton {
352+ pos,
353+ button: egui::PointerButton::Primary,
354+ pressed,
355+ modifiers: egui::Modifiers::default(),
356+ });
357+ }
358+ }
359+ }
360+ let due = match self.capture_after {
361+ Some(after) => self.started.elapsed() >= after,
362+ None => self.frames == self.capture_at,
363+ };
364+ if due {
337365 if let Some(path) = self.capture.take() {
338366 capture_frame(gl, dims, &path);
339367 }
@@ -499,6 +527,20 @@ impl App {
499527 .ok()
500528 .and_then(|v| v.parse().ok())
501529 .unwrap_or(3),
530+ click_at: std::env::var("VIDYA_CLICK_AT")
531+ .unwrap_or_default()
532+ .split(';')
533+ .filter_map(|step| {
534+ let (at, point) = step.split_once(':')?;
535+ let (x, y) = point.split_once(',')?;
536+ Some((at.parse().ok()?, x.parse().ok()?, y.parse().ok()?))
537+ })
538+ .collect(),
539+ capture_after: std::env::var("VIDYA_CAPTURE_AT")
540+ .ok()
541+ .and_then(|v| v.strip_prefix("ms:").and_then(|ms| ms.parse().ok()))
542+ .map(Duration::from_millis),
543+ started: Instant::now(),
502544 },
503545 stack: Stack::default(),
504546 frame_budget: DEFAULT_FRAME_BUDGET,
@@ -294,8 +294,14 @@ struct Handler {
294 /// `VIDYA_RESIZE_AT` parsed: (frame, width, height), in frame order.294 /// `VIDYA_RESIZE_AT` parsed: (frame, width, height), in frame order.
295 resize_at: Vec<(u32, f64, f64)>,295 resize_at: Vec<(u32, f64, f64)>,
296 /// Which frame to dump. `VIDYA_CAPTURE_AT` moves it later than the third,296 /// Which frame to dump. `VIDYA_CAPTURE_AT` moves it later than the third,
297- /// for a screen that only exists once something has loaded.297+ /// for a screen that only exists once something has loaded. `ms:2500` says
298+ /// when rather than which — a window nobody is compositing skips frames, so
299+ /// a frame number can be a wait with no end on an unfocused desktop.
298 capture_at: u32,300 capture_at: u32,
301+ capture_after: Option<Duration>,
302+ /// `VIDYA_CLICK_AT` parsed: (frame, x, y), in frame order.
303+ click_at: Vec<(u32, f32, f32)>,
304+ started: Instant,
299 }305 }
300 306
301 impl Handler {307 impl Handler {
@@ -333,7 +339,29 @@ impl Handler {
333 }339 }
334 }340 }
335 // Third frame: fonts and layout have settled by then.341 // Third frame: fonts and layout have settled by then.
336- if self.frames == self.capture_at {342+ // A click the desktop never sent: `VIDYA_CLICK_AT=frame:x,y` presses and
343+ // releases the left button at a point, so an interaction can be tested
344+ // where there is nobody to do the clicking.
345+ for &(at, x, y) in &self.click_at {
346+ if self.frames == at {
347+ let pos = egui::pos2(x, y);
348+ let events = &mut gl.winit_state.egui_input_mut().events;
349+ events.push(egui::Event::PointerMoved(pos));
350+ for pressed in [true, false] {
351+ events.push(egui::Event::PointerButton {
352+ pos,
353+ button: egui::PointerButton::Primary,
354+ pressed,
355+ modifiers: egui::Modifiers::default(),
356+ });
357+ }
358+ }
359+ }
360+ let due = match self.capture_after {
361+ Some(after) => self.started.elapsed() >= after,
362+ None => self.frames == self.capture_at,
363+ };
364+ if due {
337 if let Some(path) = self.capture.take() {365 if let Some(path) = self.capture.take() {
338 capture_frame(gl, dims, &path);366 capture_frame(gl, dims, &path);
339 }367 }
@@ -499,6 +527,20 @@ impl App {
499 .ok()527 .ok()
500 .and_then(|v| v.parse().ok())528 .and_then(|v| v.parse().ok())
501 .unwrap_or(3),529 .unwrap_or(3),
530+ click_at: std::env::var("VIDYA_CLICK_AT")
531+ .unwrap_or_default()
532+ .split(';')
533+ .filter_map(|step| {
534+ let (at, point) = step.split_once(':')?;
535+ let (x, y) = point.split_once(',')?;
536+ Some((at.parse().ok()?, x.parse().ok()?, y.parse().ok()?))
537+ })
538+ .collect(),
539+ capture_after: std::env::var("VIDYA_CAPTURE_AT")
540+ .ok()
541+ .and_then(|v| v.strip_prefix("ms:").and_then(|ms| ms.parse().ok()))
542+ .map(Duration::from_millis),
543+ started: Instant::now(),
502 },544 },
503 stack: Stack::default(),545 stack: Stack::default(),
504 frame_budget: DEFAULT_FRAME_BUDGET,546 frame_budget: DEFAULT_FRAME_BUDGET,
modified crates/jolt-vidya/src/lib.rs +144 -15
@@ -741,24 +741,56 @@ fn open_url(url: &str) -> bool {
741741 /// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the activity the
742742 /// glue holds. Any JNI failure — or a device with nothing that answers the
743743 /// intent — is a plain false; the caller shows the URL instead.
744+/// Run `body` against the activity the glue holds, on a thread attached to the
745+/// JVM for as long as it takes.
746+///
747+/// Every platform call below is shaped the same way: reach the activity, make
748+/// some JNI calls, and read a failure — a missing handle, a refused attach, a
749+/// thrown exception — as "the platform did not do it". None of them are worth
750+/// a panic; the caller has something to show instead.
744751 #[cfg(target_os = "android")]
745-fn open_url(url: &str) -> bool {
752+fn with_activity<T>(
753+ what: &str,
754+ body: impl FnOnce(&mut jni::Env, &jni::objects::JObject) -> jni::errors::Result<T>,
755+) -> Option<T> {
746756 use jni::objects::JObject;
747- use jni::{jni_sig, jni_str};
748757
749758 let Some(app) = android::android_app() else {
750- android::warn("open_url: no AndroidApp handle");
751- return false;
759+ android::warn(&format!("{what}: no AndroidApp handle"));
760+ return None;
752761 };
753762 // SAFETY: the glue owns both handles for the life of the activity, and
754763 // hands them out as raw pointers for exactly this.
755764 let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) };
756765 let activity_ptr = app.activity_as_ptr().cast();
757766
758- let started: jni::errors::Result<()> = vm.attach_current_thread(|env| {
767+ let out = vm.attach_current_thread(|env| {
759768 // SAFETY: the activity outlives this frame, and the reference is a
760769 // borrow of the glue's own, not one this side owns.
761770 let activity = unsafe { JObject::from_raw(env, activity_ptr) };
771+ let out = body(env, &activity);
772+ // An exception is thrown, not returned. Leaving one pending would fail
773+ // the next JNI call on this thread, whoever made it.
774+ if env.exception_check() {
775+ env.exception_clear();
776+ return Err(jni::errors::Error::JavaException);
777+ }
778+ out
779+ });
780+ match out {
781+ Ok(v) => Some(v),
782+ Err(e) => {
783+ android::warn(&format!("{what}: {e}"));
784+ None
785+ }
786+ }
787+}
788+
789+#[cfg(target_os = "android")]
790+fn open_url(url: &str) -> bool {
791+ use jni::{jni_sig, jni_str};
792+
793+ with_activity("vidya_open_url", |env, activity| {
762794 let url = env.new_string(url)?;
763795 let uri = env
764796 .call_static_method(
@@ -775,23 +807,120 @@ fn open_url(url: &str) -> bool {
775807 &[(&action).into(), (&uri).into()],
776808 )?;
777809 env.call_method(
778- &activity,
810+ activity,
779811 jni_str!("startActivity"),
780812 jni_sig!("(Landroid/content/Intent;)V"),
781813 &[(&intent).into()],
782814 )?;
783- // An ActivityNotFoundException is thrown, not returned. Leaving it
784- // pending would fail the next JNI call on this thread, whoever made
785- // it, so it is caught here and read as "nothing took the URL".
786- if env.exception_check() {
787- env.exception_clear();
788- return Err(jni::errors::Error::JavaException);
815+ Ok(())
816+ })
817+ .is_some()
818+}
819+
820+/// Ask the platform for a picture the reader chooses; 1 when the chooser opened.
821+///
822+/// This is not a file dialog and does not answer here: the reader is somewhere
823+/// else now, in a screen this app does not own, and may be there for a while or
824+/// never come back. What they picked arrives at `vidya_picked_image`, which the
825+/// caller polls until it does.
826+///
827+/// Only Android answers it, and only for a host activity that offers the
828+/// chooser (see `vidya_tree.h`). Everywhere else this is 0 and the caller
829+/// browses the filesystem itself, which is what a desktop has anyway.
830+///
831+/// Needs no window and no particular thread.
832+#[no_mangle]
833+pub unsafe extern "C" fn vidya_pick_image() -> c_int {
834+ guard(0, || pick_image() as c_int)
835+}
836+
837+/// Take the picture chosen since the last call and put it at `path`; 1 when
838+/// there was one.
839+///
840+/// Take, not read: the answer is handed over once, so a poll that is still
841+/// running does not attach the same picture twice.
842+///
843+/// # Safety
844+/// `path` is null or a NUL-terminated UTF-8 string.
845+#[no_mangle]
846+pub unsafe extern "C" fn vidya_picked_image(path: *const c_char) -> c_int {
847+ let path = borrowed_str(path);
848+ guard(0, || {
849+ if path.is_empty() {
850+ return 0;
789851 }
852+ picked_image(&path) as c_int
853+ })
854+}
855+
856+#[cfg(not(target_os = "android"))]
857+fn pick_image() -> bool {
858+ false
859+}
860+
861+#[cfg(not(target_os = "android"))]
862+fn picked_image(_path: &str) -> bool {
863+ false
864+}
865+
866+/// `pickImage()` on the host activity. An activity without it — a plain
867+/// `NativeActivity` — throws `NoSuchMethodError`, which reads here as "no
868+/// chooser on this device", and the caller falls back to browsing.
869+#[cfg(target_os = "android")]
870+fn pick_image() -> bool {
871+ use jni::{jni_sig, jni_str};
872+
873+ with_activity("vidya_pick_image", |env, activity| {
874+ env.call_method(activity, jni_str!("pickImage"), jni_sig!("()V"), &[])?;
790875 Ok(())
876+ })
877+ .is_some()
878+}
879+
880+/// `takePickedImage()` on the host activity, and then the file it names is
881+/// moved to where the caller wants it.
882+///
883+/// Moved rather than copied: what the activity wrote is a temporary of its own,
884+/// and leaving it behind would grow the app's cache by a picture per send. A
885+/// rename across filesystems fails, so that case falls back to copy-and-drop.
886+#[cfg(target_os = "android")]
887+fn picked_image(path: &str) -> bool {
888+ use jni::objects::JString;
889+ use jni::{jni_sig, jni_str};
890+
891+ let picked = with_activity("vidya_picked_image", |env, activity| {
892+ let picked = env
893+ .call_method(
894+ activity,
895+ jni_str!("takePickedImage"),
896+ jni_sig!("()Ljava/lang/String;"),
897+ &[],
898+ )?
899+ .l()?;
900+ if picked.is_null() {
901+ return Ok(None);
902+ }
903+ // SAFETY: the method's signature says `java.lang.String`, and the
904+ // reference is the one this frame just made.
905+ let picked: JString = unsafe { JString::from_raw(env, picked.as_raw()) };
906+ Ok(Some(picked.try_to_string(env)?))
791907 });
792908
793- if let Err(err) = &started {
794- android::warn(&format!("vidya_open_url: {err}"));
909+ let Some(Some(src)) = picked else {
910+ return false;
911+ };
912+ let src: String = src;
913+ if std::fs::rename(&src, path).is_ok() {
914+ return true;
915+ }
916+ match std::fs::copy(&src, path) {
917+ Ok(_) => {
918+ let _ = std::fs::remove_file(&src);
919+ true
920+ }
921+ Err(e) => {
922+ android::warn(&format!("vidya_picked_image: {src} -> {path}: {e}"));
923+ false
924+ }
795925 }
796- started.is_ok()
797926 }
@@ -741,24 +741,56 @@ fn open_url(url: &str) -> bool {
741 /// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the activity the741 /// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the activity the
742 /// glue holds. Any JNI failure — or a device with nothing that answers the742 /// glue holds. Any JNI failure — or a device with nothing that answers the
743 /// intent — is a plain false; the caller shows the URL instead.743 /// intent — is a plain false; the caller shows the URL instead.
744+/// Run `body` against the activity the glue holds, on a thread attached to the
745+/// JVM for as long as it takes.
746+///
747+/// Every platform call below is shaped the same way: reach the activity, make
748+/// some JNI calls, and read a failure — a missing handle, a refused attach, a
749+/// thrown exception — as "the platform did not do it". None of them are worth
750+/// a panic; the caller has something to show instead.
744 #[cfg(target_os = "android")]751 #[cfg(target_os = "android")]
745-fn open_url(url: &str) -> bool {752+fn with_activity<T>(
753+ what: &str,
754+ body: impl FnOnce(&mut jni::Env, &jni::objects::JObject) -> jni::errors::Result<T>,
755+) -> Option<T> {
746 use jni::objects::JObject;756 use jni::objects::JObject;
747- use jni::{jni_sig, jni_str};
748 757
749 let Some(app) = android::android_app() else {758 let Some(app) = android::android_app() else {
750- android::warn("open_url: no AndroidApp handle");759+ android::warn(&format!("{what}: no AndroidApp handle"));
751- return false;760+ return None;
752 };761 };
753 // SAFETY: the glue owns both handles for the life of the activity, and762 // SAFETY: the glue owns both handles for the life of the activity, and
754 // hands them out as raw pointers for exactly this.763 // hands them out as raw pointers for exactly this.
755 let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) };764 let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) };
756 let activity_ptr = app.activity_as_ptr().cast();765 let activity_ptr = app.activity_as_ptr().cast();
757 766
758- let started: jni::errors::Result<()> = vm.attach_current_thread(|env| {767+ let out = vm.attach_current_thread(|env| {
759 // SAFETY: the activity outlives this frame, and the reference is a768 // SAFETY: the activity outlives this frame, and the reference is a
760 // borrow of the glue's own, not one this side owns.769 // borrow of the glue's own, not one this side owns.
761 let activity = unsafe { JObject::from_raw(env, activity_ptr) };770 let activity = unsafe { JObject::from_raw(env, activity_ptr) };
771+ let out = body(env, &activity);
772+ // An exception is thrown, not returned. Leaving one pending would fail
773+ // the next JNI call on this thread, whoever made it.
774+ if env.exception_check() {
775+ env.exception_clear();
776+ return Err(jni::errors::Error::JavaException);
777+ }
778+ out
779+ });
780+ match out {
781+ Ok(v) => Some(v),
782+ Err(e) => {
783+ android::warn(&format!("{what}: {e}"));
784+ None
785+ }
786+ }
787+}
788+
789+#[cfg(target_os = "android")]
790+fn open_url(url: &str) -> bool {
791+ use jni::{jni_sig, jni_str};
792+
793+ with_activity("vidya_open_url", |env, activity| {
762 let url = env.new_string(url)?;794 let url = env.new_string(url)?;
763 let uri = env795 let uri = env
764 .call_static_method(796 .call_static_method(
@@ -775,23 +807,120 @@ fn open_url(url: &str) -> bool {
775 &[(&action).into(), (&uri).into()],807 &[(&action).into(), (&uri).into()],
776 )?;808 )?;
777 env.call_method(809 env.call_method(
778- &activity,810+ activity,
779 jni_str!("startActivity"),811 jni_str!("startActivity"),
780 jni_sig!("(Landroid/content/Intent;)V"),812 jni_sig!("(Landroid/content/Intent;)V"),
781 &[(&intent).into()],813 &[(&intent).into()],
782 )?;814 )?;
783- // An ActivityNotFoundException is thrown, not returned. Leaving it815+ Ok(())
784- // pending would fail the next JNI call on this thread, whoever made816+ })
785- // it, so it is caught here and read as "nothing took the URL".817+ .is_some()
786- if env.exception_check() {818+}
787- env.exception_clear();819+
788- return Err(jni::errors::Error::JavaException);820+/// Ask the platform for a picture the reader chooses; 1 when the chooser opened.
821+///
822+/// This is not a file dialog and does not answer here: the reader is somewhere
823+/// else now, in a screen this app does not own, and may be there for a while or
824+/// never come back. What they picked arrives at `vidya_picked_image`, which the
825+/// caller polls until it does.
826+///
827+/// Only Android answers it, and only for a host activity that offers the
828+/// chooser (see `vidya_tree.h`). Everywhere else this is 0 and the caller
829+/// browses the filesystem itself, which is what a desktop has anyway.
830+///
831+/// Needs no window and no particular thread.
832+#[no_mangle]
833+pub unsafe extern "C" fn vidya_pick_image() -> c_int {
834+ guard(0, || pick_image() as c_int)
835+}
836+
837+/// Take the picture chosen since the last call and put it at `path`; 1 when
838+/// there was one.
839+///
840+/// Take, not read: the answer is handed over once, so a poll that is still
841+/// running does not attach the same picture twice.
842+///
843+/// # Safety
844+/// `path` is null or a NUL-terminated UTF-8 string.
845+#[no_mangle]
846+pub unsafe extern "C" fn vidya_picked_image(path: *const c_char) -> c_int {
847+ let path = borrowed_str(path);
848+ guard(0, || {
849+ if path.is_empty() {
850+ return 0;
789 }851 }
852+ picked_image(&path) as c_int
853+ })
854+}
855+
856+#[cfg(not(target_os = "android"))]
857+fn pick_image() -> bool {
858+ false
859+}
860+
861+#[cfg(not(target_os = "android"))]
862+fn picked_image(_path: &str) -> bool {
863+ false
864+}
865+
866+/// `pickImage()` on the host activity. An activity without it — a plain
867+/// `NativeActivity` — throws `NoSuchMethodError`, which reads here as "no
868+/// chooser on this device", and the caller falls back to browsing.
869+#[cfg(target_os = "android")]
870+fn pick_image() -> bool {
871+ use jni::{jni_sig, jni_str};
872+
873+ with_activity("vidya_pick_image", |env, activity| {
874+ env.call_method(activity, jni_str!("pickImage"), jni_sig!("()V"), &[])?;
790 Ok(())875 Ok(())
876+ })
877+ .is_some()
878+}
879+
880+/// `takePickedImage()` on the host activity, and then the file it names is
881+/// moved to where the caller wants it.
882+///
883+/// Moved rather than copied: what the activity wrote is a temporary of its own,
884+/// and leaving it behind would grow the app's cache by a picture per send. A
885+/// rename across filesystems fails, so that case falls back to copy-and-drop.
886+#[cfg(target_os = "android")]
887+fn picked_image(path: &str) -> bool {
888+ use jni::objects::JString;
889+ use jni::{jni_sig, jni_str};
890+
891+ let picked = with_activity("vidya_picked_image", |env, activity| {
892+ let picked = env
893+ .call_method(
894+ activity,
895+ jni_str!("takePickedImage"),
896+ jni_sig!("()Ljava/lang/String;"),
897+ &[],
898+ )?
899+ .l()?;
900+ if picked.is_null() {
901+ return Ok(None);
902+ }
903+ // SAFETY: the method's signature says `java.lang.String`, and the
904+ // reference is the one this frame just made.
905+ let picked: JString = unsafe { JString::from_raw(env, picked.as_raw()) };
906+ Ok(Some(picked.try_to_string(env)?))
791 });907 });
792 908
793- if let Err(err) = &started {909+ let Some(Some(src)) = picked else {
794- android::warn(&format!("vidya_open_url: {err}"));910+ return false;
911+ };
912+ let src: String = src;
913+ if std::fs::rename(&src, path).is_ok() {
914+ return true;
915+ }
916+ match std::fs::copy(&src, path) {
917+ Ok(_) => {
918+ let _ = std::fs::remove_file(&src);
919+ true
920+ }
921+ Err(e) => {
922+ android::warn(&format!("vidya_picked_image: {src} -> {path}: {e}"));
923+ false
924+ }
795 }925 }
796- started.is_ok()
797 }926 }
modified crates/jolt-vidya/src/tree.rs +15 -1
@@ -815,11 +815,25 @@ impl Tree {
815815 tree.paint_children(id, ui, theme);
816816 });
817817 });
818- } else {
818+ } else if props.bool("wrap", true) {
819819 ui.horizontal_wrapped(|ui| {
820820 ui.spacing_mut().item_spacing = axis;
821821 tree.paint_children(id, ui, theme);
822822 });
823+ } else {
824+ // `:wrap false` for a row whose children are
825+ // columns rather than controls. A wrapped row moves
826+ // a child that does not fit onto a line below,
827+ // which is right for buttons beside a message and
828+ // ruinous for the second half of a split: a pane
829+ // asking for a few points more than are left is
830+ // painted under the first one, off the bottom of
831+ // the window, and reads as a pane that renders
832+ // nothing at all.
833+ ui.horizontal(|ui| {
834+ ui.spacing_mut().item_spacing = axis;
835+ tree.paint_children(id, ui, theme);
836+ });
823837 }
824838 } else {
825839 // `:align :center` puts a column's children on the
@@ -815,11 +815,25 @@ impl Tree {
815 tree.paint_children(id, ui, theme);815 tree.paint_children(id, ui, theme);
816 });816 });
817 });817 });
818- } else {818+ } else if props.bool("wrap", true) {
819 ui.horizontal_wrapped(|ui| {819 ui.horizontal_wrapped(|ui| {
820 ui.spacing_mut().item_spacing = axis;820 ui.spacing_mut().item_spacing = axis;
821 tree.paint_children(id, ui, theme);821 tree.paint_children(id, ui, theme);
822 });822 });
823+ } else {
824+ // `:wrap false` for a row whose children are
825+ // columns rather than controls. A wrapped row moves
826+ // a child that does not fit onto a line below,
827+ // which is right for buttons beside a message and
828+ // ruinous for the second half of a split: a pane
829+ // asking for a few points more than are left is
830+ // painted under the first one, off the bottom of
831+ // the window, and reads as a pane that renders
832+ // nothing at all.
833+ ui.horizontal(|ui| {
834+ ui.spacing_mut().item_spacing = axis;
835+ tree.paint_children(id, ui, theme);
836+ });
823 }837 }
824 } else {838 } else {
825 // `:align :center` puts a column's children on the839 // `:align :center` puts a column's children on the
modified jolt/glimmer-vidya/src/glimmer_vidya/core.jolt +17 -0
@@ -275,6 +275,23 @@
275275 [url]
276276 (ffi/open-url! url))
277277
278+(defn pick-image!
279+ "Open the platform's own picture chooser. True when one opened, false where
280+ there is none — a desktop, or an Android activity that does not offer it —
281+ and a false is the caller's cue to browse the filesystem itself.
282+
283+ It does not answer with the picture. The reader is in another screen by then,
284+ so what they chose arrives at `picked-image!`, which the caller polls."
285+ []
286+ (ffi/pick-image!))
287+
288+(defn picked-image!
289+ "Move the picture chosen since the last call to `path`; true when there was
290+ one. The answer is handed over once, so a poll still running does not take
291+ the same picture twice."
292+ [path]
293+ (ffi/picked-image! path))
294+
278295 (defn frame-rgba!
279296 "Hand the backend a frame of live pixels under `key`. An `:image` node with
280297 `:feed key` paints the latest one.
@@ -275,6 +275,23 @@
275 [url]275 [url]
276 (ffi/open-url! url))276 (ffi/open-url! url))
277 277
278+(defn pick-image!
279+ "Open the platform's own picture chooser. True when one opened, false where
280+ there is none — a desktop, or an Android activity that does not offer it —
281+ and a false is the caller's cue to browse the filesystem itself.
282+
283+ It does not answer with the picture. The reader is in another screen by then,
284+ so what they chose arrives at `picked-image!`, which the caller polls."
285+ []
286+ (ffi/pick-image!))
287+
288+(defn picked-image!
289+ "Move the picture chosen since the last call to `path`; true when there was
290+ one. The answer is handed over once, so a poll still running does not take
291+ the same picture twice."
292+ [path]
293+ (ffi/picked-image! path))
294+
278 (defn frame-rgba!295 (defn frame-rgba!
279 "Hand the backend a frame of live pixels under `key`. An `:image` node with296 "Hand the backend a frame of live pixels under `key`. An `:image` node with
280 `:feed key` paints the latest one.297 `:feed key` paints the latest one.
modified jolt/glimmer-vidya/src/glimmer_vidya/ffi.jolt +15 -0
@@ -79,6 +79,12 @@
7979 ;; desktop, an ACTION_VIEW intent on Android.
8080 (ffi/defcfn raw-open-url "vidya_open_url" [:string] :int)
8181
82+;; --- the platform's picture chooser ------------------------------------------
83+;; Opened here, answered later: the reader leaves for a screen of the system's
84+;; own, so the pick is polled for rather than returned.
85+(ffi/defcfn raw-pick-image "vidya_pick_image" [] :int)
86+(ffi/defcfn raw-picked-image "vidya_picked_image" [:string] :int)
87+
8288 ;; --- the int/bool seam -------------------------------------------------------
8389 ;; C has no booleans; every predicate here crosses as 0 or 1. Converting at the
8490 ;; binding rather than at each call site keeps the rest of the backend written
@@ -105,6 +111,15 @@
105111 "Hand `url` to the platform's browser; true when something took it."
106112 [url]
107113 (not (zero? (raw-open-url url))))
114+(defn pick-image!
115+ "Open the platform's picture chooser; true when one opened."
116+ []
117+ (not (zero? (raw-pick-image))))
118+(defn picked-image!
119+ "Move the picture chosen since the last call to `path`; true when there was
120+ one."
121+ [path]
122+ (not (zero? (raw-picked-image path))))
108123 (defn frame-rgba!
109124 "Paint `rgba` — a pointer to width*height*4 un-premultiplied bytes — under
110125 `key`. True when the frame was accepted; false for dimensions the length does
@@ -79,6 +79,12 @@
79 ;; desktop, an ACTION_VIEW intent on Android.79 ;; desktop, an ACTION_VIEW intent on Android.
80 (ffi/defcfn raw-open-url "vidya_open_url" [:string] :int)80 (ffi/defcfn raw-open-url "vidya_open_url" [:string] :int)
81 81
82+;; --- the platform's picture chooser ------------------------------------------
83+;; Opened here, answered later: the reader leaves for a screen of the system's
84+;; own, so the pick is polled for rather than returned.
85+(ffi/defcfn raw-pick-image "vidya_pick_image" [] :int)
86+(ffi/defcfn raw-picked-image "vidya_picked_image" [:string] :int)
87+
82 ;; --- the int/bool seam -------------------------------------------------------88 ;; --- the int/bool seam -------------------------------------------------------
83 ;; C has no booleans; every predicate here crosses as 0 or 1. Converting at the89 ;; C has no booleans; every predicate here crosses as 0 or 1. Converting at the
84 ;; binding rather than at each call site keeps the rest of the backend written90 ;; binding rather than at each call site keeps the rest of the backend written
@@ -105,6 +111,15 @@
105 "Hand `url` to the platform's browser; true when something took it."111 "Hand `url` to the platform's browser; true when something took it."
106 [url]112 [url]
107 (not (zero? (raw-open-url url))))113 (not (zero? (raw-open-url url))))
114+(defn pick-image!
115+ "Open the platform's picture chooser; true when one opened."
116+ []
117+ (not (zero? (raw-pick-image))))
118+(defn picked-image!
119+ "Move the picture chosen since the last call to `path`; true when there was
120+ one."
121+ [path]
122+ (not (zero? (raw-picked-image path))))
108 (defn frame-rgba!123 (defn frame-rgba!
109 "Paint `rgba` — a pointer to width*height*4 un-premultiplied bytes — under124 "Paint `rgba` — a pointer to width*height*4 un-premultiplied bytes — under
110 `key`. True when the frame was accepted; false for dimensions the length does125 `key`. True when the frame was accepted; false for dimensions the length does