Browse / github.com/storytold/craft-launcher / patch
An icon at the start of every menu row
An icon at the start of every menu row ## What Every row of an app's `...` menu now starts with a small icon: | Row | Icon | | --- | --- | | Release notes | `file-text` | | Download again, and each per-platform download | `download` | | Open | `play` | | Show in Explorer / Reveal in Finder / Open containing folder | `folder-open` | | Reinstall | `rotate-ccw` | | Uninstall… | `trash-2`, tinted the theme's red | | Website | `globe` (already in the repo) | | GitHub repository | `github` (already in the repo) | | All releases | `tag` | | Hide App / Unhide App | `eye-off` / `eye` | ## Why The menu is a column of similar-looking word
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- EternalShade3D (imported from pull request #642, open)
- Base
7c7271e5f62975326d0add2dcf31f03096be4189- Size
- +272 −13 in 16 files
- Review
- pending
- Shared
- 2026-10-28
- Id
29e4a9859d3d732df5d9fecc
How it went
No other operator has reported using it yet.
Diff
raw · ATTRIBUTION.md NOTICE assets/icons/LICENSE-Lucide.txt assets/icons/README.md assets/icons/download.svg assets/icons/eye-off.svg assets/icons/eye.svg assets/icons/file-text.svg assets/icons/folder-open.svg assets/icons/play.svg assets/icons/rotate-ccw.svg assets/icons/tag.svg assets/icons/trash-2.svg crates/ui-egui/src/icons.rs crates/ui-egui/src/views.rs crates/ui-egui/src/widgets.rs
@@ -15,6 +15,7 @@ next to them. | `assets/social/linkedin.svg` | LinkedIn logo, linking to ArtCraft's company page | Fonticons, Inc. ([Font Awesome Free](https://fontawesome.com) 6.7.2) | Font Awesome Free `svgs/brands/linkedin.svg`; white fill and a square view box, see [`assets/social/README.md`](assets/social/README.md) | CC BY 4.0. The logo is a trademark of LinkedIn Corporation. | | `assets/social/x.svg`, `youtube.svg`, `tiktok.svg`, `instagram.svg`, `discord.svg` | X, YouTube, TikTok, Instagram and Discord logos, linking to ArtCraft's accounts | [Simple Icons](https://simpleicons.org/) contributors | Simple Icons, `fill` set to white for tinting; see [`assets/social/README.md`](assets/social/README.md) | CC0 1.0. The logos are trademarks of X Corp., Google LLC, TikTok / ByteDance, Meta Platforms, Inc. and Discord Inc. | | `assets/social/github.svg`, `assets/social/globe.svg` | GitHub logo (linking to ArtCraft's GitHub) and a globe (linking to ArtCraft's website) | [Simple Icons](https://simpleicons.org/) contributors; [Lucide](https://lucide.dev) contributors | Simple Icons `github.svg` and Lucide `globe.svg` (lucide-static 0.468.0), white for tinting; see [`assets/social/README.md`](assets/social/README.md) | CC0 1.0 (GitHub logo; a trademark of GitHub, Inc.); ISC (globe) |+| `assets/icons/*.svg` (nine files) | The icons at the start of each row in an app's `...` menu: release notes, download, open, show in folder, reinstall, uninstall, all releases, hide and unhide | [Lucide](https://lucide.dev) contributors, with portions held by Cole Bemis 2013-2022 as part of [Feather](https://feathericons.com) (MIT) | Lucide 0.468.0, taken from `lucide-icons/lucide` at tag `0.468.0`; `stroke` and, in `tag.svg`, the dot's `fill` set to white for tinting, see [`assets/icons/README.md`](assets/icons/README.md) | ISC; the notice is `assets/icons/LICENSE-Lucide.txt` and the `NOTICE` file | | `docs/screenshots/artcraft-launcher-grid.png`, `artcraft-launcher-table.png` | Screenshots of ArtCraft Launcher (macOS: grid view in dark theme, table view in light theme), used in the README | ArtCraft Launcher contributors | Captured from the app | MIT OR Apache-2.0 for the screenshot itself; the ArtCraft logo in it stays an ArtCraft trademark ([`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt)) and the app icons in it keep their own licenses (above) | | (not bundled) the pictures on each app's details page | Screenshots of the Craft Apps, fetched when a page is opened and kept in the launcher's cache folder | The apps' contributors | [storytold/craft-launcher-assets](https://github.com/storytold/craft-launcher-assets), pinned to a tag (`SCREENSHOTS_REV` in `crates/core/src/details.rs`); each picture's source and license are listed in that repository's `ATTRIBUTION.md` | Each app's own license for the interface (MIT OR Apache-2.0); the work shown in them is public domain or made in the app, as that file says | | `docs/brand/artcraft-logo.svg`, `artcraft-logo-white.svg` | ArtCraft wordmark, shown in the window header | ArtCraft Team | Supplied brand files, unmodified (same as PhotoCraft's `docs/brand/`) | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |@@ -18,6 +18,7 @@ list (author, source, license) is in ATTRIBUTION.md. Social service icons (assets/social/*.svg) — Simple Icons, CC0 1.0 (X, YouTube, TikTok, Instagram), and Font Awesome Free 6.7.2 by Fonticons, Inc., CC BY 4.0 (LinkedIn; https://fontawesome.com/license/free). The logos are trademarks of their respective owners and are used only to link to ArtCraft's accounts.+Menu icons (assets/icons/*.svg) - Lucide 0.468.0, ISC. Copyright (c) for portions of Lucide is held by Cole Bemis 2013-2022 as part of Feather (MIT); all other copyright for Lucide is held by Lucide Contributors 2022. assets/icons/LICENSE-Lucide.txt carries the Lucide ISC notice and, for the icons Lucide derives from Feather (such as trash-2.svg), Feather's full MIT notice as well. assets/social/globe.svg comes from the same set under the same licence, so that one file covers it too. Inter font (assets/fonts/Inter-Variable.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. Archivo font (assets/fonts/Archivo-Variable.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Archivo.txt. Geist Mono font (assets/fonts/GeistMono-Variable.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-GeistMono.txt.new file mode 100644@@ -0,0 +1,42 @@+ISC License++Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.++Permission to use, copy, modify, and/or distribute this software for any+purpose with or without fee is hereby granted, provided that the above+copyright notice and this permission notice appear in all copies.++THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.++---++Portions of Lucide, including `trash-2.svg`, are derived from Feather, which is+released under the MIT License below.++The MIT License (MIT)++Copyright (c) 2013-2023 Cole Bemis++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.new file mode 100644@@ -0,0 +1,29 @@+# Menu icons++The small icon at the start of every row in an app's `...` menu. All from+[Lucide](https://lucide.dev) 0.468.0, ISC licensed, on its own 24 px grid.++| File | Lucide name | Row it marks |+| --- | --- | --- |+| `file-text.svg` | `file-text` | Release notes |+| `download.svg` | `download` | Download again, and the per-platform download list |+| `play.svg` | `play` | Open |+| `folder-open.svg` | `folder-open` | Show in Explorer / Reveal in Finder / Open containing folder |+| `rotate-ccw.svg` | `rotate-ccw` | Reinstall |+| `trash-2.svg` | `trash-2` | Uninstall |+| `tag.svg` | `tag` | All releases |+| `eye-off.svg` | `eye-off` | Hide App |+| `eye.svg` | `eye` | Unhide App |++`globe.svg` (Website) and `github.svg` (GitHub repository) live in `assets/social/`.++The `stroke="currentColor"` Lucide ships was changed to `stroke="#FFFFFF"`, the same trick+`assets/social` uses, so the app can tint each icon to its own colour: muted grey on most rows,+red on Uninstall. `tag.svg` carries one more of the same: the dot's `fill` is white too, or the+tint would leave it black.++Lucide is ISC. Its notice, which the licence asks to travel with every copy, is+`LICENSE-Lucide.txt` beside these files and is also listed in the repository's `NOTICE`.++Nothing here draws a character: the icons are images, so no font has to carry a symbol. The menu+row paints the icon and then the label, and a screen reader is told only the label.new file mode 100644@@ -0,0 +1,15 @@+<svg+ xmlns="http://www.w3.org/2000/svg"+ width="24"+ height="24"+ viewBox="0 0 24 24"+ fill="none"+ stroke="#FFFFFF"+ stroke-width="2"+ stroke-linecap="round"+ stroke-linejoin="round"+>+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />+ <polyline points="7 10 12 15 17 10" />+ <line x1="12" x2="12" y1="15" y2="3" />+</svg>new file mode 100644@@ -0,0 +1,16 @@+<svg+ xmlns="http://www.w3.org/2000/svg"+ width="24"+ height="24"+ viewBox="0 0 24 24"+ fill="none"+ stroke="#FFFFFF"+ stroke-width="2"+ stroke-linecap="round"+ stroke-linejoin="round"+>+ <path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />+ <path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />+ <path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />+ <path d="m2 2 20 20" />+</svg>new file mode 100644@@ -0,0 +1,14 @@+<svg+ xmlns="http://www.w3.org/2000/svg"+ width="24"+ height="24"+ viewBox="0 0 24 24"+ fill="none"+ stroke="#FFFFFF"+ stroke-width="2"+ stroke-linecap="round"+ stroke-linejoin="round"+>+ <path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />+ <circle cx="12" cy="12" r="3" />+</svg>new file mode 100644@@ -0,0 +1,17 @@+<svg+ xmlns="http://www.w3.org/2000/svg"+ width="24"+ height="24"+ viewBox="0 0 24 24"+ fill="none"+ stroke="#FFFFFF"+ stroke-width="2"+ stroke-linecap="round"+ stroke-linejoin="round"+>+ <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />+ <path d="M14 2v4a2 2 0 0 0 2 2h4" />+ <path d="M10 9H8" />+ <path d="M16 13H8" />+ <path d="M16 17H8" />+</svg>new file mode 100644@@ -0,0 +1,13 @@+<svg+ xmlns="http://www.w3.org/2000/svg"+ width="24"+ height="24"+ viewBox="0 0 24 24"+ fill="none"+ stroke="#FFFFFF"+ stroke-width="2"+ stroke-linecap="round"+ stroke-linejoin="round"+>+ <path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />+</svg>new file mode 100644@@ -0,0 +1,13 @@+<svg+ xmlns="http://www.w3.org/2000/svg"+ width="24"+ height="24"+ viewBox="0 0 24 24"+ fill="none"+ stroke="#FFFFFF"+ stroke-width="2"+ stroke-linecap="round"+ stroke-linejoin="round"+>+ <polygon points="6 3 20 12 6 21 6 3" />+</svg>new file mode 100644@@ -0,0 +1,14 @@+<svg+ xmlns="http://www.w3.org/2000/svg"+ width="24"+ height="24"+ viewBox="0 0 24 24"+ fill="none"+ stroke="#FFFFFF"+ stroke-width="2"+ stroke-linecap="round"+ stroke-linejoin="round"+>+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />+ <path d="M3 3v5h5" />+</svg>new file mode 100644@@ -0,0 +1,14 @@+<svg+ xmlns="http://www.w3.org/2000/svg"+ width="24"+ height="24"+ viewBox="0 0 24 24"+ fill="none"+ stroke="#FFFFFF"+ stroke-width="2"+ stroke-linecap="round"+ stroke-linejoin="round"+>+ <path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" />+ <circle cx="7.5" cy="7.5" r=".5" fill="#FFFFFF" />+</svg>new file mode 100644@@ -0,0 +1,17 @@+<svg+ xmlns="http://www.w3.org/2000/svg"+ width="24"+ height="24"+ viewBox="0 0 24 24"+ fill="none"+ stroke="#FFFFFF"+ stroke-width="2"+ stroke-linecap="round"+ stroke-linejoin="round"+>+ <path d="M3 6h18" />+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />+ <line x1="10" x2="10" y1="11" y2="17" />+ <line x1="14" x2="14" y1="11" y2="17" />+</svg>@@ -86,6 +86,25 @@ pub fn social(service: &str) -> Option<ImageSource<'static>> { }) } +/// The small icon a menu row starts with, white so the row can tint it. Lucide, ISC, on its own+/// 24 px grid; `assets/icons/README.md` and `ATTRIBUTION.md` say where each one came from.+pub fn menu(name: &str) -> Option<ImageSource<'static>> {+ Some(match name {+ "notes" => egui::include_image!("../../../assets/icons/file-text.svg"),+ "download" => egui::include_image!("../../../assets/icons/download.svg"),+ "open" => egui::include_image!("../../../assets/icons/play.svg"),+ "reveal" => egui::include_image!("../../../assets/icons/folder-open.svg"),+ "reinstall" => egui::include_image!("../../../assets/icons/rotate-ccw.svg"),+ "uninstall" => egui::include_image!("../../../assets/icons/trash-2.svg"),+ "releases" => egui::include_image!("../../../assets/icons/tag.svg"),+ "hide" => egui::include_image!("../../../assets/icons/eye-off.svg"),+ "unhide" => egui::include_image!("../../../assets/icons/eye.svg"),+ "website" => egui::include_image!("../../../assets/social/globe.svg"),+ "github" => egui::include_image!("../../../assets/social/github.svg"),+ _ => return None,+ })+}+ /// The ArtCraft "A" mark in blue, as supplied: the launcher's app icon and getartcraft.com's favicon. pub fn artcraft_mark() -> ImageSource<'static> { egui::include_image!("../../../docs/brand/artcraft-mark.svg")@@ -864,33 +864,33 @@ impl LauncherUi { let manage = self.caps.manage_apps; let reveal = self.caps.reveal_label; widgets::more_menu(ui, |ui| {- if entry.release().is_some() && ui.button(tl!("Release notes")).clicked() {+ if entry.release().is_some() && widgets::menu_item(ui, "notes", tl!("Release notes"), None).clicked() { self.dialog = Some(Dialog::Notes(id.clone())); ui.close(); } // Only where Install would download: an up-to-date app would be reinstalled. let can_download_again = manage && entry.ready && entry.paused.is_none() && matches!(status, AppStatus::Available | AppStatus::UpdateAvailable);- if can_download_again && ui.button(tl!("Download again")).clicked() {+ if can_download_again && widgets::menu_item(ui, "download", tl!("Download again"), None).clicked() { self.send(Command::Cancel { app: id.clone() }); self.install_app(&id); ui.close(); } if manage && status.is_installed() {- if ui.button(tl!("Open")).clicked() {+ if widgets::menu_item(ui, "open", tl!("Open"), None).clicked() { self.launch_app(&id); ui.close(); }- if ui.button(t(reveal)).clicked() {+ if widgets::menu_item(ui, "reveal", t(reveal), None).clicked() { self.send(Command::Reveal { app: id.clone() }); ui.close(); }- if *status == AppStatus::UpToDate && ui.button(tl!("Reinstall")).clicked() {+ if *status == AppStatus::UpToDate && widgets::menu_item(ui, "reinstall", tl!("Reinstall"), None).clicked() { self.install_app(&id); ui.close(); } ui.separator();- if ui.button(RichText::new(tl!("Uninstall…")).color(Tokens::get(ui.ctx()).bad)).clicked() {+ if widgets::menu_item(ui, "uninstall", tl!("Uninstall…"), Some(Tokens::get(ui.ctx()).bad)).clicked() { self.dialog = Some(Dialog::ConfirmUninstall(id.clone())); ui.close(); }@@ -908,7 +908,7 @@ impl LauncherUi { if !options.is_empty() { ui.separator(); for (label, url) in options {- if ui.button(label).clicked() {+ if widgets::menu_item(ui, "download", &label, None).clicked() { ui.ctx().open_url(egui::OpenUrl::new_tab(url)); ui.close(); }@@ -918,19 +918,21 @@ impl LauncherUi { // The drawer shows the links as buttons, so its menu leaves them out (but keeps Hide). if links { ui.separator();- let link = |ui: &mut Ui, label: &str, url: String| {- if ui.button(label).clicked() {+ let link = |ui: &mut Ui, icon: &str, label: &str, url: String| {+ if widgets::menu_item(ui, icon, label, None).clicked() { ui.ctx().open_url(egui::OpenUrl::new_tab(url)); ui.close(); } };- link(ui, tl!("Website"), entry.app.website());- link(ui, tl!("GitHub repository"), entry.app.repo_url());- link(ui, tl!("All releases"), entry.app.releases_url());+ link(ui, "website", tl!("Website"), entry.app.website());+ link(ui, "github", tl!("GitHub repository"), entry.app.repo_url());+ link(ui, "releases", tl!("All releases"), entry.app.releases_url()); } ui.separator(); let hidden = self.settings.hidden.contains(entry.app.id);- if ui.button(if hidden { tl!("Unhide App") } else { tl!("Hide App") }).clicked() {+ if widgets::menu_item(ui, if hidden { "unhide" } else { "hide" }, if hidden { tl!("Unhide App") } else { tl!("Hide App") }, None)+ .clicked()+ { if hidden { self.settings.hidden.remove(entry.app.id); if self.settings.hidden.is_empty() && self.settings.filter == Filter::Hidden {@@ -478,6 +478,38 @@ pub fn glyph_button(ui: &mut Ui, glyph: Glyph, tip: &str) -> Response { resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand) } +/// A menu row: an icon, the label, and a full-width hover. A screen reader hears only `label`, so+/// the icon is decoration and anything looking for the row still finds it by its words.+pub fn menu_item(ui: &mut Ui, icon: &str, label: &str, colour: Option<Color32>) -> Response {+ let t = Tokens::get(ui.ctx());+ let height = 26.0;+ let ink = colour.unwrap_or(t.ink);+ let font = egui::TextStyle::Body.resolve(ui.style());+ // The row must be wide enough for the longest label: a translated one can be much longer than+ // the English it was written from, and a label that runs past the row cannot be clicked.+ let label_width = ui.painter().layout_no_wrap(label.to_owned(), font.clone(), ink).size().x;+ let needed = 28.0 + label_width.ceil() + 16.0;+ // The row is exactly as wide as it needs to be, never as wide as the menu could be: asking for+ // `available_width` here inflates the popup, because a menu is measured before it has a width.+ // 200 is the floor `more_menu` already asks for, so short labels keep the old look.+ let width = needed.max(200.0);+ let (rect, response) = ui.allocate_exact_size(vec2(width, height), Sense::click());+ response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));+ if response.hovered() || response.has_focus() {+ // The hover fills the row and runs on to the edge of the menu, so it does not stop short+ // of the panel and look like a second, smaller button.+ let span = egui::Rect::from_min_max(egui::pos2(ui.max_rect().left(), rect.top()), egui::pos2(ui.max_rect().right(), rect.bottom()));+ ui.painter().rect_filled(span, CornerRadius::ZERO, t.sunken);+ }+ let middle = egui::pos2(rect.left() + 14.0, rect.center().y);+ if let Some(source) = crate::icons::menu(icon) {+ let tint = if colour.is_some() { ink } else { t.muted };+ egui::Image::new(source).fit_to_exact_size(vec2(14.0, 14.0)).tint(tint).paint_at(ui, egui::Rect::from_center_size(middle, vec2(14.0, 14.0)));+ }+ ui.painter().text(egui::pos2(rect.left() + 28.0, rect.center().y), egui::Align2::LEFT_CENTER, label, font, ink);+ response+}+ /// The "…" button that opens an app's menu: an outlined square like [`glyph_button`]. pub fn more_menu<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) { let t = Tokens::get(ui.ctx());