refactor(setup): complete UX overhaul of the install wizard
- Step indicator (1→2→3) on both screens showing current position
- Fixed window size (520×440, non-resizable) for consistent presentation
- view_install redesigned with card-based layout matching view_picking
- Status messages colour-coded: blue=info, green=success, red=error
- Official installer shown as a badge ("✓ Official installer") — URL hidden
- Download/install progress in a styled card with byte counter
- Finished state has distinct success (green border) / failure (red border) banners
- Installation log collapsed by default behind "Show details ▼" toggle
- Removed "via umu-run" and other internal tool references from user-facing text
- Removed raw "Paste a URL…" initial status — context is clear from the card
- Window title simplified to "umutray — <Launcher Name>"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+311
-127
@@ -30,6 +30,7 @@ pub enum Message {
|
||||
// Finished stage
|
||||
LaunchNow,
|
||||
Close,
|
||||
ToggleLog,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -65,6 +66,7 @@ struct State {
|
||||
status: String,
|
||||
download: Arc<Mutex<DownloadProgress>>,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
show_log: bool,
|
||||
}
|
||||
|
||||
impl Drop for State {
|
||||
@@ -94,14 +96,11 @@ impl State {
|
||||
status: String::new(),
|
||||
download: Arc::new(Mutex::new(DownloadProgress::default())),
|
||||
log: Arc::new(Mutex::new(Vec::new())),
|
||||
show_log: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn new_install(config: Config, launcher: Launcher) -> Self {
|
||||
let status = format!(
|
||||
"Paste an installer URL or a local .exe path for {}.",
|
||||
launcher.display
|
||||
);
|
||||
let template_options = config::presets()
|
||||
.iter()
|
||||
.map(|l| l.display.clone())
|
||||
@@ -116,9 +115,10 @@ impl State {
|
||||
installer: None,
|
||||
downloaded_temp: None,
|
||||
stage: Stage::Idle,
|
||||
status,
|
||||
status: String::new(),
|
||||
download: Arc::new(Mutex::new(DownloadProgress::default())),
|
||||
log: Arc::new(Mutex::new(Vec::new())),
|
||||
show_log: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,15 +270,21 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
state.status = format!("Download failed: {e}");
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleLog => {
|
||||
state.show_log = !state.show_log;
|
||||
Task::none()
|
||||
}
|
||||
Message::InstallPressed => {
|
||||
let Some(installer) = state.installer.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
state.stage = Stage::Installing;
|
||||
state.status = "Running installer via umu-run (this may take several minutes)…".into();
|
||||
let display_name = state.launcher.as_ref().map(|l| l.display.clone()).unwrap_or_default();
|
||||
state.status = format!("Installing {}. This may take a few minutes…", display_name);
|
||||
if let Ok(mut v) = state.log.lock() {
|
||||
v.clear();
|
||||
}
|
||||
state.show_log = false;
|
||||
let config = state.config.clone();
|
||||
let launcher = state
|
||||
.launcher
|
||||
@@ -375,6 +381,7 @@ fn view_picking(state: &State) -> Element<'_, Message> {
|
||||
// ── Header ───────────────────────────────────────────────────────────────
|
||||
let header = container(
|
||||
column![
|
||||
step_indicator(1),
|
||||
text("Add a Launcher").size(26),
|
||||
text("Choose a launcher and set its install location.").size(13)
|
||||
.style(|_: &Theme| text::Style {
|
||||
@@ -472,17 +479,91 @@ fn view_picking(state: &State) -> Element<'_, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn step_indicator<'a>(step: u8) -> Element<'a, Message> {
|
||||
let active = Color::from_rgb(0.55, 0.75, 1.0);
|
||||
let done = Color::from_rgb(0.4, 0.85, 0.4);
|
||||
let muted = Color::from_rgb(0.4, 0.4, 0.4);
|
||||
|
||||
let label = |n: u8, label: &'static str| -> Element<'a, Message> {
|
||||
let (num_color, text_color) = if n < step {
|
||||
(done, done)
|
||||
} else if n == step {
|
||||
(active, active)
|
||||
} else {
|
||||
(muted, muted)
|
||||
};
|
||||
row![
|
||||
text(format!("{n}")).size(12).style(move |_: &Theme| text::Style { color: Some(num_color) }),
|
||||
text(format!(" {label}")).size(12).style(move |_: &Theme| text::Style { color: Some(text_color) }),
|
||||
]
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
};
|
||||
|
||||
let sep = |c: Color| -> Element<'a, Message> {
|
||||
text(" — ").size(12).style(move |_: &Theme| text::Style { color: Some(c) }).into()
|
||||
};
|
||||
|
||||
container(
|
||||
row![
|
||||
label(1, "Choose"),
|
||||
sep(if step > 1 { done } else { muted }),
|
||||
label(2, "Install"),
|
||||
sep(if step > 2 { done } else { muted }),
|
||||
label(3, "Done"),
|
||||
]
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.padding(Padding { top: 0.0, right: 0.0, bottom: 12.0, left: 0.0 })
|
||||
.into()
|
||||
}
|
||||
|
||||
fn status_card(msg: String, kind: StatusKind) -> Element<'static, Message> {
|
||||
let color = match kind {
|
||||
StatusKind::Info => Color::from_rgb(0.55, 0.75, 1.0),
|
||||
StatusKind::Success => Color::from_rgb(0.35, 0.85, 0.45),
|
||||
StatusKind::Error => Color::from_rgb(1.0, 0.45, 0.35),
|
||||
StatusKind::Neutral => Color::from_rgb(0.6, 0.6, 0.6),
|
||||
};
|
||||
container(
|
||||
text(msg).size(13).style(move |_: &Theme| text::Style { color: Some(color) }),
|
||||
)
|
||||
.padding(Padding::from([10, 14]))
|
||||
.width(Length::Fill)
|
||||
.style(|theme: &Theme| {
|
||||
let p = theme.extended_palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(p.background.weak.color)),
|
||||
border: Border { color: p.background.strong.color, width: 1.0, radius: 6.0.into() },
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
enum StatusKind { Info, Success, Error, Neutral }
|
||||
|
||||
fn view_install(state: &State) -> Element<'_, Message> {
|
||||
let launcher = state
|
||||
.launcher
|
||||
.as_ref()
|
||||
.expect("launcher must be set in install stage");
|
||||
|
||||
let can_go_back = state.selected_template.is_some()
|
||||
&& matches!(state.stage, Stage::Idle | Stage::Ready | Stage::Finished);
|
||||
let finished = matches!(state.stage, Stage::Finished);
|
||||
let install_success = finished && launcher.full_exe_path().exists();
|
||||
let is_official = launcher.installer_url.as_deref()
|
||||
.map(|u| state.source == u)
|
||||
.unwrap_or(false);
|
||||
|
||||
let back_btn: Element<Message> = if state.selected_template.is_some() {
|
||||
button(text("← Back").size(13))
|
||||
// ── Step indicator ────────────────────────────────────────────────────────
|
||||
let step = if finished { 3 } else { 2 };
|
||||
let steps = step_indicator(step);
|
||||
|
||||
// ── Back button ───────────────────────────────────────────────────────────
|
||||
let can_go_back = state.selected_template.is_some()
|
||||
&& matches!(state.stage, Stage::Idle | Stage::Ready);
|
||||
let back_el: Element<Message> = if state.selected_template.is_some() && !finished {
|
||||
button(text("← Back").size(12))
|
||||
.on_press_maybe(can_go_back.then_some(Message::Back))
|
||||
.style(button::secondary)
|
||||
.into()
|
||||
@@ -490,84 +571,81 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
||||
text("").into()
|
||||
};
|
||||
|
||||
let header = row![
|
||||
text(format!("Setup: {}", launcher.display)).size(24),
|
||||
// ── Header ────────────────────────────────────────────────────────────────
|
||||
let header_row = row![
|
||||
column![
|
||||
steps,
|
||||
text(&launcher.display).size(22),
|
||||
]
|
||||
.spacing(0),
|
||||
iced::widget::horizontal_space(),
|
||||
back_btn,
|
||||
back_el,
|
||||
]
|
||||
.align_y(iced::Alignment::Center);
|
||||
.align_y(Alignment::Start);
|
||||
|
||||
let is_official = launcher.installer_url.as_deref()
|
||||
.map(|u| state.source == u)
|
||||
.unwrap_or(false);
|
||||
|
||||
let source_row: Element<Message> = if is_official && !matches!(state.stage, Stage::Idle) {
|
||||
// Already past the confirmation step — don't show the input again
|
||||
text("").into()
|
||||
} else if is_official {
|
||||
// Official URL pre-filled: show read-only info + small "use custom URL" toggle
|
||||
column![
|
||||
text("✓ Using official installer").size(12).style(|_: &Theme| text::Style {
|
||||
color: Some(Color::from_rgb(0.4, 0.85, 0.4)),
|
||||
}),
|
||||
text_input("Or paste a custom URL / local .exe path to override:", &state.source)
|
||||
.on_input(Message::SourceChanged)
|
||||
.padding(6)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
// ── Source / installer card (only shown in Idle) ───────────────────────────
|
||||
let source_card: Element<Message> = if matches!(state.stage, Stage::Idle) {
|
||||
let inner: Element<Message> = if is_official {
|
||||
column![
|
||||
text("Installer").size(12).style(|_: &Theme| text::Style {
|
||||
color: Some(Color::from_rgb(0.55, 0.75, 1.0)),
|
||||
}),
|
||||
row![
|
||||
text("✓ Official installer").size(13).style(|_: &Theme| text::Style {
|
||||
color: Some(Color::from_rgb(0.35, 0.85, 0.45)),
|
||||
}),
|
||||
iced::widget::horizontal_space(),
|
||||
]
|
||||
.align_y(Alignment::Center),
|
||||
text("Using the official download. To use a different installer, paste a URL or path below.")
|
||||
.size(11)
|
||||
.style(|_: &Theme| text::Style { color: Some(Color::from_rgb(0.45, 0.45, 0.45)) }),
|
||||
text_input("Custom URL or local .exe path (optional)", &state.source)
|
||||
.on_input(Message::SourceChanged)
|
||||
.padding(7)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(6)
|
||||
.into()
|
||||
} else {
|
||||
column![
|
||||
text("Installer").size(12).style(|_: &Theme| text::Style {
|
||||
color: Some(Color::from_rgb(0.55, 0.75, 1.0)),
|
||||
}),
|
||||
text_input("Paste a URL or local .exe path", &state.source)
|
||||
.on_input(Message::SourceChanged)
|
||||
.padding(8),
|
||||
]
|
||||
.spacing(6)
|
||||
.into()
|
||||
};
|
||||
section_card(inner)
|
||||
} else {
|
||||
column![
|
||||
text("Installer URL or local .exe path:").size(12),
|
||||
text_input("https://… or /path/to/installer.exe", &state.source)
|
||||
.on_input(Message::SourceChanged)
|
||||
.padding(8),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
text("").into()
|
||||
};
|
||||
|
||||
let finished = matches!(state.stage, Stage::Finished);
|
||||
let install_success = finished && launcher.full_exe_path().exists();
|
||||
|
||||
// Single context-aware action button (changes 3 & 4)
|
||||
let action_btn: Option<Element<Message>> = match state.stage {
|
||||
Stage::Idle => {
|
||||
let enabled = !state.source.trim().is_empty();
|
||||
Some(
|
||||
button(text("Download →"))
|
||||
.on_press_maybe(enabled.then_some(Message::PreparePressed))
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
Stage::Busy => Some(
|
||||
button(text("Downloading…"))
|
||||
.on_press_maybe(None::<Message>)
|
||||
.into(),
|
||||
),
|
||||
Stage::Ready => Some(
|
||||
button(text("Install →"))
|
||||
.on_press(Message::InstallPressed)
|
||||
.into(),
|
||||
),
|
||||
Stage::Installing => Some(
|
||||
button(text("Installing…"))
|
||||
.on_press_maybe(None::<Message>)
|
||||
.into(),
|
||||
),
|
||||
Stage::Finished => None,
|
||||
Stage::Picking => None,
|
||||
// ── Status card ───────────────────────────────────────────────────────────
|
||||
let status_el: Element<Message> = if !state.status.is_empty() {
|
||||
let kind = match state.stage {
|
||||
Stage::Finished if install_success => StatusKind::Success,
|
||||
Stage::Finished => StatusKind::Error,
|
||||
Stage::Busy | Stage::Installing => StatusKind::Info,
|
||||
Stage::Ready => StatusKind::Success,
|
||||
_ => StatusKind::Neutral,
|
||||
};
|
||||
status_card(state.status.clone(), kind)
|
||||
} else if matches!(state.stage, Stage::Idle) && is_official {
|
||||
status_card(
|
||||
format!("Ready to download the official {} installer.", launcher.display),
|
||||
StatusKind::Neutral,
|
||||
)
|
||||
} else {
|
||||
text("").into()
|
||||
};
|
||||
|
||||
let status = text(state.status.clone());
|
||||
|
||||
let progress_row: Element<Message> = if matches!(state.stage, Stage::Busy) {
|
||||
let p = state
|
||||
.download
|
||||
.lock()
|
||||
.map(|p| (p.bytes, p.total))
|
||||
.unwrap_or((0, None));
|
||||
// ── Progress bar ──────────────────────────────────────────────────────────
|
||||
let progress_el: Element<Message> = if matches!(state.stage, Stage::Busy) {
|
||||
let p = state.download.lock().map(|p| (p.bytes, p.total)).unwrap_or((0, None));
|
||||
let (bytes, total) = p;
|
||||
let fraction = match total {
|
||||
Some(t) if t > 0 => (bytes as f32) / (t as f32),
|
||||
@@ -577,79 +655,180 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
||||
Some(t) => format!("{} / {}", fmt_bytes(bytes), fmt_bytes(t)),
|
||||
None => format!("{} downloaded", fmt_bytes(bytes)),
|
||||
};
|
||||
column![progress_bar(0.0..=1.0, fraction), text(label).size(12)]
|
||||
.spacing(4)
|
||||
.into()
|
||||
section_card(
|
||||
column![progress_bar(0.0..=1.0, fraction), text(label).size(12)]
|
||||
.spacing(6),
|
||||
)
|
||||
} else {
|
||||
text("").into()
|
||||
};
|
||||
|
||||
let log_pane: Element<Message> = if matches!(state.stage, Stage::Installing | Stage::Finished) {
|
||||
let lines: Vec<String> = state.log.lock().map(|v| v.clone()).unwrap_or_default();
|
||||
let tail: Vec<Element<Message>> = lines
|
||||
.iter()
|
||||
.rev()
|
||||
.take(80)
|
||||
.rev()
|
||||
.map(|l| text(l.clone()).size(11).into())
|
||||
.collect();
|
||||
scrollable(Column::with_children(tail).spacing(2))
|
||||
.height(Length::Fixed(220.0))
|
||||
.into()
|
||||
// ── Finished banner ───────────────────────────────────────────────────────
|
||||
let finished_banner: Element<Message> = if finished {
|
||||
let (icon, msg, kind) = if install_success {
|
||||
("✓", format!("{} is ready to use.", launcher.display), StatusKind::Success)
|
||||
} else {
|
||||
("✗", "Installation did not complete successfully. See details below.".to_string(), StatusKind::Error)
|
||||
};
|
||||
container(
|
||||
column![
|
||||
text(format!("{icon} {msg}")).size(15).style({
|
||||
let color = if install_success {
|
||||
Color::from_rgb(0.35, 0.85, 0.45)
|
||||
} else {
|
||||
Color::from_rgb(1.0, 0.45, 0.35)
|
||||
};
|
||||
move |_: &Theme| text::Style { color: Some(color) }
|
||||
}),
|
||||
]
|
||||
)
|
||||
.padding(Padding::from([12, 14]))
|
||||
.width(Length::Fill)
|
||||
.style(move |theme: &Theme| {
|
||||
let p = theme.extended_palette();
|
||||
let border_color = if install_success {
|
||||
Color::from_rgb(0.2, 0.5, 0.25)
|
||||
} else {
|
||||
Color::from_rgb(0.5, 0.2, 0.2)
|
||||
};
|
||||
container::Style {
|
||||
background: Some(Background::Color(p.background.weak.color)),
|
||||
border: Border { color: border_color, width: 1.0, radius: 6.0.into() },
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.into()
|
||||
} else {
|
||||
text("").into()
|
||||
};
|
||||
|
||||
let finished_row: Element<Message> = if finished {
|
||||
let close_btn = button(text("Close").size(13))
|
||||
.on_press(Message::Close)
|
||||
// ── Log toggle + pane ─────────────────────────────────────────────────────
|
||||
let has_log = matches!(state.stage, Stage::Installing | Stage::Finished);
|
||||
let log_el: Element<Message> = if has_log {
|
||||
let toggle_label = if state.show_log { "Hide details ▲" } else { "Show details ▼" };
|
||||
let toggle_btn = button(text(toggle_label).size(12))
|
||||
.on_press(Message::ToggleLog)
|
||||
.style(button::secondary);
|
||||
let launch_btn = button(text("Open launcher").size(13))
|
||||
.on_press_maybe(install_success.then_some(Message::LaunchNow))
|
||||
.style(button::primary);
|
||||
row![close_btn, launch_btn].spacing(10).into()
|
||||
} else {
|
||||
text("").into()
|
||||
};
|
||||
|
||||
// Success banner (change 7)
|
||||
let success_banner: Element<Message> = if finished && install_success {
|
||||
text(format!("✓ {} installed successfully!", launcher.display))
|
||||
.size(14)
|
||||
.color(Color::from_rgb(0.4, 0.9, 0.4))
|
||||
if state.show_log {
|
||||
let lines: Vec<String> = state.log.lock().map(|v| v.clone()).unwrap_or_default();
|
||||
let rows: Vec<Element<Message>> = lines
|
||||
.iter().rev().take(80).rev()
|
||||
.map(|l| text(l.clone()).size(11).style(|_: &Theme| text::Style {
|
||||
color: Some(Color::from_rgb(0.55, 0.55, 0.55)),
|
||||
}).into())
|
||||
.collect();
|
||||
column![
|
||||
toggle_btn,
|
||||
container(
|
||||
scrollable(Column::with_children(rows).spacing(1))
|
||||
.height(Length::Fixed(180.0)),
|
||||
)
|
||||
.padding(Padding::from([6, 10]))
|
||||
.width(Length::Fill)
|
||||
.style(|theme: &Theme| {
|
||||
let p = theme.extended_palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(p.background.weak.color)),
|
||||
border: Border { color: p.background.strong.color, width: 1.0, radius: 4.0.into() },
|
||||
..Default::default()
|
||||
}
|
||||
}),
|
||||
]
|
||||
.spacing(6)
|
||||
.into()
|
||||
} else {
|
||||
toggle_btn.into()
|
||||
}
|
||||
} else {
|
||||
text("").into()
|
||||
};
|
||||
|
||||
let mut body = column![
|
||||
header,
|
||||
source_row,
|
||||
// ── Action button ─────────────────────────────────────────────────────────
|
||||
let action_el: Element<Message> = match state.stage {
|
||||
Stage::Idle => {
|
||||
let ready = is_official || !state.source.trim().is_empty();
|
||||
container(
|
||||
button(text("Download →").size(14))
|
||||
.on_press_maybe(ready.then_some(Message::PreparePressed))
|
||||
.style(button::primary),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::End)
|
||||
.into()
|
||||
}
|
||||
Stage::Busy => container(
|
||||
button(text("Downloading…").size(14))
|
||||
.on_press_maybe(None::<Message>)
|
||||
.style(button::primary),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::End)
|
||||
.into(),
|
||||
Stage::Ready => container(
|
||||
button(text("Install →").size(14))
|
||||
.on_press(Message::InstallPressed)
|
||||
.style(button::primary),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::End)
|
||||
.into(),
|
||||
Stage::Installing => container(
|
||||
button(text("Installing…").size(14))
|
||||
.on_press_maybe(None::<Message>)
|
||||
.style(button::primary),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::End)
|
||||
.into(),
|
||||
Stage::Finished => {
|
||||
let close_btn = button(text("Close").size(13))
|
||||
.on_press(Message::Close)
|
||||
.style(button::secondary);
|
||||
let launch_btn = button(text("Open Launcher").size(13))
|
||||
.on_press_maybe(install_success.then_some(Message::LaunchNow))
|
||||
.style(button::primary);
|
||||
container(
|
||||
row![close_btn, launch_btn].spacing(10),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::End)
|
||||
.into()
|
||||
}
|
||||
Stage::Picking => text("").into(),
|
||||
};
|
||||
|
||||
// ── Assembly ──────────────────────────────────────────────────────────────
|
||||
let body = column![
|
||||
header_row,
|
||||
source_card,
|
||||
status_el,
|
||||
progress_el,
|
||||
finished_banner,
|
||||
log_el,
|
||||
action_el,
|
||||
]
|
||||
.spacing(12)
|
||||
.padding(20);
|
||||
.padding(Padding { top: 24.0, right: 24.0, bottom: 24.0, left: 24.0 });
|
||||
|
||||
if let Some(btn) = action_btn {
|
||||
body = body.push(btn);
|
||||
}
|
||||
|
||||
let body = body
|
||||
.push(progress_row)
|
||||
.push(status)
|
||||
.push(success_banner)
|
||||
.push(finished_row)
|
||||
.push(log_pane);
|
||||
|
||||
container(body).into()
|
||||
container(scrollable(body))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
|
||||
let config = config.clone();
|
||||
let launcher = launcher.clone();
|
||||
let title = format!("umutray setup — {}", launcher.display);
|
||||
let title = format!("umutray — {}", launcher.display);
|
||||
iced::application(move |_: &State| title.clone(), update, view)
|
||||
.subscription(subscription)
|
||||
.theme(|_| Theme::Dark)
|
||||
.window(iced::window::Settings {
|
||||
size: iced::Size::new(520.0, 440.0),
|
||||
resizable: false,
|
||||
..Default::default()
|
||||
})
|
||||
.run_with(move || {
|
||||
(
|
||||
State::new_install(config.clone(), launcher.clone()),
|
||||
@@ -664,6 +843,11 @@ pub fn run_new(config: &Config) -> Result<()> {
|
||||
iced::application(|_: &State| "umutray — Add Launcher".to_string(), update, view)
|
||||
.subscription(subscription)
|
||||
.theme(|_| Theme::Dark)
|
||||
.window(iced::window::Settings {
|
||||
size: iced::Size::new(520.0, 440.0),
|
||||
resizable: false,
|
||||
..Default::default()
|
||||
})
|
||||
.run_with(move || (State::new_picking(config.clone()), Task::none()))
|
||||
.map_err(|e| anyhow::anyhow!("iced: {e}"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user