Open the bundled manual from Help and from Settings

The packages now carry the manual, but nothing in the application opened
it: the help sheet listed gestures and stopped there.

The help sheet gains a Manual button beside Done, and Settings a Manual
row under About beside the version. Both go through dr_ui::manual, which
finds the installed page through dr_plat::system_data_dirs (the package's
share directory on Linux, the executable's directory on Windows), and a
development build also in the checkout it was compiled from. A copy with
no manual says so on the status line rather than doing nothing.

On the desktop the page goes to the system browser. A section is a URL
fragment, and xdg-open's generic mode and Windows' FileProtocolHandler
both turn a file: URL into a path and drop the fragment, so a section is
opened through a one-line redirect page written to the data directory:
the opener gets a plain path, which every opener keeps, and the browser
follows the redirect to index.html#section itself. The launcher behind
the sign-in's open_in_browser is split out so both share it; the https
check stays with the sign-in.

Android has no path to give a browser: an asset is not a file, a copy in
private storage is unreadable to other apps, a file: URI across apps is
refused, and a content: URI leaves the browser resolving every picture
against the provider. So ManualActivity, a WebView reading
file:///android_asset/manual/index.html straight out of the APK, shows
it, started by class name with the section as an extra. JavaScript is
off, links off the page go to the browser, and the theme is day-night so
the page's own light and dark follow the system. A test checks that the
manifest, the Java class and dr_ui agree on the name and the extra.
This commit is contained in:
2026-09-24 22:56:09 -04:00
parent d8f26fb5cd
commit 352e59498b
19 changed files with 571 additions and 98 deletions
+4
View File
@@ -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"] }
+10
View File
@@ -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")))]
+1
View File
@@ -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;
+20
View File
@@ -199,6 +199,26 @@ pub fn wire<F>(
.collect::<Vec<_>>(),
)));
// 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::<Library>()
.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::<Library>().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);
+221
View File
@@ -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<PathBuf> {
#[allow(unused_mut)]
let mut candidates: Vec<PathBuf> = 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!(
"<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\n\
<title>DarkRoom manual</title>\n\
<meta http-equiv=\"refresh\" content=\"0; url={target}\">\n\
</head><body><p><a href=\"{target}\">Open the DarkRoom manual</a></p></body></html>\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\"><script>"), "xscript");
assert_eq!(clean_anchor(""), "");
}
#[cfg(not(target_os = "android"))]
#[test]
fn the_redirect_carries_the_fragment() {
let page = redirect_page("file:///usr/share/darkroom/manual/index.html#looking-closer");
assert!(page.contains(
"content=\"0; url=file:///usr/share/darkroom/manual/index.html#looking-closer\""
));
}
/// A development build finds the page in the checkout, so this is also
/// the test that the committed page is where the packagers copy it from.
#[test]
fn a_development_build_finds_the_page() {
let page = page().expect("docs/manual/index.html is not where the packagers expect it");
assert!(page.ends_with("index.html"));
}
}
+2
View File
@@ -1121,6 +1121,7 @@ in property <bool> panel-visible: true;
diagnostics-prepare() => { root.diagnostics-prepare(); }
diagnostics-save() => { root.diagnostics-save(); }
diagnostics-discard() => { root.diagnostics-discard(); }
open-manual() => { Library.library-open-manual(""); }
close() => { root.settings-close(); }
reset-defaults() => { root.settings-reset(); }
@@ -1439,6 +1440,7 @@ in property <bool> panel-visible: true;
filter-eyes-open: Library.library-filter-eyes-open;
people: Library.library-people;
gestures: Library.library-gestures;
open-manual(anchor) => { Library.library-open-manual(anchor); }
filter-min-rating: Library.library-filter-min-rating;
filter-max-rating: Library.library-filter-max-rating;
filter-rating-range(low, high) => {
+10
View File
@@ -69,6 +69,8 @@ export component GestureSheet inherits Rectangle {
in property <[GestureRow]> rows;
callback close();
/// Open the manual, at a section's anchor or, empty, at the top.
callback open-manual(string);
background: #000000CC;
@@ -172,6 +174,14 @@ export component GestureSheet inherits Rectangle {
HorizontalLayout {
alignment: end;
spacing: Theme.gap;
// The manual is the other half of this sheet: the sheet says
// which move does a thing, the manual shows the thing being
// done. From the one place a puzzled user already is.
Button {
text: "Manual";
clicked => { root.open-manual(""); }
}
Button {
text: "Done";
primary: true;
+5
View File
@@ -723,6 +723,8 @@ export global Library {
/// TRACES: FR-UI-4
/// The gesture reference's rows, read from the generated table.
in property <[GestureRow]> library-gestures;
/// Open the bundled manual, at a section's anchor or, empty, at the top.
callback library-open-manual(string);
in property <[PersonChip]> library-people;
callback library-people-listed();
callback library-filter-person-toggled(int);
@@ -1586,6 +1588,8 @@ export component LibraryGrid inherits Rectangle {
/// The gesture reference's rows, from Rust — which reads them from the
/// generated table. See gestures.slint for why they cannot be written here.
in property <[GestureRow]> gestures;
/// Open the manual, at the section `anchor` names or at the top.
callback open-manual(string);
/// Whether the reference is up. Local, like `naming` and `filing`: nothing
/// in Rust needs to know a sheet is open.
property <bool> helping: false;
@@ -4978,6 +4982,7 @@ export component LibraryGrid inherits Rectangle {
height: 100%;
rows: root.gestures;
close => { root.helping = false; }
open-manual(anchor) => { root.open-manual(anchor); }
}
// --- the naming sheet (FR-CAT-5, FR-CAT-7) ------------------------------
+22
View File
@@ -163,6 +163,10 @@ export component SettingsPage inherits Rectangle {
callback diagnostics-save();
callback diagnostics-discard();
/// TRACES: FR-UI-4
/// Open the manual that came with the application (`dr_ui::manual`).
callback open-manual();
/// TRACES: FR-DSP-8
/// The display showing the canvas, and the colour it is being given.
///
@@ -741,6 +745,24 @@ export component SettingsPage inherits Rectangle {
Value { text: root.app-version; horizontal-stretch: 1; overflow: elide; }
}
// The manual, beside the version because both answer
// "what is this application": one says which, the
// other what it does. Installed with it, so it opens
// with no network.
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Manual"; vertical-alignment: center; }
Rectangle {
horizontal-stretch: 1;
height: Theme.control-height;
Button {
x: 0;
text: "Open the manual";
clicked => { root.open-manual(); }
}
}
}
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Graphics"; }