What That Claude DO?! Screenshot tooling. Also there is a visual bug when creating a screenshot when moving

This commit is contained in:
2026-05-03 23:58:55 +02:00
parent 01057c1c38
commit 7fe112293e
12 changed files with 5939 additions and 1 deletions
+358
View File
@@ -0,0 +1,358 @@
//! 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,
)
}