//! Region selection overlay. //! //! Input modes (all available simultaneously): //! - Window pick: hover over a window → it highlights; single click captures it. //! - Drag: press and drag → rubber-band selection (overrides window pick). //! - Two-click: click once (anchor), move, click again → selection. //! //! Coordinate system: //! egui screen_rect == physical pixels of the monitor. //! capture::Region also uses physical pixels. //! Window rects from hyprctl are logical pixels — multiplied by scale to get physical. //! No scale division anywhere: Region handed to capture.rs is in physical pixels, //! and capture.rs crops from the physical full-screen capture directly. use eframe::egui::{self, Color32, CursorIcon, Pos2, Rect, Rounding, Stroke, Vec2}; use image::RgbaImage; use crate::capture::Region; use crate::hyprland::WindowRect; /// Result returned when the user completes or cancels the selection. #[derive(Debug)] pub enum SelectionResult { Selected(Region), Cancelled, } /// Input state machine. #[derive(Default)] enum SelectionState { #[default] Idle, Dragging { start: Pos2 }, AwaitingSecondClick { start: Pos2 }, Done { start: Pos2, end: Pos2 }, Cancelled, } const MAX_TEX: u32 = 2048; pub struct SelectionOverlay { background: egui::TextureHandle, /// Hyprland monitor scale (logical→physical). Used only for window rect conversion. scale: f32, state: SelectionState, result: Option, windows: Vec, hovered_window: Option, diag_printed: bool, } impl SelectionOverlay { pub fn new(cc: &eframe::CreationContext<'_>, background_snapshot: RgbaImage, scale: f32) -> Self { let tex_image = fit_to_max_texture(background_snapshot); let (tw, th) = tex_image.dimensions(); let color_image = egui::ColorImage::from_rgba_unmultiplied( [tw as usize, th as usize], tex_image.as_raw(), ); let texture = cc.egui_ctx .load_texture("background", color_image, egui::TextureOptions::LINEAR); let windows = crate::hyprland::active_workspace_windows(); Self { background: texture, scale, state: SelectionState::default(), result: None, windows, hovered_window: None, diag_printed: false, } } pub fn take_result(&mut self) -> Option { self.result.take() } /// Convert a hyprctl WindowRect (logical px) to an egui Rect (logical points). fn window_to_egui_rect(win: &WindowRect, _scale: f32) -> Rect { Rect::from_min_size( Pos2::new(win.x as f32, win.y as f32), Vec2::new(win.width as f32, win.height as f32), ) } } impl eframe::App for SelectionOverlay { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { ctx.set_cursor_icon(CursorIcon::Crosshair); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { self.state = SelectionState::Cancelled; } let screen_rect = ctx.screen_rect(); if !self.diag_printed { self.diag_printed = true; eprintln!( "[overlay diag] screen_rect={screen_rect:?} ppp={} scale={}", ctx.pixels_per_point(), self.scale, ); } let dim = Color32::from_black_alpha(140); const DRAG_THRESHOLD: f32 = 4.0; let (hover_pos, press_origin, primary_down, primary_released) = ctx.input(|i| { ( i.pointer.hover_pos(), i.pointer.press_origin(), i.pointer.primary_down(), i.pointer.primary_released(), ) }); let travel: f32 = match (press_origin, hover_pos) { (Some(o), Some(p)) => o.distance(p), _ => 0.0, }; let is_click = primary_released && travel <= DRAG_THRESHOLD; let is_drag = primary_down && travel > DRAG_THRESHOLD; // ── Window hover detection ──────────────────────────────────────────── self.hovered_window = None; if matches!(self.state, SelectionState::Idle) { if let Some(pos) = hover_pos { for (i, win) in self.windows.iter().enumerate().rev() { if Self::window_to_egui_rect(win, self.scale).contains(pos) { self.hovered_window = Some(i); break; } } } } // ── State transitions ───────────────────────────────────────────────── match &self.state { SelectionState::Idle => { if is_drag { self.hovered_window = None; self.state = SelectionState::Dragging { start: press_origin.unwrap_or_default(), }; } else if is_click { if let Some(idx) = self.hovered_window { let win = &self.windows[idx]; let rect = Self::window_to_egui_rect(win, self.scale).intersect(screen_rect); self.state = SelectionState::Done { start: rect.min, end: rect.max, }; } else { self.state = SelectionState::AwaitingSecondClick { start: press_origin.unwrap_or_default(), }; } } } SelectionState::Dragging { start } => { let start = *start; if primary_released { if let Some(end) = hover_pos { self.state = SelectionState::Done { start, end }; } else { self.state = SelectionState::Idle; } } if !primary_down && !primary_released { self.state = SelectionState::AwaitingSecondClick { start }; } } SelectionState::AwaitingSecondClick { start } => { let start = *start; if is_click { if let Some(end) = hover_pos { self.state = SelectionState::Done { start, end }; } } } SelectionState::Done { .. } | SelectionState::Cancelled => {} } // ── Current live rect ───────────────────────────────────────────────── let current_rect: Option = match &self.state { SelectionState::Dragging { start } => hover_pos.map(|p| Rect::from_two_pos(*start, p)), SelectionState::AwaitingSecondClick { start } => hover_pos.map(|p| Rect::from_two_pos(*start, p)), SelectionState::Done { start, end } => Some(Rect::from_two_pos(*start, *end)), _ => None, }; // ── Draw ────────────────────────────────────────────────────────────── egui::CentralPanel::default() .frame(egui::Frame::none()) .show(ctx, |ui| { let painter = ui.painter(); // 1. Background screenshot. painter.image( self.background.id(), screen_rect, Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), Color32::WHITE, ); // 2. Dim overlay. let active_rect = current_rect.or_else(|| { self.hovered_window .map(|i| Self::window_to_egui_rect(&self.windows[i], self.scale).intersect(screen_rect)) }); if let Some(sel) = active_rect { let s = sel.intersect(screen_rect); painter.rect_filled( Rect::from_min_max(screen_rect.min, Pos2::new(screen_rect.max.x, s.min.y)), Rounding::ZERO, dim, ); painter.rect_filled( Rect::from_min_max(Pos2::new(screen_rect.min.x, s.max.y), screen_rect.max), Rounding::ZERO, dim, ); painter.rect_filled( Rect::from_min_max( Pos2::new(screen_rect.min.x, s.min.y), Pos2::new(s.min.x, s.max.y), ), Rounding::ZERO, dim, ); painter.rect_filled( Rect::from_min_max( Pos2::new(s.max.x, s.min.y), Pos2::new(screen_rect.max.x, s.max.y), ), Rounding::ZERO, dim, ); let border_color = if self.hovered_window.is_some() && current_rect.is_none() { Color32::from_rgb(255, 190, 50) } else { Color32::from_rgb(100, 180, 255) }; painter.rect_stroke(s, Rounding::ZERO, Stroke::new(1.5, border_color)); // Size label in physical pixels. let label = format!("{} × {}", s.width().round() as u32, s.height().round() as u32); let lp = Pos2::new(s.min.x + 4.0, s.min.y - 18.0) .clamp(Pos2::ZERO, screen_rect.max); painter.text(lp, egui::Align2::LEFT_TOP, label, egui::FontId::monospace(13.0), Color32::WHITE); if let Some(idx) = self.hovered_window { if current_rect.is_none() { let title = &self.windows[idx].title; if !title.is_empty() { painter.text( Pos2::new(s.min.x + 4.0, s.min.y + 4.0), egui::Align2::LEFT_TOP, title, egui::FontId::proportional(12.0), Color32::from_rgba_unmultiplied(255, 190, 50, 220), ); } } } } else { painter.rect_filled(screen_rect, Rounding::ZERO, dim); } // 3. Crosshair. if let Some(pos) = hover_pos { let s = Stroke::new(1.0, Color32::from_white_alpha(180)); painter.line_segment( [Pos2::new(screen_rect.min.x, pos.y), Pos2::new(screen_rect.max.x, pos.y)], s); painter.line_segment( [Pos2::new(pos.x, screen_rect.min.y), Pos2::new(pos.x, screen_rect.max.y)], s); } // 4. Hint text. let hint = match &self.state { SelectionState::Idle if self.hovered_window.is_some() => "Click to capture window | Drag for custom selection | Esc to cancel", SelectionState::Idle => "Click or drag to select | Esc to cancel", SelectionState::Dragging { .. } => "Release to capture", SelectionState::AwaitingSecondClick { .. } => "Click to set the second corner | Esc to cancel", _ => "", }; if !hint.is_empty() { painter.text( Pos2::new(screen_rect.center().x, screen_rect.max.y - 28.0), egui::Align2::CENTER_BOTTOM, hint, egui::FontId::proportional(14.0), Color32::from_white_alpha(200), ); } }); // ── Resolve ─────────────────────────────────────────────────────────── match &self.state { SelectionState::Done { start, end } => { let rect = Rect::from_two_pos(*start, *end).intersect(screen_rect); if rect.width() > 2.0 && rect.height() > 2.0 { let ppp = ctx.pixels_per_point(); // egui coords are logical points — scale to physical pixels. let x = ((rect.min.x - screen_rect.min.x) * ppp).round() as i32; let y = ((rect.min.y - screen_rect.min.y) * ppp).round() as i32; let width = (rect.width() * ppp).round() as u32; let height = (rect.height() * ppp).round() as u32; eprintln!("[overlay] physical x={x} y={y} w={width} h={height}"); self.result = Some(SelectionResult::Selected(Region { x: x.max(0), y: y.max(0), width, height, })); ctx.send_viewport_cmd(egui::ViewportCommand::Close); } else { self.state = SelectionState::Idle; } } SelectionState::Cancelled => { self.result = Some(SelectionResult::Cancelled); ctx.send_viewport_cmd(egui::ViewportCommand::Close); } _ => {} } ctx.request_repaint_after(std::time::Duration::from_millis(16)); } fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] } } fn fit_to_max_texture(img: RgbaImage) -> RgbaImage { let (w, h) = img.dimensions(); if w <= MAX_TEX && h <= MAX_TEX { return img; } let scale = (MAX_TEX as f32 / w as f32).min(MAX_TEX as f32 / h as f32); image::imageops::resize( &img, (w as f32 * scale) as u32, (h as f32 * scale) as u32, image::imageops::FilterType::Triangle, ) }