Web: installable as an app, with Install in the File menu (#1233)
The web build ships a manifest so the browser can install it as an app, and offers Install in the File menu while the browser allows it.
This commit is contained in:
1 parent
20a7dd8764
commit
e8c340c59d
22 files changed
+531
-45
No files matched your search
@@ -177,7 +177,6 @@ const ALLOWED_LITERALS: &[(&str, usize, &str)] = &[
|
||||
("sketch_text.rs", 1, "white text on the selection chip"),
|
||||
("titlebar.rs", 4, "the window close button's red hover (the system's convention), white hovers and ink"),
|
||||
("toolbar.rs", 3, "white hover overlays and the version text"),
|
||||
("update.rs", 1, "text laid out only to measure its width, never drawn"),
|
||||
];
|
||||
|
||||
const COLOUR_LITERALS: &[&str] = &[
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Install as an app (the browser build). A browser that can install the web app (a PWA: its own
|
||||
//! window and launcher icon, working offline) offers it to the page; while it does, the File menu
|
||||
//! has Install app…, which shows the browser's own install prompt. An installed app, or the app
|
||||
//! running installed, gets no offer, so the item is gone. The host keeps the offer
|
||||
//! (`Services::install_app`); desktop builds have none.
|
||||
//!
|
||||
//! Safari installs web apps too (Add to Home Screen on iPhone and iPad, Add to Dock on a Mac) but
|
||||
//! offers no prompt a page can show. There the item opens a window with the steps.
|
||||
|
||||
use egui::RichText;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// How the browser installs the app.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Offer {
|
||||
/// Its own install prompt (`InstallApp::prompt`).
|
||||
Prompt,
|
||||
/// No prompt a page can show: SolidCraft shows these steps (Safari).
|
||||
Steps(Steps),
|
||||
}
|
||||
|
||||
/// Safari's steps for installing a web app.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Steps {
|
||||
/// iPhone and iPad: Share ▸ Add to Home Screen.
|
||||
HomeScreen,
|
||||
/// A Mac (Safari 17 or later): File ▸ Add to Dock.
|
||||
Dock,
|
||||
}
|
||||
|
||||
impl Steps {
|
||||
/// The steps' message ids, in order, and the one that says what follows.
|
||||
fn messages(self) -> (&'static [&'static str], &'static str) {
|
||||
match self {
|
||||
Steps::HomeScreen => (&["install.home_screen_1", "install.home_screen_2", "install.home_screen_3"], "install.home_screen_after"),
|
||||
Steps::Dock => (&["install.dock_1", "install.dock_2"], "install.dock_after"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser's offer to install the app, kept by the host.
|
||||
pub struct InstallApp {
|
||||
/// What the browser offers now; None: nothing (installed, or it can't install).
|
||||
pub offer: Box<dyn Fn() -> Option<Offer>>,
|
||||
/// Show the browser's install prompt (`Offer::Prompt`). Browsers only show it right after a
|
||||
/// click.
|
||||
pub prompt: Box<dyn Fn()>,
|
||||
}
|
||||
|
||||
/// Safari's steps for this browser, from its `navigator.userAgent`, `navigator.vendor` and
|
||||
/// `navigator.maxTouchPoints`; None: not Safari, or a Safari that can't install web apps.
|
||||
/// Other iOS browsers are WebKit too (vendor Apple) but have no `Version/` and name themselves.
|
||||
pub fn safari_steps(user_agent: &str, vendor: &str, touch_points: i32) -> Option<Steps> {
|
||||
let other = ["CriOS/", "FxiOS/", "EdgiOS/", "OPiOS/", "OPT/"];
|
||||
let safari = vendor.starts_with("Apple") && user_agent.contains("Safari/") && !other.iter().any(|o| user_agent.contains(o));
|
||||
if !safari {
|
||||
return None;
|
||||
}
|
||||
let version = user_agent.split("Version/").nth(1)?;
|
||||
// iPadOS Safari presents itself as a Mac; only its touch screen tells them apart.
|
||||
let mobile = ["iPhone", "iPad", "iPod"].iter().any(|d| user_agent.contains(d));
|
||||
if mobile || (user_agent.contains("Macintosh") && touch_points > 1) {
|
||||
return Some(Steps::HomeScreen);
|
||||
}
|
||||
let major: u32 = version.split(|c: char| !c.is_ascii_digit()).next()?.parse().ok()?;
|
||||
(user_agent.contains("Macintosh") && major >= 17).then_some(Steps::Dock)
|
||||
}
|
||||
|
||||
/// What the browser offers now (None: nothing, so File has no Install app… item).
|
||||
pub fn offered(app: &SolveApp) -> Option<Offer> {
|
||||
app.services.install_app.as_ref().and_then(|i| (i.offer)())
|
||||
}
|
||||
|
||||
/// File ▸ Install app…: the browser's own prompt (browsers only show it right after a click), or
|
||||
/// Safari's steps.
|
||||
pub fn start(app: &mut SolveApp, offer: Offer) {
|
||||
match offer {
|
||||
Offer::Prompt => {
|
||||
if let Some(install) = &app.services.install_app {
|
||||
(install.prompt)();
|
||||
}
|
||||
}
|
||||
Offer::Steps(steps) => app.install_steps = Some(steps),
|
||||
}
|
||||
}
|
||||
|
||||
/// The window with Safari's steps, while open.
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(steps) = app.install_steps else { return };
|
||||
let t = Tokens::get();
|
||||
let (lines, after) = steps.messages();
|
||||
let mut open = true;
|
||||
let mut done = false;
|
||||
crate::frame::window(ctx, crate::i18n::tr("install.title"), crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_install_steps"))
|
||||
.open(&mut open)
|
||||
.resizable(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.label(crate::i18n::tr("install.steps_intro"));
|
||||
ui.add_space(4.0);
|
||||
for (i, id) in lines.iter().enumerate() {
|
||||
ui.label(format!("{}. {}", i + 1, crate::i18n::tr(id)));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.add(egui::Label::new(RichText::new(crate::i18n::tr(after)).color(t.text_dim)).wrap());
|
||||
ui.add_space(6.0);
|
||||
if ui.button(crate::i18n::tr("install.done")).clicked() {
|
||||
done = true;
|
||||
}
|
||||
});
|
||||
if !open || done {
|
||||
app.install_steps = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::scenario::{Harness, handle_at};
|
||||
|
||||
const ITEM: &str = "install.menu";
|
||||
|
||||
fn host(h: &mut Harness, offer: Rc<Cell<Option<Offer>>>, prompts: Rc<Cell<u32>>) {
|
||||
h.app.services.install_app =
|
||||
Some(InstallApp { offer: Box::new(move || offer.get()), prompt: Box::new(move || prompts.set(prompts.get() + 1)) });
|
||||
}
|
||||
|
||||
/// Open the File menu and look for Install app….
|
||||
fn file_menu_item(h: &mut Harness) -> bool {
|
||||
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu"), true));
|
||||
h.frames(2);
|
||||
h.widget(crate::i18n::tr(ITEM)).is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_is_in_the_file_menu_only_while_offered_and_prompts_on_click() {
|
||||
let offer = Rc::new(Cell::new(None));
|
||||
let prompts = Rc::new(Cell::new(0));
|
||||
let mut h = Harness::new();
|
||||
host(&mut h, offer.clone(), prompts.clone());
|
||||
assert!(!file_menu_item(&mut h), "no offer, no menu item");
|
||||
|
||||
offer.set(Some(Offer::Prompt));
|
||||
assert!(file_menu_item(&mut h), "offered: File has Install app…");
|
||||
assert_eq!(h.step(&json!({ "widget": crate::i18n::tr(ITEM) })), Ok(()));
|
||||
assert_eq!(prompts.get(), 1, "a click shows the browser's prompt");
|
||||
assert_eq!(h.app.install_steps, None, "no steps window for a browser with a prompt");
|
||||
|
||||
offer.set(None);
|
||||
assert!(!file_menu_item(&mut h), "installed: the item is gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_is_not_in_the_application_bar() {
|
||||
let mut h = Harness::new();
|
||||
host(&mut h, Rc::new(Cell::new(Some(Offer::Prompt))), Rc::new(Cell::new(0)));
|
||||
h.frames(2);
|
||||
assert!(handle_at("titlebar:install").is_none());
|
||||
assert!(h.widget(crate::i18n::tr(ITEM)).is_none(), "only in the File menu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safari_shows_its_steps() {
|
||||
let prompts = Rc::new(Cell::new(0));
|
||||
let mut h = Harness::new();
|
||||
host(&mut h, Rc::new(Cell::new(Some(Offer::Steps(Steps::Dock)))), prompts.clone());
|
||||
assert!(file_menu_item(&mut h));
|
||||
assert_eq!(h.step(&json!({ "widget": crate::i18n::tr(ITEM) })), Ok(()));
|
||||
assert_eq!(h.app.install_steps, Some(Steps::Dock), "a click opens the steps");
|
||||
assert_eq!(prompts.get(), 0, "Safari has no prompt");
|
||||
h.frames(2);
|
||||
assert_eq!(h.step(&json!({ "widget": crate::i18n::tr("install.done") })), Ok(()));
|
||||
assert_eq!(h.app.install_steps, None, "Done closes the steps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_install_item_without_the_host_service() {
|
||||
let mut h = Harness::new();
|
||||
assert!(!file_menu_item(&mut h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safari_steps_by_device() {
|
||||
const APPLE: &str = "Apple Computer, Inc.";
|
||||
let iphone =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1";
|
||||
let mac =
|
||||
|v: &str| format!("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/{v} Safari/605.1.15");
|
||||
assert_eq!(safari_steps(iphone, APPLE, 5), Some(Steps::HomeScreen));
|
||||
assert_eq!(safari_steps(&mac("18.5"), APPLE, 5), Some(Steps::HomeScreen), "an iPad presents itself as a Mac");
|
||||
assert_eq!(safari_steps(&mac("18.5"), APPLE, 0), Some(Steps::Dock));
|
||||
assert_eq!(safari_steps(&mac("17.0"), APPLE, 0), Some(Steps::Dock));
|
||||
assert_eq!(safari_steps(&mac("16.6"), APPLE, 0), None, "Add to Dock came with Safari 17");
|
||||
let chrome_ios = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.156 Mobile/15E148 Safari/604.1";
|
||||
assert_eq!(safari_steps(chrome_ios, APPLE, 5), None, "Chrome on iOS isn't Safari");
|
||||
let chrome = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
|
||||
assert_eq!(safari_steps(chrome, "Google Inc.", 0), None, "Chrome prompts instead");
|
||||
let firefox = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0";
|
||||
assert_eq!(safari_steps(firefox, "", 0), None);
|
||||
assert_eq!(safari_steps("", APPLE, 0), None, "hostile input is just not Safari");
|
||||
assert_eq!(safari_steps("Safari/ Version/99999999999999999999 Macintosh", APPLE, 0), None);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ mod i18n_menu_tests;
|
||||
mod i18n_ui_tests;
|
||||
pub mod icons;
|
||||
pub mod inference;
|
||||
pub mod install;
|
||||
pub mod issue_report;
|
||||
pub mod keymap;
|
||||
#[cfg(test)]
|
||||
@@ -244,6 +245,8 @@ pub struct Services {
|
||||
pub http_get: Option<update::HttpGet>,
|
||||
/// HTTPS download to a file, with progress, for one-click updates (`update::install`).
|
||||
pub http_download: Option<update::install::HttpDownload>,
|
||||
/// The browser's offer to install the web app (`install`); None: no Install button (desktop).
|
||||
pub install_app: Option<install::InstallApp>,
|
||||
}
|
||||
|
||||
pub struct SolveApp {
|
||||
@@ -286,6 +289,8 @@ pub struct SolveApp {
|
||||
pub crashes: crash_ui::CrashUi,
|
||||
/// The update check and its window (`update`).
|
||||
pub updater: update::Updater,
|
||||
/// Safari's steps for installing the web app, while their window is open (`install`).
|
||||
pub install_steps: Option<install::Steps>,
|
||||
/// Toolbar buttons the user pinned, removed or reordered.
|
||||
pub toolbar_custom: toolbar_custom::ToolbarCustom,
|
||||
pub sketch_choices: sketch_families::Choices,
|
||||
@@ -394,6 +399,7 @@ impl SolveApp {
|
||||
issue_report: issue_report::ReportState::default(),
|
||||
crashes: crash_ui::CrashUi::default(),
|
||||
updater: update::Updater::default(),
|
||||
install_steps: None,
|
||||
toolbar_custom: toolbar_custom::ToolbarCustom::default(),
|
||||
sketch_choices: sketch_families::Choices::default(),
|
||||
preferences: prefs::Prefs::default(),
|
||||
@@ -1261,6 +1267,7 @@ impl SolveApp {
|
||||
file_load::show(self, ui.ctx());
|
||||
documents::prompt(self, ui.ctx());
|
||||
update::show(self, ui.ctx());
|
||||
install::show(self, ui.ctx());
|
||||
if self.home.open {
|
||||
home::show(self, ui);
|
||||
if self.home.open {
|
||||
|
||||
@@ -166,6 +166,34 @@ fn report_rect(bar: Rect, captions: f32, tabs_start: f32, label_width: f32) -> (
|
||||
(Rect::from_min_size(pos2(right - width, bar.top() + 4.0), vec2(width, 26.0)), labeled)
|
||||
}
|
||||
|
||||
/// An application-bar button for something on offer (Update, Install) at the right of `span` (the
|
||||
/// bar's free space): an accent-framed `icon`, labeled with `text` when `labeled` and it fits.
|
||||
/// None when even the icon alone doesn't fit: it never covers the bar's other buttons. `handle`
|
||||
/// names it for scenarios.
|
||||
pub(crate) fn offer_button(ui: &mut egui::Ui, span: Rect, labeled: bool, text: &str, icon: &str, handle: &str) -> Option<(Rect, egui::Response)> {
|
||||
let t = Tokens::get();
|
||||
let full = 30.0 + ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), FontId::proportional(13.0), t.app_bar_text).size().x) + 10.0;
|
||||
let labeled = labeled && full <= span.width();
|
||||
let width = if labeled { full } else { 28.0 };
|
||||
if width > span.width() {
|
||||
return None;
|
||||
}
|
||||
let rect = Rect::from_min_size(pos2(span.right() - width, span.top()), vec2(width, 26.0));
|
||||
let response = ui.interact(rect, ui.id().with(("sc_offer_button", handle)), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text));
|
||||
crate::scenario::publish_handle(handle, rect.center());
|
||||
crate::scenario::publish_rect(handle, rect);
|
||||
let fill = if response.hovered() || response.has_focus() { t.hover } else { t.app_bar };
|
||||
ui.painter().rect_filled(rect, 4.0, fill);
|
||||
ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
let glyph = Rect::from_center_size(pos2(rect.left() + 14.0, rect.center().y), vec2(18.0, 18.0));
|
||||
icons::paint(ui.painter(), glyph, icon, t.accent, t.accent, t.app_bar);
|
||||
if labeled {
|
||||
ui.painter().text(pos2(rect.left() + 29.0, rect.center().y), Align2::LEFT_CENTER, text, FontId::proportional(13.0), t.app_bar_text);
|
||||
}
|
||||
Some((rect, response))
|
||||
}
|
||||
|
||||
/// Where the Report a bug text starts, from the button's left edge.
|
||||
const REPORT_TEXT_X: f32 = 29.0;
|
||||
|
||||
@@ -206,6 +234,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, idle, &label));
|
||||
crate::scenario::publish_handle("appbar:file", br.center());
|
||||
crate::scenario::publish_handle("appbar:folder", br.center());
|
||||
crate::scenario::publish_rect("appbar:file", br);
|
||||
let open = ui.ctx().data(|d| d.get_temp::<bool>(egui::Id::new("sc_file_menu")).unwrap_or(false));
|
||||
if idle && (resp.hovered() || open) {
|
||||
ui.painter().rect_filled(br, 4.0, t.app_bar_hover);
|
||||
@@ -222,6 +251,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let resp = ui.interact(br, ui.id().with(("ab", icon)), if enabled { Sense::click() } else { Sense::hover() });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip));
|
||||
crate::scenario::publish_handle(&format!("appbar:{icon}"), br.center());
|
||||
crate::scenario::publish_rect(&format!("appbar:{icon}"), br);
|
||||
if enabled && resp.hovered() {
|
||||
ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30));
|
||||
}
|
||||
@@ -277,8 +307,10 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
d.insert_temp(egui::Id::new("sc_help_anchor"), at);
|
||||
});
|
||||
}
|
||||
// A newer release: its Update button, left of Report a bug (`update`).
|
||||
let update = crate::update::button(app, ui, report.left() - 6.0, report.top(), report_label);
|
||||
// A newer release: its Update button, left of Report a bug (`update`), in the space up to
|
||||
// the bar's own buttons on the left (File, Undo and Redo, which end before `x`).
|
||||
let span = Rect::from_min_max(pos2(x, report.top()), pos2(report.left() - 6.0, report.bottom()));
|
||||
let update = crate::update::button(app, ui, span, report_label);
|
||||
let tabs_end = update.map_or(report.left(), |u| u.left()) - 8.0;
|
||||
// Narrow title bars can fit only part of the tab strip. Its hit regions must not
|
||||
// extend into the persistent report and window controls.
|
||||
@@ -289,6 +321,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let response = ui.interact(report, ui.id().with("sc_report_button"), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), report_text));
|
||||
crate::scenario::publish_handle("titlebar:report", report.center());
|
||||
crate::scenario::publish_rect("titlebar:report", report);
|
||||
ui.painter().rect_filled(report, 4.0, if response.hovered() || response.has_focus() { t.hover } else { t.app_bar });
|
||||
ui.painter().rect_stroke(report, 4.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let icon = Rect::from_center_size(pos2(report.left() + 14.0, report.center().y), vec2(18.0, 18.0));
|
||||
@@ -433,6 +466,16 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
close = true;
|
||||
}
|
||||
ui.separator();
|
||||
// The browser can install SolidCraft as an app (`install`): only while it offers to.
|
||||
if let Some(offer) = crate::install::offered(app)
|
||||
&& ui
|
||||
.add(egui::Button::new(crate::i18n::tr("install.menu")).min_size(vec2(180.0, 22.0)))
|
||||
.on_hover_text(crate::i18n::tr("install.button_tip"))
|
||||
.clicked()
|
||||
{
|
||||
crate::install::start(app, offer);
|
||||
close = true;
|
||||
}
|
||||
if item(ui, &format!("{}…", app.tr("ui.preferences")), "") {
|
||||
app.prefs_window.open = true;
|
||||
close = true;
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
use egui::{Color32, FontId, RichText, text::LayoutJob};
|
||||
use egui::{FontId, RichText, text::LayoutJob};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -714,34 +714,10 @@ pub fn take_launch(app: &mut SolveApp) -> Option<(PathBuf, Vec<String>)> {
|
||||
|
||||
// ---- user interface ---------------------------------------------------------------------------
|
||||
|
||||
/// The application bar's Update button, right of `right`; returns its rect (None: not shown).
|
||||
pub fn button(app: &mut SolveApp, ui: &mut egui::Ui, right: f32, top: f32, labeled: bool) -> Option<egui::Rect> {
|
||||
/// The application bar's Update button, at the right of `span`; returns its rect (None: not shown).
|
||||
pub fn button(app: &mut SolveApp, ui: &mut egui::Ui, span: egui::Rect, labeled: bool) -> Option<egui::Rect> {
|
||||
offered(app)?;
|
||||
let t = Tokens::get();
|
||||
let text = crate::i18n::tr("update.button");
|
||||
let width = if labeled {
|
||||
30.0 + ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), FontId::proportional(13.0), Color32::WHITE).size().x) + 10.0
|
||||
} else {
|
||||
28.0
|
||||
};
|
||||
let rect = egui::Rect::from_min_size(egui::pos2(right - width, top), egui::vec2(width, 26.0));
|
||||
let response = ui.interact(rect, ui.id().with("sc_update_button"), egui::Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text));
|
||||
crate::scenario::publish_handle("titlebar:update", rect.center());
|
||||
let fill = if response.hovered() || response.has_focus() { t.hover } else { t.app_bar };
|
||||
ui.painter().rect_filled(rect, 4.0, fill);
|
||||
ui.painter().rect_stroke(rect, 4.0, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
let icon = egui::Rect::from_center_size(egui::pos2(rect.left() + 14.0, rect.center().y), egui::vec2(18.0, 18.0));
|
||||
crate::icons::paint(ui.painter(), icon, "update", t.accent, t.accent, t.app_bar);
|
||||
if labeled {
|
||||
ui.painter().text(
|
||||
egui::pos2(rect.left() + 29.0, rect.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
text,
|
||||
FontId::proportional(13.0),
|
||||
t.app_bar_text,
|
||||
);
|
||||
}
|
||||
let (rect, response) = crate::toolbar::offer_button(ui, span, labeled, crate::i18n::tr("update.button"), "update", "titlebar:update")?;
|
||||
let version = app.updater.release.as_ref().map(|r| r.version().to_string()).unwrap_or_default();
|
||||
if response.on_hover_text(crate::i18n::format("update.button_tip", &[("version", &version)])).clicked() {
|
||||
app.updater.open = !app.updater.open;
|
||||
|
||||
Reference in new issue
Block a user