Links open in the system browser via a platform service (#14) (#20)

ctx.open_url is unreliable on native (notably Windows), so the Help menu,
the Discord buttons, and the start-page links did nothing. Added an open_url
Service hook wired to the `open` crate on native (ShellExecute on Windows);
links::open prefers it and falls back to ctx.open_url (web). link_row now
routes clicks through it instead of ui.hyperlink_to. Test drives every Help
link id + the open() helper through a mock service.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 01:34:18 -04:00 committed by GitHub
parent b7cac05f69
commit 34d138c11f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 51 additions and 7 deletions

1
Cargo.lock generated
View File

@ -3241,6 +3241,7 @@ dependencies = [
"eframe",
"egui",
"log",
"open",
"photocraft-codecs",
"photocraft-color",
"photocraft-compose",

View File

@ -29,6 +29,7 @@ log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = "3"
open = "5"
[build-dependencies]
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.

View File

@ -80,6 +80,7 @@ pub fn native() -> Services {
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
})),
inbox: None,
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
clipboard_set_image: Some(Box::new(|w: u32, h: u32, px: &[u8]| {
let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())

View File

@ -597,7 +597,7 @@ fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.add_space(26.0);
crate::links::discord_button(app, ui, 190.0);
ui.add_space(10.0);
crate::links::link_row(ui);
crate::links::link_row(app, ui);
});
});
}

View File

@ -46,7 +46,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
ui.vertical_centered(|ui| {
crate::links::discord_button(app, ui, 220.0);
ui.add_space(8.0);
crate::links::link_row(ui);
crate::links::link_row(app, ui);
});
ui.add_space(10.0);
ui.weak("egui · wgpu · photocraft-engine");

View File

@ -90,6 +90,8 @@ pub type ExportFn = Box<dyn Fn(&Document, &str, &ExportSettings) -> Result<Vec<u
pub type PickOpenFn = Box<dyn FnMut() -> Option<(String, Vec<u8>)>>;
pub type PickSaveFn = Box<dyn FnMut(&str) -> Option<String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
/// Open a URL in the system browser (native) — reliable cross-platform, unlike `ctx.open_url`.
pub type OpenUrlFn = Box<dyn Fn(&str) -> Result<(), String>>;
pub type EncodePngFn = Box<dyn Fn(u32, u32, &[u8]) -> Result<Vec<u8>, String>>;
/// Put an RGBA8 image (width, height, pixels) on the OS clipboard.
pub type ClipboardSetFn = Box<dyn FnMut(u32, u32, &[u8]) -> Result<(), String>>;
@ -126,6 +128,8 @@ pub struct Services {
pub write: Option<WriteFn>,
/// Encode an RGBA8 image as PNG (used for screenshots and `ui.render`).
pub encode_png: Option<EncodePngFn>,
/// Open a URL in the system browser (native). Falls back to `ctx.open_url` (web) when unset.
pub open_url: Option<OpenUrlFn>,
/// Files delivered asynchronously (web file pickers, drag-and-drop): drained every frame.
pub inbox: Option<Inbox>,
/// OS clipboard images: copies go out, screenshots and images from other apps come in.

View File

@ -25,9 +25,13 @@ pub fn url_for(id: &str) -> Option<&'static str> {
COMMANDS.iter().find(|c| c.0 == id).map(|c| c.1)
}
/// Open `url` in the system browser (a new tab on the web) and note it in the status bar.
/// Open `url` in the system browser (a new tab on the web) and note it in the status bar. Prefers
/// the platform `open_url` service (reliable on Windows/macOS/Linux); falls back to `ctx.open_url`.
pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context, url: &str) -> Value {
ctx.open_url(egui::OpenUrl::new_tab(url));
let opened = app.services.open_url.as_ref().map(|f| f(url).is_ok()).unwrap_or(false);
if !opened {
ctx.open_url(egui::OpenUrl::new_tab(url));
}
app.ui.status = format!("Opened {url}");
json!({"url": url})
}
@ -41,14 +45,16 @@ pub fn discord_button(app: &mut PhotocraftApp, ui: &mut egui::Ui, min_width: f32
r
}
/// "PhotoCraft website · GitHub · ArtCraft" as hyperlinks, centred in the available width.
pub fn link_row(ui: &mut egui::Ui) {
/// "PhotoCraft website · GitHub · ArtCraft" as links, centred. Clicks route through [`open`] (the
/// platform browser service) rather than `ui.hyperlink_to`, which uses the unreliable `ctx.open_url`.
pub fn link_row(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get(ui.ctx());
let links = [("PhotoCraft website", APP_PAGE), ("GitHub", GITHUB), ("ArtCraft", ARTCRAFT_WEBSITE)];
let font = egui::FontId::proportional(12.5);
let sep = " · ";
let width: f32 = links.iter().map(|(l, _)| ui.painter().layout_no_wrap((*l).into(), font.clone(), t.text).size().x).sum::<f32>()
+ 2.0 * ui.painter().layout_no_wrap(sep.into(), font.clone(), t.text).size().x;
let mut clicked: Option<&str> = None;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.add_space(((ui.available_width() - width) / 2.0).max(0.0));
@ -56,15 +62,46 @@ pub fn link_row(ui: &mut egui::Ui) {
if i > 0 {
ui.label(egui::RichText::new(sep).font(font.clone()).color(t.text_faint));
}
ui.hyperlink_to(egui::RichText::new(*label).font(font.clone()), *url).on_hover_text(*url);
if ui.link(egui::RichText::new(*label).font(font.clone()).color(t.accent)).on_hover_text(*url).clicked() {
clicked = Some(url);
}
}
});
if let Some(url) = clicked {
open(app, ui.ctx(), url);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn links_open_through_the_platform_service() {
// Issue #14: links (Help menu, Discord button, start-page links) must open in the browser.
// They route through the `open_url` service rather than the unreliable `ctx.open_url`.
use std::sync::{Arc, Mutex};
let opened: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let rec = opened.clone();
let services = crate::Services {
open_url: Some(Box::new(move |u: &str| {
rec.lock().unwrap().push(u.to_string());
Ok(())
})),
..Default::default()
};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
let ctx = egui::Context::default();
// Every Help-menu link id reaches the service with its URL.
for (id, url) in COMMANDS {
crate::menus::invoke(&mut app, &ctx, id, json!({})).unwrap();
assert_eq!(opened.lock().unwrap().last().map(String::as_str), Some(*url), "{id}");
}
// The direct open() helper (Discord button / start-page links) also uses it.
open(&mut app, &ctx, DISCORD);
assert_eq!(opened.lock().unwrap().last().map(String::as_str), Some(DISCORD));
}
#[test]
fn help_menu_lists_links_then_separator_then_about() {
let app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());