//! # CorbelPurge GUI //! //! iced 0.13 dashboard GUI for the CorbelPurge security pipeline. //! //! Only built when the `gui` feature is enabled: //! //! ```bash //! cargo build --release --features gui --bin corbel-purge-gui //! ``` //! //! Layout (matches the design mockup): //! - 48 px header – brand + version + license badge //! - Collapsible left panel – PATHS / OPTIONS / CONSOLE sections //! - 280 px right sidebar – progress gauge, stats, RUN CONFIG //! - 56 px footer – action buttons + status bar use std::path::PathBuf; use corbel_purge::{Config, Pipeline, PipelineResult, ThreatClassification}; use iced::widget::{ button, column, container, row, scrollable, Space, text, text_input, toggler, }; use iced::{ Alignment, Border, Color, Element, Length, Padding, Task, Theme, }; use iced::widget::text::Style as TextStyle; use iced::widget::button::Style as ButtonStyle; // --------------------------------------------------------------------------- // Colour palette // --------------------------------------------------------------------------- mod colors { use super::Color; pub fn bg() -> Color { Color::from([0.051, 0.051, 0.051]) } pub fn panel() -> Color { Color::from([0.10, 0.10, 0.10 ]) } pub fn panel_hi() -> Color { Color::from([0.13, 0.13, 0.13 ]) } pub fn header_bg() -> Color { Color::from([0.07, 0.07, 0.07 ]) } pub fn footer_bg() -> Color { Color::from([0.07, 0.07, 0.07 ]) } pub fn gold() -> Color { Color::from([0.83, 0.69, 0.22 ]) } pub fn white() -> Color { Color::from([0.92, 0.92, 0.92 ]) } pub fn dim() -> Color { Color::from([0.55, 0.55, 0.58 ]) } pub fn green() -> Color { Color::from([0.35, 0.85, 0.45 ]) } pub fn red() -> Color { Color::from([0.90, 0.30, 0.30 ]) } pub fn yellow() -> Color { Color::from([0.95, 0.75, 0.25 ]) } pub fn teal() -> Color { Color::from([0.20, 0.65, 0.60 ]) } pub fn input_bg() -> Color { Color::from([0.12, 0.12, 0.12 ]) } pub fn border_dim() -> Color { Color::from([0.22, 0.22, 0.22 ]) } pub fn console_bg() -> Color { Color::from([0.06, 0.06, 0.06 ]) } } // --------------------------------------------------------------------------- // Style helpers (iced 0.13 closure-based API) // --------------------------------------------------------------------------- fn color_style(c: Color) -> impl Fn(&Theme) -> TextStyle { move |_t: &Theme| TextStyle { color: Some(c) } } fn panel_style() -> impl Fn(&Theme) -> iced::widget::container::Style { |_t: &Theme| iced::widget::container::Style { background: Some(colors::panel().into()), border: Border { color: colors::border_dim(), width: 1.0, radius: 4.0.into() }, ..iced::widget::container::Style::default() } } fn header_style() -> impl Fn(&Theme) -> iced::widget::container::Style { |_t: &Theme| iced::widget::container::Style { background: Some(colors::header_bg().into()), ..iced::widget::container::Style::default() } } fn footer_style() -> impl Fn(&Theme) -> iced::widget::container::Style { |_t: &Theme| iced::widget::container::Style { background: Some(colors::footer_bg().into()), border: Border { color: colors::border_dim(), width: 1.0, radius: 0.0.into() }, ..iced::widget::container::Style::default() } } fn console_style() -> impl Fn(&Theme) -> iced::widget::container::Style { |_t: &Theme| iced::widget::container::Style { background: Some(colors::console_bg().into()), border: Border { color: colors::border_dim(), width: 1.0, radius: 4.0.into() }, ..iced::widget::container::Style::default() } } fn section_hdr_style() -> impl Fn(&Theme) -> iced::widget::container::Style { |_t: &Theme| iced::widget::container::Style { background: Some(colors::panel_hi().into()), ..iced::widget::container::Style::default() } } /// iced 0.13 button style closures must accept `(&Theme, button::Status)`. fn gold_btn_style() -> impl Fn(&Theme, iced::widget::button::Status) -> ButtonStyle { move |_t: &Theme, _s: iced::widget::button::Status| ButtonStyle { background: Some(colors::gold().into()), text_color: Color::BLACK, border: Border { color: colors::gold(), width: 0.0, radius: 4.0.into() }, ..ButtonStyle::default() } } fn dim_btn_style() -> impl Fn(&Theme, iced::widget::button::Status) -> ButtonStyle { move |_t: &Theme, _s: iced::widget::button::Status| ButtonStyle { background: Some(colors::panel_hi().into()), text_color: colors::dim(), border: Border { color: colors::border_dim(), width: 1.0, radius: 4.0.into() }, ..ButtonStyle::default() } } fn teal_btn_style() -> impl Fn(&Theme, iced::widget::button::Status) -> ButtonStyle { move |_t: &Theme, _s: iced::widget::button::Status| ButtonStyle { background: Some(colors::teal().into()), text_color: Color::BLACK, border: Border { color: colors::teal(), width: 0.0, radius: 4.0.into() }, ..ButtonStyle::default() } } fn green_badge_style() -> impl Fn(&Theme) -> iced::widget::container::Style { |_t: &Theme| iced::widget::container::Style { background: Some(colors::green().into()), border: Border { color: colors::green(), width: 1.0, radius: 3.0.into() }, ..iced::widget::container::Style::default() } } fn separator_style() -> impl Fn(&Theme) -> iced::widget::container::Style { |_t: &Theme| iced::widget::container::Style { background: Some(colors::border_dim().into()), ..iced::widget::container::Style::default() } } /// Shorthand: coloured text. /// /// `size` is `f32` to match iced 0.13's `Text::size` signature. fn t(content: impl ToString, size: f32, color: Color) -> iced::widget::Text<'static> { text(content.to_string()).size(size).style(color_style(color)) } /// Shorthand: f32 padding (iced 0.13 Padding fields are f32). fn pad(t: f32, r: f32, b: f32, l: f32) -> Padding { Padding { top: t, right: r, bottom: b, left: l } } // --------------------------------------------------------------------------- // Application // --------------------------------------------------------------------------- fn main() -> iced::Result { iced::application("CorbelPurge", update, view) .theme(|_| Theme::Dark) .window_size((1100.0, 750.0)) .run() } // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- #[derive(Debug, Default)] struct CorbelGui { input_path: String, output_path: String, preserve_format: bool, strip_metadata: bool, recursive: bool, abort_on_threat: bool, paths_open: bool, options_open: bool, console_open: bool, about_open: bool, console_lines: Vec, is_processing: bool, last_result: Option, cleansed_content: Option, error: Option, processed: u32, cleaned: u32, copied: u32, errors: u32, } #[derive(Debug, Clone)] struct ConsoleLine { timestamp: String, tag: String, message: String, is_error: bool, } // --------------------------------------------------------------------------- // Messages // --------------------------------------------------------------------------- #[derive(Debug, Clone)] enum Message { InputPathChanged(String), OutputPathChanged(String), BrowseInput, BrowseOutput, PreserveFormatToggled(bool), StripMetadataToggled(bool), RecursiveToggled(bool), AbortOnThreatToggled(bool), TogglePaths, ToggleOptions, ToggleConsole, ToggleAbout, CloseAbout, StartProcessing, StopProcessing, ClearLog, FilePickedInput(Option), FilePickedOutput(Option), ScanCompleted(Result), OpenQuarantineFolder, } // --------------------------------------------------------------------------- // Update // --------------------------------------------------------------------------- fn update(state: &mut CorbelGui, msg: Message) -> Task { match msg { Message::InputPathChanged(s) => { state.input_path = s; Task::none() } Message::OutputPathChanged(s) => { state.output_path = s; Task::none() } Message::BrowseInput => Task::perform(pick_file(), Message::FilePickedInput), Message::BrowseOutput => Task::perform(pick_folder(), Message::FilePickedOutput), Message::FilePickedInput(p) => { if let Some(p) = p { state.input_path = p.display().to_string(); } Task::none() } Message::FilePickedOutput(p) => { if let Some(p) = p { state.output_path = p.display().to_string(); } Task::none() } Message::PreserveFormatToggled(b) => { state.preserve_format = b; Task::none() } Message::StripMetadataToggled(b) => { state.strip_metadata = b; Task::none() } Message::RecursiveToggled(b) => { state.recursive = b; Task::none() } Message::AbortOnThreatToggled(b) => { state.abort_on_threat = b; Task::none() } Message::TogglePaths => { state.paths_open = !state.paths_open; Task::none() } Message::ToggleOptions => { state.options_open = !state.options_open; Task::none() } Message::ToggleConsole => { state.console_open = !state.console_open; Task::none() } Message::ToggleAbout => { state.about_open = !state.about_open; Task::none() } Message::CloseAbout => { state.about_open = false; Task::none() } Message::ClearLog => { state.console_lines.clear(); state.processed = 0; state.cleaned = 0; state.copied = 0; state.errors = 0; Task::none() } Message::StopProcessing => { state.is_processing = false; Task::none() } Message::StartProcessing => { if state.input_path.is_empty() { state.console_lines.push(ConsoleLine { timestamp: now_hms(), tag: "ERROR".into(), message: "No input path specified.".into(), is_error: true, }); state.errors += 1; return Task::none(); } state.is_processing = true; state.error = None; let path = PathBuf::from(&state.input_path); let ws = if state.output_path.is_empty() { None } else { Some(PathBuf::from(&state.output_path)) }; let abort = state.abort_on_threat; let pf = state.preserve_format; state.console_lines.push(ConsoleLine { timestamp: now_hms(), tag: "INFO".into(), message: format!("corbel-purge v{} starting...", env!("CARGO_PKG_VERSION")), is_error: false, }); Task::perform(run_pipeline(path, ws, abort, pf), Message::ScanCompleted) } Message::ScanCompleted(result) => { state.is_processing = false; state.processed += 1; match result { Ok(pr) => { let malicious = pr.scan_report.malicious_count(); let total = pr.scan_report.findings.len(); let src = pr.source_path.as_ref() .map(|p| p.display().to_string()) .unwrap_or_else(|| "".into()); if malicious > 0 { state.console_lines.push(ConsoleLine { timestamp: now_hms(), tag: "ERROR".into(), message: format!("{} — {} finding(s) — cleaned", src, total), is_error: true, }); state.cleaned += 1; state.errors += 1; } else { state.console_lines.push(ConsoleLine { timestamp: now_hms(), tag: "OK".into(), message: format!("{} clean — copied", src), is_error: false, }); state.copied += 1; } if let Some(cp) = &pr.cleansed_path { match std::fs::read_to_string(cp) { Ok(c) => state.cleansed_content = Some(c), Err(e) => state.error = Some(format!("read cleansed: {e}")), } } state.last_result = Some(pr); } Err(e) => { state.console_lines.push(ConsoleLine { timestamp: now_hms(), tag: "ERROR".into(), message: e.clone(), is_error: true, }); state.errors += 1; state.error = Some(e); } } Task::none() } Message::OpenQuarantineFolder => { if let Some(r) = &state.last_result { if let Some(q) = &r.quarantine_path { if let Some(parent) = q.parent() { open_dir(parent); } } } Task::none() } } } // --------------------------------------------------------------------------- // View // --------------------------------------------------------------------------- fn view(state: &CorbelGui) -> Element<'_, Message> { let header = build_header(); let body = build_body(state); let footer = build_footer(state); let main = column![header, body, footer].spacing(0); if state.about_open { // About overlay takes over the view: dim backdrop + floating panel // anchored top-right. The main UI is hidden behind the dim layer // (iced 0.13 has no Stack widget for true non-modal overlays). build_about_overlay() } else { main.into() } } /// 48 px header. fn build_header<'a>() -> Element<'a, Message> { let brand = row![ t("[+]", 16.0, colors::gold()), t("CORBELPURGE", 16.0, colors::gold()), t(format!(" v{}", env!("CARGO_PKG_VERSION")), 12.0, colors::dim()), t(" // defensive document research & cleaning", 12.0, colors::dim()), ].spacing(6.0); let license_badge = container(t("GPL-3.0", 11.0, Color::BLACK)) .padding(pad(2.0, 8.0, 2.0, 8.0)) .style(green_badge_style()); let header_row = row![brand, row![].width(Length::Fill), license_badge] .align_y(Alignment::Center) .padding(pad(12.0, 16.0, 12.0, 16.0)); container(header_row) .width(Length::Fill) .height(48) .center_y(Length::Shrink) .style(header_style()) .into() } /// Main body: left panel + right sidebar. fn build_body(state: &CorbelGui) -> Element<'_, Message> { let left = build_left_panel(state); let right = build_right_sidebar(state); row![left, right].spacing(0).height(Length::Fill).into() } /// Collapsible left panel with PATHS / OPTIONS / CONSOLE. fn build_left_panel(state: &CorbelGui) -> Element<'_, Message> { let paths_section = collapsible_section( "PATHS", state.paths_open, Message::TogglePaths, build_paths_content(state), ); let options_section = collapsible_section( "OPTIONS", state.options_open, Message::ToggleOptions, build_options_content(state), ); let console_section = section_console(state); let left_col = column![paths_section, options_section, console_section] .spacing(4.0) .width(Length::Fill); container(left_col) .width(Length::Fill) .height(Length::Fill) .padding(pad(6.0, 6.0, 6.0, 6.0)) .into() } fn build_paths_content(state: &CorbelGui) -> Element<'_, Message> { let in_row = row![ t("IN", 12.0, colors::gold()), row![].width(Length::Fill), button(t("...", 14.0, colors::dim())) .style(dim_btn_style()) .padding(pad(4.0, 4.0, 4.0, 4.0)) .on_press(Message::BrowseInput), ] .spacing(8.0) .align_y(Alignment::Center); let in_input = text_input("/path/to/input", &state.input_path) .on_input(Message::InputPathChanged) .padding(pad(6.0, 6.0, 6.0, 6.0)) .size(12); let out_row = row![ t("OUT", 12.0, colors::gold()), row![].width(Length::Fill), button(t("...", 14.0, colors::dim())) .style(dim_btn_style()) .padding(pad(4.0, 4.0, 4.0, 4.0)) .on_press(Message::BrowseOutput), ] .spacing(8.0) .align_y(Alignment::Center); let out_input = text_input("/path/to/output (workspace)", &state.output_path) .on_input(Message::OutputPathChanged) .padding(pad(6.0, 6.0, 6.0, 6.0)) .size(12); column![in_row, in_input, out_row, out_input] .spacing(6.0) .padding(pad(8.0, 10.0, 8.0, 10.0)) .into() } fn build_options_content(state: &CorbelGui) -> Element<'_, Message> { let t1 = toggler(state.preserve_format) .label("PRESERVE FORMAT".to_string()) .on_toggle(Message::PreserveFormatToggled); let t2 = toggler(state.strip_metadata) .label("STRIP METADATA".to_string()) .on_toggle(Message::StripMetadataToggled); let t3 = toggler(state.recursive) .label("RECURSIVE".to_string()) .on_toggle(Message::RecursiveToggled); let t4 = toggler(state.abort_on_threat) .label("ABORT ON THREAT".to_string()) .on_toggle(Message::AbortOnThreatToggled); column![t1, t2, t3, t4] .spacing(8.0) .padding(pad(8.0, 10.0, 8.0, 10.0)) .into() } /// Console section (always visible, larger). fn section_console(state: &CorbelGui) -> Element<'_, Message> { let chevron = if state.console_open { "-" } else { "+" }; let hdr = container( button( row![ t(format!("{} # CONSOLE", chevron), 12.0, colors::gold()), ] .spacing(4.0) .align_y(Alignment::Center), ) .style(dim_btn_style()) .padding(pad(4.0, 8.0, 4.0, 8.0)) .on_press(Message::ToggleConsole), ) .width(Length::Fill) .style(section_hdr_style()) .padding(pad(0.0, 0.0, 0.0, 0.0)); let console_body: Element<'_, Message> = if state.console_open { let mut lines_col = column![].spacing(2.0); for line in &state.console_lines { let tag_color = if line.is_error { colors::red() } else { colors::green() }; lines_col = lines_col.push( row![ t(&line.timestamp, 11.0, colors::dim()), t(" ", 11.0, colors::dim()), t(format!("[{}]", line.tag), 11.0, tag_color), t(format!(" {}", line.message), 11.0, colors::white()), ].spacing(0.0), ); } if state.console_lines.is_empty() { lines_col = lines_col.push( t("# ready.", 12.0, colors::dim()), ); } scrollable(lines_col) .height(Length::Fill) .width(Length::Fill) .into() } else { column![].into() }; column![hdr, console_body] .spacing(0.0) .width(Length::Fill) .height(Length::FillPortion(3)) .into() } /// 280 px right sidebar. fn build_right_sidebar(state: &CorbelGui) -> Element<'_, Message> { let total = state.processed; let gauge_text = build_gauge(total); let processed_label = t(format!("{} PROCESSED", total), 11.0, colors::gold()); let stats = column![ stat_row("*", "CLEANED", state.cleaned, colors::gold()), stat_row(">", "COPIED", state.copied, colors::green()), stat_row("!", "ERRORS", state.errors, colors::red()), ] .spacing(10.0) .padding(pad(12.0, 14.0, 12.0, 14.0)); let in_display = if state.input_path.is_empty() { "(none)".to_string() } else { state.input_path.clone() }; let out_display = if state.output_path.is_empty() { "(cwd)".to_string() } else { state.output_path.clone() }; let mode = if state.preserve_format { "PreserveFormat" } else { "Markdown" }; let run_config = column![ t("RUN CONFIG", 11.0, colors::gold()), t(format!("IN: {}", in_display), 10.0, colors::dim()), t(format!("OUT: {}", out_display), 10.0, colors::dim()), t(format!("MODE: {}", mode), 10.0, colors::dim()), ] .spacing(4.0) .padding(pad(8.0, 14.0, 12.0, 14.0)); let findings_block = if let Some(r) = &state.last_result { let mut fcol = column![t("LAST SCAN", 11.0, colors::gold())].spacing(4.0); for finding in &r.scan_report.findings { let class_str = format!("{:?}", finding.classification); let color = match finding.classification { ThreatClassification::Malicious(_) => colors::red(), ThreatClassification::Suspicious => colors::yellow(), ThreatClassification::EducationalContent => colors::teal(), ThreatClassification::Benign => colors::dim(), }; fcol = fcol.push(row![ t("*", 10.0, color), t(class_str, 9.0, color), ].spacing(4.0)); fcol = fcol.push(t(format!(" {}", finding.location), 9.0, colors::dim())); // TODO #12: CVE badges — parse CVE tags from context_notes. let cve_tags = extract_cve_tags(&finding.context_notes); for tag in &cve_tags { fcol = fcol.push( container(t(tag, 8.0, Color::WHITE)) .padding(pad(1.0, 4.0, 1.0, 4.0)) .style(cve_badge_style()), ); fcol = fcol.push(t(" ", 6.0, colors::dim())); } } if r.scan_report.findings.is_empty() { fcol = fcol.push(t("(no findings)", 10.0, colors::dim())); } container(fcol).padding(pad(8.0, 14.0, 8.0, 14.0)).width(Length::Fill) } else { container(column![]).width(Length::Fill) }; let sidebar_col = column![ column![gauge_text, processed_label] .spacing(4.0) .align_x(Alignment::Center) .padding(pad(16.0, 0.0, 8.0, 0.0)), container("").height(1).width(Length::Fill).style(separator_style()), stats, container("").height(1).width(Length::Fill).style(separator_style()), run_config, container("").height(1).width(Length::Fill).style(separator_style()), findings_block, // TODO #4: EPUB/Markdown cleansed document viewer. build_cleansed_viewer(state), ] .spacing(0.0); container(scrollable(sidebar_col)) .width(280) .height(Length::Fill) .style(panel_style()) .into() } /// 56 px footer. fn build_footer(state: &CorbelGui) -> Element<'_, Message> { let start_btn = button( row![ t(">", 14.0, Color::BLACK), t("START PROCESSING", 12.0, Color::BLACK), ] .spacing(6.0) .align_y(Alignment::Center), ) .style(gold_btn_style()) .padding(pad(8.0, 16.0, 8.0, 16.0)) .on_press_maybe(if state.is_processing { None } else { Some(Message::StartProcessing) }); let stop_btn = button(t("STOP", 12.0, colors::dim())) .style(dim_btn_style()) .padding(pad(8.0, 16.0, 8.0, 16.0)) .on_press_maybe(if state.is_processing { Some(Message::StopProcessing) } else { None }); let clear_btn = button( row![ t("x", 13.0, Color::BLACK), t("CLEAR LOG", 12.0, Color::BLACK), ] .spacing(6.0) .align_y(Alignment::Center), ) .style(teal_btn_style()) .padding(pad(8.0, 16.0, 8.0, 16.0)) .on_press(Message::ClearLog); let about_btn = button( row![ t("i", 14.0, colors::dim()), t("ABOUT / LICENSE", 12.0, colors::dim()), ] .spacing(6.0) .align_y(Alignment::Center), ) .style(dim_btn_style()) .padding(pad(8.0, 16.0, 8.0, 16.0)) .on_press(Message::ToggleAbout); let buttons = row![start_btn, stop_btn, clear_btn, about_btn] .spacing(8.0) .align_y(Alignment::Center); let status_left = t( format!("CorbelPurge v{} - Defensive Document Research & Cleaning Tool", env!("CARGO_PKG_VERSION")), 10.0, colors::dim(), ); let status_right = t("GPL-3.0-or-later | Jeremy Anderson", 10.0, colors::dim()); let status_row = row![status_left, row![].width(Length::Fill), status_right] .align_y(Alignment::Center); let footer_col = column![buttons, status_row] .spacing(4.0) .padding(pad(6.0, 16.0, 6.0, 16.0)); container(footer_col) .width(Length::Fill) .height(56) .center_y(Length::Shrink) .style(footer_style()) .into() } // --------------------------------------------------------------------------- // Reusable widget builders // --------------------------------------------------------------------------- fn collapsible_section<'a>( label: &str, is_open: bool, toggle_msg: Message, content: Element<'a, Message>, ) -> Element<'a, Message> { let chevron = if is_open { "-" } else { "+" }; let display_label = format!("{} {}", chevron, label); let hdr = container( button(t(&display_label, 12.0, colors::gold())) .style(dim_btn_style()) .padding(pad(4.0, 8.0, 4.0, 8.0)) .on_press(toggle_msg), ) .width(Length::Fill) .style(section_hdr_style()) .padding(pad(0.0, 0.0, 0.0, 0.0)); if is_open { column![hdr, content].spacing(0.0).width(Length::Fill).into() } else { column![hdr].width(Length::Fill).into() } } fn stat_row(icon: &str, label: &str, count: u32, color: Color) -> Element<'static, Message> { row![ t(icon, 16.0, color), t(label, 11.0, colors::dim()), row![].width(Length::Fill), t(count.to_string(), 18.0, color), ] .spacing(6.0) .align_y(Alignment::Center) .into() } // --------------------------------------------------------------------------- // EPUB / Markdown cleansed document viewer (TODO #4 + #5) // --------------------------------------------------------------------------- fn build_cleansed_viewer(state: &CorbelGui) -> Element<'_, Message> { let content = match &state.cleansed_content { Some(c) => c, None => return container(column![]).into(), }; // Simple syntax highlighting for fenced code blocks (TODO #5). // We highlight code block backgrounds with a darker panel. let mut highlighted = String::new(); let mut in_code_block = false; for line in content.lines() { if line.starts_with("```") { in_code_block = !in_code_block; // Keep the fence line as-is. highlighted.push_str(line); highlighted.push('\n'); continue; } if in_code_block { // Prefix code lines with a marker that the console-style // dark background already provides visual distinction. highlighted.push_str(line); highlighted.push('\n'); } else { // Regular Markdown text — pass through. highlighted.push_str(line); highlighted.push('\n'); } } let viewer_content = scrollable( column![ t("CLEANSED DOCUMENT", 11.0, colors::gold()), Space::with_height(4), t(highlighted, 11.0, colors::white()), ] .spacing(2.0) ) .height(Length::FillPortion(4)); container(viewer_content) .padding(pad(8.0, 10.0, 8.0, 10.0)) .width(Length::Fill) .into() } // --------------------------------------------------------------------------- // About / License overlay // --------------------------------------------------------------------------- // // Floating info panel anchored to the top-right corner, modelled on the // ferret about-panel screenshot. Visual style: dark panel, gold border, // drop shadow, close (X) button in the top-right, structured metadata // (title, version, description, author, website, license, tech stack, // copyright). // // Implementation note: iced 0.13 has no Stack widget (true non-modal // overlays landed in 0.14), so we emulate the floating-panel feel with // a full-window dim backdrop. The panel sits in the top-right corner via // a row + Space::Fill layout. Close is via the X button in the panel // header; click-outside-to-close and ESC handling are left as future // enhancements (would require upgrading to iced 0.14+ or adding a // keyboard subscription). fn build_about_panel() -> Element<'static, Message> { // --- Header row: title + version ... [X] --- let title_row = row![ t("CORBELPURGE", 18.0, colors::gold()), t(format!(" v{}", env!("CARGO_PKG_VERSION")), 11.0, colors::dim()), row![].width(Length::Fill), button(t("X", 11.0, colors::gold())) .style(about_close_btn_style()) .padding(pad(2.0, 6.0, 2.0, 6.0)) .on_press(Message::CloseAbout), ] .spacing(6.0) .align_y(Alignment::Center); // --- Description --- let description = t( "Strict Rust document sanitizer & threat neutralizer\nfor PDF, EPUB, Markdown, and DOCX.", 11.0, colors::white(), ); // --- Metadata rows --- let meta_row = |label: &str, value: &str, value_color: Color| -> Element<'static, Message> { row![ t(format!("{}:", label), 10.0, colors::dim()), t(value.to_string(), 10.0, value_color), ] .spacing(6.0) .align_y(Alignment::Center) .into() }; let author_row = meta_row("Author", "Jeremy Anderson", colors::white()); let website_row = meta_row("Website", env!("CARGO_PKG_REPOSITORY"), colors::gold()); let license_row = meta_row("License", env!("CARGO_PKG_LICENSE"), colors::white()); // --- Separator (thin horizontal rule) --- let separator = container(Space::new(Length::Fill, 1.0)) .width(Length::Fill) .style(separator_style()); // --- Footer: tech stack + copyright --- let tech_stack = t( "Built with Rust, iced 0.13, lopdf, pulldown-cmark, and zip.", 9.0, colors::dim(), ); let copyright = t( "Copyright (c) 2026 Jeremy Anderson.", 9.0, colors::dim(), ); // --- Assemble panel --- let panel_body = column![ title_row, Space::with_height(8), description, Space::with_height(12), author_row, website_row, license_row, Space::with_height(10), separator, Space::with_height(8), tech_stack, copyright, ] .spacing(2.0) .width(320); container(panel_body) .style(about_panel_style()) .padding(pad(16.0, 18.0, 16.0, 18.0)) .width(320) .into() } /// Full-window overlay: dim backdrop + About panel anchored to the top-right /// corner. /// /// Click-outside-to-close is sacrificed here because iced 0.13 lacks a true /// Stack widget (added in 0.14) and wrapping the panel in a no-op button /// would make the panel's own labels close the modal on click. The X button /// in the panel header remains the close affordance; ESC handling can be /// added later via a keyboard subscription. fn build_about_overlay() -> Element<'static, Message> { let layout = row![ Space::with_width(Length::Fill), column![ Space::with_height(20.0), build_about_panel(), Space::with_height(Length::Fill), ] .width(320) .height(Length::Fill), Space::with_width(20.0), ] .align_y(Alignment::Start) .height(Length::Fill) .width(Length::Fill); container(layout) .width(Length::Fill) .height(Length::Fill) .style(about_dim_style()) .into() } fn build_gauge(processed: u32) -> Element<'static, Message> { let bar_width = 16usize; let filled = ((processed as usize).min(20) * bar_width) / 20; let empty = bar_width - filled; let bar_str = format!("{}{}", "#".repeat(filled), "-".repeat(empty)); column![ t(processed.to_string(), 36.0, colors::gold()), t(bar_str, 10.0, colors::gold()), ] .align_x(Alignment::Center) .spacing(4.0) .into() } // --------------------------------------------------------------------------- // Background tasks // --------------------------------------------------------------------------- async fn pick_file() -> Option { rfd::AsyncFileDialog::new() .add_filter("Documents", &["pdf", "epub", "md", "markdown", "docx"]) .set_title("Select input file") .pick_file() .await .map(|h| h.path().to_path_buf()) } async fn pick_folder() -> Option { rfd::AsyncFileDialog::new() .set_title("Select output directory") .pick_folder() .await .map(|h| h.path().to_path_buf()) } async fn run_pipeline( path: PathBuf, workspace: Option, abort_on_threat: bool, preserve_format: bool, ) -> Result { let mut config = match workspace { Some(p) => Config::with_workspace(p), None => Config::default(), }; config.abort_on_threat = abort_on_threat; if preserve_format { config.cleanse_mode = corbel_purge::CleanseMode::PreserveFormat; } let pipeline = Pipeline::with_config(config); tokio::task::spawn_blocking(move || pipeline.run(&path)) .await .map_err(|e| format!("task join error: {e}"))? .map_err(|e| e.to_string()) } // --------------------------------------------------------------------------- // CVE badge helper (TODO #12) // --------------------------------------------------------------------------- /// Extract CVE tags (e.g. `[CVE-2017-11882: Equation Editor RCE]`) /// from a context_notes string. fn extract_cve_tags(notes: &str) -> Vec { let mut tags = Vec::new(); let mut search_from = 0; while let Some(start) = notes[search_from..].find('[') { let rest = ¬es[search_from + start + 1..]; if let Some(end) = rest.find(']') { let tag = rest[..end].trim(); if tag.starts_with("CVE-") || tag.starts_with("EPUB-") { // Truncate long descriptions to just the CVE ID. let short = tag.split(':').next().unwrap_or(tag); tags.push(short.to_string()); } search_from += start + 1 + end + 1; } else { break; } } tags } /// Style for CVE badge containers. fn cve_badge_style() -> impl Fn(&Theme) -> iced::widget::container::Style { |_t: &Theme| iced::widget::container::Style { background: Some(colors::teal().into()), border: Border { color: colors::teal(), width: 1.0, radius: 3.0.into() }, ..iced::widget::container::Style::default() } } /// Dimmed backdrop behind the About overlay. Subtle alpha so the underlying /// UI is still partially visible — matches the "floating panel" feel of the /// ferret reference without requiring iced's Stack widget (added in 0.14). fn about_dim_style() -> impl Fn(&Theme) -> iced::widget::container::Style { |_t: &Theme| iced::widget::container::Style { background: Some(Color::from_rgba(0.0, 0.0, 0.0, 0.55).into()), ..iced::widget::container::Style::default() } } /// Style for the About panel itself: dark panel background, gold border, /// rounded corners — mirrors the ferret about-panel aesthetic. fn about_panel_style() -> impl Fn(&Theme) -> iced::widget::container::Style { |_t: &Theme| iced::widget::container::Style { background: Some(colors::panel().into()), border: Border { color: colors::gold(), width: 1.5, radius: 6.0.into() }, shadow: iced::Shadow { color: Color::from_rgba(0.0, 0.0, 0.0, 0.6), offset: iced::Vector::new(0.0, 4.0), blur_radius: 12.0, }, ..iced::widget::container::Style::default() } } /// Style for the small close (X) button in the About panel header. fn about_close_btn_style() -> impl Fn(&Theme, iced::widget::button::Status) -> ButtonStyle { move |_t: &Theme, _s: iced::widget::button::Status| ButtonStyle { background: Some(colors::panel_hi().into()), text_color: colors::gold(), border: Border { color: colors::gold(), width: 1.0, radius: 3.0.into() }, ..ButtonStyle::default() } } // Note: a `about_backdrop_btn_style` (transparent, click-outside-to-close // backdrop) was originally drafted for this overlay but is omitted because // iced 0.13 cannot layer a button under a sibling widget without making // the sibling's own clicks bubble up. Click-outside-to-close can be added // later by upgrading to iced 0.14+ (which has a Stack widget) or by // adding a keyboard subscription for the Escape key. // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn now_hms() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); let h = (secs / 3600) % 24; let m = (secs / 60) % 60; let s = secs % 60; format!("{:02}:{:02}:{:02}", h, m, s) } fn open_dir(path: &std::path::Path) { #[cfg(target_os = "linux")] { let _ = std::process::Command::new("xdg-open").arg(path).spawn(); } #[cfg(target_os = "macos")] { let _ = std::process::Command::new("open").arg(path).spawn(); } #[cfg(target_os = "windows")] { let _ = std::process::Command::new("explorer").arg(path).spawn(); } }