Making it the first time turned up nine faults, each fixed in its own
commit before the pictures were taken: the folder picker could not choose
the top level, month headings overprinted each other, a category mask
diff --git a/ui/dr-ui/Cargo.toml b/ui/dr-ui/Cargo.toml
index 60e5cfd..3a64d9d 100644
--- a/ui/dr-ui/Cargo.toml
+++ b/ui/dr-ui/Cargo.toml
@@ -115,6 +115,10 @@ slint = { workspace = true, features = [
"renderer-femtovg-wgpu",
"unstable-wgpu-29",
] }
+# The manual's `file:` URL (`manual::desktop_open`): a Windows path and a
+# path with a space in it are both URLs only after encoding, and this is the
+# crate the workspace already encodes URLs with.
+url.workspace = true
[target.'cfg(target_os = "android")'.dependencies]
slint = { workspace = true, features = ["backend-android-activity-06"] }
diff --git a/ui/dr-ui/src/launch_ui.rs b/ui/dr-ui/src/launch_ui.rs
index fc38a31..20536ee 100644
--- a/ui/dr-ui/src/launch_ui.rs
+++ b/ui/dr-ui/src/launch_ui.rs
@@ -847,6 +847,16 @@ fn open_in_browser(url: &str) -> std::io::Result<()> {
format!("refusing to open a sign-in address that is not https: {url}"),
));
}
+ hand_to_system(url)
+}
+
+/// Hand `target` — a URL, or a local file's path — to whatever the platform
+/// opens it with.
+///
+/// No check on what it is: [`open_in_browser`] is the caller with a server's
+/// URL in hand and refuses anything but https before it gets here, and
+/// `manual::open` hands over a page it wrote itself.
+pub(crate) fn hand_to_system(url: &str) -> std::io::Result<()> {
// Not `target_os = "linux"`: Android is its own target_os, and reached this
// arm's `Ok(())` fallback, so the browser silently never opened.
#[cfg(all(unix, not(target_os = "android"), not(target_os = "macos")))]
diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs
index 7c3fea6..0eddea3 100644
--- a/ui/dr-ui/src/lib.rs
+++ b/ui/dr-ui/src/lib.rs
@@ -48,6 +48,7 @@ mod library;
mod library_ui;
#[cfg(live_style)]
mod live_style;
+pub mod manual;
mod masks_ui;
pub mod memory;
pub mod merge;
diff --git a/ui/dr-ui/src/library_ui/grid.rs b/ui/dr-ui/src/library_ui/grid.rs
index 1533b07..5443b58 100644
--- a/ui/dr-ui/src/library_ui/grid.rs
+++ b/ui/dr-ui/src/library_ui/grid.rs
@@ -199,6 +199,26 @@ pub fn wire(
.collect::>(),
)));
+ // TRACES: FR-UI-4
+ // The manual, from the help sheet and from Settings. Opened on this
+ // thread: all it does is write a one-line page and start a process (or,
+ // on Android, an activity), which is quicker than handing it to a worker.
+ // A failure — in practice, a build with no manual installed — goes to the
+ // status line, since the button the user pressed otherwise did nothing.
+ {
+ let weak = window.as_weak();
+ window
+ .global::()
+ .on_library_open_manual(move |anchor| {
+ if let Err(e) = crate::manual::open(&anchor) {
+ log::warn!("manual: {e}");
+ if let Some(w) = weak.upgrade() {
+ w.global::().set_library_status(e.into());
+ }
+ }
+ });
+ }
+
// Shared rather than moved: a click and `Return` both open an image, and
// they are two callbacks.
let on_open_image: OpenImage = Rc::new(on_open_image);
diff --git a/ui/dr-ui/src/manual.rs b/ui/dr-ui/src/manual.rs
new file mode 100644
index 0000000..fd4b2cc
--- /dev/null
+++ b/ui/dr-ui/src/manual.rs
@@ -0,0 +1,221 @@
+//! TRACES: FR-UI-4
+//! Opening the bundled manual, at a section when asked for one.
+//!
+//! The manual is `docs/manual/index.html` and its pictures, rendered by
+//! `tools/traceability` and installed by each package beside the models:
+//! `/usr/share/darkroom/manual` on Linux, `manual\` beside the executable on
+//! Windows, `assets/manual` inside the APK. Bundled rather than linked to on
+//! the forge, because the moment somebody opens a help sheet is not a moment
+//! to require a network.
+//!
+//! # Desktop: the system browser, by way of a one-line page
+//!
+//! A section is a fragment — `index.html#rating-and-flagging` — and the
+//! fragment is exactly what the platforms' openers lose. `xdg-open` in its
+//! generic mode, and `url.dll,FileProtocolHandler` on Windows, turn a `file:`
+//! URL into a path before they hand it on, and a path has no fragment: the
+//! manual opens at the top and the "See it" link has done nothing a plain
+//! "Manual" link would not. So a section is opened through a small page of
+//! our own whose only content is a redirect to the full URL, fragment and
+//! all; the opener is handed that page's *path*, which every opener keeps
+//! intact, and the browser follows the redirect itself. Written to the user
+//! data directory, overwritten on each use.
+//!
+//! # Android: a WebView of our own
+//!
+//! Android has no path to hand a browser. An asset inside the APK is not a
+//! file; a copy unpacked to app-private storage is a file no other app may
+//! read, and a `file:` URI handed across apps is refused outright since API
+//! 24. A content provider would serve the page, but a browser then asks the
+//! same provider for every picture by a relative URL it resolves against a
+//! `content:` authority — which the browsers do not reliably do. So the page is
+//! shown by `ManualActivity`, a WebView that reads
+//! `file:///android_asset/manual/index.html` straight out of the APK, pictures
+//! and fragment included, with nothing unpacked.
+
+use std::path::PathBuf;
+
+/// The Android activity that shows the manual. Named by string in the Intent,
+/// so no class has to be loaded to start it; the manifest test in
+/// `darkroom-android` checks the manifest declares this exact name.
+pub const ANDROID_ACTIVITY: &str = "paris.tourolle.darkroom.ManualActivity";
+
+/// The Intent extra carrying the section, read by `ManualActivity`.
+pub const ANDROID_EXTRA_ANCHOR: &str = "anchor";
+
+/// Where the installed manual is, if a package installed one.
+///
+/// The package directories first, as the models are found. A development
+/// build also looks in the checkout it was compiled from, so `cargo run`
+/// opens the page the tree has; a release build never does, because the
+/// checkout is not on the user's machine.
+pub fn page() -> Option {
+ #[allow(unused_mut)]
+ let mut candidates: Vec = dr_plat::system_data_dirs()
+ .into_iter()
+ .map(|d| d.join("manual").join("index.html"))
+ .collect();
+ #[cfg(debug_assertions)]
+ candidates.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../docs/manual/index.html"));
+ candidates.into_iter().find(|p| p.is_file())
+}
+
+/// A section's anchor, as it may appear in a URL.
+///
+/// Anchors are the forge's slugs — lower-case letters, digits and hyphens — so
+/// anything else in one is not a section of this manual and is dropped rather
+/// than escaped: it came from the generated gesture table, and a table entry
+/// that needs escaping is a bug to notice, not to route around.
+fn clean_anchor(anchor: &str) -> String {
+ anchor
+ .chars()
+ .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
+ .collect()
+}
+
+/// The redirect page for one section of the manual at `target`.
+#[cfg(not(target_os = "android"))]
+fn redirect_page(target: &str) -> String {
+ format!(
+ "\n\n\
+ DarkRoom manual\n\
+ \n\
+ Open the DarkRoom manual
\n"
+ )
+}
+
+/// Open the manual, at `anchor` when it is not empty.
+///
+/// The error is a sentence for the status line: the one case a user can do
+/// anything about is a manual that is not installed.
+pub fn open(anchor: &str) -> Result<(), String> {
+ let anchor = clean_anchor(anchor);
+ #[cfg(target_os = "android")]
+ {
+ android_open(&anchor)
+ }
+ #[cfg(not(target_os = "android"))]
+ {
+ desktop_open(&anchor)
+ }
+}
+
+#[cfg(not(target_os = "android"))]
+fn desktop_open(anchor: &str) -> Result<(), String> {
+ let Some(page) = page() else {
+ return Err("The manual is not installed with this copy of DarkRoom".into());
+ };
+ // Unix only: on Windows `canonicalize` answers with a `\\?\` verbatim
+ // path, which is not one a `file:` URL can be made from — and there the
+ // path is the executable's directory, already absolute.
+ #[cfg(unix)]
+ let page = page.canonicalize().unwrap_or(page);
+ if anchor.is_empty() {
+ log::info!("opening the manual at {}", page.display());
+ return crate::launch_ui::hand_to_system(&page.to_string_lossy())
+ .map_err(|e| format!("Could not open the manual: {e}"));
+ }
+
+ let Ok(mut url) = url::Url::from_file_path(&page) else {
+ return Err(format!(
+ "{} is not a path a browser can open",
+ page.display()
+ ));
+ };
+ url.set_fragment(Some(anchor));
+
+ let link = dr_plat::base_dir(dr_plat::Base::Data).join("manual-link.html");
+ if let Some(dir) = link.parent() {
+ std::fs::create_dir_all(dir).map_err(|e| format!("Could not open the manual: {e}"))?;
+ }
+ std::fs::write(&link, redirect_page(url.as_str()))
+ .map_err(|e| format!("Could not open the manual: {e}"))?;
+ log::info!("opening the manual at {url} by way of {}", link.display());
+ crate::launch_ui::hand_to_system(&link.to_string_lossy())
+ .map_err(|e| format!("Could not open the manual: {e}"))
+}
+
+/// Start `ManualActivity` with the section as an extra.
+///
+/// `Intent.setClassName(Context, String)` rather than a class object: the
+/// activity's class is in this APK's dex, which a native thread's `FindClass`
+/// cannot see (see `darkroom-android`'s `load_class`), and a name needs no
+/// class at all. The extra is a string rather than the Intent's data URI,
+/// because a `file:` data URI trips the platform's exposure check.
+#[cfg(target_os = "android")]
+fn android_open(anchor: &str) -> Result<(), String> {
+ let ctx = ndk_context::android_context();
+ if ctx.vm().is_null() || ctx.context().is_null() {
+ return Err("no Android context available".into());
+ }
+ // SAFETY: the pointer comes from ndk_context, which android-activity fills
+ // in with the process's real JavaVM before any Rust runs.
+ let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
+ let raw_activity: jni::sys::jobject = ctx.context().cast();
+
+ vm.attach_current_thread(|env| {
+ // SAFETY: valid for this frame, which is all the Intent needs.
+ let activity = unsafe { jni::objects::JObject::from_raw(env, raw_activity) };
+ let intent = env.new_object(
+ jni::jni_str!("android/content/Intent"),
+ jni::jni_sig!("()V"),
+ &[],
+ )?;
+ let class = env.new_string(ANDROID_ACTIVITY)?;
+ env.call_method(
+ &intent,
+ jni::jni_str!("setClassName"),
+ jni::jni_sig!("(Landroid/content/Context;Ljava/lang/String;)Landroid/content/Intent;"),
+ &[(&activity).into(), (&class).into()],
+ )?;
+ let key = env.new_string(ANDROID_EXTRA_ANCHOR)?;
+ let value = env.new_string(anchor)?;
+ env.call_method(
+ &intent,
+ jni::jni_str!("putExtra"),
+ jni::jni_sig!("(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;"),
+ &[(&key).into(), (&value).into()],
+ )?;
+ env.call_method(
+ &activity,
+ jni::jni_str!("startActivity"),
+ jni::jni_sig!("(Landroid/content/Intent;)V"),
+ &[(&intent).into()],
+ )?;
+ if env.exception_check() {
+ env.exception_clear();
+ return Err(jni::errors::Error::JavaException);
+ }
+ Ok(())
+ })
+ .map_err(|e: jni::errors::Error| format!("Could not open the manual: {e}"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn an_anchor_keeps_only_what_a_slug_can_hold() {
+ assert_eq!(clean_anchor("rating-and-flagging"), "rating-and-flagging");
+ assert_eq!(clean_anchor("x\">