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:
funman300
2026-04-18 23:49:36 -07:00
parent f70498158a
commit 9ad1e6a745
+311 -127
View File
@@ -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}"))
}