360 lines
15 KiB
Rust
360 lines
15 KiB
Rust
//! 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 = 8192;
|
||
|
||
pub struct SelectionOverlay {
|
||
background: Option<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: Option<RgbaImage>, scale: f32) -> Self {
|
||
let texture = background_snapshot.map(|img| {
|
||
let tex_image = fit_to_max_texture(img);
|
||
let (tw, th) = tex_image.dimensions();
|
||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
||
[tw as usize, th as usize],
|
||
tex_image.as_raw(),
|
||
);
|
||
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 (if in freeze mode).
|
||
if let Some(bg) = &self.background {
|
||
painter.image(
|
||
bg.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,
|
||
);
|
||
|
||
// Only draw a stroke if we are actively dragging a selection.
|
||
if current_rect.is_some() {
|
||
painter.rect_stroke(s, Rounding::ZERO, Stroke::new(1.5, Color32::from_rgb(100, 180, 255)));
|
||
}
|
||
|
||
// 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,
|
||
)
|
||
}
|