From f8a68b5806a9c67f0a74b68abda664a83b19cbb8 Mon Sep 17 00:00:00 2001 From: sudo-py-dev Date: Thu, 29 Oct 2026 17:09:57 +0200 Subject: [PATCH 1/4] GNOME look; scroll bars keep clear of the content Adwaita colours and accents, flat neutral buttons and entries, cards with a soft shade, neutral selection in the sidebar and Settings, GNOME-style switches and a header bar with a round close button. Dialogs fade and grow in when they open. Scroll bars float over a gutter kept free of content, so they no longer cover text (Settings in Hebrew ran its headings under the bar). Co-Authored-By: Claude Opus 5.5 --- src/gui/mod.rs | 4 +- src/gui/options.rs | 27 ++-- src/gui/style.rs | 299 ++++++++++++++++++++++++++++----------------- 3 files changed, 204 insertions(+), 126 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 6588fc0..a42ce46 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -354,7 +354,7 @@ impl App { ui.add_space(8.0); let rec = crate::watcher::recorder_running(); // The recorder's state, with the theme switch at its far end. - egui::Frame::new().fill(p.surface_alt).corner_radius(10).inner_margin(egui::Margin { left: 10, right: 2, top: 1, bottom: 1 }).show(ui, |ui| { + egui::Frame::new().fill(p.hover).corner_radius(10).inner_margin(egui::Margin { left: 10, right: 2, top: 1, bottom: 1 }).show(ui, |ui| { ui.set_width(ui.available_width()); crate::gui::style::row(ui, |ui| { ui.spacing_mut().item_spacing.x = 6.0; @@ -483,7 +483,7 @@ impl eframe::App for App { let side = if style::rtl() { egui::Panel::right("sidebar") } else { egui::Panel::left("sidebar") }; side.resizable(false) .exact_size(236.0) - .frame(egui::Frame::new().fill(p.surface).stroke(egui::Stroke::new(1.0, p.border)).inner_margin(egui::Margin::symmetric(12, 0))) + .frame(egui::Frame::new().fill(p.sidebar).stroke(egui::Stroke::new(1.0, p.border)).inner_margin(egui::Margin::symmetric(12, 0))) .show(ui, |ui| self.sidebar(ui)); egui::CentralPanel::default().frame(egui::Frame::new().fill(p.bg).inner_margin(egui::Margin { left: 28, right: 28, top: 22, bottom: 18 })).show(ui, |ui| { diff --git a/src/gui/options.rs b/src/gui/options.rs index e0097f8..9ac69d6 100644 --- a/src/gui/options.rs +++ b/src/gui/options.rs @@ -69,18 +69,21 @@ impl SettingsView { let screen = ctx.content_rect(); let size = Vec2::new((screen.width() - 64.0).clamp(520.0, 980.0), (screen.height() - 150.0).clamp(320.0, 620.0)); let mut close = false; - let frame = egui::Frame::new().fill(p.surface).stroke(egui::Stroke::new(1.0, p.border)).corner_radius(14); - let shown = egui::Modal::new(egui::Id::unique("settings-dialog")).frame(frame).show(ctx, |ui| { + let frame = egui::Frame::new().fill(p.bg).corner_radius(15).shadow(ctx.global_style().visuals.window_shadow); + let id = egui::Id::unique("settings-dialog"); + let intro = style::dialog_intro(ctx, id); + let shown = egui::Modal::new(id).frame(frame).show(ctx, |ui| { + ui.multiply_opacity(intro); ui.set_width(size.x); ui.spacing_mut().item_spacing.y = 0.0; // Title bar: the name in the middle, the close button at the far end. - egui::Frame::new().fill(p.bg).corner_radius(egui::CornerRadius { nw: 14, ne: 14, sw: 0, se: 0 }).show(ui, |ui| { - let (rect, _) = ui.allocate_exact_size(Vec2::new(size.x, 56.0), egui::Sense::hover()); - let title = crate::tf!("{} {}", icon::GEAR, tr("Settings")); - ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, style::vstr(title), egui::FontId::new(19.0, style::semibold()), p.text); + // Header bar, as in GNOME: the title centred, a round close button at the far end. + egui::Frame::new().show(ui, |ui| { + let (rect, _) = ui.allocate_exact_size(Vec2::new(size.x, 50.0), egui::Sense::hover()); + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, style::vstr(tr("Settings")), egui::FontId::new(15.0, style::semibold()), p.text); let m = style::Mirror::new(rect); - let x = m.rect(size.x - 52.0, rect.center().y - 16.0, Vec2::splat(32.0)); - if ui.put(x, |ui: &mut Ui| style::icon_button(ui, icon::X, "Close")).clicked() { + let x = m.rect(size.x - 38.0, rect.center().y - 12.0, Vec2::splat(24.0)); + if ui.put(x, |ui: &mut Ui| style::close_button(ui, "Close")).clicked() { close = true; } }); @@ -506,10 +509,10 @@ fn category(ui: &mut Ui, s: Section, selected: bool, p: Palette) -> egui::Respon let (rect, resp) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 46.0), egui::Sense::click()); // Tabs, so assistive tools (and the translation audit) see them as one group. resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Tab, true, selected, label)); - if selected { - ui.painter().rect_filled(rect, 10.0, p.accent_soft); - } else if resp.hovered() { - ui.painter().rect_filled(rect, 10.0, p.hover); + let h = ui.ctx().animate_bool_with_time(resp.id.with("hover"), resp.hovered() && !selected, style::FADE); + let s_t = ui.ctx().animate_bool_with_time(resp.id.with("sel"), selected, style::FADE); + if s_t > 0.0 || h > 0.0 { + ui.painter().rect_filled(rect, 8.0, p.hover.gamma_multiply(h).lerp_to_gamma(p.selected, s_t)); } let m = style::Mirror::new(rect); let tile = m.rect(10.0, rect.center().y - 15.0, Vec2::splat(30.0)); diff --git a/src/gui/style.rs b/src/gui/style.rs index 434fd52..2b5aedd 100644 --- a/src/gui/style.rs +++ b/src/gui/style.rs @@ -3,8 +3,11 @@ //! Fonts: Inter (SIL OFL 1.1) for text, Phosphor regular and fill (MIT) for icons; egui's //! bundled font stays as a fallback so other scripts still render. //! -//! Motion: hover and press states fade (`FADE`), selection indicators slide (`ease_to`), and -//! pages fade in when they open. Everything runs off egui's frame clock, so it also works in +//! Look: GNOME's Adwaita: flat neutral buttons and entries, white cards with a soft shade, +//! neutral selection, overlay scroll bars that keep clear of the content. +//! +//! Motion: hover and press states fade (`FADE`), selection indicators slide (`ease_to`), pages +//! fade in when they open and dialogs fade and grow in (`dialog_intro`). Everything runs off egui's frame clock, so it also works in //! the kittest harness. use crate::i18n::tr; @@ -183,18 +186,25 @@ pub struct Palette { pub on_accent: Color32, /// Background of a hovered borderless control. pub hover: Color32, + /// Background of the selected entry in a sidebar or list (neutral, as in Adwaita). + pub selected: Color32, + /// Background of the navigation sidebar. + pub sidebar: Color32, + /// Resting fill of buttons and entries. + pub control: Color32, pub success: Color32, pub warning: Color32, pub danger: Color32, } +/// GNOME's accent colours (the stored keys predate them). pub const ACCENTS: [(&str, &str, Color32); 6] = [ - ("blue", "Blue", Color32::from_rgb(37, 99, 235)), - ("indigo", "Indigo", Color32::from_rgb(79, 70, 229)), - ("violet", "Violet", Color32::from_rgb(124, 58, 237)), - ("teal", "Teal", Color32::from_rgb(13, 148, 136)), - ("green", "Green", Color32::from_rgb(22, 163, 74)), - ("rose", "Rose", Color32::from_rgb(225, 29, 72)), + ("blue", "Blue", Color32::from_rgb(53, 132, 228)), + ("indigo", "Indigo", Color32::from_rgb(98, 96, 214)), + ("violet", "Violet", Color32::from_rgb(145, 65, 172)), + ("teal", "Teal", Color32::from_rgb(33, 144, 164)), + ("green", "Green", Color32::from_rgb(58, 148, 74)), + ("rose", "Rose", Color32::from_rgb(230, 45, 66)), ]; fn soft(c: Color32, alpha: u8) -> Color32 { @@ -205,42 +215,49 @@ impl Palette { pub fn new(dark: bool, accent: &str) -> Self { let base = ACCENTS.iter().find(|a| a.0 == accent).map(|a| a.2).unwrap_or(ACCENTS[0].2); // Lift the accent a little on dark backgrounds so it keeps its contrast. - let accent = if dark { base.lerp_to_gamma(Color32::WHITE, 0.18) } else { base }; + let accent = if dark { base.lerp_to_gamma(Color32::WHITE, 0.12) } else { base }; + // Adwaita's colours: neutral greys with a touch of blue, translucent fills for controls. if dark { Self { dark, - bg: Color32::from_rgb(15, 17, 21), - surface: Color32::from_rgb(23, 26, 33), - surface_alt: Color32::from_rgb(31, 35, 44), - border: Color32::from_rgb(42, 47, 58), - text: Color32::from_rgb(230, 232, 236), - muted: Color32::from_rgb(152, 162, 179), - faint: Color32::from_rgb(102, 112, 133), + bg: Color32::from_rgb(34, 34, 38), + surface: Color32::from_rgb(52, 52, 56), + surface_alt: Color32::from_rgb(44, 44, 48), + border: Color32::from_rgb(64, 64, 69), + text: Color32::from_rgb(242, 242, 244), + muted: Color32::from_rgb(160, 160, 166), + faint: Color32::from_rgb(118, 118, 124), accent, - accent_soft: soft(accent, 40), + accent_soft: soft(accent, 48), on_accent: Color32::WHITE, - hover: Color32::from_rgb(38, 43, 54), - success: Color32::from_rgb(50, 213, 131), - warning: Color32::from_rgb(253, 176, 34), - danger: Color32::from_rgb(249, 112, 102), + hover: Color32::from_white_alpha(14), + selected: Color32::from_white_alpha(24), + sidebar: Color32::from_rgb(46, 46, 50), + control: Color32::from_white_alpha(20), + success: Color32::from_rgb(120, 233, 171), + warning: Color32::from_rgb(255, 194, 82), + danger: Color32::from_rgb(255, 147, 140), } } else { Self { dark, - bg: Color32::from_rgb(246, 247, 249), + bg: Color32::from_rgb(250, 250, 251), surface: Color32::WHITE, - surface_alt: Color32::from_rgb(242, 244, 247), - border: Color32::from_rgb(228, 231, 236), - text: Color32::from_rgb(16, 24, 40), - muted: Color32::from_rgb(102, 112, 133), - faint: Color32::from_rgb(152, 162, 179), + surface_alt: Color32::from_rgb(242, 242, 244), + border: Color32::from_rgb(226, 226, 229), + text: Color32::from_rgb(46, 46, 52), + muted: Color32::from_rgb(110, 110, 118), + faint: Color32::from_rgb(150, 150, 156), accent, - accent_soft: soft(accent, 26), + accent_soft: soft(accent, 30), on_accent: Color32::WHITE, - hover: Color32::from_rgb(236, 238, 242), - success: Color32::from_rgb(18, 183, 106), - warning: Color32::from_rgb(220, 104, 3), - danger: Color32::from_rgb(217, 45, 32), + hover: Color32::from_rgba_unmultiplied(0, 0, 6, 14), + selected: Color32::from_rgba_unmultiplied(0, 0, 6, 24), + sidebar: Color32::from_rgb(235, 235, 237), + control: Color32::from_rgba_unmultiplied(0, 0, 6, 20), + success: Color32::from_rgb(27, 133, 83), + warning: Color32::from_rgb(156, 110, 3), + danger: Color32::from_rgb(192, 28, 40), } } } @@ -268,11 +285,11 @@ pub fn apply(ctx: &egui::Context, theme: &str, accent: &str) { v.panel_fill = p.bg; v.window_fill = p.surface; v.window_stroke = Stroke::new(1.0, p.border); - v.window_corner_radius = CornerRadius::same(14); - v.menu_corner_radius = CornerRadius::same(10); - v.window_shadow = egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if p.dark { 110 } else { 36 }) }; - v.popup_shadow = egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if p.dark { 90 } else { 28 }) }; - v.extreme_bg_color = if p.dark { Color32::from_rgb(18, 20, 26) } else { Color32::WHITE }; + v.window_corner_radius = CornerRadius::same(15); + v.menu_corner_radius = CornerRadius::same(12); + v.window_shadow = egui::Shadow { offset: [0, 6], blur: 30, spread: 2, color: Color32::from_black_alpha(if p.dark { 120 } else { 40 }) }; + v.popup_shadow = egui::Shadow { offset: [0, 2], blur: 14, spread: 1, color: Color32::from_black_alpha(if p.dark { 100 } else { 30 }) }; + v.extreme_bg_color = if p.dark { Color32::from_rgb(29, 29, 32) } else { Color32::WHITE }; v.faint_bg_color = p.surface_alt; v.code_bg_color = p.surface_alt; v.hyperlink_color = p.accent; @@ -290,21 +307,21 @@ pub fn apply(ctx: &egui::Context, theme: &str, accent: &str) { w.noninteractive.bg_stroke = Stroke::new(1.0, p.border); w.noninteractive.fg_stroke = Stroke::new(1.0, p.text); w.noninteractive.corner_radius = r; - w.inactive.weak_bg_fill = p.surface_alt; - w.inactive.bg_fill = if p.dark { Color32::from_rgb(52, 58, 70) } else { Color32::from_rgb(208, 213, 221) }; - // Visible outline for inputs, checkboxes and combo boxes on white cards. - w.inactive.bg_stroke = Stroke::new(1.0, p.border); + // Flat translucent fills, no outlines (Adwaita buttons and entries); checkboxes keep a rim. + w.inactive.weak_bg_fill = p.control; + w.inactive.bg_fill = if p.dark { Color32::from_white_alpha(36) } else { Color32::from_rgba_unmultiplied(0, 0, 6, 36) }; + w.inactive.bg_stroke = Stroke::NONE; w.inactive.fg_stroke = Stroke::new(1.0, p.text); w.inactive.corner_radius = r; - w.hovered.weak_bg_fill = p.hover; + w.hovered.weak_bg_fill = p.control.gamma_multiply(1.5); w.hovered.bg_fill = w.hovered.weak_bg_fill; - w.hovered.bg_stroke = Stroke::new(1.0, p.border); + w.hovered.bg_stroke = Stroke::NONE; w.hovered.fg_stroke = Stroke::new(1.0, p.text); w.hovered.corner_radius = r; w.hovered.expansion = 0.0; - w.active.weak_bg_fill = if p.dark { Color32::from_rgb(48, 54, 66) } else { Color32::from_rgb(225, 228, 234) }; + w.active.weak_bg_fill = p.control.gamma_multiply(2.4); w.active.bg_fill = w.active.weak_bg_fill; - w.active.bg_stroke = Stroke::new(1.0, p.accent); + w.active.bg_stroke = Stroke::new(1.0, soft(p.accent, 140)); w.active.fg_stroke = Stroke::new(1.0, p.text); w.active.corner_radius = r; w.active.expansion = 0.0; @@ -323,8 +340,8 @@ pub fn apply(ctx: &egui::Context, theme: &str, accent: &str) { s.spacing.menu_margin = Margin::same(8); s.spacing.window_margin = Margin::same(20); s.spacing.icon_spacing = 6.0; - s.animation_time = 0.16; - s.spacing.scroll = egui::style::ScrollStyle::thin(); + s.animation_time = 0.2; + s.spacing.scroll = scroll_style(); s.text_styles = [ (TextStyle::Heading, FontId::new(22.0, semibold())), (TextStyle::Body, FontId::proportional(14.0)), @@ -336,6 +353,28 @@ pub fn apply(ctx: &egui::Context, theme: &str, accent: &str) { }); } +/// Overlay scroll bars, as in GNOME: a slim handle that widens under the pointer, over a gutter +/// kept free of content so it never covers text (on either reading direction's edge). +fn scroll_style() -> egui::style::ScrollStyle { + egui::style::ScrollStyle { + floating: true, + bar_width: 8.0, + floating_width: 4.0, + floating_allocated_width: 14.0, + bar_inner_margin: 3.0, + bar_outer_margin: 3.0, + handle_min_length: 24.0, + foreground_color: false, + dormant_background_opacity: 0.0, + active_background_opacity: 0.0, + interact_background_opacity: 0.3, + dormant_handle_opacity: 0.35, + active_handle_opacity: 0.6, + interact_handle_opacity: 0.9, + ..egui::style::ScrollStyle::solid() + } +} + pub fn semibold() -> FontFamily { FontFamily::Name("semibold".into()) } @@ -852,13 +891,13 @@ fn button_text(icon: &str, text: &str) -> String { with_icon(icon, tr(text)) } -/// Filled accent button for the main action of a page. Darkens and glows on hover. +/// Filled accent button for the main action of a page (Adwaita's suggested action). pub fn primary(ui: &mut Ui, icon: &str, text: &str) -> egui::Response { let p = Palette::get(ui.ctx()); let id = ui.next_auto_id(); let (t, pressed) = interaction(ui, id); let enabled = ui.is_enabled(); - let hovered = if p.dark { p.accent.lerp_to_gamma(Color32::WHITE, 0.12) } else { p.accent.lerp_to_gamma(Color32::BLACK, 0.12) }; + let hovered = p.accent.lerp_to_gamma(Color32::WHITE, 0.1); let mut fill = p.accent.lerp_to_gamma(hovered, t); if pressed { fill = fill.lerp_to_gamma(Color32::BLACK, 0.1); @@ -866,41 +905,40 @@ pub fn primary(ui: &mut Ui, icon: &str, text: &str) -> egui::Response { if !enabled { fill = p.accent.gamma_multiply(0.45); } - let glow = ui.painter().add(egui::Shape::Noop); - let r = ui.add( + ui.add( egui::Button::new(vis(rich(button_text(icon, text)).color(p.on_accent).family(semibold()))) .fill(fill) .stroke(Stroke::NONE) - .corner_radius(8) + .corner_radius(RADIUS) .min_size(Vec2::new(0.0, CONTROL_H)), - ); - if enabled { - let a = (if p.dark { 40.0 } else { 34.0 } + 50.0 * t) as u8; - let shadow = egui::Shadow { offset: [0, 2 + (2.0 * t) as i8], blur: 6 + (8.0 * t) as u8, spread: 0, color: soft(p.accent, a) }; - ui.painter().set(glow, shadow.as_shape(r.rect, 8)); - } - r + ) } -/// Outlined neutral button. +/// Corner radius of buttons and entries. +const RADIUS: u8 = 8; + +/// Fill of a flat neutral control at hover amount `t` (Adwaita's plain button). +fn control_fill(p: &Palette, t: f32, pressed: bool) -> Color32 { + p.control.gamma_multiply(if pressed { 2.4 } else { 1.0 + 0.6 * t }) +} + +/// Flat neutral button. pub fn secondary(ui: &mut Ui, icon: &str, text: &str) -> egui::Response { let p = Palette::get(ui.ctx()); let (t, pressed) = interaction(ui, ui.next_auto_id()); - let fill = if pressed { p.hover.lerp_to_gamma(p.border, 0.5) } else { p.surface.lerp_to_gamma(p.hover, t) }; - let stroke = p.border.lerp_to_gamma(p.faint, t * 0.6); - ui.add(egui::Button::new(vis(button_text(icon, text))).fill(fill).stroke(Stroke::new(1.0, stroke)).corner_radius(8).min_size(Vec2::new(0.0, CONTROL_H))) + ui.add(egui::Button::new(vis(button_text(icon, text))).fill(control_fill(&p, t, pressed)).stroke(Stroke::NONE).corner_radius(RADIUS).min_size(Vec2::new(0.0, CONTROL_H))) } -/// Destructive outlined button; tints red on hover. +/// Destructive button: red text on a red-tinted fill that deepens on hover. pub fn danger(ui: &mut Ui, icon: &str, text: &str) -> egui::Response { let p = Palette::get(ui.ctx()); let (t, pressed) = interaction(ui, ui.next_auto_id()); - let fill = p.surface.lerp_to_gamma(p.danger, if pressed { 0.16 } else { 0.08 * t }); + let a = if pressed { 70.0 } else { 26.0 + 22.0 * t }; ui.add( egui::Button::new(vis(rich(button_text(icon, text)).color(p.danger))) - .fill(fill) - .stroke(Stroke::new(1.0, p.border.lerp_to_gamma(p.danger, t * 0.55))) - .corner_radius(8) + .fill(soft(p.danger, a as u8)) + .stroke(Stroke::NONE) + .corner_radius(RADIUS) .min_size(Vec2::new(0.0, CONTROL_H)), ) } @@ -908,7 +946,7 @@ pub fn danger(ui: &mut Ui, icon: &str, text: &str) -> egui::Response { /// Background of a borderless control at hover amount `t`. fn hover_fill(p: &Palette, t: f32, pressed: bool) -> Color32 { if pressed { - p.hover.lerp_to_gamma(p.border, 0.5) + p.selected } else { p.hover.gamma_multiply(t) } @@ -918,7 +956,7 @@ fn hover_fill(p: &Palette, t: f32, pressed: bool) -> Color32 { pub fn ghost(ui: &mut Ui, icon: &str, text: &str) -> egui::Response { let p = Palette::get(ui.ctx()); let (t, pressed) = interaction(ui, ui.next_auto_id()); - ui.add(egui::Button::new(vis(button_text(icon, text))).fill(hover_fill(&p, t, pressed)).stroke(Stroke::NONE).corner_radius(8).min_size(Vec2::new(30.0, CONTROL_H))) + ui.add(egui::Button::new(vis(button_text(icon, text))).fill(hover_fill(&p, t, pressed)).stroke(Stroke::NONE).corner_radius(RADIUS).min_size(Vec2::new(30.0, CONTROL_H))) } /// Icon-only borderless button with a tooltip (the tooltip text is also its accessible name). @@ -928,7 +966,17 @@ pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str) -> egui::Response { let (t, pressed) = interaction(ui, ui.next_auto_id()); let color = p.muted.lerp_to_gamma(p.text, t); let r = - ui.add(egui::Button::new(vis(rich(icon).size(17.0).color(color))).fill(hover_fill(&p, t, pressed)).stroke(Stroke::NONE).corner_radius(8).min_size(Vec2::new(32.0, 32.0))); + ui.add(egui::Button::new(vis(rich(icon).size(17.0).color(color))).fill(hover_fill(&p, t, pressed)).stroke(Stroke::NONE).corner_radius(RADIUS).min_size(Vec2::new(32.0, 32.0))); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); + r.on_hover_text(vis(tip)) +} + +/// Round close button with a neutral fill, like a GNOME window's. +pub fn close_button(ui: &mut Ui, tip: &str) -> egui::Response { + let tip = tr(tip); + let p = Palette::get(ui.ctx()); + let (t, pressed) = interaction(ui, ui.next_auto_id()); + let r = ui.add(egui::Button::new(vis(rich(icon::X).size(13.0).color(p.text))).fill(control_fill(&p, t, pressed)).stroke(Stroke::NONE).corner_radius(12).min_size(Vec2::splat(24.0))); r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); r.on_hover_text(vis(tip)) } @@ -942,9 +990,10 @@ pub fn tile_frame(ui: &mut Ui, rect: egui::Rect, resp: &egui::Response, selected let a = ((if p.dark { 70.0 } else { 22.0 }) * t) as u8; ui.painter().add(egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(a) }.as_shape(rect, 12)); } - let fill = if selected { p.surface.lerp_to_gamma(p.accent, if p.dark { 0.16 } else { 0.07 }) } else { p.surface }; - let border = if selected { p.accent } else { p.border.lerp_to_gamma(p.accent, t * 0.75) }; - ui.painter().rect(rect, 12.0, fill, Stroke::new(if selected { 1.5 } else { 1.0 + 0.5 * t }, border), egui::StrokeKind::Inside); + ui.painter().add(card_shade(&p).as_shape(rect, 12)); + let fill = if selected { p.surface.lerp_to_gamma(p.accent, if p.dark { 0.16 } else { 0.07 }) } else { p.surface.lerp_to_gamma(p.surface_alt, 0.6 * t) }; + let border = if selected { p.accent } else { card_rim(&p).lerp_to_gamma(soft(p.accent, 160), t * 0.6) }; + ui.painter().rect(rect, 12.0, fill, Stroke::new(if selected { 1.5 } else { 1.0 }, border), egui::StrokeKind::Inside); if resp.hovered() && ui.is_enabled() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } @@ -986,7 +1035,8 @@ pub fn stat_tile(ui: &mut Ui, width: f32, glyph: &str, value: &str, label: &str, let p = Palette::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(Vec2::new(width, 74.0), egui::Sense::hover()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, format!("{label}: {value}"))); - ui.painter().rect(rect, 12.0, p.surface, Stroke::new(1.0, p.border), egui::StrokeKind::Inside); + ui.painter().add(card_shade(&p).as_shape(rect, 12)); + ui.painter().rect(rect, 12.0, p.surface, Stroke::new(1.0, card_rim(&p)), egui::StrokeKind::Inside); let m = Mirror::new(rect); let tile = m.rect(14.0, rect.top() + 17.0, Vec2::splat(40.0)); ui.painter().rect_filled(tile, 11.0, soft(color, if p.dark { 44 } else { 26 })); @@ -1022,7 +1072,8 @@ pub fn hero(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { ui.painter().set( slot, egui::Shape::Vec(vec![ - egui::Shape::Rect(egui::epaint::RectShape::new(rect, 16.0, p.surface, Stroke::new(1.0, p.border), egui::StrokeKind::Inside)), + card_shade(&p).as_shape(rect, 16).into(), + egui::Shape::Rect(egui::epaint::RectShape::new(rect, 16.0, p.surface, Stroke::new(1.0, card_rim(&p)), egui::StrokeKind::Inside)), egui::Shape::Rect(egui::epaint::RectShape::filled(rect.shrink(1.0), 15.0, wash).with_texture(tex.id(), uv)), ]), ); @@ -1103,12 +1154,27 @@ pub fn brand(ui: &mut Ui, size: f32) -> egui::Response { r } -/// White rounded card with a hairline border. +/// The soft shade under a card (Adwaita's `.card`). +fn card_shade(p: &Palette) -> egui::Shadow { + egui::Shadow { offset: [0, 1], blur: 4, spread: 0, color: Color32::from_black_alpha(if p.dark { 60 } else { 16 }) } +} + +/// A card's faint rim, just enough to hold its edge on a light window. +fn card_rim(p: &Palette) -> Color32 { + if p.dark { + Color32::from_white_alpha(10) + } else { + Color32::from_rgba_unmultiplied(0, 0, 6, 16) + } +} + +/// White rounded card with a soft shade. pub fn card(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { let p = Palette::get(ui.ctx()); egui::Frame::new() .fill(p.surface) - .stroke(Stroke::new(1.0, p.border)) + .stroke(Stroke::new(1.0, card_rim(&p))) + .shadow(card_shade(&p)) .corner_radius(12) .inner_margin(Margin::same(16)) .show(ui, |ui| { @@ -1298,9 +1364,33 @@ pub fn tabs(ui: &mut Ui, current: &mut T, items: &[(T, &str changed } +/// Makes a dialog fade in and grow from 96% when it opens: call before showing the modal `id` +/// every frame it is open, and `multiply_opacity` its contents by the returned amount. +pub fn dialog_intro(ctx: &egui::Context, id: egui::Id) -> f32 { + let (now, frame) = (ctx.input(|i| i.time), ctx.cumulative_frame_nr()); + let key = id.with("intro"); + // A dialog that was not shown last frame is opening now. + let start = match ctx.data(|d| d.get_temp::<(f64, u64)>(key)) { + Some((start, last)) if last + 1 >= frame => start, + _ => now, + }; + ctx.data_mut(|d| d.insert_temp(key, (start, frame))); + let t = egui::emath::easing::cubic_out((((now - start) as f32) / 0.22).clamp(0.0, 1.0)); + let layer = egui::Modal::default_area(id).layer(); + let scale = 0.96 + 0.04 * t; + let c = ctx.content_rect().center().to_vec2(); + ctx.set_transform_layer(layer, egui::emath::TSTransform::from_translation(c * (1.0 - scale)) * egui::emath::TSTransform::from_scaling(scale)); + if t < 1.0 { + ctx.request_repaint(); + } + t +} + /// A modal dialog `width` wide whose contents line up at the start of the reading direction. pub fn modal(id: &str, width: f32, ctx: &egui::Context, add: impl FnOnce(&mut Ui) -> R) -> egui::ModalResponse { + let t = dialog_intro(ctx, egui::Id::unique(id)); egui::Modal::new(egui::Id::unique(id)).show(ctx, |ui| { + ui.multiply_opacity(t); // The width first, in egui's left-to-right layout: a right-to-left column in a dialog // that sizes itself to its contents would otherwise stretch across the window. ui.set_width(width); @@ -1314,8 +1404,8 @@ pub fn dropdown(ui: &mut Ui, id: &str, selected: &str, width: f32, contents: ui.scope(|ui| { ui.spacing_mut().interact_size.y = CONTROL_H; let w = &mut ui.visuals_mut().widgets; - w.inactive.weak_bg_fill = p.surface; - w.inactive.bg_stroke = Stroke::new(1.0, p.border); + w.inactive.weak_bg_fill = p.control; + w.inactive.bg_stroke = Stroke::NONE; let (response, inner) = if rtl() { dropdown_rtl(ui, id, selected, width, contents) } else { @@ -1398,10 +1488,10 @@ pub fn nav_item(ui: &mut Ui, icon: &str, label: &str, selected: bool) -> egui::R resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label)); let t = ui.ctx().animate_bool_with_time(resp.id.with("hover"), resp.hovered() && !selected, FADE); if t > 0.0 { - ui.painter().rect_filled(rect, 9.0, p.hover.gamma_multiply(t)); + ui.painter().rect_filled(rect, 8.0, p.hover.gamma_multiply(t)); } let s = ui.ctx().animate_bool_with_time(resp.id.with("sel"), selected, SLIDE); - let fg = p.text.lerp_to_gamma(p.accent, s); + let fg = p.text; let icon_color = p.muted.lerp_to_gamma(p.text, t).lerp_to_gamma(p.accent, s); // Icons nudge right a touch on hover, like a cursor following them. let m = Mirror::new(rect); @@ -1415,7 +1505,7 @@ pub fn nav_item(ui: &mut Ui, icon: &str, label: &str, selected: bool) -> egui::R resp } -/// The selected navigation entry's pill and accent bar, gliding to `target` (the selected +/// The selected navigation entry's neutral pill, gliding to `target` (the selected /// entry's rect). Paint it into a slot reserved before the entries so it sits behind them. /// `clip` is the visible part of the list the entry is in (the list scrolls). pub fn nav_indicator(ui: &Ui, slot: egui::layers::ShapeIdx, target: egui::Rect, clip: egui::Rect) { @@ -1428,9 +1518,7 @@ pub fn nav_indicator(ui: &Ui, slot: egui::layers::ShapeIdx, target: egui::Rect, ui.painter().set(slot, egui::Shape::Noop); return; } - let edge = if rtl() { rect.right() - 1.5 } else { rect.left() + 1.5 }; - let bar = egui::Rect::from_center_size(egui::pos2(edge, rect.center().y), Vec2::new(3.0, 18.0)); - ui.painter().set(slot, egui::Shape::Vec(vec![egui::Shape::rect_filled(rect, 9.0, p.accent_soft), egui::Shape::rect_filled(bar, 1.5, p.accent)])); + ui.painter().set(slot, egui::Shape::rect_filled(rect, 8.0, p.selected)); } /// Pill-shaped option in a segmented control. @@ -1439,7 +1527,7 @@ pub fn segment(ui: &mut Ui, selected: bool, text: &str) -> egui::Response { let p = Palette::get(ui.ctx()); let (t, _) = interaction(ui, ui.next_auto_id()); let text = if selected { rich(text).color(p.accent).family(semibold()) } else { rich(text).color(p.muted.lerp_to_gamma(p.text, t)) }; - let fill = if selected { p.accent_soft } else { p.hover.gamma_multiply(t) }; + let fill = if selected { p.accent_soft } else { control_fill(&p, t, false).gamma_multiply(0.6 + 0.4 * t) }; let r = ui.add(egui::Button::new(vis(text)).fill(fill).stroke(Stroke::NONE).corner_radius(16).min_size(Vec2::new(0.0, 30.0))); r.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, "")); r @@ -1449,7 +1537,7 @@ pub fn segment(ui: &mut Ui, selected: bool, text: &str) -> egui::Response { pub fn toggle(ui: &mut Ui, on: &mut bool, label: &str) -> egui::Response { let label = tr(label); let p = Palette::get(ui.ctx()); - let size = Vec2::new(40.0, 22.0); + let size = Vec2::new(46.0, 22.0); let (rect, mut resp) = ui.allocate_exact_size(size, egui::Sense::click()); if resp.clicked() { *on = !*on; @@ -1458,22 +1546,19 @@ pub fn toggle(ui: &mut Ui, on: &mut bool, label: &str) -> egui::Response { resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Switch, ui.is_enabled(), *on, label)); if ui.is_rect_visible(rect) { let t = ui.ctx().animate_bool_responsive(resp.id, *on); - let off_bg = if p.dark { Color32::from_rgb(60, 66, 80) } else { Color32::from_rgb(208, 213, 221) }; - let mut bg = off_bg.lerp_to_gamma(p.accent, t); + let h = ui.ctx().animate_bool_with_time(resp.id.with("hover"), resp.hovered() && ui.is_enabled(), FADE); + let off_bg = if p.dark { Color32::from_rgb(70, 70, 75).lerp_to_gamma(Color32::from_rgb(84, 84, 90), h) } else { Color32::from_rgb(214, 214, 218).lerp_to_gamma(Color32::from_rgb(200, 200, 205), h) }; + let mut bg = off_bg.lerp_to_gamma(p.accent.lerp_to_gamma(Color32::WHITE, 0.08 * h), t); if !ui.is_enabled() { bg = bg.gamma_multiply(0.5); } - let h = ui.ctx().animate_bool_with_time(resp.id.with("hover"), resp.hovered() && ui.is_enabled(), FADE); - if h > 0.0 { - ui.painter().rect_filled(rect.expand(3.0), 14.0, soft(p.accent, (30.0 * h) as u8)); - } ui.painter().rect_filled(rect, 11.0, bg); // Off sits at the start of the reading direction, on at the far end. let (off, on_x) = if rtl() { (rect.right() - 11.0, rect.left() + 11.0) } else { (rect.left() + 11.0, rect.right() - 11.0) }; let x = egui::lerp(off..=on_x, t); let knob = egui::pos2(x, rect.center().y); - ui.painter().circle_filled(knob + Vec2::new(0.0, 1.0), 8.5, Color32::from_black_alpha(40)); - ui.painter().circle_filled(knob, 8.0 + h * 0.5, Color32::WHITE); + ui.painter().circle_filled(knob + Vec2::new(0.0, 1.0), 9.0, Color32::from_black_alpha(36)); + ui.painter().circle_filled(knob, 9.0, Color32::WHITE); if resp.hovered() && ui.is_enabled() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } @@ -1481,7 +1566,7 @@ pub fn toggle(ui: &mut Ui, on: &mut bool, label: &str) -> egui::Response { resp } -/// A bordered single-line field of CONTROL_H height: optional leading icon, a clear button +/// A filled single-line field of CONTROL_H height: optional leading icon, a clear button /// when there is text (search fields), and an accent border while focused. fn field(ui: &mut Ui, text: &mut String, hint: &str, width: f32, lead: Option<&str>) -> egui::Response { let hint = tr(hint); @@ -1511,20 +1596,10 @@ fn field(ui: &mut Ui, text: &mut String, hint: &str, width: f32, lead: Option<&s } let f = ui.ctx().animate_bool_with_time(r.id.with("focus"), r.has_focus(), FADE); let h = ui.ctx().animate_bool_with_time(r.id.with("hover"), r.hovered(), FADE); - let stroke = Stroke::new(1.0 + 0.5 * f, p.border.lerp_to_gamma(p.faint, h * 0.6).lerp_to_gamma(p.accent, f)); - let mut shapes = Vec::new(); - if f > 0.0 { - // A soft focus ring outside the border. - shapes.push(egui::Shape::Rect(egui::epaint::RectShape::new( - rect.expand(3.0), - 10.0, - Color32::TRANSPARENT, - Stroke::new(3.0, soft(p.accent, (50.0 * f) as u8)), - egui::StrokeKind::Inside, - ))); - } - shapes.push(egui::Shape::Rect(egui::epaint::RectShape::new(rect, 8.0, p.surface, stroke, egui::StrokeKind::Inside))); - ui.painter().set(bg, egui::Shape::Vec(shapes)); + // A filled entry; focus draws Adwaita's translucent accent outline around it. + let fill = control_fill(&p, h * 0.5, false); + let ring = Stroke::new(2.0, soft(p.accent, (128.0 * f) as u8)); + ui.painter().set(bg, egui::Shape::Rect(egui::epaint::RectShape::new(rect, RADIUS as f32, fill, ring, egui::StrokeKind::Outside))); r } @@ -1533,7 +1608,7 @@ pub fn search_field(ui: &mut Ui, text: &mut String, hint: &str, width: f32) -> e field(ui, text, hint, width + 48.0, Some(icon::SEARCH)) } -/// Text input with a border, for forms. `width` is the whole field's width. +/// Text input, for forms. `width` is the whole field's width. pub fn text_input(ui: &mut Ui, text: &mut String, hint: &str, width: f32) -> egui::Response { field(ui, text, hint, width, None) } From 3e6c24d9c5789e9c0d4523fbbab14049f7cb4cae Mon Sep 17 00:00:00 2001 From: sudo-py-dev Date: Thu, 29 Oct 2026 17:18:16 +0200 Subject: [PATCH 2/4] Tidy: rustfmt, drop an unused icon, clear clippy warnings - A `Cell` type for table cells and a `Summary` alias for the leftovers result - The trace session is boxed so the trace page's state stays small - `hunter` options in a `HunterArgs` struct, like `UninstallArgs` - Small idiom fixes (char pattern, struct init in a test) No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- src/gui/autorun.rs | 2 +- src/gui/cleaner.rs | 2 +- src/gui/style.rs | 20 +++++++++++----- src/gui/traced.rs | 4 ++-- src/gui/uninstaller.rs | 7 ++++-- src/i18n.rs | 2 +- src/main.rs | 54 +++++++++++++++++++++++------------------- src/uninstaller/mod.rs | 3 +-- src/watcher.rs | 3 ++- 9 files changed, 56 insertions(+), 41 deletions(-) diff --git a/src/gui/autorun.rs b/src/gui/autorun.rs index 2a7a774..ce0fc09 100644 --- a/src/gui/autorun.rs +++ b/src/gui/autorun.rs @@ -379,7 +379,7 @@ impl AutorunView { body.rows(66.0, rows.len(), |mut row| { let e = &rows[row.index()]; row.set_selected(self.focus.as_deref() == Some(&e.id)); - let mut cells: Vec> = vec![ + let mut cells: Vec = vec![ Box::new(|ui: &mut Ui| { let mut on = e.enabled; ui.add_enabled_ui(e.source != Source::RunOnce, |ui| { diff --git a/src/gui/cleaner.rs b/src/gui/cleaner.rs index cadd7c5..6e78084 100644 --- a/src/gui/cleaner.rs +++ b/src/gui/cleaner.rs @@ -591,7 +591,7 @@ impl CleanerView { let mut all = n_sel == idx.len(); let cb = egui::Checkbox::new(&mut all, style::vis("")).indeterminate(n_sel > 0 && n_sel < idx.len()); let r = ui.add(cb); - r.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, ui.is_enabled(), all, &name)); + r.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, ui.is_enabled(), all, name)); if r.changed() { idx.iter().for_each(|i| checked[*i] = all); } diff --git a/src/gui/style.rs b/src/gui/style.rs index 2b5aedd..22dd66f 100644 --- a/src/gui/style.rs +++ b/src/gui/style.rs @@ -39,7 +39,6 @@ pub mod icon { pub const WARNING: &str = "\u{E4E0}"; pub const X_CIRCLE: &str = "\u{E4F8}"; pub const INFO: &str = "\u{E2CE}"; - pub const BELL: &str = "\u{E0CE}"; pub const PLAY: &str = "\u{E3D0}"; pub const PAUSE: &str = "\u{E39E}"; pub const RECORD: &str = "\u{E3EE}"; @@ -661,8 +660,11 @@ impl ColumnsInOrder for egui_extras::TableBuilder<'_> { } } +/// What draws one table cell. +pub type Cell<'a> = Box; + /// A table row's cells, given in left-to-right order, added in reading order. -pub fn cells_in_order<'a>(row: &mut egui_extras::TableRow<'_, '_>, cells: Vec>) { +pub fn cells_in_order(row: &mut egui_extras::TableRow<'_, '_>, cells: Vec>) { for c in ordered(cells) { row.col(c); } @@ -965,8 +967,9 @@ pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str) -> egui::Response { let p = Palette::get(ui.ctx()); let (t, pressed) = interaction(ui, ui.next_auto_id()); let color = p.muted.lerp_to_gamma(p.text, t); - let r = - ui.add(egui::Button::new(vis(rich(icon).size(17.0).color(color))).fill(hover_fill(&p, t, pressed)).stroke(Stroke::NONE).corner_radius(RADIUS).min_size(Vec2::new(32.0, 32.0))); + let r = ui.add( + egui::Button::new(vis(rich(icon).size(17.0).color(color))).fill(hover_fill(&p, t, pressed)).stroke(Stroke::NONE).corner_radius(RADIUS).min_size(Vec2::new(32.0, 32.0)), + ); r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); r.on_hover_text(vis(tip)) } @@ -976,7 +979,8 @@ pub fn close_button(ui: &mut Ui, tip: &str) -> egui::Response { let tip = tr(tip); let p = Palette::get(ui.ctx()); let (t, pressed) = interaction(ui, ui.next_auto_id()); - let r = ui.add(egui::Button::new(vis(rich(icon::X).size(13.0).color(p.text))).fill(control_fill(&p, t, pressed)).stroke(Stroke::NONE).corner_radius(12).min_size(Vec2::splat(24.0))); + let r = ui + .add(egui::Button::new(vis(rich(icon::X).size(13.0).color(p.text))).fill(control_fill(&p, t, pressed)).stroke(Stroke::NONE).corner_radius(12).min_size(Vec2::splat(24.0))); r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); r.on_hover_text(vis(tip)) } @@ -1547,7 +1551,11 @@ pub fn toggle(ui: &mut Ui, on: &mut bool, label: &str) -> egui::Response { if ui.is_rect_visible(rect) { let t = ui.ctx().animate_bool_responsive(resp.id, *on); let h = ui.ctx().animate_bool_with_time(resp.id.with("hover"), resp.hovered() && ui.is_enabled(), FADE); - let off_bg = if p.dark { Color32::from_rgb(70, 70, 75).lerp_to_gamma(Color32::from_rgb(84, 84, 90), h) } else { Color32::from_rgb(214, 214, 218).lerp_to_gamma(Color32::from_rgb(200, 200, 205), h) }; + let off_bg = if p.dark { + Color32::from_rgb(70, 70, 75).lerp_to_gamma(Color32::from_rgb(84, 84, 90), h) + } else { + Color32::from_rgb(214, 214, 218).lerp_to_gamma(Color32::from_rgb(200, 200, 205), h) + }; let mut bg = off_bg.lerp_to_gamma(p.accent.lerp_to_gamma(Color32::WHITE, 0.08 * h), t); if !ui.is_enabled() { bg = bg.gamma_multiply(0.5); diff --git a/src/gui/traced.rs b/src/gui/traced.rs index 5ff4031..9d6269c 100644 --- a/src/gui/traced.rs +++ b/src/gui/traced.rs @@ -20,7 +20,7 @@ pub enum TracedAction { enum TraceStep { Snapshot(Pending), - Tracing(Option), + Tracing(Option>), Analyzing(Pending<(Vec, usize)>), Review(Vec), } @@ -84,7 +84,7 @@ impl TraceScreen { // Advance background steps if let TraceStep::Snapshot(pend) = &mut self.step { if let Some(session) = pend.poll() { - self.step = TraceStep::Tracing(Some(session)); + self.step = TraceStep::Tracing(Some(Box::new(session))); } } if let TraceStep::Analyzing(pend) = &mut self.step { diff --git a/src/gui/uninstaller.rs b/src/gui/uninstaller.rs index a6cb5ed..5ca16c8 100644 --- a/src/gui/uninstaller.rs +++ b/src/gui/uninstaller.rs @@ -37,6 +37,9 @@ enum Step { Done, } +/// What removing the leftovers came to: how many went, the backup they went to, and what failed. +type Summary = (usize, String, Vec<(String, String)>); + struct Wizard { program: Option, label: String, @@ -57,7 +60,7 @@ struct Wizard { leftovers: Vec, checked: Vec, removing: Option>>, - summary: Option<(usize, String, Vec<(String, String)>)>, + summary: Option, /// Uninstalling with a trace log (deleted afterwards if so configured). log_id: Option, /// The Advanced Uninstall follow-up scan was already offered and run. @@ -773,7 +776,7 @@ impl UninstallerView { let prog = &self.programs[visible[row.index()]]; let ticked = self.checked.contains(&prog.id); row.set_selected(ticked); - let cells: Vec> = vec![ + let cells: Vec = vec![ Box::new(|ui: &mut Ui| { let mut c = self.checked.contains(&prog.id); let r = ui.checkbox(&mut c, style::vis("")); diff --git a/src/i18n.rs b/src/i18n.rs index df96159..22073cf 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -520,7 +520,7 @@ msgstr "Zeile \"eins\"\n" for marker in ["tr(\"", "tf!(\""] { for (at, _) in text.match_indices(marker) { let rest = &text[at + marker.len()..]; - let Some(end) = rest.find(|c| c == '"').filter(|_| !rest.starts_with('"')) else { continue }; + let Some(end) = rest.find('"').filter(|_| !rest.starts_with('"')) else { continue }; // Find the closing quote, skipping escaped ones. let mut j = 0; let b = rest.as_bytes(); diff --git a/src/main.rs b/src/main.rs index 0583c27..9c72c47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -120,28 +120,7 @@ enum Cmd { #[command(subcommand)] Autorun(AutorunCmd), /// Hunter Mode: identify a running program and act on it - Hunter { - /// List programs with a window - #[arg(long)] - list: bool, - /// With --list: every process of the desktop session, not just applications - #[arg(long)] - all: bool, - /// Open Hunter Mode in the GUI (falls back to --list without the GUI) - #[arg(long)] - gui: bool, - /// Target process id - #[arg(long)] - pid: Option, - /// Use the window under the mouse pointer (Windows; Linux on X11) - #[arg(long)] - at_pointer: bool, - /// info | uninstall | stop-autorun | kill | kill-delete | folder | search - #[arg(long, default_value = "info")] - action: String, - #[arg(short, long)] - yes: bool, - }, + Hunter(HunterArgs), /// Logs database: shared trace logs #[command(subcommand)] Logsdb(LogsDbCmd), @@ -573,6 +552,30 @@ enum TraceCmd { }, } +#[derive(Args)] +struct HunterArgs { + /// List programs with a window + #[arg(long)] + list: bool, + /// With --list: every process of the desktop session, not just applications + #[arg(long)] + all: bool, + /// Open Hunter Mode in the GUI (falls back to --list without the GUI) + #[arg(long)] + gui: bool, + /// Target process id + #[arg(long)] + pid: Option, + /// Use the window under the mouse pointer (Windows; Linux on X11) + #[arg(long)] + at_pointer: bool, + /// info | uninstall | stop-autorun | kill | kill-delete | folder | search + #[arg(long, default_value = "info")] + action: String, + #[arg(short, long)] + yes: bool, +} + #[derive(Args)] struct UninstallArgs { /// Program name(s) as listed by `cleancore programs`; several = Multiple Uninstall @@ -1268,7 +1271,7 @@ fn run(cli: Cli) -> Result<()> { Cmd::Trace(t) => run_trace(&settings, t)?, Cmd::Extensions(c) => run_extensions(&settings, c)?, Cmd::Autorun(c) => run_autorun(&settings, c)?, - Cmd::Hunter { list, all, gui, pid, at_pointer, action, yes } => run_hunter(gui, &settings, list, all, pid, at_pointer, &action, yes)?, + Cmd::Hunter(a) => run_hunter(&settings, a)?, Cmd::Logsdb(c) => run_logsdb(&settings, c)?, Cmd::Junk(c) => run_junk(&settings, c)?, Cmd::Dupes(c) => run_dupes(&settings, c)?, @@ -1598,7 +1601,8 @@ fn run_autorun(settings: &Settings, cmd: AutorunCmd) -> Result<()> { Ok(()) } -fn run_hunter(gui: bool, settings: &Settings, list: bool, all: bool, pid: Option, at_pointer: bool, action: &str, yes: bool) -> Result<()> { +fn run_hunter(settings: &Settings, a: HunterArgs) -> Result<()> { + let HunterArgs { list, all, gui, pid, at_pointer, action, yes } = a; use cleancore::hunter; if gui { if cfg!(feature = "gui") { @@ -1629,7 +1633,7 @@ fn run_hunter(gui: bool, settings: &Settings, list: bool, all: bool, pid: Option for a in &t.autoruns { println!(" Starts automatically: {} [{}]{}", a.name, a.source.label(), if a.enabled { "" } else { " (disabled)" }); } - match action { + match action.as_str() { "info" => {} "folder" => hunter::open_folder(&t)?, "search" => hunter::search_online(&t)?, diff --git a/src/uninstaller/mod.rs b/src/uninstaller/mod.rs index cd162ac..952c777 100644 --- a/src/uninstaller/mod.rs +++ b/src/uninstaller/mod.rs @@ -1025,8 +1025,7 @@ mod tests { fn store_apps_alone_make_no_restore_point() { let app = Program::new("a", "Quick Assist", ProgramKind::WindowsApp); let win32 = Program::new("w", "Notepad++", ProgramKind::Win32); - let mut s = Settings::default(); - s.uninstall_restore_point = true; + let mut s = Settings { uninstall_restore_point: true, ..Settings::default() }; assert!(!settings_for(&s, [&app]).uninstall_restore_point); assert!(!settings_for(&s, &vec![app.clone(), app.clone()]).uninstall_restore_point); assert!(settings_for(&s, [&app, &win32]).uninstall_restore_point); diff --git a/src/watcher.rs b/src/watcher.rs index 0c694eb..564f249 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -499,7 +499,8 @@ mod tests { fn diff_detects_all_kinds() { let old = snap(&[("A", &[("x", "1"), ("y", "2")]), ("B", &[])]); let new = snap(&[("A", &[("x", "1"), ("y", "3"), ("z", "9")]), ("C", &[("q", "0")])]); - let ev: Vec<(Op, String, Option, Option, Option)> = diff(&old, &new).into_iter().map(|e| (e.op, e.key, e.value, e.old_data, e.new_data)).collect(); + type Event = (Op, String, Option, Option, Option); + let ev: Vec = diff(&old, &new).into_iter().map(|e| (e.op, e.key, e.value, e.old_data, e.new_data)).collect(); let s = |x: &str| Some(x.to_string()); assert!(ev.contains(&(Op::ValueSet, "A".into(), s("y"), s("2"), s("3")))); assert!(ev.contains(&(Op::ValueSet, "A".into(), s("z"), None, s("9")))); From f835a168b84f27f3aa69dc6db98586bd60531fc7 Mon Sep 17 00:00:00 2001 From: sudo-py-dev Date: Thu, 29 Oct 2026 17:28:12 +0200 Subject: [PATCH 3/4] Autorun: the Details table keeps to its columns - The row's three icon buttons fit their 120-point column (they ran off the page edge in Hebrew) - A long program name is cut short, leaving room for its badges, instead of widening the cell and clipping the command and location under it - The Program column may shrink to 180 points, so the table fits the smallest window instead of pushing the On switches under the sidebar Co-Authored-By: Claude Opus 5.5 --- src/gui/autorun.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/gui/autorun.rs b/src/gui/autorun.rs index ce0fc09..dad4053 100644 --- a/src/gui/autorun.rs +++ b/src/gui/autorun.rs @@ -353,7 +353,7 @@ impl AutorunView { let wide = ui.available_width() >= 960.0; let mut columns = vec![ Column::exact(56.0), - Column::remainder().at_least(240.0).clip(true), + Column::remainder().at_least(180.0).clip(true), Column::exact(140.0).clip(true), Column::exact(150.0).clip(true), Column::exact(160.0).clip(true), @@ -393,7 +393,15 @@ impl AutorunView { ui.spacing_mut().item_spacing.y = 2.0; ui.spacing_mut().interact_size.y = 18.0; crate::gui::style::row(ui, |ui| { - ui.add(style::Label::new(style::strong(e.display_name()).color(if e.enabled { p.text } else { p.faint }))); + // A long name is cut short, leaving room for the badges, so the cell keeps to its column. + let gap = ui.spacing().item_spacing.x; + let badges = + [(e.running, "Running"), (!e.valid, "Program missing")].iter().filter(|b| b.0).map(|b| style::badge_width(ui, b.1) + gap).sum::() + + if e.all_users { 14.0 + gap } else { 0.0 }; + ui.scope(|ui| { + ui.set_max_width((ui.available_width() - badges).max(60.0)); + ui.add(style::Label::new(style::strong(e.display_name()).color(if e.enabled { p.text } else { p.faint })).truncate()); + }); if e.running { style::badge(ui, "Running", p.success); } @@ -417,6 +425,9 @@ impl AutorunView { }), Box::new(|ui: &mut Ui| { crate::gui::style::row(ui, |ui| { + // Three 32-point buttons fit the 120-point column only without their text padding. + ui.spacing_mut().button_padding.x = 6.0; + ui.spacing_mut().item_spacing.x = 4.0; let tip = if e.removable { tr("Remove (backed up)") } else { tr("Can't be deleted: disable it instead") }; ui.add_enabled_ui(e.removable, |ui| { if style::icon_button(ui, icon::TRASH, tip).clicked() { From 6889e5e490fc4bda0d6875de105c3ba381df8f91 Mon Sep 17 00:00:00 2001 From: sudo-py-dev Date: Thu, 29 Oct 2026 17:39:29 +0200 Subject: [PATCH 4/4] Autorun: badges shrink to icons when the Program column is narrow An all-users entry with a missing program needs more than the column's 180-point minimum with full badges, so one could be clipped. When the badges leave the name under 100 points they turn into icons that name themselves on hover (and to screen readers). Co-Authored-By: Claude Opus 5.5 --- src/gui/autorun.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/gui/autorun.rs b/src/gui/autorun.rs index dad4053..5e0adb9 100644 --- a/src/gui/autorun.rs +++ b/src/gui/autorun.rs @@ -394,19 +394,26 @@ impl AutorunView { ui.spacing_mut().interact_size.y = 18.0; crate::gui::style::row(ui, |ui| { // A long name is cut short, leaving room for the badges, so the cell keeps to its column. + // When even the badges don't fit, they shrink to icons that name themselves on hover. let gap = ui.spacing().item_spacing.x; - let badges = - [(e.running, "Running"), (!e.valid, "Program missing")].iter().filter(|b| b.0).map(|b| style::badge_width(ui, b.1) + gap).sum::() - + if e.all_users { 14.0 + gap } else { 0.0 }; + let marks = [(e.running, "Running", icon::CHECK_CIRCLE, p.success), (!e.valid, "Program missing", icon::WARNING, p.danger)]; + let marks: Vec<_> = marks.into_iter().filter(|m| m.0).collect(); + let users = if e.all_users { 14.0 + gap } else { 0.0 }; + let full = marks.iter().map(|m| style::badge_width(ui, m.1) + gap).sum::() + users; + let compact = ui.available_width() - full < 100.0; + let badges = if compact { marks.len() as f32 * (16.0 + gap) + users } else { full }; ui.scope(|ui| { - ui.set_max_width((ui.available_width() - badges).max(60.0)); + ui.set_max_width((ui.available_width() - badges).max(40.0)); ui.add(style::Label::new(style::strong(e.display_name()).color(if e.enabled { p.text } else { p.faint })).truncate()); }); - if e.running { - style::badge(ui, "Running", p.success); - } - if !e.valid { - style::badge(ui, "Program missing", p.danger); + for (_, label, glyph, color) in marks { + if compact { + let r = ui.add(style::Label::new(style::rich(glyph).color(color).size(14.0))); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tr(label))); + r.on_hover_text(style::vis(tr(label))); + } else { + style::badge(ui, label, color); + } } if e.all_users { ui.add(style::Label::new(style::rich(icon::USERS).color(p.faint).size(13.0))).on_hover_text(style::vis(tr("For all users")));