//! Right-click context menu — gpicview-inspired, with SVG icons. //! //! Items (in order): //! Open With… → opens with system default app (xdg-open) //! ───────────── //! Copy Path → clipboard: file path //! Copy Image → clipboard: image pixels //! ───────────── //! Rotate 90° CW //! Rotate 90° CCW //! ───────────── //! Set as Wallpaper //! Move to Trash → deletes file, loads next //! ───────────── //! Properties → shows info modal use iced::widget::{button, column, container, text}; use iced::{Element, Length}; use super::icons::Icon; use super::theme; const MENU_WIDTH: f32 = 200.0; const ITEM_HEIGHT: f32 = 28.0; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContextMenuItem { OpenWith, CopyPath, CopyImage, CopyToPictures, RotateCw, RotateCcw, SetAsWallpaper, MoveToTrash, DeletePermanently, Properties, About, ExportToVideo, } #[derive(Debug, Clone, Copy)] pub enum ContextMenuMessage { Show { x: f32, y: f32 }, Selected(ContextMenuItem), } #[derive(Debug, Clone, Default)] pub struct ContextMenu { pub visible: bool, pub x: f32, pub y: f32, } impl ContextMenu { pub fn new() -> Self { Self::default() } pub fn show(&mut self, x: f32, y: f32, window_w: f32, window_h: f32) { // Clamp so the menu stays on screen. let approx_menu_h = 14.0 * ITEM_HEIGHT + 16.0; // 12 items + padding self.x = x.min(window_w - MENU_WIDTH - 8.0).max(8.0); self.y = y.min(window_h - approx_menu_h - 8.0).max(8.0); self.visible = true; } pub fn dismiss(&mut self) { self.visible = false; } /// Render the menu content (without positioning). The app layer handles /// the overlay backdrop. pub fn content_view(&self) -> Element<'static, ContextMenuMessage> { let icon_color = theme::text_secondary_hex(); let danger_color = theme::danger_hex(); let items: Vec<(Option, &'static str, ContextMenuItem, bool)> = vec![ (Some(Icon::OpenWith), "Open With…", ContextMenuItem::OpenWith, false), (None, "", ContextMenuItem::OpenWith, true), // separator (Some(Icon::Copy), "Copy Path", ContextMenuItem::CopyPath, false), (Some(Icon::Copy), "Copy Image", ContextMenuItem::CopyImage, false), (Some(Icon::FolderOpen), "Copy to Pictures", ContextMenuItem::CopyToPictures, false), (None, "", ContextMenuItem::CopyPath, true), // separator (Some(Icon::RotateCw), "Rotate 90° CW", ContextMenuItem::RotateCw, false), (Some(Icon::RotateCcw), "Rotate 90° CCW", ContextMenuItem::RotateCcw, false), (None, "", ContextMenuItem::RotateCcw, true), // separator (Some(Icon::Wallpaper), "Set as Wallpaper", ContextMenuItem::SetAsWallpaper, false), (Some(Icon::Trash), "Move to Trash", ContextMenuItem::MoveToTrash, true), // danger (Some(Icon::Trash), "Delete Permanently", ContextMenuItem::DeletePermanently, true), // danger (None, "", ContextMenuItem::DeletePermanently, true), // separator (Some(Icon::Properties), "Properties", ContextMenuItem::Properties, false), (Some(Icon::Info), "About marten", ContextMenuItem::About, false), (None, "", ContextMenuItem::About, true), (Some(Icon::Film), "Export folder as video…", ContextMenuItem::ExportToVideo, false), ]; let children: Vec> = items .iter() .map(|(icon, label, msg, is_separator_or_danger)| { if *is_separator_or_danger && label.is_empty() { // Separator line container(text("").height(Length::Fixed(1.0))) .width(Length::Fill) .height(Length::Fixed(1.0)) .style(|_| container::Style { background: Some(theme::BG_CHROME_BORDER.into()), ..Default::default() }) .into() } else { let danger = matches!( *msg, ContextMenuItem::MoveToTrash | ContextMenuItem::DeletePermanently ); let color_hex = if danger { danger_color.clone() } else { icon_color.clone() }; let label_color = if danger { theme::DANGER } else { theme::TEXT_PRIMARY }; let icon_el: Element<'static, ContextMenuMessage> = match icon { Some(ic) => ic.widget(14.0, &color_hex).into(), None => text("").width(Length::Fixed(14.0)).into(), }; let row = iced::widget::row![ container(icon_el).width(Length::Fixed(20.0)), text(*label).color(label_color).size(12), ] .align_y(iced::Alignment::Center) .spacing(6.0); button(row) .on_press(ContextMenuMessage::Selected(*msg)) .width(Length::Fill) .height(Length::Fixed(ITEM_HEIGHT)) .padding(iced::Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 10.0, }) .style(move |_theme, status| { let bg = match status { button::Status::Hovered if danger => Some(theme::DANGER_DIM), button::Status::Hovered => Some(theme::BG_CHROME_HOVER), button::Status::Pressed if danger => Some(theme::DANGER_DIM), button::Status::Pressed => Some(theme::ACCENT_DIM), _ => None, }; button::Style { background: bg.map(iced::Background::Color), ..Default::default() } }) .into() } }) .collect(); column(children) .padding(iced::Padding { top: 4.0, right: 0.0, bottom: 4.0, left: 0.0, }) .into() } /// Render the menu as a positioned overlay element. /// This is meant to be placed inside a stack/overlay by the app. pub fn overlay_view(&self) -> Element<'_, ContextMenuMessage> { let menu = container(self.content_view()) .width(Length::Fixed(MENU_WIDTH)) .style(|_| container::Style { background: Some(theme::BG_CHROME.into()), border: iced::Border { color: theme::BG_CHROME_BORDER, width: 1.0, radius: 6.0.into(), }, shadow: iced::Shadow { color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.6), offset: iced::Vector::new(0.0, 4.0), blur_radius: 16.0, }, ..Default::default() }); container(menu) .width(Length::Fill) .height(Length::Fill) .padding(iced::Padding { top: self.y, right: 0.0, bottom: 0.0, left: self.x, }) .style(|_| container::Style { background: Some(iced::Color::TRANSPARENT.into()), ..Default::default() }) .into() } }