diff --git a/ASSETS.md b/ASSETS.md index 3e66903d..41d0e201 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -203,6 +203,10 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/library.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/link-2-off.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/link.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | +| `assets/icons/external-link.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Help → app page link | +| `assets/icons/git-branch.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Help → GitHub link | +| `assets/icons/globe.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Help → website link | +| `assets/icons/message-circle.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Discord button and Help → Discord link | | `assets/icons/lock-open.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/lock.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/map.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | diff --git a/Cargo.lock b/Cargo.lock index 00fb280e..4dad99b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -793,6 +793,17 @@ dependencies = [ "objc2 0.6.4", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "dlib" version = "0.5.3" @@ -1234,6 +1245,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1621,6 +1641,110 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.25.10" @@ -1916,6 +2040,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "litrs" version = "1.0.0" @@ -2705,6 +2835,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3612,6 +3751,16 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.13.3" @@ -3784,6 +3933,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "usvg" version = "0.45.1" @@ -3834,6 +3995,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.26.1" @@ -3858,6 +4025,7 @@ dependencies = [ "serde_json", "vectorcraft-engine", "vectorcraft-ui-egui", + "webbrowser", ] [[package]] @@ -4455,6 +4623,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + [[package]] name = "weezl" version = "0.1.12" @@ -5007,6 +5191,12 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "x11-dl" version = "2.21.0" @@ -5263,6 +5453,39 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index b6168945..9f8a0203 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" arboard = { version = "3.6", default-features = false } +webbrowser = "1" [workspace.lints.rust] unsafe_code = "deny" diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index c8de7ec4..afa9a81d 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -20,6 +20,7 @@ log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } arboard = { workspace = true } +webbrowser = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index bc887d02..8982bb19 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -108,6 +108,10 @@ fn services() -> Services { write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), // Menu-bar Paste never sees egui's Paste event, so read the clipboard directly. clipboard_read: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())), + // Help → Discord / website / GitHub, the Discord button, About and Home links. + open_url: Some(Box::new(|url: &str| { + let _ = webbrowser::open(url); + })), ..Default::default() } } diff --git a/assets/icons/external-link.svg b/assets/icons/external-link.svg new file mode 100644 index 00000000..3797f06c --- /dev/null +++ b/assets/icons/external-link.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/git-branch.svg b/assets/icons/git-branch.svg new file mode 100644 index 00000000..1b9e3785 --- /dev/null +++ b/assets/icons/git-branch.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/globe.svg b/assets/icons/globe.svg new file mode 100644 index 00000000..e6124c58 --- /dev/null +++ b/assets/icons/globe.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/message-circle.svg b/assets/icons/message-circle.svg new file mode 100644 index 00000000..df60b563 --- /dev/null +++ b/assets/icons/message-circle.svg @@ -0,0 +1,15 @@ + + + + diff --git a/crates/engine/src/cmd/help.rs b/crates/engine/src/cmd/help.rs new file mode 100644 index 00000000..aba4d608 --- /dev/null +++ b/crates/engine/src/cmd/help.rs @@ -0,0 +1,51 @@ +//! Help → community and project links (the ArtCraft Discord, website, this app's page and its +//! GitHub repository). The commands return the URL; the frontend opens it. + +use serde_json::{Value, json}; + +use super::*; + +/// The app's id on getartcraft.com and GitHub. +pub const APP_ID: &str = "vectorcraft"; +pub const DISCORD_URL: &str = "https://discord.gg/artcraft"; +pub const WEBSITE_URL: &str = "https://getartcraft.com"; + +/// This app's page on the website. +pub fn app_page_url() -> String { + format!("{WEBSITE_URL}/apps/{APP_ID}") +} + +/// This app's source repository. +pub fn github_url() -> String { + format!("https://github.com/storytold/{APP_ID}") +} + +pub fn specs() -> Vec { + vec![ + cmd!(query "help.discord", "Join Our Discord", ["Help"], None, "{} → {url} the ArtCraft community Discord", always, |_, _| url(DISCORD_URL.into())), + cmd!(query "help.website", "ArtCraft Website", ["Help"], None, "{} → {url}", always, |_, _| url(WEBSITE_URL.into())), + cmd!(query "help.appPage", "VectorCraft on getartcraft.com", ["Help"], None, "{} → {url} this app's page", always, |_, _| url(app_page_url())), + cmd!(query "help.github", "VectorCraft on GitHub", ["Help"], None, "{} → {url} source code, issues and releases", always, |_, _| url(github_url())), + cmd!(query "help.links", "Links", [], None, "{} → {discord, website, appPage, github}", always, |_, _| { + Ok(json!({ "discord": DISCORD_URL, "website": WEBSITE_URL, "appPage": app_page_url(), "github": github_url() })) + }), + ] +} + +fn url(u: String) -> Result { + Ok(json!({ "url": u })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn links() { + let mut s = Session::new(); + assert_eq!(s.execute("help.discord", &json!({})).unwrap()["url"], "https://discord.gg/artcraft"); + assert_eq!(s.execute("help.appPage", &json!({})).unwrap()["url"], "https://getartcraft.com/apps/vectorcraft"); + assert_eq!(s.execute("help.github", &json!({})).unwrap()["url"], "https://github.com/storytold/vectorcraft"); + assert_eq!(s.execute("help.links", &json!({})).unwrap()["website"], "https://getartcraft.com"); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5e947f56..2fda8942 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -14,6 +14,7 @@ mod edit; mod effectcmd; mod file; mod fonts; +pub mod help; mod layer; mod live; pub(crate) mod menucmds; @@ -156,6 +157,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(textedit::specs()); v.extend(textstyles::specs()); v.extend(fonts::specs()); + v.extend(help::specs()); v.extend(threads::specs()); v.extend(docmenu::specs()); v.extend(docinfo::specs()); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 80956d45..521d3762 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -851,6 +851,10 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { } } }); + ui.add_space(28.0); + ui.label(egui::RichText::new("Community").font(theme::semibold(14.0)).color(t.text)); + ui.add_space(10.0); + crate::community::links(app, ui); } fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 7423730b..6d978236 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -30,7 +30,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { menus::menu_bar(app, ui); } let full = ui.max_rect(); - let right = egui::Rect::from_min_max(egui::pos2(full.right() - 340.0, full.top()), full.right_bottom()); + let right = egui::Rect::from_min_max(egui::pos2(full.right() - 440.0, full.top()), full.right_bottom()); let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center))); let ui = &mut rui; // Workspace switcher: shows the current workspace, opens Window → Workspace. @@ -52,6 +52,8 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { app.ui.palette_open = true; app.ui.palette_query.clear(); } + ui.add_space(10.0); + crate::community::discord_button(app, ui, false); }); }); } diff --git a/crates/ui-egui/src/community.rs b/crates/ui-egui/src/community.rs new file mode 100644 index 00000000..0beb7a1f --- /dev/null +++ b/crates/ui-egui/src/community.rs @@ -0,0 +1,87 @@ +//! Community and project links: the Discord button (header, About, Home) and the link list. +//! Icons are Lucide's (ISC); Discord's own logo is a trademark, so the button uses a chat icon +//! on Discord's colour with the word "Discord". + +use egui::{Color32, CornerRadius, Sense, Ui, vec2}; + +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, icons}; + +/// Discord's brand colour (a colour, not a logo). +const DISCORD: Color32 = Color32::from_rgb(0x58, 0x65, 0xF2); + +/// A Discord pill button: compact for the header, large for About / Home. +pub fn discord_button(app: &mut VectorcraftApp, ui: &mut Ui, large: bool) -> egui::Response { + let (h, font, label) = if large { (36.0, theme::semibold(14.0), "Join our Discord") } else { (24.0, theme::semibold(12.0), "Discord") }; + let galley = ui.painter().layout_no_wrap(label.to_string(), font, Color32::WHITE); + let icon = h * 0.55; + let w = galley.size().x + icon + h * 0.9; + let (r, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click()); + let fill = if resp.hovered() { DISCORD.gamma_multiply(0.85) } else { DISCORD }; + ui.painter().rect_filled(r, CornerRadius::same((h / 2.0) as u8), fill); + let ir = egui::Rect::from_center_size(r.left_center() + vec2(h * 0.35 + icon / 2.0, 0.0), vec2(icon, icon)); + icons::paint(ui, "message-circle", ir, Color32::WHITE); + ui.painter().galley(egui::pos2(ir.right() + h * 0.2, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + let resp = resp.on_hover_text("Join the ArtCraft community on Discord (discord.gg/artcraft)"); + if resp.clicked() { + app.open_link("help.discord"); + } + resp +} + +/// One link row: icon, label, opens the command's URL. +fn link(app: &mut VectorcraftApp, ui: &mut Ui, icon: &str, label: &str, cmd: &str, url: &str) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + icons::icon(ui, icon, 16.0, t.icon); + let r = ui.add(egui::Button::new(egui::RichText::new(label).color(t.accent).size(13.0)).frame(false)).on_hover_text(url); + if r.clicked() { + app.open_link(cmd); + } + }); +} + +/// The Discord button and the website / app page / GitHub links. +pub fn links(app: &mut VectorcraftApp, ui: &mut Ui) { + discord_button(app, ui, true); + ui.add_space(8.0); + let l = app.session.execute("help.links", &serde_json::json!({})).unwrap_or_default(); + let s = |k: &str| l[k].as_str().unwrap_or("").to_string(); + link(app, ui, "globe", "ArtCraft website", "help.website", &s("website")); + link(app, ui, "external-link", "VectorCraft on getartcraft.com", "help.appPage", &s("appPage")); + link(app, ui, "git-branch", "Source code on GitHub", "help.github", &s("github")); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn link_clicks_open_the_right_urls() { + use std::sync::{Arc, Mutex}; + let opened = Arc::new(Mutex::new(vec![])); + let o = opened.clone(); + let services = crate::Services { open_url: Some(Box::new(move |u: &str| o.lock().unwrap().push(u.to_string()))), ..Default::default() }; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), services); + for id in ["help.discord", "help.website", "help.appPage", "help.github"] { + crate::menus::invoke(&mut app, id, serde_json::json!({})); + } + assert_eq!( + *opened.lock().unwrap(), + [ + "https://discord.gg/artcraft", + "https://getartcraft.com", + "https://getartcraft.com/apps/vectorcraft", + "https://github.com/storytold/vectorcraft" + ] + ); + // The control channel / MCP path returns the URL without opening a browser. + let v = app.run("help.discord", serde_json::json!({})).unwrap(); + assert_eq!(v["url"], "https://discord.gg/artcraft"); + assert_eq!(opened.lock().unwrap().len(), 4); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| links(&mut app, ui)); + out.textures_delta.clear(); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 09089d0a..497f79fd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -251,10 +251,26 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { .resizable(false) .open(&mut open) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .frame(egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::same(18))) .show(ctx, |ui| { - ui.label(egui::RichText::new("VectorCraft").font(theme::semibold(22.0))); - ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION"))); - ui.label("MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft."); + ui.set_width(380.0); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::hover()); + crate::brand::paint_mark(ui.painter(), r); + ui.vertical(|ui| { + ui.label(egui::RichText::new("VectorCraft").font(theme::semibold(22.0))); + ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION"))); + }); + }); + ui.add_space(12.0); + crate::community::links(app, ui); + ui.add_space(12.0); + ui.label( + egui::RichText::new( + "Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft.", + ) + .size(11.0), + ); }); app.ui.about = open; } diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index c6895244..64f2ceb7 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -182,6 +182,10 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("layout-grid", include_bytes!("../../../assets/icons/layout-grid.svg")), ("library", include_bytes!("../../../assets/icons/library.svg")), ("link", include_bytes!("../../../assets/icons/link.svg")), + ("external-link", include_bytes!("../../../assets/icons/external-link.svg")), + ("git-branch", include_bytes!("../../../assets/icons/git-branch.svg")), + ("globe", include_bytes!("../../../assets/icons/globe.svg")), + ("message-circle", include_bytes!("../../../assets/icons/message-circle.svg")), ("link-2-off", include_bytes!("../../../assets/icons/link-2-off.svg")), ("lock", include_bytes!("../../../assets/icons/lock.svg")), ("lock-open", include_bytes!("../../../assets/icons/lock-open.svg")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6625797e..d1c7fcd3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ mod brand; pub mod canvas; pub mod chrome; +pub mod community; pub mod control; pub mod cursors; pub mod dialogs; @@ -44,6 +45,9 @@ pub type WriteFn = Box Result<(), String>>; pub type Inbox = Arc)>>>; pub type DownloadFn = Box; +/// Opens a URL in the system browser. +pub type OpenUrlFn = Box; + /// Platform services injected by the host app (desktop or web). #[derive(Default)] pub struct Services { @@ -62,6 +66,8 @@ pub struct Services { /// Read the system clipboard's text (desktop). Without it, pasted text only arrives with /// egui's Paste event (web, and keyboard paste everywhere). pub clipboard_read: Option Option>>, + /// Open a URL in the system browser (desktop). Without it, egui opens it (a new tab on the web). + pub open_url: Option, } /// Cached canvas raster. @@ -131,6 +137,8 @@ pub struct VectorcraftApp { clipboard_out: Option, clipboard_published: Option, pub(crate) clipboard_in: Option, + /// A URL to open through egui next frame (when the host has no `open_url` service). + pending_url: Option, } impl VectorcraftApp { @@ -157,6 +165,7 @@ impl VectorcraftApp { clipboard_out: None, clipboard_published: None, clipboard_in: None, + pending_url: None, control_rx: None, pending_screenshots: vec![], queued_screenshots: vec![], @@ -244,6 +253,25 @@ impl VectorcraftApp { } } + /// Open a link in the browser (Help → Discord, website, GitHub…). + pub fn open_url(&mut self, url: &str) { + match self.services.open_url.as_mut() { + Some(open) => open(url), + None => self.pending_url = Some(url.to_string()), + } + self.ui.status = format!("Opened {url}"); + } + + /// Run a Help link command and open the URL it returns. + pub fn open_link(&mut self, id: &str) { + if let Ok(v) = self.run(id, serde_json::json!({})) + && let Some(u) = v["url"].as_str() + { + let u = u.to_string(); + self.open_url(&u); + } + } + /// Select a tool (also used by the toolbar and shortcuts). pub fn select_tool(&mut self, id: &str) { let v = self.view_info(); @@ -388,6 +416,9 @@ impl VectorcraftApp { } self.collect_screenshots(ctx); self.issue_screenshots(ctx); + if let Some(u) = self.pending_url.take() { + ctx.open_url(egui::OpenUrl::new_tab(u)); + } if let Some(t) = self.clipboard_out.take() { ctx.copy_text(t); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a792e1cd..75ff8d96 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -512,6 +512,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { "VectorCraft", vec![ c("About VectorCraft", "help.about"), + c("Join Our Discord", "help.discord"), Sep, c("Settings…", "edit.preferences"), Sep, @@ -1058,7 +1059,20 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { sub("Symbol Libraries", vec![todo("Arrows"), todo("Charts"), todo("Web Buttons and Bars")]), ], ), - ("Help", vec![c("Search Commands…", "help.commandPalette"), todos("VectorCraft Help…", "F1"), Sep, c("About VectorCraft", "help.about")]), + ( + "Help", + vec![ + c("Join Our Discord", "help.discord"), + c("ArtCraft Website", "help.website"), + c("VectorCraft on getartcraft.com", "help.appPage"), + c("VectorCraft on GitHub", "help.github"), + Sep, + c("Search Commands…", "help.commandPalette"), + todos("VectorCraft Help…", "F1"), + Sep, + c("About VectorCraft", "help.about"), + ], + ), ] } @@ -1186,6 +1200,11 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } return; } + // Help links: the command returns the URL; a menu click opens it (agents just get the URL). + if matches!(id, "help.discord" | "help.website" | "help.appPage" | "help.github") { + app.open_link(id); + return; + } if let Err(e) = app.run(id, p) { app.status(e); } else if matches!(id, "object.pattern.make" | "object.pattern.edit") {