From 3605636d4a087840a7a228ef48d60d3faee877e4 Mon Sep 17 00:00:00 2001 From: SecretLUL <18047775+SecretLUL@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:45:56 +0200 Subject: [PATCH] feat(gui): redesign desktop navigation and dashboard --- src/gui/mod.rs | 292 +++++++++++++++++++++----------- src/gui/modals.rs | 23 ++- src/gui/theme.rs | 234 +++++++++++++++++-------- src/gui/views/dashboard.rs | 338 ++++++++++++++++++++++++++----------- src/gui/views/repair.rs | 14 +- src/gui/views/scanner.rs | 101 ++++++----- src/gui/views/settings.rs | 36 +++- src/gui/views/triage.rs | 61 ++++--- 8 files changed, 754 insertions(+), 345 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index e10e223..9278619 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -25,7 +25,7 @@ use crate::app::{ use eframe::egui::{self, RichText}; use std::time::{Duration, Instant}; -/// The tab strip, in order. The indices are the `TAB_*` constants. +/// Navigation destinations, in the order of the `TAB_*` constants. const TABS: [&str; 5] = [ "Dashboard", "Health Scan", @@ -42,7 +42,7 @@ const BUSY_REPAINT: Duration = Duration::from_millis(40); /// How often to redraw when nothing is running. /// -/// Not zero, because the header carries live CPU and memory figures that would +/// Not zero, because the dashboard carries live CPU and memory figures that would /// otherwise freeze until the user moved the mouse. const IDLE_REPAINT: Duration = Duration::from_millis(500); @@ -51,7 +51,7 @@ pub struct WinMedicApp { last_telemetry_tick: Instant, } -/// Draw the whole frame: header, tab strip, body, status bar and overlays. +/// Draw the navigation, page header, body, status bar and overlays. /// /// Separate from [`WinMedicApp::ui`], which owns the parts a test has no use /// for — reading the keyboard and closing the window — so that a test can put @@ -61,18 +61,22 @@ pub fn show(ui: &mut egui::Ui, app: &mut App) { // context rather than nested inside a `Ui`. let ctx = ui.ctx().clone(); - egui::Panel::top("header").show(ui, |ui| { - header(ui, app); - tab_bar(ui, app); - }); + egui::Panel::left("navigation") + .exact_size(216.0) + .resizable(false) + .frame(egui::Frame::NONE.fill(theme::BG_SUNKEN).inner_margin(16)) + .show(ui, |ui| sidebar(ui, app)); egui::Panel::bottom("footer").show(ui, |ui| { footer(ui, app); }); - egui::CentralPanel::default().show(ui, |ui| { - body(ui, app); - }); + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(theme::BG_DEEP).inner_margin(24)) + .show(ui, |ui| { + header(ui, app); + body(ui, app); + }); // Overlays, in the order the terminal front end stacked them: a pending // confirmation outranks a setting being edited, which outranks help. @@ -98,106 +102,134 @@ impl WinMedicApp { } } -fn header(ui: &mut egui::Ui, app: &mut App) { - ui.add_space(4.0); +fn sidebar(ui: &mut egui::Ui, app: &mut App) { + ui.add_space(14.0); ui.horizontal(|ui| { - ui.label( - RichText::new(format!("WinMedic v{}", env!("CARGO_PKG_VERSION"))) - .color(theme::CYAN) - .strong() - .size(16.0), + let (rect, _) = ui.allocate_exact_size(egui::vec2(36.0, 36.0), egui::Sense::hover()); + ui.painter().rect_filled(rect, 10, theme::SELECTED); + theme::icon(ui, rect.shrink(6.0), 1, theme::CYAN); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 1.0; + ui.label(RichText::new("WinMedic").size(21.0).strong()); + ui.label(theme::muted("WINDOWS CARE").size(10.0)); + }); + }); + ui.add_space(36.0); + ui.label(theme::muted("WORKSPACE").size(10.0).strong()); + ui.add_space(8.0); + let open_issues = app.issues.iter().filter(|i| !i.is_fixed).count(); + for (index, title) in TABS.iter().enumerate() { + let selected = app.active_tab == index; + let color = if selected { theme::CYAN } else { theme::MUTED }; + let response = ui.add_sized( + [ui.available_width(), 46.0], + egui::Button::new("") + .selected(selected) + .fill(if selected { + theme::SELECTED + } else { + egui::Color32::TRANSPARENT + }) + .stroke(egui::Stroke::NONE), ); - ui.label(theme::muted("Windows Self-Healing Engine")); - - if app.dry_run { - theme::badge(ui, "SIMULATION", theme::AMBER); + response.widget_info(|| { + egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, *title) + }); + ui.painter().text( + egui::pos2(response.rect.left() + 38.0, response.rect.center().y), + egui::Align2::LEFT_CENTER, + *title, + egui::FontId::proportional(13.0), + color, + ); + // The button owns keyboard focus and its accessible label; the icon is decorative. + theme::icon( + ui, + egui::Rect::from_center_size( + egui::pos2(response.rect.left() + 16.0, response.rect.center().y), + egui::vec2(17.0, 17.0), + ), + index, + color, + ); + if selected { + ui.painter().rect_filled( + egui::Rect::from_min_size( + response.rect.left_top() + egui::vec2(0.0, 13.0), + egui::vec2(3.0, 20.0), + ), + 2, + theme::CYAN, + ); } - - // A repair that has done its half of the work and is waiting on the - // machine. Saying so beside the brand keeps it visible from every tab, - // which is the point: the findings behind it read as unfixed until the - // restart happens. - if app.has_pending_reboot() { - theme::badge(ui, "REBOOT PENDING", theme::AMBER); + if response + .on_hover_text(format!("{title} · {}", index + 1)) + .clicked() + { + app.goto_tab(index); } + } + ui.add_space(16.0); + if app.is_scanning { + theme::badge(ui, "Scan in progress", theme::CYAN); + } else if open_issues > 0 { + theme::badge(ui, &format!("{open_issues} open findings"), theme::AMBER); + } + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + ui.add_space(8.0); + ui.label(theme::muted(format!("Version {}", env!("CARGO_PKG_VERSION"))).size(11.0)); + ui.add_space(12.0); + ui.separator(); + ui.add_space(12.0); + ui.label(theme::muted("Preview repair steps before applying changes.").size(12.0)); + let mut dry_run = app.dry_run; + if ui.checkbox(&mut dry_run, "Simulation mode").changed() { + app.toggle_dry_run(); + } + }); +} - // Right-aligned, so the system readout sits opposite the brand and - // does not shift as the numbers change width. +fn header(ui: &mut egui::Ui, app: &mut App) { + let (title, description) = match app.active_tab { + TAB_SCANNER => ( + "Scan your system", + "Follow each diagnostic check as it runs.", + ), + TAB_TRIAGE => ( + "Review findings", + "Understand each issue and choose what to repair.", + ), + TAB_REPAIR => ( + "Repair workspace", + "Track repairs, safeguards and command output.", + ), + TAB_SETTINGS => ( + "Preferences & protection", + "Configure your checks and manage recovery options.", + ), + _ => ("System overview", "A clearer picture of your PC's health."), + }; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label(RichText::new(title).size(28.0).strong()); + ui.label(theme::muted(description)); + }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if app.is_admin { - theme::badge(ui, "ADMIN", theme::EMERALD); + theme::badge(ui, "Administrator", theme::EMERALD); } else { - theme::badge(ui, "NO ADMIN", theme::CORAL); + theme::badge(ui, "Standard access", theme::MUTED); } - - match app.telemetry.as_ref() { - Some(telemetry) => { - ui.label(theme::muted(format!( - "{} {}", - telemetry.os_name, telemetry.os_version - ))); - ui.separator(); - ui.label(format!( - "RAM {:.1}/{:.1} GB", - telemetry.ram_used_mb as f32 / 1024.0, - telemetry.ram_total_mb as f32 / 1024.0 - )); - ui.separator(); - ui.label(format!("CPU {:.1}%", telemetry.cpu_usage)); - } - None => { - ui.label(theme::muted("Reading system telemetry...")); - } + if app.has_pending_reboot() { + theme::badge(ui, "Restart pending", theme::AMBER); } }); }); - ui.add_space(4.0); + ui.add_space(22.0); } - -fn tab_bar(ui: &mut egui::Ui, app: &mut App) { - let open_issues = app.issues.iter().filter(|i| !i.is_fixed).count(); - - ui.horizontal(|ui| { - for (index, title) in TABS.iter().enumerate() { - // The two tabs that carry live information say so in the strip, - // so a user watching another tab still sees a scan finish. - let label = match index { - TAB_SCANNER if app.is_scanning => format!("{title} (running)"), - TAB_TRIAGE if open_issues > 0 => format!("{title} [{open_issues}]"), - _ => (*title).to_string(), - }; - - let selected = app.active_tab == index; - let text = if selected { - RichText::new(label).color(theme::CYAN).strong() - } else { - RichText::new(label).color(theme::MUTED) - }; - - if ui.selectable_label(selected, text).clicked() { - app.goto_tab(index); - } - } - }); - ui.add_space(2.0); -} - fn footer(ui: &mut egui::Ui, app: &mut App) { ui.add_space(3.0); ui.horizontal(|ui| { - if app.dry_run { - theme::badge(ui, "SIMULATION", theme::AMBER); - } - - match app.status_message.as_deref() { - Some(message) => { - ui.label(RichText::new(message).color(theme::EMERALD)); - } - None => { - ui.label(theme::muted("Ready")); - } - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Help").clicked() { app.show_help = true; @@ -205,11 +237,28 @@ fn footer(ui: &mut egui::Ui, app: &mut App) { if app.is_busy() && ui.button("Cancel").clicked() { app.cancel_current_operation(); } + if app.dry_run { + theme::badge(ui, "SIMULATION", theme::AMBER); + } + let message = app.status_message.as_deref().unwrap_or("Ready"); + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + let (rect, _) = ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover()); + ui.painter().circle_filled( + rect.center(), + 3.0, + if app.is_busy() { + theme::CYAN + } else { + theme::MUTED + }, + ); + ui.add(egui::Label::new(theme::muted(message).size(12.0)).truncate()) + .on_hover_text(message); + }); }); }); ui.add_space(3.0); } - fn body(ui: &mut egui::Ui, app: &mut App) { match app.active_tab { TAB_DASHBOARD => views::dashboard::show(ui, app), @@ -280,6 +329,7 @@ mod tests { let mut harness = Harness::builder() .with_size(egui::vec2(1400.0, 900.0)) .build_ui_state(|ui, app: &mut App| show(ui, app), app); + theme::apply(&harness.ctx); harness.run(); harness } @@ -357,8 +407,17 @@ mod tests { let mut harness = Harness::builder() .with_size(egui::vec2(size.0, size.1)) .build_ui_state(|ui, app: &mut App| show(ui, app), app); + theme::apply(&harness.ctx); harness.run(); + let help_rect = harness.get_by_label("Help").rect(); + assert!( + help_rect.top() > size.1 - 70.0 && help_rect.height() < 40.0, + "tab {tab}: the status bar consumed the page at {}x{}: {help_rect:?}", + size.0, + size.1, + ); + assert!( harness.query_by_label_contains(title).is_some(), "tab {tab} at {}x{} did not draw its own strip entry", @@ -390,6 +449,49 @@ mod tests { assert!(harness.query_by_label_contains("Backups & Logs").is_none()); } + #[test] + fn sidebar_buttons_open_their_destination() { + let mut harness = window(populated_app()); + for (index, title) in TABS.iter().enumerate() { + harness.get_by_label(title).click(); + harness.run(); + assert_eq!(harness.state().active_tab, index); + } + } + + #[test] + fn dashboard_severity_link_opens_findings_without_stale_filters() { + let mut app = populated_app(); + app.search_query = "old search".into(); + app.module_filter = Some("storage".into()); + let mut harness = window(app); + harness.get_by_label("Warnings").click(); + harness.run(); + assert_eq!(harness.state().active_tab, TAB_TRIAGE); + assert_eq!( + harness.state().severity_filter, + Some(crate::engine::issue::Severity::Warning) + ); + assert!(harness.state().search_query.is_empty()); + assert!(harness.state().module_filter.is_none()); + } + + #[test] + fn confirmation_blocks_clicks_on_the_navigation() { + let mut app = populated_app(); + app.pending_confirm = Some(ConfirmRequest::Elevate); + let mut harness = window(app); + harness.get_by_label("Health Scan").click(); + harness.run(); + assert_eq!(harness.state().active_tab, TAB_DASHBOARD); + assert!(harness.state().pending_confirm.is_some()); + harness + .get_by_label("Continue without Administrator") + .click(); + harness.run(); + assert!(harness.state().pending_confirm.is_none()); + } + /// The whole point of that merge: none of the safety surface may go missing. #[test] fn the_settings_tab_carries_the_whole_safety_surface() { diff --git a/src/gui/modals.rs b/src/gui/modals.rs index b8b53dd..0457810 100644 --- a/src/gui/modals.rs +++ b/src/gui/modals.rs @@ -20,11 +20,10 @@ pub fn show(ctx: &egui::Context, app: &mut App) { } /// The frame every overlay shares: modal, centred, not resizable. -fn modal_window<'a>(title: &'a str) -> egui::Window<'a> { - egui::Window::new(title) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) +fn modal_window(title: &str) -> egui::Modal { + egui::Modal::new(egui::Id::new(title)) + .frame(theme::surface().inner_margin(24)) + .backdrop_color(egui::Color32::from_black_alpha(150)) } fn confirm(ctx: &egui::Context, app: &mut App) { @@ -44,6 +43,8 @@ fn confirm(ctx: &egui::Context, app: &mut App) { modal_window(title).show(ctx, |ui| { ui.set_max_width(560.0); + ui.label(RichText::new(title).size(18.0).strong()); + ui.add_space(12.0); for line in &body { if line.is_empty() { @@ -58,13 +59,7 @@ fn confirm(ctx: &egui::Context, app: &mut App) { ui.add_space(6.0); ui.horizontal(|ui| { - if ui - .add( - egui::Button::new(RichText::new(confirm_label).color(theme::BG_DEEP).strong()) - .fill(theme::CYAN), - ) - .clicked() - { + if ui.add(theme::primary_button(confirm_label)).clicked() { confirmed = true; } if ui.button(dismiss_label).clicked() { @@ -97,6 +92,8 @@ fn setting_input(ctx: &egui::Context, app: &mut App) { modal_window("Edit setting").show(ctx, |ui| { ui.set_max_width(420.0); + ui.label(RichText::new("Edit setting").size(20.0).strong()); + ui.add_space(12.0); ui.label(RichText::new(&title).color(theme::CYAN).strong()); ui.label(theme::muted(format!("Allowed: {min}–{max} {unit}"))); @@ -143,6 +140,8 @@ fn help(ctx: &egui::Context, app: &mut App) { modal_window("Keyboard shortcuts").show(ctx, |ui| { ui.set_max_width(520.0); + ui.label(RichText::new("Keyboard shortcuts").size(20.0).strong()); + ui.add_space(12.0); ui.label(theme::muted( "Every action below is also a button in the window; these are the shortcuts.", )); diff --git a/src/gui/theme.rs b/src/gui/theme.rs index d12b249..0e3019d 100644 --- a/src/gui/theme.rs +++ b/src/gui/theme.rs @@ -1,67 +1,95 @@ -//! The visual identity, carried over unchanged from the terminal front end. -//! -//! The palette is the same set of colours WinMedic has always used, because it -//! is the same product: cyan is the brand, and emerald / amber / coral are the -//! three severities the whole tool is organised around. What changed is only -//! how they reach the screen — egui takes `Color32` where ratatui took `Color`. +//! Shared desktop palette, typography and reusable controls. use crate::engine::issue::Severity; use eframe::egui::{self, Color32, RichText, Stroke, Visuals}; -// Cyber-Medic / Dark Slate palette. -/// Primary brand colour. -pub const CYAN: Color32 = Color32::from_rgb(0, 210, 255); -/// Success / healthy. -pub const EMERALD: Color32 = Color32::from_rgb(16, 185, 129); -/// Warning. -pub const AMBER: Color32 = Color32::from_rgb(245, 158, 11); -/// Critical / error. -pub const CORAL: Color32 = Color32::from_rgb(239, 68, 68); -/// Window background. -pub const BG_DEEP: Color32 = Color32::from_rgb(15, 23, 42); -/// Card and panel surface. -pub const CARD_SURFACE: Color32 = Color32::from_rgb(30, 41, 59); -/// Borders and inactive elements. -pub const BORDER: Color32 = Color32::from_rgb(71, 85, 105); -/// Secondary text. -pub const MUTED: Color32 = Color32::from_rgb(148, 163, 184); -/// Primary text. -pub const TEXT_WHITE: Color32 = Color32::from_rgb(248, 250, 252); -pub const ACCENT_PURPLE: Color32 = Color32::from_rgb(168, 85, 247); +pub const CYAN: Color32 = Color32::from_rgb(91, 214, 201); +pub const EMERALD: Color32 = Color32::from_rgb(105, 216, 162); +pub const AMBER: Color32 = Color32::from_rgb(239, 190, 104); +pub const CORAL: Color32 = Color32::from_rgb(244, 133, 143); +pub const BG_DEEP: Color32 = Color32::from_rgb(17, 22, 31); +pub const CARD_SURFACE: Color32 = Color32::from_rgb(25, 32, 43); +pub const BORDER: Color32 = Color32::from_rgb(43, 53, 67); +pub const MUTED: Color32 = Color32::from_rgb(153, 168, 187); +pub const TEXT_WHITE: Color32 = Color32::from_rgb(233, 239, 247); +pub const ACCENT_PURPLE: Color32 = Color32::from_rgb(183, 165, 239); +pub const BG_SUNKEN: Color32 = Color32::from_rgb(13, 18, 26); +pub const SELECTED: Color32 = Color32::from_rgb(31, 58, 62); +pub const HOVER: Color32 = Color32::from_rgb(36, 46, 60); -/// The darkest surface, for sunken areas: log views and text fields. -/// -/// The terminal front end got this for free — anything it did not paint was the -/// terminal's own background. A window has no such default, so the shade the -/// logs used to sit on has to be named. -pub const BG_SUNKEN: Color32 = Color32::from_rgb(2, 6, 23); - -/// Install the palette on a freshly created context. -/// -/// Called once at startup rather than per frame: egui keeps the style until it -/// is replaced, and rebuilding it every frame would throw away any adjustment -/// made in between. pub fn apply(ctx: &egui::Context) { + // Use the Windows UI font when available, retaining the bundled fallbacks. + if let Some(windows) = std::env::var_os("SystemRoot") + && let Ok(data) = std::fs::read(std::path::PathBuf::from(windows).join("Fonts/segoeui.ttf")) + { + let mut fonts = egui::FontDefinitions::default(); + fonts + .font_data + .insert("Segoe UI".into(), egui::FontData::from_owned(data).into()); + fonts + .families + .entry(egui::FontFamily::Proportional) + .or_default() + .insert(0, "Segoe UI".into()); + ctx.set_fonts(fonts); + } let mut visuals = Visuals::dark(); - visuals.panel_fill = BG_DEEP; visuals.window_fill = CARD_SURFACE; visuals.faint_bg_color = CARD_SURFACE; visuals.extreme_bg_color = BG_SUNKEN; + visuals.override_text_color = Some(TEXT_WHITE); + visuals.weak_text_color = Some(MUTED); visuals.window_stroke = Stroke::new(1.0, BORDER); + visuals.window_corner_radius = 16.into(); visuals.hyperlink_color = CYAN; - visuals.selection.bg_fill = CYAN.linear_multiply(0.35); + visuals.selection.bg_fill = SELECTED; visuals.selection.stroke = Stroke::new(1.0, CYAN); - + visuals.text_edit_bg_color = Some(BG_SUNKEN); + for widget in [ + &mut visuals.widgets.noninteractive, + &mut visuals.widgets.inactive, + &mut visuals.widgets.hovered, + &mut visuals.widgets.active, + &mut visuals.widgets.open, + ] { + widget.corner_radius = 8.into(); + widget.bg_stroke = Stroke::new(1.0, BORDER); + widget.fg_stroke = Stroke::new(1.0, TEXT_WHITE); + widget.expansion = 0.0; + } + visuals.widgets.noninteractive.bg_fill = CARD_SURFACE; + visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, MUTED); + visuals.widgets.inactive.bg_fill = HOVER; + visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; + visuals.widgets.hovered.bg_fill = HOVER; + visuals.widgets.hovered.weak_bg_fill = HOVER; + visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, MUTED); + visuals.widgets.active.bg_fill = SELECTED; + visuals.widgets.active.weak_bg_fill = SELECTED; + visuals.widgets.active.bg_stroke = Stroke::new(1.0, CYAN); ctx.set_visuals(visuals); - ctx.all_styles_mut(|style| { - style.spacing.item_spacing = egui::vec2(8.0, 6.0); - style.spacing.button_padding = egui::vec2(10.0, 5.0); + for (kind, size) in [ + (egui::TextStyle::Heading, 28.0), + (egui::TextStyle::Body, 14.0), + (egui::TextStyle::Button, 14.0), + (egui::TextStyle::Small, 12.0), + ] { + style + .text_styles + .insert(kind, egui::FontId::proportional(size)); + } + style + .text_styles + .insert(egui::TextStyle::Monospace, egui::FontId::monospace(12.0)); + style.spacing.item_spacing = egui::vec2(12.0, 8.0); + style.spacing.button_padding = egui::vec2(14.0, 8.0); + style.spacing.interact_size.y = 34.0; + style.spacing.window_margin = egui::Margin::same(24); }); } -/// The colour that stands for a severity, everywhere it is shown. pub fn severity_color(severity: Severity) -> Color32 { match severity { Severity::Critical => CORAL, @@ -70,10 +98,6 @@ pub fn severity_color(severity: Severity) -> Color32 { } } -/// The colour a health score should be read in. -/// -/// The thresholds match what the score already means elsewhere in the tool, so -/// a machine the dashboard calls healthy is never painted in the warning colour. pub fn health_color(score: u8) -> Color32 { match score { 80..=100 => EMERALD, @@ -82,34 +106,102 @@ pub fn health_color(score: u8) -> Color32 { } } -/// A titled surface, the direct equivalent of the terminal front end's bordered -/// block. Everything that was a box on screen stays a box. -pub fn card(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui) -> R) { - egui::Frame::group(ui.style()) +pub fn surface() -> egui::Frame { + egui::Frame::NONE .fill(CARD_SURFACE) .stroke(Stroke::new(1.0, BORDER)) - .show(ui, |ui| { - // Without this the frame shrinks to its content and a row of cards - // ends up ragged, each one a different width. - ui.set_width(ui.available_width()); - ui.label(RichText::new(title).color(CYAN).strong()); - ui.separator(); - add(ui); - }); + .corner_radius(14) + .inner_margin(20) } -/// A small filled badge: the admin state, the simulation mode, a severity count. -pub fn badge(ui: &mut egui::Ui, text: &str, fill: Color32) { - egui::Frame::NONE - .fill(fill) - .inner_margin(egui::Margin::symmetric(6, 2)) - .corner_radius(3) - .show(ui, |ui| { - ui.label(RichText::new(text).color(BG_DEEP).strong().size(11.0)); - }); +pub fn card(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui) -> R) { + surface().show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(muted(title).size(12.0).strong()); + ui.add_space(6.0); + add(ui); + }); +} + +pub fn primary_button(text: impl Into) -> egui::Button<'static> { + egui::Button::new(RichText::new(text.into()).color(BG_SUNKEN).strong()) + .fill(CYAN) + .stroke(Stroke::NONE) + .min_size(egui::vec2(0.0, 38.0)) +} + +pub fn badge(ui: &mut egui::Ui, text: &str, color: Color32) { + let galley = + ui.painter() + .layout_no_wrap(text.to_owned(), egui::FontId::proportional(12.0), color); + let (rect, response) = + ui.allocate_exact_size(galley.size() + egui::vec2(16.0, 8.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, 6, color.gamma_multiply(0.12)); + ui.painter() + .galley(rect.min + egui::vec2(8.0, 4.0), galley, color); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text)); } -/// Muted secondary text, for everything that labels rather than reports. pub fn muted(text: impl Into) -> RichText { RichText::new(text.into()).color(MUTED) } + +/// Geometry stays crisp at every scale without relying on platform emoji fonts. +pub fn icon(ui: &egui::Ui, rect: egui::Rect, kind: usize, color: Color32) { + let painter = ui.painter(); + let stroke = Stroke::new(1.6, color); + let point = |x: f32, y: f32| rect.min + egui::vec2(x, y) * rect.width() / 20.0; + let line = |points: &[(f32, f32)]| { + painter.add(egui::Shape::line( + points.iter().map(|&(x, y)| point(x, y)).collect(), + stroke, + )); + }; + match kind { + 0 => { + for (x, y) in [(2.0, 2.0), (12.0, 2.0), (2.0, 12.0), (12.0, 12.0)] { + painter.rect_stroke( + egui::Rect::from_min_max(point(x, y), point(x + 6.0, y + 6.0)), + 1, + stroke, + egui::StrokeKind::Inside, + ); + } + } + 1 => line(&[ + (1.0, 10.0), + (5.0, 10.0), + (8.0, 3.0), + (12.0, 17.0), + (15.0, 10.0), + (19.0, 10.0), + ]), + 2 => { + for y in [4.0, 10.0, 16.0] { + painter.circle_filled(point(3.0, y), 1.3, color); + line(&[(7.0, y), (18.0, y)]); + } + } + 3 => { + line(&[ + (10.0, 2.0), + (17.0, 5.0), + (16.0, 13.0), + (10.0, 18.0), + (4.0, 13.0), + (3.0, 5.0), + (10.0, 2.0), + ]); + line(&[(7.0, 10.0), (13.0, 10.0)]); + line(&[(10.0, 7.0), (10.0, 13.0)]); + } + _ => { + for (y, x) in [(4.0, 7.0), (10.0, 14.0), (16.0, 7.0)] { + line(&[(2.0, y), (18.0, y)]); + painter.circle_filled(point(x, y), 2.5, CARD_SURFACE); + painter.circle_stroke(point(x, y), 2.5, stroke); + } + } + } +} diff --git a/src/gui/views/dashboard.rs b/src/gui/views/dashboard.rs index 7a10a06..3644212 100644 --- a/src/gui/views/dashboard.rs +++ b/src/gui/views/dashboard.rs @@ -1,7 +1,6 @@ -//! The landing tab: how healthy the machine is, what the last scan found, and -//! the one button that starts another one. +//! System overview: health, live resources and the latest diagnostic results. -use crate::app::{App, TAB_SETTINGS}; +use crate::app::{App, TAB_SETTINGS, TAB_TRIAGE}; use crate::engine::issue::Severity; use crate::gui::theme; use crate::modules::ModuleStatus; @@ -13,99 +12,217 @@ pub fn show(ui: &mut egui::Ui, app: &mut App) { health(&mut columns[0], app); system(&mut columns[1], app); }); - - ui.add_space(8.0); + ui.add_space(12.0); + findings(ui, app); + ui.add_space(18.0); modules(ui, app); - ui.add_space(8.0); + ui.add_space(12.0); last_action(ui, app); }); } +fn has_results(app: &App) -> bool { + app.scan_duration.is_some() + || !app.issues.is_empty() + || app + .module_statuses + .iter() + .any(|(_, _, _, state)| !matches!(state, ModuleStatus::Idle)) +} + fn health(ui: &mut egui::Ui, app: &mut App) { theme::card(ui, "SYSTEM HEALTH", |ui| { - let score = app.health_score; - + ui.set_min_height(230.0); + let assessed = has_results(app) && !app.is_scanning; + let color = if assessed { + theme::health_color(app.health_score) + } else { + theme::CYAN + }; ui.horizontal(|ui| { - ui.label( - RichText::new(format!("{score}")) - .color(theme::health_color(score)) - .strong() - .size(46.0), + let diameter = if ui.available_width() < 380.0 { + 110.0 + } else { + 132.0 + }; + let (rect, response) = + ui.allocate_exact_size(egui::vec2(diameter, diameter), egui::Sense::hover()); + let center = rect.center(); + let radius = diameter / 2.0 - 7.0; + ui.painter() + .circle_stroke(center, radius, egui::Stroke::new(7.0, theme::BORDER)); + let fraction = if assessed { + app.health_score as f32 / 100.0 + } else if app.is_scanning { + app.scan_overall_progress as f32 / 100.0 + } else { + 0.0 + }; + if fraction > 0.0 { + let points = (0..=80) + .map(|n| { + let angle = -std::f32::consts::FRAC_PI_2 + + std::f32::consts::TAU * fraction * n as f32 / 80.0; + center + egui::vec2(angle.cos(), angle.sin()) * radius + }) + .collect(); + ui.painter() + .add(egui::Shape::line(points, egui::Stroke::new(7.0, color))); + } + let value = if assessed { + app.health_score.to_string() + } else if app.is_scanning { + format!("{}%", app.scan_overall_progress) + } else { + "--".to_string() + }; + ui.painter().text( + center - egui::vec2(0.0, 7.0), + egui::Align2::CENTER_CENTER, + &value, + egui::FontId::proportional(34.0), + theme::TEXT_WHITE, + ); + ui.painter().text( + center + egui::vec2(0.0, 24.0), + egui::Align2::CENTER_CENTER, + if assessed { + "OUT OF 100" + } else if app.is_scanning { + "SCANNING" + } else { + "NOT SCANNED" + }, + egui::FontId::proportional(9.0), + theme::MUTED, ); + response.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Label, + true, + format!("System health: {value}"), + ) + }); ui.vertical(|ui| { - ui.add_space(14.0); - ui.label(theme::muted("out of 100")); - ui.label(RichText::new(verdict(score)).color(theme::health_color(score))); + ui.add_space(12.0); + let title = if app.is_scanning { + "Checking your PC" + } else if assessed { + verdict(app.health_score) + } else { + "Ready for a checkup" + }; + ui.label(RichText::new(title).size(20.0).strong().color(color)); + ui.label( + theme::muted(if app.is_scanning { + "Results will appear as the checks finish." + } else if assessed { + "Based on your latest diagnostic findings." + } else { + "Run a scan to discover what needs your attention." + }) + .size(13.0), + ); + if let Some(duration) = app.scan_duration { + ui.label( + theme::muted(format!( + "Last scan took {}", + super::scanner::format_duration(duration) + )) + .size(11.0), + ); + } }); }); - - ui.add_space(6.0); - ui.add( - egui::ProgressBar::new(score as f32 / 100.0) - .fill(theme::health_color(score)) - .desired_height(8.0), - ); - ui.add_space(10.0); - - let counts = severity_counts(app); - ui.horizontal(|ui| { - theme::badge(ui, &format!("{} critical", counts.0), theme::CORAL); - theme::badge(ui, &format!("{} warnings", counts.1), theme::AMBER); - theme::badge(ui, &format!("{} info", counts.2), theme::CYAN); - }); - ui.add_space(12.0); ui.horizontal(|ui| { - let busy = app.is_busy(); if ui - .add_enabled(!busy, egui::Button::new("Start health scan")) + .add_enabled(!app.is_busy(), theme::primary_button("Start health scan")) .clicked() { app.start_scan(); } - let mut dry_run = app.dry_run; - if ui.checkbox(&mut dry_run, "Simulation mode").changed() { - app.toggle_dry_run(); - } + ui.label(theme::muted("S to scan").size(11.0)); }); }); } fn verdict(score: u8) -> &'static str { match score { - 95..=100 => "Healthy", + 95..=100 => "Looking healthy", 80..=94 => "Minor findings", 50..=79 => "Needs attention", - _ => "Critical", + _ => "Needs your attention", } } -fn severity_counts(app: &App) -> (usize, usize, usize) { - let open = || app.issues.iter().filter(|issue| !issue.is_fixed); - ( - open().filter(|i| i.severity == Severity::Critical).count(), - open().filter(|i| i.severity == Severity::Warning).count(), - open().filter(|i| i.severity == Severity::Info).count(), - ) +fn findings(ui: &mut egui::Ui, app: &mut App) { + let counts = [Severity::Critical, Severity::Warning, Severity::Info].map(|severity| { + app.issues + .iter() + .filter(|i| !i.is_fixed && i.severity == severity) + .count() + }); + ui.columns(3, |columns| { + for (index, (label, severity)) in [ + ("Critical", Severity::Critical), + ("Warnings", Severity::Warning), + ("Informational", Severity::Info), + ] + .iter() + .enumerate() + { + theme::surface() + .inner_margin(egui::Margin::symmetric(16, 12)) + .show(&mut columns[index], |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label( + RichText::new(counts[index].to_string()) + .size(27.0) + .strong() + .color(theme::severity_color(*severity)), + ); + if ui.link(*label).clicked() { + app.clear_filters(); + app.severity_filter = Some(*severity); + app.clamp_filtered_selection(); + app.goto_tab(TAB_TRIAGE); + } + }); + }); + } + }); } fn system(ui: &mut egui::Ui, app: &mut App) { - theme::card(ui, "SYSTEM", |ui| { + theme::card(ui, "LIVE RESOURCES", |ui| { + ui.set_min_height(230.0); + ui.spacing_mut().item_spacing.y = 6.0; let Some(telemetry) = app.telemetry.as_ref() else { ui.label(theme::muted("Reading system telemetry...")); return; }; - - ui.label(RichText::new(&telemetry.cpu_name).strong()); - ui.label(theme::muted(format!( - "{} {} · {} · {} cores", - telemetry.os_name, telemetry.os_version, telemetry.host_name, telemetry.cpu_count - ))); - ui.add_space(8.0); - + ui.add(egui::Label::new(RichText::new(&telemetry.cpu_name).strong()).truncate()) + .on_hover_text(&telemetry.cpu_name); + ui.add( + egui::Label::new( + theme::muted(format!( + "{} {} · {} cores", + telemetry.os_name, telemetry.os_version, telemetry.cpu_count + )) + .size(12.0), + ) + .truncate(), + ) + .on_hover_text(format!( + "{} · {} {}", + telemetry.host_name, telemetry.os_name, telemetry.os_version + )); + ui.add_space(4.0); meter( ui, - "CPU", + "Processor", telemetry.cpu_usage / 100.0, &format!("{:.1}%", telemetry.cpu_usage), ); @@ -119,54 +236,84 @@ fn system(ui: &mut egui::Ui, app: &mut App) { telemetry.ram_total_mb as f32 / 1024.0 ), ); - - ui.add_space(8.0); - for disk in &telemetry.disks { - meter( - ui, - &disk.mount_point, - disk.used_percent / 100.0, - &format!( - "{:.0} GB free of {:.0} GB", - disk.available_space_gb, disk.total_space_gb - ), - ); - } + egui::ScrollArea::vertical() + .id_salt("dashboard_disks") + .max_height(60.0) + .show(ui, |ui| { + for disk in &telemetry.disks { + meter( + ui, + &disk.mount_point, + disk.used_percent / 100.0, + &format!( + "{:.0} GB free of {:.0} GB", + disk.available_space_gb, disk.total_space_gb + ), + ); + } + }); }); } -/// A labelled bar. Load is coloured the same way a health score is, so a disk -/// at 95% reads as critical without needing a legend. fn meter(ui: &mut egui::Ui, label: &str, fraction: f32, value: &str) { ui.horizontal(|ui| { - ui.label(theme::muted(label)); + ui.label(theme::muted(label).size(12.0)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(value); + ui.label(RichText::new(value).size(12.0)); }); }); ui.add( egui::ProgressBar::new(fraction.clamp(0.0, 1.0)) - .fill(theme::health_color( - 100u8.saturating_sub((fraction * 100.0) as u8), - )) - .desired_height(6.0), + .fill(if fraction >= 0.9 { + theme::CORAL + } else if fraction >= 0.75 { + theme::AMBER + } else { + theme::CYAN + }) + .desired_height(5.0), ); - ui.add_space(4.0); } fn modules(ui: &mut egui::Ui, app: &mut App) { - theme::card(ui, "DIAGNOSTIC MODULES", |ui| { - for (_, name, icon, status) in &app.module_statuses { - ui.horizontal(|ui| { - ui.label(icon.as_str()); - ui.label(name.as_str()); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let (text, color) = describe(status); - ui.label(RichText::new(text).color(color)); - }); - }); - } + ui.horizontal(|ui| { + ui.label(RichText::new("Diagnostic coverage").size(17.0).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(theme::muted(format!("{} modules", app.module_statuses.len())).size(12.0)); + }); }); + ui.add_space(4.0); + for pair in app.module_statuses.chunks(2) { + ui.columns(2, |columns| { + for (index, (id, name, _, status)) in pair.iter().enumerate() { + theme::surface() + .inner_margin(egui::Margin::symmetric(14, 10)) + .corner_radius(10) + .show(&mut columns[index], |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (text, color) = describe(status); + let (rect, _) = + ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover()); + ui.painter().circle_filled(rect.center(), 3.0, color); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 3.0; + ui.add( + egui::Label::new(RichText::new(name).size(13.0).strong()) + .truncate(), + ) + .on_hover_text(id); + ui.add( + egui::Label::new(RichText::new(&text).size(11.0).color(color)) + .truncate(), + ) + .on_hover_text(text); + }); + }); + }); + } + }); + } } fn describe(status: &ModuleStatus) -> (String, egui::Color32) { @@ -180,20 +327,15 @@ fn describe(status: &ModuleStatus) -> (String, egui::Color32) { } } -/// The audit trail's most recent entry, and a way to the rest of it. -/// -/// A machine that has never been repaired has no last action, and an empty row -/// saying so is worse than no row at all. fn last_action(ui: &mut egui::Ui, app: &mut App) { let Some(entry) = app.audit_entries.last().cloned() else { return; }; - - theme::card(ui, "AUDIT TRAIL", |ui| { - ui.horizontal(|ui| { + theme::card(ui, "RECENT ACTIVITY", |ui| { + ui.horizontal_wrapped(|ui| { ui.label(theme::muted("Last action:")); ui.label(RichText::new(&entry.title).strong()); - ui.label(theme::muted(format!("({})", entry.timestamp))); + ui.label(theme::muted(&entry.timestamp).size(12.0)); }); if ui.link("Full log, backups & rollback").clicked() { app.goto_tab(TAB_SETTINGS); diff --git a/src/gui/views/repair.rs b/src/gui/views/repair.rs index b248b19..f518cf9 100644 --- a/src/gui/views/repair.rs +++ b/src/gui/views/repair.rs @@ -34,8 +34,8 @@ fn status(ui: &mut egui::Ui, app: &mut App) { } else { if ui .add_enabled( - selected > 0, - egui::Button::new(if app.dry_run { + selected > 0 && !app.is_busy(), + theme::primary_button(if app.dry_run { "Simulate repairs" } else { "Start repairs" @@ -67,11 +67,14 @@ fn status(ui: &mut egui::Ui, app: &mut App) { } else { theme::EMERALD }) - .text(format!("{done} / {}", app.total_to_fix)), + .desired_height(8.0), ); ui.add_space(6.0); ui.horizontal(|ui| { + ui.label( + theme::muted(format!("{done} / {} completed", app.total_to_fix)).size(12.0), + ); theme::badge(ui, &format!("{} repaired", app.fixed_count), theme::EMERALD); if app.failed_count > 0 { theme::badge(ui, &format!("{} failed", app.failed_count), theme::CORAL); @@ -98,6 +101,11 @@ fn console(ui: &mut egui::Ui, app: &mut App) { .stick_to_bottom(true) .auto_shrink([false, false]) .show(ui, |ui| { + if app.repair_console_lines.is_empty() { + ui.label(theme::muted( + "Repair output will appear here. Every step is recorded in your audit log.", + )); + } for line in &app.repair_console_lines { // The engine already marks its own outcomes in the text, so // colouring on those markers keeps a long run scannable diff --git a/src/gui/views/scanner.rs b/src/gui/views/scanner.rs index ae46862..5f04f25 100644 --- a/src/gui/views/scanner.rs +++ b/src/gui/views/scanner.rs @@ -29,18 +29,17 @@ fn controls(ui: &mut egui::Ui, app: &mut App) { if ui.button("Cancel scan").clicked() { app.cancel_current_operation(); } - } else if ui.button("Start health scan").clicked() { + } else if ui + .add_enabled(!app.is_busy(), theme::primary_button("Start health scan")) + .clicked() + { app.start_scan(); } ui.add_space(12.0); - let progress = app.scan_overall_progress as f32 / 100.0; - ui.add( - egui::ProgressBar::new(progress) - .fill(theme::CYAN) - .desired_width(260.0) - .text(format!("{}%", app.scan_overall_progress)), + ui.label( + RichText::new(format!("{}% complete", app.scan_overall_progress)).color(theme::CYAN), ); if let Some(elapsed) = app.scan_elapsed() { @@ -58,42 +57,59 @@ fn controls(ui: &mut egui::Ui, app: &mut App) { } fn modules(ui: &mut egui::Ui, app: &mut App) { + ui.add( + egui::ProgressBar::new(app.scan_overall_progress as f32 / 100.0) + .fill(theme::CYAN) + .desired_height(6.0), + ); + ui.add_space(8.0); egui::ScrollArea::vertical() .id_salt("scan_modules") .show(ui, |ui| { - for module in &app.module_progress_list { - theme::card(ui, &format!("{} {}", module.icon, module.name), |ui| { - let color = if module.failure.is_some() { - theme::CORAL - } else if module.is_done { - theme::EMERALD - } else { - theme::CYAN - }; - - ui.add( - egui::ProgressBar::new(module.percent as f32 / 100.0) - .fill(color) - .desired_height(8.0), - ); - - ui.horizontal(|ui| { - let step = if module.step.is_empty() { - "Waiting..." - } else { - &module.step - }; - ui.label(RichText::new(step).color(color)); - - // How long the current step has been running is the - // difference between "working" and "hung" for a module - // sitting on a slow DISM call with nothing to report. - if let Some(elapsed) = module.step_elapsed() - && elapsed >= Duration::from_secs(3) - { - ui.label(theme::muted(format!("({})", format_duration(elapsed)))); - } - }); + for pair in app.module_progress_list.chunks(2) { + ui.columns(2, |columns| { + for (index, module) in pair.iter().enumerate() { + theme::card(&mut columns[index], &module.name, |ui| { + let color = if module.failure.is_some() { + theme::CORAL + } else if module.is_done { + theme::EMERALD + } else { + theme::CYAN + }; + + ui.set_min_height(42.0); + ui.add( + egui::ProgressBar::new(module.percent as f32 / 100.0) + .fill(color) + .desired_height(5.0), + ); + + ui.horizontal(|ui| { + let step = if module.step.is_empty() { + "Waiting..." + } else { + &module.step + }; + ui.add( + egui::Label::new(RichText::new(step).color(color).size(12.0)) + .wrap(), + ); + + // How long the current step has been running is the + // difference between "working" and "hung" for a module + // sitting on a slow DISM call with nothing to report. + if let Some(elapsed) = module.step_elapsed() + && elapsed >= Duration::from_secs(3) + { + ui.label(theme::muted(format!( + "({})", + format_duration(elapsed) + ))); + } + }); + }); + } }); } }); @@ -106,6 +122,11 @@ fn log(ui: &mut egui::Ui, app: &mut App) { .stick_to_bottom(true) .auto_shrink([false, false]) .show(ui, |ui| { + if app.scan_log_messages.is_empty() { + ui.label(theme::muted( + "Diagnostic activity will appear here when you start a scan.", + )); + } for line in &app.scan_log_messages { ui.label(RichText::new(line).monospace().color(theme::MUTED)); } diff --git a/src/gui/views/settings.rs b/src/gui/views/settings.rs index 30e625c..a081385 100644 --- a/src/gui/views/settings.rs +++ b/src/gui/views/settings.rs @@ -40,10 +40,11 @@ fn settings(ui: &mut egui::Ui, app: &mut App) { egui::Frame::NONE .fill(fill) - .inner_margin(egui::Margin::symmetric(6, 4)) + .corner_radius(10) + .inner_margin(egui::Margin::symmetric(12, 12)) .show(ui, |ui| { + ui.set_width(ui.available_width()); ui.horizontal(|ui| { - ui.label(RichText::new(label).strong()); ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -51,7 +52,21 @@ fn settings(ui: &mut egui::Ui, app: &mut App) { // dialog; the rest are booleans a click // can flip outright. let numeric = matches!(index, 4 | 5); - if ui.button(&value).clicked() { + let button = egui::Button::new( + RichText::new(&value).size(12.0).strong().color( + if value == "ON" { + theme::CYAN + } else { + theme::TEXT_WHITE + }, + ), + ) + .fill(if value == "ON" { + theme::SELECTED + } else { + theme::HOVER + }); + if ui.add(button).on_hover_text(label).clicked() { app.safety_focus = SafetyFocus::Settings; app.selected_setting_index = index; if numeric { @@ -60,10 +75,23 @@ fn settings(ui: &mut egui::Ui, app: &mut App) { app.toggle_current_setting(); } } + ui.with_layout( + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.add( + egui::Label::new(RichText::new(label).strong()) + .wrap(), + ); + }, + ); }, ); }); - ui.label(theme::muted(explanation)); + // Keyboard hints stay available on hover without repeating + // terminal instructions in every settings description. + let description = explanation.split(" [").next().unwrap_or(explanation); + ui.label(theme::muted(description).size(12.0)) + .on_hover_text(explanation); }); ui.add_space(4.0); } diff --git a/src/gui/views/triage.rs b/src/gui/views/triage.rs index 4b9f728..c6e4a00 100644 --- a/src/gui/views/triage.rs +++ b/src/gui/views/triage.rs @@ -10,7 +10,7 @@ use eframe::egui::{self, RichText}; pub fn show(ui: &mut egui::Ui, app: &mut App) { filters(ui, app); - ui.add_space(6.0); + ui.add_space(16.0); let indices = app.filtered_issue_indices(); @@ -41,11 +41,11 @@ pub fn show(ui: &mut egui::Ui, app: &mut App) { fn filters(ui: &mut egui::Ui, app: &mut App) { ui.horizontal_wrapped(|ui| { - ui.label(theme::muted("Search")); let field = ui.add( egui::TextEdit::singleline(&mut app.search_query) - .desired_width(200.0) - .hint_text("title, module or category"), + .desired_width(220.0) + .margin(egui::vec2(12.0, 10.0)) + .hint_text("Search findings..."), ); // `[/]` asks for this field; honouring the request here is what keeps // the shortcut from having to know anything about widgets. @@ -61,11 +61,7 @@ fn filters(ui: &mut egui::Ui, app: &mut App) { for severity in [Severity::Critical, Severity::Warning, Severity::Info] { let active = app.severity_filter == Some(severity); - let text = RichText::new(severity.short_label()).color(if active { - theme::BG_DEEP - } else { - theme::severity_color(severity) - }); + let text = RichText::new(severity.short_label()).color(theme::severity_color(severity)); if ui.selectable_label(active, text).clicked() { app.toggle_severity_filter(severity); } @@ -84,7 +80,11 @@ fn filters(ui: &mut egui::Ui, app: &mut App) { if app.has_active_filters() && ui.button("Clear filters").clicked() { app.clear_filters(); } - + }); + ui.horizontal(|ui| { + ui.label( + theme::muted(format!("{} findings", app.filtered_issue_indices().len())).size(12.0), + ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let selected = app .issues @@ -94,7 +94,7 @@ fn filters(ui: &mut egui::Ui, app: &mut App) { if ui .add_enabled( selected > 0 && !app.is_busy(), - egui::Button::new(if app.dry_run { + theme::primary_button(if app.dry_run { format!("Simulate {selected} repairs") } else { format!("Repair {selected} issues") @@ -128,15 +128,25 @@ fn list(ui: &mut egui::Ui, app: &mut App, indices: &[usize]) { fn row(ui: &mut egui::Ui, app: &mut App, issue_index: usize, highlighted: bool, position: usize) { let fill = if highlighted { - theme::CARD_SURFACE + theme::SELECTED } else { - egui::Color32::TRANSPARENT + theme::CARD_SURFACE }; let response = egui::Frame::NONE .fill(fill) - .inner_margin(egui::Margin::symmetric(6, 4)) + .corner_radius(10) + .stroke(egui::Stroke::new( + 1.0, + if highlighted { + theme::CYAN.gamma_multiply(0.5) + } else { + theme::BORDER + }, + )) + .inner_margin(egui::Margin::symmetric(12, 12)) .show(ui, |ui| { + ui.set_width(ui.available_width()); ui.horizontal(|ui| { let issue = &mut app.issues[issue_index]; @@ -159,15 +169,18 @@ fn row(ui: &mut egui::Ui, app: &mut App, issue_index: usize, highlighted: bool, .strong() .size(11.0), ); - ui.label(RichText::new(&issue.title).strong()); + ui.add(egui::Label::new(RichText::new(&issue.title).strong()).wrap()); }); ui.horizontal(|ui| { ui.add_space(28.0); let issue = &app.issues[issue_index]; - ui.label(theme::muted(format!( - "{} · {}", - issue.category, issue.module_id - ))); + ui.add( + egui::Label::new( + theme::muted(format!("{} · {}", issue.category, issue.module_id)) + .size(11.0), + ) + .truncate(), + ); }); }) .response; @@ -233,13 +246,17 @@ fn detail(ui: &mut egui::Ui, app: &mut App, indices: &[usize]) { fn empty(ui: &mut egui::Ui, app: &mut App, headline: &str, hint: &str) { ui.vertical_centered(|ui| { - ui.add_space(60.0); - ui.label(RichText::new(headline).size(15.0)); + ui.add_space(70.0); + let (rect, _) = ui.allocate_exact_size(egui::vec2(64.0, 64.0), egui::Sense::hover()); + ui.painter().rect_filled(rect, 18, theme::SELECTED); + theme::icon(ui, rect.shrink(17.0), 2, theme::CYAN); + ui.add_space(16.0); + ui.label(RichText::new(headline).size(21.0).strong()); ui.label(theme::muted(hint)); ui.add_space(12.0); if app.issues.is_empty() { if ui - .add_enabled(!app.is_busy(), egui::Button::new("Start health scan")) + .add_enabled(!app.is_busy(), theme::primary_button("Start health scan")) .clicked() { app.start_scan();