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>
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); | ||
| 181 | 181 | */ |
| 182 | 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 | 202 | #ifdef __cplusplus |
| 185 | 203 | } |
| 186 | 204 | #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 __cplusplus | 202 | #ifdef __cplusplus |
| 185 | } | 203 | } |
| 186 | #endif | 204 | #endif |
modified
crates/jolt-vidya/src/android.rs +31 -0 | @@ -27,6 +27,35 @@ const APP_LIBRARY: &str = "libjoltapp.so"; | ||
| 27 | 27 | /// Set once, before any application code runs, and read on the loop thread. |
| 28 | 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 | 59 | /// The handle winit needs to build an event loop. `None` off Android's own |
| 31 | 60 | /// thread, or before the glue has started. |
| 32 | 61 | pub fn android_app() -> Option<AndroidApp> { |
| @@ -74,6 +103,8 @@ fn last_dl_error() -> String { | ||
| 74 | 103 | /// flash, so a failure to reach the application is logged rather than silent. |
| 75 | 104 | #[no_mangle] |
| 76 | 105 | pub extern "C" fn android_main(app: AndroidApp) { |
| 106 | + set_home_from(&app); | |
| 107 | + | |
| 77 | 108 | if let Ok(mut slot) = ANDROID_APP.lock() { |
| 78 | 109 | *slot = Some(app); |
| 79 | 110 | } |
| @@ -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 own | 59 | /// 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 { | ||
| 294 | 294 | /// `VIDYA_RESIZE_AT` parsed: (frame, width, height), in frame order. |
| 295 | 295 | resize_at: Vec<(u32, f64, f64)>, |
| 296 | 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 | 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 | 307 | impl Handler { |
| @@ -333,7 +339,29 @@ impl Handler { | ||
| 333 | 339 | } |
| 334 | 340 | } |
| 335 | 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 | 365 | if let Some(path) = self.capture.take() { |
| 338 | 366 | capture_frame(gl, dims, &path); |
| 339 | 367 | } |
| @@ -499,6 +527,20 @@ impl App { | ||
| 499 | 527 | .ok() |
| 500 | 528 | .and_then(|v| v.parse().ok()) |
| 501 | 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 | 545 | stack: Stack::default(), |
| 504 | 546 | 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 { | ||
| 741 | 741 | /// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the activity the |
| 742 | 742 | /// glue holds. Any JNI failure — or a device with nothing that answers the |
| 743 | 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 | 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 | 756 | use jni::objects::JObject; |
| 747 | - use jni::{jni_sig, jni_str}; | |
| 748 | 757 | |
| 749 | 758 | 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; | |
| 752 | 761 | }; |
| 753 | 762 | // SAFETY: the glue owns both handles for the life of the activity, and |
| 754 | 763 | // hands them out as raw pointers for exactly this. |
| 755 | 764 | let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) }; |
| 756 | 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 | 768 | // SAFETY: the activity outlives this frame, and the reference is a |
| 760 | 769 | // borrow of the glue's own, not one this side owns. |
| 761 | 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 | 794 | let url = env.new_string(url)?; |
| 763 | 795 | let uri = env |
| 764 | 796 | .call_static_method( |
| @@ -775,23 +807,120 @@ fn open_url(url: &str) -> bool { | ||
| 775 | 807 | &[(&action).into(), (&uri).into()], |
| 776 | 808 | )?; |
| 777 | 809 | env.call_method( |
| 778 | - &activity, | |
| 810 | + activity, | |
| 779 | 811 | jni_str!("startActivity"), |
| 780 | 812 | jni_sig!("(Landroid/content/Intent;)V"), |
| 781 | 813 | &[(&intent).into()], |
| 782 | 814 | )?; |
| 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; | |
| 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 | 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 { | |
| 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 | + } | |
| 795 | 925 | } |
| 796 | - started.is_ok() | |
| 797 | 926 | } |
| @@ -741,24 +741,56 @@ fn open_url(url: &str) -> bool { | |||
| 741 | /// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the activity the | 741 | /// `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 the | 742 | /// 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, and | 762 | // 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 a | 768 | // 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 = env | 795 | 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 it | 815 | + Ok(()) |
| 784 | - // pending would fail the next JNI call on this thread, whoever made | 816 | + }) |
| 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 { | ||
| 815 | 815 | tree.paint_children(id, ui, theme); |
| 816 | 816 | }); |
| 817 | 817 | }); |
| 818 | - } else { | |
| 818 | + } else if props.bool("wrap", true) { | |
| 819 | 819 | ui.horizontal_wrapped(|ui| { |
| 820 | 820 | ui.spacing_mut().item_spacing = axis; |
| 821 | 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 | 838 | } else { |
| 825 | 839 | // `: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 the | 839 | // `:align :center` puts a column's children on the |
modified
jolt/glimmer-vidya/src/glimmer_vidya/core.jolt +17 -0 | @@ -275,6 +275,23 @@ | ||
| 275 | 275 | [url] |
| 276 | 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 | 295 | (defn frame-rgba! |
| 279 | 296 | "Hand the backend a frame of live pixels under `key`. An `:image` node with |
| 280 | 297 | `: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 with | 296 | "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 @@ | ||
| 79 | 79 | ;; desktop, an ACTION_VIEW intent on Android. |
| 80 | 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 | 88 | ;; --- the int/bool seam ------------------------------------------------------- |
| 83 | 89 | ;; C has no booleans; every predicate here crosses as 0 or 1. Converting at the |
| 84 | 90 | ;; binding rather than at each call site keeps the rest of the backend written |
| @@ -105,6 +111,15 @@ | ||
| 105 | 111 | "Hand `url` to the platform's browser; true when something took it." |
| 106 | 112 | [url] |
| 107 | 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 | 123 | (defn frame-rgba! |
| 109 | 124 | "Paint `rgba` — a pointer to width*height*4 un-premultiplied bytes — under |
| 110 | 125 | `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 the | 89 | ;; 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 written | 90 | ;; 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 — under | 124 | "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 does | 125 | `key`. True when the frame was accepted; false for dimensions the length does |