collective

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

diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md--- a/ATTRIBUTION.md+++ b/ATTRIBUTION.md@@ -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) |diff --git a/NOTICE b/NOTICE--- a/NOTICE+++ b/NOTICE@@ -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.diff --git a/assets/icons/LICENSE-Lucide.txt b/assets/icons/LICENSE-Lucide.txtnew file mode 100644--- /dev/null+++ b/assets/icons/LICENSE-Lucide.txt@@ -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.diff --git a/assets/icons/README.md b/assets/icons/README.mdnew file mode 100644--- /dev/null+++ b/assets/icons/README.md@@ -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.diff --git a/assets/icons/download.svg b/assets/icons/download.svgnew file mode 100644--- /dev/null+++ b/assets/icons/download.svg@@ -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>diff --git a/assets/icons/eye-off.svg b/assets/icons/eye-off.svgnew file mode 100644--- /dev/null+++ b/assets/icons/eye-off.svg@@ -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>diff --git a/assets/icons/eye.svg b/assets/icons/eye.svgnew file mode 100644--- /dev/null+++ b/assets/icons/eye.svg@@ -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>diff --git a/assets/icons/file-text.svg b/assets/icons/file-text.svgnew file mode 100644--- /dev/null+++ b/assets/icons/file-text.svg@@ -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>diff --git a/assets/icons/folder-open.svg b/assets/icons/folder-open.svgnew file mode 100644--- /dev/null+++ b/assets/icons/folder-open.svg@@ -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>diff --git a/assets/icons/play.svg b/assets/icons/play.svgnew file mode 100644--- /dev/null+++ b/assets/icons/play.svg@@ -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>diff --git a/assets/icons/rotate-ccw.svg b/assets/icons/rotate-ccw.svgnew file mode 100644--- /dev/null+++ b/assets/icons/rotate-ccw.svg@@ -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>diff --git a/assets/icons/tag.svg b/assets/icons/tag.svgnew file mode 100644--- /dev/null+++ b/assets/icons/tag.svg@@ -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>diff --git a/assets/icons/trash-2.svg b/assets/icons/trash-2.svgnew file mode 100644--- /dev/null+++ b/assets/icons/trash-2.svg@@ -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>diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs--- a/crates/ui-egui/src/icons.rs+++ b/crates/ui-egui/src/icons.rs@@ -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")diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs--- a/crates/ui-egui/src/views.rs+++ b/crates/ui-egui/src/views.rs@@ -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 {diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs--- a/crates/ui-egui/src/widgets.rs+++ b/crates/ui-egui/src/widgets.rs@@ -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());