diff --git a/src/setup.rs b/src/setup.rs index a6ba418..542a5b1 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -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>, log: Arc>>, + 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 { 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 = 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 = 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 = 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 = if matches!(state.stage, Stage::Idle) { + let inner: Element = 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> = 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::) - .into(), - ), - Stage::Ready => Some( - button(text("Install →")) - .on_press(Message::InstallPressed) - .into(), - ), - Stage::Installing => Some( - button(text("Installing…")) - .on_press_maybe(None::) - .into(), - ), - Stage::Finished => None, - Stage::Picking => None, + // ── Status card ─────────────────────────────────────────────────────────── + let status_el: Element = 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 = 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 = 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 = if matches!(state.stage, Stage::Installing | Stage::Finished) { - let lines: Vec = state.log.lock().map(|v| v.clone()).unwrap_or_default(); - let tail: Vec> = 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 = 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 = 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 = 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 = 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 = state.log.lock().map(|v| v.clone()).unwrap_or_default(); + let rows: Vec> = 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 = 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::) + .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::) + .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}")) }