Files
What_That_Claude_Do/src/overlay.rs
T

359 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<SelectionResult>,
windows: Vec<WindowRect>,
hovered_window: Option<usize>,
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<SelectionResult> {
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<Rect> = 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,
)
}