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") {