#[cfg(debug_assertions)]
use std::{thread, time::Duration};
use anyhow::{Context, Result, bail, ensure};
use x11rb::{
COPY_DEPTH_FROM_PARENT, CURRENT_TIME, NONE, connect,
connection::Connection,
protocol::{
Event,
shape::{ConnectionExt as ShapeConnectionExt, SK, SO},
xproto::{
AtomEnum, ButtonPressEvent, ButtonReleaseEvent, ChangeWindowAttributesAux,
ClipOrdering, Colormap, ConfigureWindowAux, ConnectionExt, CreateGCAux,
CreateWindowAux, Cursor, EventMask, ExposeEvent, Gcontext, GrabMode, GrabStatus,
KeyButMask, KeyPressEvent, LineStyle, MotionNotifyEvent, PropMode, Rectangle,
StackMode, Window, WindowClass,
},
},
rust_connection::RustConnection,
wrapper::ConnectionExt as _,
};
use crate::pipeline::CaptureArea;
const OVERLAY_OPACITY: u32 = 0x8a00_0000;
const DEFAULT_SELECTION_MIN_SIZE: i32 = 80;
const DEFAULT_SELECTION_MAX_SIZE: i32 = 480;
const HANDLE_SIZE: i32 = 18;
const BORDER_LINE_WIDTH: u32 = 2;
const BORDER_SHAPE_PADDING: i32 = 8;
const LABEL_CHAR_WIDTH: i32 = 8;
const LABEL_TEXT_HEIGHT: i32 = 14;
const LABEL_PADDING: i32 = 12;
const LABEL_BOX_PADDING_X: i32 = 10;
const LABEL_BOX_PADDING_Y: i32 = 6;
const HELP_TITLE: &str = "Select recording area";
const HELP_TEXT: &str = "Drag anywhere, then refine with handles";
const SHORTCUT_TEXT: &str = "Drag to move Handles resize Enter confirms Esc cancels";
const CONFIRM_TEXT: &str = "CONFIRM CROP";
const CANCEL_TEXT: &str = "ESC CANCEL";
const ACTION_BUTTON_HEIGHT: i32 = 38;
const ACTION_BUTTON_PADDING_X: i32 = 18;
const ACTION_BUTTON_GAP: i32 = 12;
const ACTION_BUTTON_SHADOW_OFFSET: i32 = 4;
const CONFIRM_BUTTON_MIN_WIDTH: i32 = 150;
const CANCEL_BUTTON_MIN_WIDTH: i32 = 110;
const SELECTION_GRID_THICKNESS: i32 = 1;
const SELECTION_CORNER_LENGTH: i32 = 42;
const SELECTION_CORNER_THICKNESS: i32 = 4;
const XC_BOTTOM_LEFT_CORNER: u16 = 12;
const XC_BOTTOM_RIGHT_CORNER: u16 = 14;
const XC_BOTTOM_SIDE: u16 = 16;
const XC_CROSSHAIR: u16 = 34;
const XC_FLEUR: u16 = 52;
const XC_LEFT_SIDE: u16 = 70;
const XC_RIGHT_SIDE: u16 = 96;
const XC_TOP_LEFT_CORNER: u16 = 134;
const XC_TOP_RIGHT_CORNER: u16 = 136;
const XC_TOP_SIDE: u16 = 138;
const XK_ESCAPE: u32 = 0xff1b;
const XK_RETURN: u32 = 0xff0d;
const XK_KP_ENTER: u32 = 0xff8d;
const XK_SPACE: u32 = 0x20;
const XK_BACK_SPACE: u32 = 0xff08;
const XK_DELETE: u32 = 0xffff;
const XK_LEFT: u32 = 0xff51;
const XK_UP: u32 = 0xff52;
const XK_RIGHT: u32 = 0xff53;
const XK_DOWN: u32 = 0xff54;
#[cfg(debug_assertions)]
const AUTODRAG_ENV: &str = "OCULA_SELECT_AUTODRAG";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Selection {
pub area: CaptureArea,
pub screen_width: i32,
pub screen_height: i32,
}
pub fn select_area() -> Result<Selection> {
select_area_with_mode(SelectorMode::Editable, None)
}
pub fn select_area_once() -> Result<Selection> {
select_area_with_mode(SelectorMode::ConfirmOnRelease, None)
}
pub fn adjust_area(initial: CaptureArea) -> Result<Selection> {
select_area_with_mode(SelectorMode::Editable, Some(initial))
}
fn select_area_with_mode(mode: SelectorMode, initial: Option<CaptureArea>) -> Result<Selection> {
ensure!(
std::env::var_os("DISPLAY").is_some(),
"--select requires an X11/XWayland DISPLAY; use --area X,Y,WIDTH,HEIGHT instead"
);
eprintln!("{}", mode.instructions());
let (conn, screen_num) = connect(None).context("failed to connect to X11 display")?;
let screen = &conn.setup().roots[screen_num];
let root = screen.root;
let width = screen.width_in_pixels;
let height = screen.height_in_pixels;
let screen_width = i32::from(width);
let screen_height = i32::from(height);
let window = conn.generate_id()?;
conn.create_window(
COPY_DEPTH_FROM_PARENT,
window,
root,
0,
0,
width,
height,
0,
WindowClass::INPUT_OUTPUT,
0,
&CreateWindowAux::new()
.background_pixel(screen.black_pixel)
.override_redirect(1)
.event_mask(EventMask::EXPOSURE | pointer_event_mask() | EventMask::KEY_PRESS),
)?;
let mut guard = SelectorWindow::new(&conn, window);
let opacity_atom = conn
.intern_atom(false, b"_NET_WM_WINDOW_OPACITY")?
.reply()?
.atom;
conn.change_property32(
PropMode::REPLACE,
window,
opacity_atom,
AtomEnum::CARDINAL,
&[OVERLAY_OPACITY],
)?;
conn.change_property8(
PropMode::REPLACE,
window,
AtomEnum::WM_NAME,
AtomEnum::STRING,
b"Ocula Area Selector",
)?;
let initial = initial.map(|area| move_area(area, 0, 0, screen_width, screen_height));
update_overlay_shape(&conn, window, screen_width, screen_height, initial)?;
let graphics = SelectorGraphics::new(
&conn,
window,
screen.white_pixel,
screen.black_pixel,
screen.default_colormap,
)?;
let cursors = SelectorCursors::new(&conn)?;
conn.change_window_attributes(
window,
&ChangeWindowAttributesAux::new().cursor(cursors.id(CursorKind::Crosshair)),
)?;
conn.map_window(window)?;
conn.configure_window(
window,
&ConfigureWindowAux::new().stack_mode(StackMode::ABOVE),
)?;
conn.flush()?;
let pointer = conn
.grab_pointer(
false,
window,
pointer_event_mask(),
GrabMode::ASYNC,
GrabMode::ASYNC,
NONE,
cursors.id(CursorKind::Crosshair),
CURRENT_TIME,
)?
.reply()?;
ensure!(
pointer.status == GrabStatus::SUCCESS,
"failed to grab pointer for area selection"
);
guard.pointer_grabbed = true;
let keyboard = conn
.grab_keyboard(
false,
window,
CURRENT_TIME,
GrabMode::ASYNC,
GrabMode::ASYNC,
)?
.reply()?;
ensure!(
keyboard.status == GrabStatus::SUCCESS,
"failed to grab keyboard for area selection"
);
guard.keyboard_grabbed = true;
#[cfg(debug_assertions)]
spawn_autodrag_if_requested(root, window);
let mut selection = initial;
let mut drag = None;
let mut active_cursor = CursorKind::Crosshair;
loop {
let event = conn.wait_for_event()?;
match event {
Event::Expose(event) => redraw_selection(
&conn,
window,
&graphics,
event,
screen_width,
screen_height,
selection,
)?,
Event::ButtonPress(event) if is_cancel_button_press(&event) => {
bail!("area selection cancelled");
}
Event::ButtonPress(event) if is_primary_button_press(&event) => {
let point = (event.event_x, event.event_y);
if let Some(area) = selection {
match action_button_hit(point, area, screen_width, screen_height) {
Some(ActionButton::Confirm) => {
return Ok(Selection {
area,
screen_width,
screen_height,
});
}
Some(ActionButton::Cancel) => bail!("area selection cancelled"),
None => {}
}
}
let active_drag = start_drag(point, selection);
drag = Some(active_drag);
update_active_cursor(
&conn,
&cursors,
&mut active_cursor,
cursor_kind_for_drag(active_drag),
)?;
if selection.is_none() {
selection = default_area_around(point, screen_width, screen_height).ok();
}
redraw(
&conn,
window,
&graphics,
screen_width,
screen_height,
selection,
)?;
}
Event::MotionNotify(event) => {
let point = motion_point(&conn, window, &event);
update_active_cursor(
&conn,
&cursors,
&mut active_cursor,
cursor_kind_for_state(point, selection, drag),
)?;
let Some(active_drag) = drag else {
continue;
};
selection = update_drag(active_drag, point, screen_width, screen_height)?;
redraw(
&conn,
window,
&graphics,
screen_width,
screen_height,
selection,
)?;
#[cfg(debug_assertions)]
if autodrag_requested()
&& let Some(area) = selection
{
return Ok(Selection {
area,
screen_width,
screen_height,
});
}
}
Event::ButtonRelease(event) if is_primary_button_release(&event) => {
if let Some(active_drag) = drag.take() {
let point = (event.event_x, event.event_y);
selection = update_drag(active_drag, point, screen_width, screen_height)?
.or_else(|| default_area_around(point, screen_width, screen_height).ok());
update_active_cursor(
&conn,
&cursors,
&mut active_cursor,
cursor_kind_for_state(point, selection, drag),
)?;
if let Some(area) = selection_on_release(mode, selection) {
return Ok(Selection {
area,
screen_width,
screen_height,
});
}
}
redraw(
&conn,
window,
&graphics,
screen_width,
screen_height,
selection,
)?;
}
Event::KeyPress(event) => match key_action(&conn, &event)? {
KeyAction::Confirm if let Some(area) = selection => {
return Ok(Selection {
area,
screen_width,
screen_height,
});
}
KeyAction::Cancel => bail!("area selection cancelled"),
KeyAction::Reset => {
selection = None;
drag = None;
update_active_cursor(
&conn,
&cursors,
&mut active_cursor,
CursorKind::Crosshair,
)?;
redraw(
&conn,
window,
&graphics,
screen_width,
screen_height,
selection,
)?;
}
KeyAction::Move { dx, dy } if let Some(area) = selection => {
selection = Some(move_area(area, dx, dy, screen_width, screen_height));
redraw(
&conn,
window,
&graphics,
screen_width,
screen_height,
selection,
)?;
}
KeyAction::Resize { dw, dh } if let Some(area) = selection => {
selection = Some(resize_area_by_delta(
area,
dw,
dh,
screen_width,
screen_height,
)?);
redraw(
&conn,
window,
&graphics,
screen_width,
screen_height,
selection,
)?;
}
_ => {}
},
_ => {}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SelectorMode {
Editable,
ConfirmOnRelease,
}
impl SelectorMode {
fn instructions(self) -> &'static str {
match self {
Self::Editable => {
"drag to select; click Use Area or press Enter to record; edge/corner handles resize; arrows move; Shift+arrows resize; Ctrl adjusts faster; Delete/Backspace clears; Escape/right-click cancels..."
}
Self::ConfirmOnRelease => {
"drag an area and release to select; Escape/right-click cancels..."
}
}
}
}
fn selection_on_release(mode: SelectorMode, selection: Option<CaptureArea>) -> Option<CaptureArea> {
match mode {
SelectorMode::ConfirmOnRelease => selection,
SelectorMode::Editable => None,
}
}
struct SelectorGraphics {
border_gc: Gcontext,
edge_gc: Gcontext,
glow_gc: Gcontext,
grid_gc: Gcontext,
handle_gc: Gcontext,
handle_border_gc: Gcontext,
panel_gc: Gcontext,
panel_shadow_gc: Gcontext,
panel_border_gc: Gcontext,
text_gc: Gcontext,
dim_text_gc: Gcontext,
confirm_gc: Gcontext,
confirm_text_gc: Gcontext,
cancel_gc: Gcontext,
cancel_text_gc: Gcontext,
button_shadow_gc: Gcontext,
button_border_gc: Gcontext,
}
impl SelectorGraphics {
fn new(
conn: &RustConnection,
window: Window,
foreground: u32,
background: u32,
colormap: Colormap,
) -> Result<Self> {
let font = conn.generate_id()?;
conn.open_font(font, b"fixed")?.check()?;
let accent_color = color_pixel(conn, colormap, b"#7df9ff", foreground);
let glow_color = color_pixel(conn, colormap, b"#00d5ff", foreground);
let edge_color = color_pixel(conn, colormap, b"#f4fdff", foreground);
let grid_color = color_pixel(conn, colormap, b"#b7fbff", foreground);
let handle_color = color_pixel(conn, colormap, b"#f8ffff", foreground);
let panel_color = color_pixel(conn, colormap, b"#101822", background);
let panel_shadow_color = color_pixel(conn, colormap, b"#02060a", background);
let dim_text_color = color_pixel(conn, colormap, b"#b8d4e0", foreground);
let confirm_color = color_pixel(conn, colormap, b"#00e0a4", foreground);
let confirm_text_color = color_pixel(conn, colormap, b"#031612", background);
let cancel_color = color_pixel(conn, colormap, b"#263140", foreground);
let cancel_text_color = color_pixel(conn, colormap, b"#edf7ff", foreground);
let border_gc = conn.generate_id()?;
conn.create_gc(
border_gc,
window,
&CreateGCAux::new()
.foreground(accent_color)
.line_width(BORDER_LINE_WIDTH)
.line_style(LineStyle::SOLID),
)?;
let edge_gc = conn.generate_id()?;
conn.create_gc(
edge_gc,
window,
&CreateGCAux::new()
.foreground(edge_color)
.line_width(1)
.line_style(LineStyle::SOLID),
)?;
let glow_gc = conn.generate_id()?;
conn.create_gc(
glow_gc,
window,
&CreateGCAux::new()
.foreground(glow_color)
.line_width(8)
.line_style(LineStyle::SOLID),
)?;
let grid_gc = conn.generate_id()?;
conn.create_gc(
grid_gc,
window,
&CreateGCAux::new()
.foreground(grid_color)
.line_width(u32::try_from(SELECTION_GRID_THICKNESS).unwrap_or(1))
.line_style(LineStyle::SOLID),
)?;
let handle_gc = conn.generate_id()?;
conn.create_gc(
handle_gc,
window,
&CreateGCAux::new()
.foreground(handle_color)
.background(handle_color),
)?;
let handle_border_gc = conn.generate_id()?;
conn.create_gc(
handle_border_gc,
window,
&CreateGCAux::new()
.foreground(accent_color)
.line_width(1)
.line_style(LineStyle::SOLID),
)?;
let panel_gc = conn.generate_id()?;
conn.create_gc(
panel_gc,
window,
&CreateGCAux::new()
.foreground(panel_color)
.background(panel_color),
)?;
let panel_shadow_gc = conn.generate_id()?;
conn.create_gc(
panel_shadow_gc,
window,
&CreateGCAux::new()
.foreground(panel_shadow_color)
.background(panel_shadow_color),
)?;
let panel_border_gc = conn.generate_id()?;
conn.create_gc(
panel_border_gc,
window,
&CreateGCAux::new()
.foreground(accent_color)
.line_width(1)
.line_style(LineStyle::SOLID),
)?;
let text_gc = conn.generate_id()?;
conn.create_gc(
text_gc,
window,
&CreateGCAux::new()
.foreground(edge_color)
.background(panel_color)
.font(font),
)?;
let dim_text_gc = conn.generate_id()?;
conn.create_gc(
dim_text_gc,
window,
&CreateGCAux::new()
.foreground(dim_text_color)
.background(panel_color)
.font(font),
)?;
let confirm_gc = conn.generate_id()?;
conn.create_gc(
confirm_gc,
window,
&CreateGCAux::new()
.foreground(confirm_color)
.background(confirm_color),
)?;
let confirm_text_gc = conn.generate_id()?;
conn.create_gc(
confirm_text_gc,
window,
&CreateGCAux::new()
.foreground(confirm_text_color)
.background(confirm_color)
.font(font),
)?;
let cancel_gc = conn.generate_id()?;
conn.create_gc(
cancel_gc,
window,
&CreateGCAux::new()
.foreground(cancel_color)
.background(cancel_color),
)?;
let cancel_text_gc = conn.generate_id()?;
conn.create_gc(
cancel_text_gc,
window,
&CreateGCAux::new()
.foreground(cancel_text_color)
.background(cancel_color)
.font(font),
)?;
let button_shadow_gc = conn.generate_id()?;
conn.create_gc(
button_shadow_gc,
window,
&CreateGCAux::new()
.foreground(panel_shadow_color)
.background(panel_shadow_color),
)?;
let button_border_gc = conn.generate_id()?;
conn.create_gc(
button_border_gc,
window,
&CreateGCAux::new()
.foreground(edge_color)
.line_width(1)
.line_style(LineStyle::SOLID),
)?;
conn.close_font(font)?;
Ok(Self {
border_gc,
edge_gc,
glow_gc,
grid_gc,
handle_gc,
handle_border_gc,
panel_gc,
panel_shadow_gc,
panel_border_gc,
text_gc,
dim_text_gc,
confirm_gc,
confirm_text_gc,
cancel_gc,
cancel_text_gc,
button_shadow_gc,
button_border_gc,
})
}
}
fn color_pixel(conn: &RustConnection, colormap: Colormap, name: &[u8], fallback: u32) -> u32 {
conn.alloc_named_color(colormap, name)
.ok()
.and_then(|cookie| cookie.reply().ok())
.map_or(fallback, |reply| reply.pixel)
}
struct SelectorCursors {
crosshair: Cursor,
move_: Cursor,
top_left: Cursor,
top_right: Cursor,
bottom_right: Cursor,
bottom_left: Cursor,
top: Cursor,
right: Cursor,
bottom: Cursor,
left: Cursor,
}
impl SelectorCursors {
fn new(conn: &RustConnection) -> Result<Self> {
let font = conn.generate_id()?;
conn.open_font(font, b"cursor")?.check()?;
let cursors = Self {
crosshair: create_cursor(conn, font, XC_CROSSHAIR)?,
move_: create_cursor(conn, font, XC_FLEUR)?,
top_left: create_cursor(conn, font, XC_TOP_LEFT_CORNER)?,
top_right: create_cursor(conn, font, XC_TOP_RIGHT_CORNER)?,
bottom_right: create_cursor(conn, font, XC_BOTTOM_RIGHT_CORNER)?,
bottom_left: create_cursor(conn, font, XC_BOTTOM_LEFT_CORNER)?,
top: create_cursor(conn, font, XC_TOP_SIDE)?,
right: create_cursor(conn, font, XC_RIGHT_SIDE)?,
bottom: create_cursor(conn, font, XC_BOTTOM_SIDE)?,
left: create_cursor(conn, font, XC_LEFT_SIDE)?,
};
conn.close_font(font)?.check()?;
Ok(cursors)
}
fn id(&self, kind: CursorKind) -> Cursor {
match kind {
CursorKind::Crosshair => self.crosshair,
CursorKind::Move => self.move_,
CursorKind::Resize(Handle::TopLeft) => self.top_left,
CursorKind::Resize(Handle::TopRight) => self.top_right,
CursorKind::Resize(Handle::BottomRight) => self.bottom_right,
CursorKind::Resize(Handle::BottomLeft) => self.bottom_left,
CursorKind::Resize(Handle::Top) => self.top,
CursorKind::Resize(Handle::Right) => self.right,
CursorKind::Resize(Handle::Bottom) => self.bottom,
CursorKind::Resize(Handle::Left) => self.left,
}
}
}
fn create_cursor(conn: &RustConnection, font: u32, shape: u16) -> Result<Cursor> {
let cursor = conn.generate_id()?;
conn.create_glyph_cursor(
cursor,
font,
font,
shape,
shape + 1,
u16::MAX,
u16::MAX,
u16::MAX,
0,
0,
0,
)?;
Ok(cursor)
}
struct SelectorWindow<'a> {
conn: &'a RustConnection,
window: Window,
pointer_grabbed: bool,
keyboard_grabbed: bool,
}
impl<'a> SelectorWindow<'a> {
fn new(conn: &'a RustConnection, window: Window) -> Self {
Self {
conn,
window,
pointer_grabbed: false,
keyboard_grabbed: false,
}
}
}
impl Drop for SelectorWindow<'_> {
fn drop(&mut self) {
if self.pointer_grabbed {
let _ = self.conn.ungrab_pointer(CURRENT_TIME);
}
if self.keyboard_grabbed {
let _ = self.conn.ungrab_keyboard(CURRENT_TIME);
}
let _ = self.conn.destroy_window(self.window);
let _ = self.conn.flush();
}
}
fn is_primary_button_press(event: &ButtonPressEvent) -> bool {
is_primary_button_detail(event.detail)
}
fn is_cancel_button_press(event: &ButtonPressEvent) -> bool {
is_cancel_button_detail(event.detail)
}
fn is_primary_button_detail(detail: u8) -> bool {
detail == 1
}
fn is_cancel_button_detail(detail: u8) -> bool {
detail == 3
}
fn is_primary_button_release(event: &ButtonReleaseEvent) -> bool {
event.detail == 1
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Drag {
New {
start: (i16, i16),
},
Move {
start: (i16, i16),
original: CaptureArea,
},
Resize {
handle: Handle,
original: CaptureArea,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Handle {
TopLeft,
TopRight,
BottomRight,
BottomLeft,
Top,
Right,
Bottom,
Left,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Hit {
Handle(Handle),
Inside,
Outside,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ActionButton {
Confirm,
Cancel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CursorKind {
Crosshair,
Move,
Resize(Handle),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KeyAction {
Confirm,
Cancel,
Reset,
Move { dx: i32, dy: i32 },
Resize { dw: i32, dh: i32 },
Other,
}
fn start_drag(point: (i16, i16), selection: Option<CaptureArea>) -> Drag {
let Some(area) = selection else {
return Drag::New { start: point };
};
match hit_test(point, area) {
Hit::Handle(handle) => Drag::Resize {
handle,
original: area,
},
Hit::Inside => Drag::Move {
start: point,
original: area,
},
Hit::Outside => Drag::New { start: point },
}
}
fn cursor_kind_for_state(
point: (i16, i16),
selection: Option<CaptureArea>,
drag: Option<Drag>,
) -> CursorKind {
if let Some(drag) = drag {
return cursor_kind_for_drag(drag);
}
cursor_kind_for_point(point, selection)
}
fn cursor_kind_for_drag(drag: Drag) -> CursorKind {
match drag {
Drag::New { .. } => CursorKind::Crosshair,
Drag::Move { .. } => CursorKind::Move,
Drag::Resize { handle, .. } => CursorKind::Resize(handle),
}
}
fn cursor_kind_for_point(point: (i16, i16), selection: Option<CaptureArea>) -> CursorKind {
let Some(area) = selection else {
return CursorKind::Crosshair;
};
match hit_test(point, area) {
Hit::Handle(handle) => CursorKind::Resize(handle),
Hit::Inside => CursorKind::Move,
Hit::Outside => CursorKind::Crosshair,
}
}
fn update_active_cursor(
conn: &RustConnection,
cursors: &SelectorCursors,
active: &mut CursorKind,
next: CursorKind,
) -> Result<()> {
if *active == next {
return Ok(());
}
conn.change_active_pointer_grab(cursors.id(next), CURRENT_TIME, pointer_event_mask())?;
*active = next;
Ok(())
}
fn pointer_event_mask() -> EventMask {
EventMask::BUTTON_PRESS
| EventMask::BUTTON_RELEASE
| EventMask::POINTER_MOTION
| EventMask::POINTER_MOTION_HINT
}
fn motion_point(conn: &RustConnection, window: Window, event: &MotionNotifyEvent) -> (i16, i16) {
#[cfg(debug_assertions)]
if autodrag_requested() {
return (event.event_x, event.event_y);
}
conn.query_pointer(window)
.ok()
.and_then(|cookie| cookie.reply().ok())
.filter(|reply| reply.same_screen)
.map_or((event.event_x, event.event_y), |reply| {
(reply.win_x, reply.win_y)
})
}
fn update_drag(
drag: Drag,
point: (i16, i16),
screen_width: i32,
screen_height: i32,
) -> Result<Option<CaptureArea>> {
match drag {
Drag::New { start } => area_from_screen_points(start, point, screen_width, screen_height),
Drag::Move { start, original } => Ok(Some(move_area(
original,
i32::from(point.0) - i32::from(start.0),
i32::from(point.1) - i32::from(start.1),
screen_width,
screen_height,
))),
Drag::Resize { handle, original } => {
resize_area(original, handle, point, screen_width, screen_height).map(Some)
}
}
}
fn hit_test(point: (i16, i16), area: CaptureArea) -> Hit {
for (handle, rectangle) in handle_rectangles_with_handles(area) {
if rectangle_contains(rectangle, point) {
return Hit::Handle(handle);
}
}
if point_in_area(point, area) {
Hit::Inside
} else {
Hit::Outside
}
}
fn point_in_area(point: (i16, i16), area: CaptureArea) -> bool {
let x = i32::from(point.0);
let y = i32::from(point.1);
x >= area.x && x < area.right() && y >= area.y && y < area.bottom()
}
fn rectangle_contains(rectangle: Rectangle, point: (i16, i16)) -> bool {
let x = i32::from(point.0);
let y = i32::from(point.1);
let left = i32::from(rectangle.x);
let top = i32::from(rectangle.y);
let right = left + i32::from(rectangle.width);
let bottom = top + i32::from(rectangle.height);
x >= left && x < right && y >= top && y < bottom
}
fn move_area(
area: CaptureArea,
dx: i32,
dy: i32,
screen_width: i32,
screen_height: i32,
) -> CaptureArea {
let max_x = (screen_width - area.width).max(0);
let max_y = (screen_height - area.height).max(0);
CaptureArea {
x: area.x.saturating_add(dx).clamp(0, max_x),
y: area.y.saturating_add(dy).clamp(0, max_y),
width: area.width.min(screen_width.max(1)),
height: area.height.min(screen_height.max(1)),
}
}
fn resize_area_by_delta(
area: CaptureArea,
dw: i32,
dh: i32,
screen_width: i32,
screen_height: i32,
) -> Result<CaptureArea> {
let max_width = (screen_width - area.x).max(1);
let max_height = (screen_height - area.y).max(1);
let width = area.width.saturating_add(dw).clamp(1, max_width);
let height = area.height.saturating_add(dh).clamp(1, max_height);
CaptureArea::new(area.x, area.y, width, height)
}
fn resize_area(
area: CaptureArea,
handle: Handle,
point: (i16, i16),
screen_width: i32,
screen_height: i32,
) -> Result<CaptureArea> {
let x = i32::from(point.0).clamp(0, screen_width.max(0));
let y = i32::from(point.1).clamp(0, screen_height.max(0));
let (start, end) = match handle {
Handle::TopLeft => ((area.right(), area.bottom()), (x, y)),
Handle::TopRight => ((area.x, area.bottom()), (x, y)),
Handle::BottomRight => ((area.x, area.y), (x, y)),
Handle::BottomLeft => ((area.right(), area.y), (x, y)),
Handle::Top => ((area.x, area.bottom()), (area.right(), y)),
Handle::Right => ((area.x, area.y), (x, area.bottom())),
Handle::Bottom => ((area.x, area.y), (area.right(), y)),
Handle::Left => ((area.right(), area.y), (x, area.bottom())),
};
area_from_resize_points(start, end, handle, screen_width, screen_height)
}
fn area_from_resize_points(
fixed: (i32, i32),
moving: (i32, i32),
handle: Handle,
screen_width: i32,
screen_height: i32,
) -> Result<CaptureArea> {
let (x, width) = resize_interval(
fixed.0,
moving.0,
screen_width,
matches!(handle, Handle::TopLeft | Handle::BottomLeft | Handle::Left),
);
let (y, height) = resize_interval(
fixed.1,
moving.1,
screen_height,
matches!(handle, Handle::TopLeft | Handle::TopRight | Handle::Top),
);
CaptureArea::new(x, y, width, height)
}
fn resize_interval(
fixed: i32,
moving: i32,
screen_limit: i32,
fixed_is_max_when_equal: bool,
) -> (i32, i32) {
if moving < fixed {
return (moving, fixed.saturating_sub(moving));
}
if moving > fixed {
return (fixed, moving.saturating_sub(fixed));
}
let limit = screen_limit.max(1);
let (start, end) = if fixed_is_max_when_equal {
(fixed.saturating_sub(1).max(0), fixed)
} else {
(fixed, fixed.saturating_add(1).min(limit))
};
if end > start {
(start, end.saturating_sub(start))
} else if fixed > 0 {
(fixed.saturating_sub(1), 1)
} else {
(0, 1)
}
}
fn key_action(conn: &RustConnection, event: &KeyPressEvent) -> Result<KeyAction> {
let keymap = conn.get_keyboard_mapping(event.detail, 1)?.reply()?;
Ok(key_action_from_keysyms(&keymap.keysyms, event.state))
}
fn key_action_from_keysyms(keysyms: &[u32], state: KeyButMask) -> KeyAction {
if keysyms.contains(&XK_ESCAPE) {
return KeyAction::Cancel;
}
if keysyms.contains(&XK_BACK_SPACE) || keysyms.contains(&XK_DELETE) {
return KeyAction::Reset;
}
if keysyms
.iter()
.any(|keysym| matches!(*keysym, XK_RETURN | XK_KP_ENTER | XK_SPACE))
{
return KeyAction::Confirm;
}
let step = if has_modifier(state, KeyButMask::CONTROL) {
10
} else {
1
};
for keysym in keysyms {
let (dx, dy) = match *keysym {
XK_LEFT => (-step, 0),
XK_RIGHT => (step, 0),
XK_UP => (0, -step),
XK_DOWN => (0, step),
_ => continue,
};
return if has_modifier(state, KeyButMask::SHIFT) {
KeyAction::Resize { dw: dx, dh: dy }
} else {
KeyAction::Move { dx, dy }
};
}
KeyAction::Other
}
fn has_modifier(state: KeyButMask, mask: KeyButMask) -> bool {
u16::from(state) & u16::from(mask) != 0
}
fn redraw_selection(
conn: &RustConnection,
window: Window,
graphics: &SelectorGraphics,
_event: ExposeEvent,
screen_width: i32,
screen_height: i32,
selection: Option<CaptureArea>,
) -> Result<()> {
redraw(
conn,
window,
graphics,
screen_width,
screen_height,
selection,
)
}
fn redraw(
conn: &RustConnection,
window: Window,
graphics: &SelectorGraphics,
screen_width: i32,
screen_height: i32,
selection: Option<CaptureArea>,
) -> Result<()> {
update_overlay_shape(conn, window, screen_width, screen_height, selection)?;
conn.clear_area(false, window, 0, 0, 0, 0)?;
if let Some(area) = selection {
draw_selection_frame(conn, window, graphics, area)?;
draw_selection_grid(conn, window, graphics, area)?;
draw_handles(conn, window, graphics, area)?;
draw_dimension_label(conn, window, graphics, area, screen_width, screen_height)?;
draw_shortcut_label(conn, window, graphics, area, screen_width, screen_height)?;
draw_action_buttons(conn, window, graphics, area, screen_width, screen_height)?;
} else {
draw_help_label(conn, window, graphics, screen_width, screen_height)?;
}
conn.flush()?;
Ok(())
}
fn draw_selection_frame(
conn: &RustConnection,
window: Window,
graphics: &SelectorGraphics,
area: CaptureArea,
) -> Result<()> {
if let Some(rectangle) = rectangle_from_area(area) {
conn.poly_rectangle(window, graphics.glow_gc, &[rectangle])?;
conn.poly_rectangle(window, graphics.border_gc, &[rectangle])?;
conn.poly_rectangle(window, graphics.edge_gc, &[rectangle])?;
}
let corners = selection_corner_rectangles(area);
conn.poly_fill_rectangle(window, graphics.border_gc, &corners)?;
Ok(())
}
fn draw_selection_grid(
conn: &RustConnection,
window: Window,
graphics: &SelectorGraphics,
area: CaptureArea,
) -> Result<()> {
let grid = selection_grid_rectangles(area);
if !grid.is_empty() {
conn.poly_fill_rectangle(window, graphics.grid_gc, &grid)?;
}
Ok(())
}
fn draw_handles(
conn: &RustConnection,
window: Window,
graphics: &SelectorGraphics,
area: CaptureArea,
) -> Result<()> {
for (_, rectangle) in handle_rectangles_with_handles(area) {
let shadow = offset_rectangle(rectangle, 2, 2).unwrap_or(rectangle);
conn.poly_fill_rectangle(window, graphics.panel_shadow_gc, &[shadow])?;
conn.poly_fill_rectangle(window, graphics.handle_gc, &[rectangle])?;
conn.poly_rectangle(window, graphics.handle_border_gc, &[rectangle])?;
}
Ok(())
}
fn draw_dimension_label(
conn: &RustConnection,
window: Window,
graphics: &SelectorGraphics,
area: CaptureArea,
screen_width: i32,
screen_height: i32,
) -> Result<()> {
let label = format!("{}x{} px", area.width, area.height);
let (x, y) = label_position(area, screen_width, screen_height, label.len());
draw_text_panel(conn, window, graphics, x, y, &label, graphics.text_gc)?;
Ok(())
}
fn draw_shortcut_label(
conn: &RustConnection,
window: Window,
graphics: &SelectorGraphics,
area: CaptureArea,
screen_width: i32,
screen_height: i32,
) -> Result<()> {
let (x, y) = shortcut_label_position(area, screen_width, screen_height, SHORTCUT_TEXT.len());
draw_text_panel(
conn,
window,
graphics,
x,
y,
SHORTCUT_TEXT,
graphics.dim_text_gc,
)?;
Ok(())
}
fn draw_action_buttons(
conn: &RustConnection,
window: Window,
graphics: &SelectorGraphics,
area: CaptureArea,
screen_width: i32,
screen_height: i32,
) -> Result<()> {
for (button, rectangle) in action_button_rectangles(area, screen_width, screen_height) {
let (fill_gc, text_gc, label) = match button {
ActionButton::Confirm => (graphics.confirm_gc, graphics.confirm_text_gc, CONFIRM_TEXT),
ActionButton::Cancel => (graphics.cancel_gc, graphics.cancel_text_gc, CANCEL_TEXT),
};
if let Some(shadow) = offset_rectangle(
rectangle,
0,
ACTION_BUTTON_SHADOW_OFFSET.min(i32::from(u16::MAX)),
) {
conn.poly_fill_rectangle(window, graphics.button_shadow_gc, &[shadow])?;
}
conn.poly_fill_rectangle(window, fill_gc, &[rectangle])?;
conn.poly_rectangle(window, graphics.button_border_gc, &[rectangle])?;
let text_width = label_width(label.len());
let x = i32::from(rectangle.x)
.saturating_add((i32::from(rectangle.width).saturating_sub(text_width) / 2).max(0));
let y = i32::from(rectangle.y)
.saturating_add((ACTION_BUTTON_HEIGHT + LABEL_TEXT_HEIGHT) / 2)
.saturating_sub(3);
conn.image_text8(
window,
text_gc,
clamp_i16(x),
clamp_i16(y),
label.as_bytes(),
)?;
}
Ok(())
}
fn draw_help_label(
conn: &RustConnection,
window: Window,
graphics: &SelectorGraphics,
screen_width: i32,
screen_height: i32,
) -> Result<()> {
let panel = help_panel_rectangle(screen_width, screen_height);
conn.poly_fill_rectangle(window, graphics.panel_shadow_gc, &[panel.shadow])?;
conn.poly_fill_rectangle(window, graphics.panel_gc, &[panel.body])?;
conn.poly_rectangle(window, graphics.panel_border_gc, &[panel.body])?;
conn.image_text8(
window,
graphics.text_gc,
panel.title_x,
panel.title_y,
HELP_TITLE.as_bytes(),
)?;
conn.image_text8(
window,
graphics.dim_text_gc,
panel.body_x,
panel.body_y,
HELP_TEXT.as_bytes(),
)?;
Ok(())
}
fn draw_text_panel(
conn: &RustConnection,
window: Window,
graphics: &SelectorGraphics,
x: i16,
y: i16,
text: &str,
text_gc: Gcontext,
) -> Result<()> {
if let Some(rectangle) = text_panel_rectangle(x, y, text.len()) {
if let Some(shadow) = offset_rectangle(rectangle, 0, 3) {
conn.poly_fill_rectangle(window, graphics.panel_shadow_gc, &[shadow])?;
}
conn.poly_fill_rectangle(window, graphics.panel_gc, &[rectangle])?;
conn.poly_rectangle(window, graphics.panel_border_gc, &[rectangle])?;
}
conn.image_text8(window, text_gc, x, y, text.as_bytes())?;
Ok(())
}
struct HelpPanel {
body: Rectangle,
shadow: Rectangle,
title_x: i16,
title_y: i16,
body_x: i16,
body_y: i16,
}
fn help_panel_rectangle(screen_width: i32, screen_height: i32) -> HelpPanel {
let title_width = label_width(HELP_TITLE.len());
let body_width = label_width(HELP_TEXT.len());
let content_width = title_width.max(body_width);
let wanted_width = content_width.saturating_add(LABEL_BOX_PADDING_X.saturating_mul(2));
let wanted_height = LABEL_TEXT_HEIGHT
.saturating_mul(2)
.saturating_add(LABEL_BOX_PADDING_Y.saturating_mul(3));
let panel_width = wanted_width.min(screen_width.max(1));
let panel_height = wanted_height.min(screen_height.max(1));
let x = screen_width.saturating_sub(panel_width).max(0) / 2;
let y = screen_height.saturating_sub(panel_height).max(0) / 2;
let body = rect_i32(x, y, panel_width, panel_height).unwrap_or(Rectangle {
x: 0,
y: 0,
width: 1,
height: 1,
});
let shadow = offset_rectangle(body, 0, 4).unwrap_or(body);
let content_left = x.saturating_add(LABEL_BOX_PADDING_X);
let title_x = content_left.saturating_add((content_width.saturating_sub(title_width)) / 2);
let body_x = content_left.saturating_add((content_width.saturating_sub(body_width)) / 2);
let title_y = y
.saturating_add(LABEL_BOX_PADDING_Y)
.saturating_add(LABEL_TEXT_HEIGHT);
let body_y = title_y
.saturating_add(LABEL_TEXT_HEIGHT)
.saturating_add(LABEL_BOX_PADDING_Y);
HelpPanel {
body,
shadow,
title_x: clamp_i16(title_x),
title_y: clamp_i16(title_y),
body_x: clamp_i16(body_x),
body_y: clamp_i16(body_y),
}
}
fn selection_grid_rectangles(area: CaptureArea) -> Vec<Rectangle> {
if area.width < 24 || area.height < 24 {
return Vec::new();
}
let thickness = SELECTION_GRID_THICKNESS.max(1);
[
rect_i32(area.x + area.width / 3, area.y, thickness, area.height),
rect_i32(
area.x + area.width.saturating_mul(2) / 3,
area.y,
thickness,
area.height,
),
rect_i32(area.x, area.y + area.height / 3, area.width, thickness),
rect_i32(
area.x,
area.y + area.height.saturating_mul(2) / 3,
area.width,
thickness,
),
]
.into_iter()
.flatten()
.collect()
}
fn selection_corner_rectangles(area: CaptureArea) -> Vec<Rectangle> {
let thickness = SELECTION_CORNER_THICKNESS.max(1);
let half = thickness / 2;
let length = SELECTION_CORNER_LENGTH
.min(area.width.max(1))
.min(area.height.max(1))
.max(thickness);
let left = area.x.saturating_sub(half);
let top = area.y.saturating_sub(half);
let right = area.right().saturating_sub(length).saturating_add(half);
let bottom = area.bottom().saturating_sub(thickness).saturating_add(half);
let edge_right = area.right().saturating_sub(thickness).saturating_add(half);
let edge_bottom = area.bottom().saturating_sub(length).saturating_add(half);
[
rect_i32(left, top, length, thickness),
rect_i32(left, top, thickness, length),
rect_i32(right, top, length, thickness),
rect_i32(edge_right, top, thickness, length),
rect_i32(left, bottom, length, thickness),
rect_i32(left, edge_bottom, thickness, length),
rect_i32(right, bottom, length, thickness),
rect_i32(edge_right, edge_bottom, thickness, length),
]
.into_iter()
.flatten()
.collect()
}
fn text_panel_rectangle(x: i16, y: i16, label_len: usize) -> Option<Rectangle> {
rect_i32(
i32::from(x).saturating_sub(LABEL_BOX_PADDING_X),
i32::from(y)
.saturating_sub(LABEL_TEXT_HEIGHT)
.saturating_sub(LABEL_BOX_PADDING_Y),
label_box_width(label_len),
label_box_height(),
)
}
#[cfg(test)]
fn help_label_position(screen_width: i32, screen_height: i32, label_len: usize) -> (i16, i16) {
let width = label_width(label_len);
let available_width = screen_width.saturating_sub(width);
let max_x = available_width.max(0);
let x = (available_width / 2).clamp(0, max_x);
let max_y = screen_height
.saturating_sub(LABEL_PADDING)
.max(LABEL_TEXT_HEIGHT);
let y = (screen_height / 2).clamp(LABEL_TEXT_HEIGHT, max_y);
(clamp_i16(x), clamp_i16(y))
}
fn shortcut_label_position(
area: CaptureArea,
screen_width: i32,
screen_height: i32,
label_len: usize,
) -> (i16, i16) {
let width = label_width(label_len);
let available_width = screen_width.saturating_sub(width);
let max_x = available_width.max(0);
let x = (available_width / 2).clamp(0, max_x);
let bottom_y = screen_height
.saturating_sub(LABEL_PADDING)
.max(LABEL_TEXT_HEIGHT);
let top_y = LABEL_TEXT_HEIGHT
.saturating_add(LABEL_PADDING)
.min(bottom_y);
let dimension_label = format!("{}x{} px", area.width, area.height);
let (_, dimension_y) = label_position(area, screen_width, screen_height, dimension_label.len());
let bottom_overlaps_dimension = label_baselines_overlap(i32::from(dimension_y), bottom_y);
let bottom_overlaps_selection = label_overlaps_area(x, bottom_y, width, area);
let top_overlaps_selection = label_overlaps_area(x, top_y, width, area);
let should_use_top = (bottom_overlaps_selection && !top_overlaps_selection)
|| (bottom_overlaps_dimension && (bottom_overlaps_selection || !top_overlaps_selection));
let y = if should_use_top { top_y } else { bottom_y };
(clamp_i16(x), clamp_i16(y))
}
fn label_baselines_overlap(first: i32, second: i32) -> bool {
let first_top = first.saturating_sub(LABEL_TEXT_HEIGHT);
let first_bottom = first.saturating_add(4);
let second_top = second.saturating_sub(LABEL_TEXT_HEIGHT);
let second_bottom = second.saturating_add(4);
first_top <= second_bottom && second_top <= first_bottom
}
fn label_overlaps_area(x: i32, y: i32, width: i32, area: CaptureArea) -> bool {
let left = x.saturating_sub(2);
let right = x.saturating_add(width).saturating_add(2);
let top = y.saturating_sub(LABEL_TEXT_HEIGHT);
let bottom = y.saturating_add(4);
left < area.right() && right > area.x && top < area.bottom() && bottom > area.y
}
fn label_position(
area: CaptureArea,
screen_width: i32,
screen_height: i32,
label_len: usize,
) -> (i16, i16) {
let label_width = label_width(label_len);
let max_x = screen_width
.saturating_sub(label_width)
.saturating_sub(LABEL_PADDING)
.max(0);
let x = area.x.saturating_add(LABEL_PADDING).clamp(0, max_x);
let above = area.y.saturating_sub(LABEL_PADDING);
let below = area
.bottom()
.saturating_add(LABEL_TEXT_HEIGHT)
.saturating_add(LABEL_PADDING);
let max_y = screen_height
.saturating_sub(LABEL_PADDING)
.max(LABEL_TEXT_HEIGHT);
let y = if above >= LABEL_TEXT_HEIGHT {
above
} else {
below.clamp(LABEL_TEXT_HEIGHT, max_y)
};
(clamp_i16(x), clamp_i16(y))
}
fn label_width(label_len: usize) -> i32 {
i32::try_from(label_len)
.unwrap_or(i32::MAX)
.saturating_mul(LABEL_CHAR_WIDTH)
}
fn clamp_i16(value: i32) -> i16 {
value.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16
}
fn update_overlay_shape(
conn: &RustConnection,
window: Window,
screen_width: i32,
screen_height: i32,
area: Option<CaptureArea>,
) -> Result<()> {
let rectangles = overlay_shape_rectangles(screen_width, screen_height, area);
conn.shape_rectangles(
SO::SET,
SK::BOUNDING,
ClipOrdering::UNSORTED,
window,
0,
0,
&rectangles,
)?;
Ok(())
}
fn overlay_shape_rectangles(
screen_width: i32,
screen_height: i32,
area: Option<CaptureArea>,
) -> Vec<Rectangle> {
let mut rectangles = shade_rectangles(screen_width, screen_height, area);
let Some(area) = area else {
return rectangles;
};
rectangles.extend(border_shape_rectangles(area, screen_width, screen_height));
rectangles.extend(selection_grid_rectangles(area));
rectangles.extend(selection_corner_rectangles(area));
rectangles.extend(handle_rectangles(area));
rectangles.extend(handle_shadow_rectangles(area));
if let Some(label) = label_rectangle(area, screen_width, screen_height) {
rectangles.push(label);
}
if let Some(label) = shortcut_label_rectangle(area, screen_width, screen_height) {
rectangles.push(label);
}
for (_, rect) in action_button_rectangles(area, screen_width, screen_height) {
rectangles.push(rect);
if let Some(shadow) = offset_rectangle(rect, 0, ACTION_BUTTON_SHADOW_OFFSET) {
rectangles.push(shadow);
}
}
rectangles
}
fn shade_rectangles(
screen_width: i32,
screen_height: i32,
area: Option<CaptureArea>,
) -> Vec<Rectangle> {
let Some(area) = area else {
return rect_i32(0, 0, screen_width, screen_height)
.into_iter()
.collect();
};
[
rect_i32(0, 0, screen_width, area.y),
rect_i32(
0,
area.bottom(),
screen_width,
screen_height - area.bottom(),
),
rect_i32(0, area.y, area.x, area.height),
rect_i32(
area.right(),
area.y,
screen_width - area.right(),
area.height,
),
]
.into_iter()
.flatten()
.collect()
}
fn border_shape_rectangles(
area: CaptureArea,
screen_width: i32,
screen_height: i32,
) -> Vec<Rectangle> {
let pad = BORDER_SHAPE_PADDING;
let padding = pad.saturating_mul(2);
let thickness = padding.saturating_add(1);
let padded_width = area.width.saturating_add(padding);
let padded_height = area.height.saturating_add(padding);
let left = area.x.saturating_sub(pad);
let top = area.y.saturating_sub(pad);
let right = area.right().saturating_sub(pad);
let bottom = area.bottom().saturating_sub(pad);
[
clipped_rect_i32(
left,
top,
padded_width,
thickness,
screen_width,
screen_height,
),
clipped_rect_i32(
left,
bottom,
padded_width,
thickness,
screen_width,
screen_height,
),
clipped_rect_i32(
left,
top,
thickness,
padded_height,
screen_width,
screen_height,
),
clipped_rect_i32(
right,
top,
thickness,
padded_height,
screen_width,
screen_height,
),
]
.into_iter()
.flatten()
.collect()
}
fn label_rectangle(area: CaptureArea, screen_width: i32, screen_height: i32) -> Option<Rectangle> {
let label = format!("{}x{} px", area.width, area.height);
let (x, y) = label_position(area, screen_width, screen_height, label.len());
clipped_rect_i32(
i32::from(x) - 2,
i32::from(y) - LABEL_TEXT_HEIGHT,
label_box_width(label.len()),
label_box_height(),
screen_width,
screen_height,
)
}
fn shortcut_label_rectangle(
area: CaptureArea,
screen_width: i32,
screen_height: i32,
) -> Option<Rectangle> {
let (x, y) = shortcut_label_position(area, screen_width, screen_height, SHORTCUT_TEXT.len());
clipped_rect_i32(
i32::from(x) - 2,
i32::from(y) - LABEL_TEXT_HEIGHT,
label_box_width(SHORTCUT_TEXT.len()),
label_box_height(),
screen_width,
screen_height,
)
}
fn action_button_hit(
point: (i16, i16),
area: CaptureArea,
screen_width: i32,
screen_height: i32,
) -> Option<ActionButton> {
action_button_rectangles(area, screen_width, screen_height)
.find(|(_, rectangle)| rectangle_contains(*rectangle, point))
.map(|(button, _)| button)
}
fn action_button_rectangles(
area: CaptureArea,
screen_width: i32,
screen_height: i32,
) -> impl Iterator<Item = (ActionButton, Rectangle)> {
let confirm_width = action_button_width(ActionButton::Confirm);
let cancel_width = action_button_width(ActionButton::Cancel);
let total_width = confirm_width
.saturating_add(ACTION_BUTTON_GAP)
.saturating_add(cancel_width);
let center_x = area.x.saturating_add(area.width / 2);
let max_x = screen_width.saturating_sub(total_width).max(0);
let x = center_x.saturating_sub(total_width / 2).clamp(0, max_x);
let y = action_button_y(area, screen_height);
[
rect_i32(x, y, confirm_width, ACTION_BUTTON_HEIGHT)
.map(|rect| (ActionButton::Confirm, rect)),
rect_i32(
x.saturating_add(confirm_width)
.saturating_add(ACTION_BUTTON_GAP),
y,
cancel_width,
ACTION_BUTTON_HEIGHT,
)
.map(|rect| (ActionButton::Cancel, rect)),
]
.into_iter()
.flatten()
}
fn action_button_y(area: CaptureArea, screen_height: i32) -> i32 {
let max_y = screen_height.saturating_sub(ACTION_BUTTON_HEIGHT).max(0);
let below = area.bottom().saturating_add(LABEL_PADDING);
if below <= max_y {
return below;
}
area.y
.saturating_sub(LABEL_PADDING)
.saturating_sub(ACTION_BUTTON_HEIGHT)
.clamp(0, max_y)
}
fn action_button_width(button: ActionButton) -> i32 {
let (label_len, min_width) = match button {
ActionButton::Confirm => (CONFIRM_TEXT.len(), CONFIRM_BUTTON_MIN_WIDTH),
ActionButton::Cancel => (CANCEL_TEXT.len(), CANCEL_BUTTON_MIN_WIDTH),
};
label_width(label_len)
.saturating_add(ACTION_BUTTON_PADDING_X.saturating_mul(2))
.max(min_width)
}
fn label_box_width(label_len: usize) -> i32 {
label_width(label_len).saturating_add(LABEL_BOX_PADDING_X.saturating_mul(2))
}
fn label_box_height() -> i32 {
LABEL_TEXT_HEIGHT.saturating_add(LABEL_BOX_PADDING_Y.saturating_mul(2))
}
fn rect_i32(x: i32, y: i32, width: i32, height: i32) -> Option<Rectangle> {
if width <= 0 || height <= 0 {
return None;
}
Some(Rectangle {
x: i16::try_from(x).ok()?,
y: i16::try_from(y).ok()?,
width: u16::try_from(width).ok()?,
height: u16::try_from(height).ok()?,
})
}
fn clipped_rect_i32(
x: i32,
y: i32,
width: i32,
height: i32,
screen_width: i32,
screen_height: i32,
) -> Option<Rectangle> {
if width <= 0 || height <= 0 {
return None;
}
let left = x.clamp(0, screen_width.max(0));
let top = y.clamp(0, screen_height.max(0));
let right = x.saturating_add(width).clamp(0, screen_width.max(0));
let bottom = y.saturating_add(height).clamp(0, screen_height.max(0));
rect_i32(left, top, right - left, bottom - top)
}
fn offset_rectangle(rectangle: Rectangle, dx: i32, dy: i32) -> Option<Rectangle> {
rect_i32(
i32::from(rectangle.x).saturating_add(dx),
i32::from(rectangle.y).saturating_add(dy),
i32::from(rectangle.width),
i32::from(rectangle.height),
)
}
#[cfg(test)]
fn area_from_points(start: (i16, i16), end: (i16, i16)) -> Result<Option<CaptureArea>> {
area_from_i32_points(
(i32::from(start.0), i32::from(start.1)),
(i32::from(end.0), i32::from(end.1)),
)
}
fn area_from_screen_points(
start: (i16, i16),
end: (i16, i16),
screen_width: i32,
screen_height: i32,
) -> Result<Option<CaptureArea>> {
area_from_i32_points(
clamp_point_to_screen(start, screen_width, screen_height),
clamp_point_to_screen(end, screen_width, screen_height),
)
}
fn clamp_point_to_screen(point: (i16, i16), screen_width: i32, screen_height: i32) -> (i32, i32) {
(
i32::from(point.0).clamp(0, screen_width.max(0)),
i32::from(point.1).clamp(0, screen_height.max(0)),
)
}
fn area_from_i32_points(start: (i32, i32), end: (i32, i32)) -> Result<Option<CaptureArea>> {
let x1 = start.0.min(end.0);
let y1 = start.1.min(end.1);
let x2 = start.0.max(end.0);
let y2 = start.1.max(end.1);
let width = x2 - x1;
let height = y2 - y1;
if width == 0 || height == 0 {
return Ok(None);
}
CaptureArea::new(x1, y1, width, height).map(Some)
}
fn rectangle_from_area(area: CaptureArea) -> Option<Rectangle> {
Some(Rectangle {
x: i16::try_from(area.x).ok()?,
y: i16::try_from(area.y).ok()?,
width: u16::try_from(area.width).ok()?,
height: u16::try_from(area.height).ok()?,
})
}
fn default_area_around(
point: (i16, i16),
screen_width: i32,
screen_height: i32,
) -> Result<CaptureArea> {
let size = default_selection_size(screen_width, screen_height);
let half = size / 2;
let max_x = (screen_width - size).max(0);
let max_y = (screen_height - size).max(0);
let x = (i32::from(point.0) - half).clamp(0, max_x);
let y = (i32::from(point.1) - half).clamp(0, max_y);
CaptureArea::new(x, y, size, size)
}
fn default_selection_size(screen_width: i32, screen_height: i32) -> i32 {
let short_side = screen_width.min(screen_height).max(1);
(short_side / 4)
.clamp(DEFAULT_SELECTION_MIN_SIZE, DEFAULT_SELECTION_MAX_SIZE)
.min(short_side)
}
fn handle_rectangles(area: CaptureArea) -> Vec<Rectangle> {
handle_rectangles_with_handles(area)
.into_iter()
.map(|(_, rectangle)| rectangle)
.collect()
}
fn handle_shadow_rectangles(area: CaptureArea) -> Vec<Rectangle> {
handle_rectangles_with_handles(area)
.into_iter()
.filter_map(|(_, rectangle)| offset_rectangle(rectangle, 2, 2))
.collect()
}
fn handle_rectangles_with_handles(area: CaptureArea) -> [(Handle, Rectangle); 8] {
let half = HANDLE_SIZE / 2;
let center_x = area.x + area.width / 2;
let center_y = area.y + area.height / 2;
[
(Handle::TopLeft, handle_rectangle(area.x, area.y, half)),
(
Handle::TopRight,
handle_rectangle(area.right(), area.y, half),
),
(
Handle::BottomRight,
handle_rectangle(area.right(), area.bottom(), half),
),
(
Handle::BottomLeft,
handle_rectangle(area.x, area.bottom(), half),
),
(Handle::Top, handle_rectangle(center_x, area.y, half)),
(
Handle::Right,
handle_rectangle(area.right(), center_y, half),
),
(
Handle::Bottom,
handle_rectangle(center_x, area.bottom(), half),
),
(Handle::Left, handle_rectangle(area.x, center_y, half)),
]
}
fn handle_rectangle(x: i32, y: i32, half: i32) -> Rectangle {
Rectangle {
x: clamp_i16(x - half),
y: clamp_i16(y - half),
width: u16::try_from(HANDLE_SIZE).unwrap_or(u16::MAX),
height: u16::try_from(HANDLE_SIZE).unwrap_or(u16::MAX),
}
}
#[cfg(debug_assertions)]
fn autodrag_requested() -> bool {
std::env::var_os(AUTODRAG_ENV).is_some()
}
#[cfg(debug_assertions)]
fn spawn_autodrag_if_requested(root: Window, window: Window) {
let Some(raw) = std::env::var_os(AUTODRAG_ENV) else {
return;
};
let raw = raw.to_string_lossy().into_owned();
let area = match raw.parse::<CaptureArea>() {
Ok(area) => area,
Err(err) => {
eprintln!("warning: ignoring invalid {AUTODRAG_ENV}={raw:?}: {err:#}");
return;
}
};
thread::spawn(move || {
thread::sleep(Duration::from_millis(250));
if let Err(err) = autodrag(root, window, area) {
eprintln!("warning: automated selector drag failed: {err:#}");
}
});
}
#[cfg(debug_assertions)]
fn autodrag(root: Window, window: Window, area: CaptureArea) -> Result<()> {
use x11rb::protocol::xproto::{
BUTTON_PRESS_EVENT, BUTTON_RELEASE_EVENT, KeyButMask, MOTION_NOTIFY_EVENT, Motion,
MotionNotifyEvent,
};
let (conn, _) = connect(None).context("failed to connect to X11 display for autodrag")?;
let start_x = i16::try_from(area.x).context("autodrag x is outside X11 coordinate range")?;
let start_y = i16::try_from(area.y).context("autodrag y is outside X11 coordinate range")?;
let end_x = i16::try_from(area.x + area.width)
.context("autodrag right edge is outside X11 coordinate range")?;
let end_y = i16::try_from(area.y + area.height)
.context("autodrag bottom edge is outside X11 coordinate range")?;
conn.send_event(
false,
window,
EventMask::BUTTON_PRESS,
ButtonPressEvent {
response_type: BUTTON_PRESS_EVENT,
detail: 1,
sequence: 0,
time: CURRENT_TIME,
root,
event: window,
child: NONE,
root_x: start_x,
root_y: start_y,
event_x: start_x,
event_y: start_y,
state: KeyButMask::default(),
same_screen: true,
},
)?;
conn.flush()?;
thread::sleep(Duration::from_millis(100));
conn.send_event(
false,
window,
EventMask::POINTER_MOTION,
MotionNotifyEvent {
response_type: MOTION_NOTIFY_EVENT,
detail: Motion::NORMAL,
sequence: 0,
time: CURRENT_TIME,
root,
event: window,
child: NONE,
root_x: end_x,
root_y: end_y,
event_x: end_x,
event_y: end_y,
state: KeyButMask::BUTTON1,
same_screen: true,
},
)?;
conn.flush()?;
thread::sleep(Duration::from_millis(100));
conn.send_event(
false,
window,
EventMask::BUTTON_RELEASE,
ButtonPressEvent {
response_type: BUTTON_RELEASE_EVENT,
detail: 1,
sequence: 0,
time: CURRENT_TIME,
root,
event: window,
child: NONE,
root_x: end_x,
root_y: end_y,
event_x: end_x,
event_y: end_y,
state: KeyButMask::BUTTON1,
same_screen: true,
},
)?;
conn.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn area_from_drag_points_normalizes_direction() {
assert_eq!(
area_from_points((300, 200), (100, 50)).unwrap(),
Some(CaptureArea {
x: 100,
y: 50,
width: 200,
height: 150,
})
);
}
#[test]
fn area_from_drag_points_rejects_click_without_drag() {
assert_eq!(area_from_points((100, 50), (100, 50)).unwrap(), None);
}
#[test]
fn button_mapping_distinguishes_primary_and_cancel() {
assert!(is_primary_button_detail(1));
assert!(!is_primary_button_detail(3));
assert!(is_cancel_button_detail(3));
assert!(!is_cancel_button_detail(1));
}
#[test]
fn area_from_screen_points_clamps_to_screen_bounds() {
assert_eq!(
area_from_screen_points((-10, -20), (150, 90), 100, 80).unwrap(),
Some(CaptureArea {
x: 0,
y: 0,
width: 100,
height: 80,
})
);
}
#[test]
fn update_drag_clamps_new_selection_to_screen_bounds() {
assert_eq!(
update_drag(Drag::New { start: (50, 40) }, (150, 90), 100, 80).unwrap(),
Some(CaptureArea {
x: 50,
y: 40,
width: 50,
height: 40,
})
);
}
#[test]
fn default_area_around_click_is_centered() {
assert_eq!(
default_area_around((100, 100), 300, 300).unwrap(),
CaptureArea {
x: 60,
y: 60,
width: 80,
height: 80,
}
);
}
#[test]
fn default_area_around_click_stays_inside_screen() {
assert_eq!(
default_area_around((5, 5), 100, 100).unwrap(),
CaptureArea {
x: 0,
y: 0,
width: 80,
height: 80,
}
);
}
#[test]
fn default_area_shrinks_to_tiny_screen() {
assert_eq!(
default_area_around((5, 5), 30, 20).unwrap(),
CaptureArea {
x: 0,
y: 0,
width: 20,
height: 20,
}
);
}
#[test]
fn default_selection_size_scales_with_screen_size() {
assert_eq!(default_selection_size(100, 100), 80);
assert_eq!(default_selection_size(1000, 500), 125);
assert_eq!(default_selection_size(3840, 2160), 480);
}
#[test]
fn default_area_around_click_scales_on_large_screen() {
assert_eq!(
default_area_around((500, 250), 1000, 500).unwrap(),
CaptureArea {
x: 438,
y: 188,
width: 125,
height: 125,
}
);
}
#[test]
fn handle_rectangles_are_centered_on_corners_and_edges() {
let handles = handle_rectangles(CaptureArea::new(20, 10, 30, 40).unwrap());
assert_eq!(handles.len(), 8);
assert_eq!(rect_tuple(handles[0]), (11, 1, 18, 18));
assert_eq!(rect_tuple(handles[1]), (41, 1, 18, 18));
assert_eq!(rect_tuple(handles[2]), (41, 41, 18, 18));
assert_eq!(rect_tuple(handles[3]), (11, 41, 18, 18));
assert_eq!(rect_tuple(handles[4]), (26, 1, 18, 18));
assert_eq!(rect_tuple(handles[5]), (41, 21, 18, 18));
assert_eq!(rect_tuple(handles[6]), (26, 41, 18, 18));
assert_eq!(rect_tuple(handles[7]), (11, 21, 18, 18));
}
#[test]
fn hit_test_detects_handles_inside_and_outside() {
let area = CaptureArea::new(20, 10, 30, 40).unwrap();
assert_eq!(hit_test((20, 10), area), Hit::Handle(Handle::TopLeft));
assert_eq!(hit_test((35, 25), area), Hit::Inside);
assert_eq!(hit_test((80, 70), area), Hit::Outside);
}
#[test]
fn hit_test_keeps_area_edges_resizable() {
let area = CaptureArea::new(20, 10, 30, 40).unwrap();
assert_eq!(hit_test((50, 18), area), Hit::Handle(Handle::TopRight));
assert_eq!(hit_test((35, 50), area), Hit::Handle(Handle::Bottom));
}
#[test]
fn rectangle_contains_treats_right_and_bottom_as_exclusive() {
let rectangle = Rectangle {
x: 10,
y: 20,
width: 30,
height: 40,
};
assert!(rectangle_contains(rectangle, (39, 59)));
assert!(!rectangle_contains(rectangle, (40, 59)));
assert!(!rectangle_contains(rectangle, (39, 60)));
}
#[test]
fn clipped_rect_saturates_edges_before_clamping() {
assert_eq!(
rect_tuple(clipped_rect_i32(90, 90, i32::MAX, i32::MAX, 100, 100).unwrap()),
(90, 90, 10, 10)
);
}
#[test]
fn hit_test_detects_edge_handles() {
let area = CaptureArea::new(20, 10, 30, 40).unwrap();
assert_eq!(hit_test((35, 10), area), Hit::Handle(Handle::Top));
assert_eq!(hit_test((50, 30), area), Hit::Handle(Handle::Right));
assert_eq!(hit_test((35, 50), area), Hit::Handle(Handle::Bottom));
assert_eq!(hit_test((20, 30), area), Hit::Handle(Handle::Left));
}
#[test]
fn cursor_kind_tracks_selection_hit_target() {
let area = CaptureArea::new(20, 10, 30, 40).unwrap();
assert_eq!(cursor_kind_for_point((5, 5), None), CursorKind::Crosshair);
assert_eq!(
cursor_kind_for_point((80, 70), Some(area)),
CursorKind::Crosshair
);
assert_eq!(
cursor_kind_for_point((35, 25), Some(area)),
CursorKind::Move
);
assert_eq!(
cursor_kind_for_point((50, 50), Some(area)),
CursorKind::Resize(Handle::BottomRight)
);
assert_eq!(
cursor_kind_for_point((35, 10), Some(area)),
CursorKind::Resize(Handle::Top)
);
}
#[test]
fn cursor_kind_stays_locked_to_active_drag() {
let area = CaptureArea::new(20, 10, 30, 40).unwrap();
assert_eq!(
cursor_kind_for_state(
(90, 70),
Some(area),
Some(Drag::Resize {
handle: Handle::TopLeft,
original: area,
}),
),
CursorKind::Resize(Handle::TopLeft)
);
assert_eq!(
cursor_kind_for_state(
(90, 70),
Some(area),
Some(Drag::Move {
start: (35, 25),
original: area,
}),
),
CursorKind::Move
);
}
#[test]
fn move_area_clamps_to_screen() {
assert_eq!(
move_area(CaptureArea::new(20, 10, 30, 40).unwrap(), 100, 100, 100, 80),
CaptureArea {
x: 70,
y: 40,
width: 30,
height: 40,
}
);
}
#[test]
fn move_area_saturates_extreme_delta_before_clamping() {
assert_eq!(
move_area(
CaptureArea::new(i32::MAX - 20, 10, 10, 10).unwrap(),
i32::MAX,
i32::MIN,
i32::MAX,
100,
),
CaptureArea {
x: i32::MAX - 10,
y: 0,
width: 10,
height: 10,
}
);
}
#[test]
fn resize_area_by_delta_clamps_to_screen() {
assert_eq!(
resize_area_by_delta(CaptureArea::new(80, 60, 30, 40).unwrap(), 100, 100, 100, 80)
.unwrap(),
CaptureArea {
x: 80,
y: 60,
width: 20,
height: 20,
}
);
}
#[test]
fn resize_area_by_delta_saturates_extreme_delta_before_clamping() {
assert_eq!(
resize_area_by_delta(
CaptureArea::new(0, 0, i32::MAX - 10, 10).unwrap(),
i32::MAX,
i32::MIN,
i32::MAX,
100,
)
.unwrap(),
CaptureArea {
x: 0,
y: 0,
width: i32::MAX,
height: 1,
}
);
}
#[test]
fn resize_interval_saturates_extreme_endpoints() {
assert_eq!(
resize_interval(i32::MAX, i32::MIN, i32::MAX, true),
(i32::MIN, i32::MAX)
);
assert_eq!(
resize_interval(i32::MIN, i32::MAX, i32::MAX, false),
(i32::MIN, i32::MAX)
);
assert_eq!(
resize_interval(i32::MAX, i32::MAX, i32::MAX, false),
(i32::MAX - 1, 1)
);
}
#[test]
fn resize_area_from_corner_normalizes_crossed_edges() {
assert_eq!(
resize_area(
CaptureArea::new(20, 10, 30, 40).unwrap(),
Handle::TopLeft,
(80, 70),
100,
80,
)
.unwrap(),
CaptureArea {
x: 50,
y: 50,
width: 30,
height: 20,
}
);
}
#[test]
fn resize_area_from_edge_keeps_opposite_edge_fixed() {
assert_eq!(
resize_area(
CaptureArea::new(20, 10, 30, 40).unwrap(),
Handle::Left,
(10, 30),
100,
80,
)
.unwrap(),
CaptureArea {
x: 10,
y: 10,
width: 40,
height: 40,
}
);
assert_eq!(
resize_area(
CaptureArea::new(20, 10, 30, 40).unwrap(),
Handle::Bottom,
(35, 70),
100,
80,
)
.unwrap(),
CaptureArea {
x: 20,
y: 10,
width: 30,
height: 60,
}
);
}
#[test]
fn resize_area_collapses_corner_to_one_pixel_instead_of_erroring() {
assert_eq!(
resize_area(
CaptureArea::new(20, 10, 30, 40).unwrap(),
Handle::TopLeft,
(50, 50),
100,
80,
)
.unwrap(),
CaptureArea {
x: 49,
y: 49,
width: 1,
height: 1,
}
);
assert_eq!(
resize_area(
CaptureArea::new(20, 10, 30, 40).unwrap(),
Handle::BottomRight,
(20, 10),
100,
80,
)
.unwrap(),
CaptureArea {
x: 20,
y: 10,
width: 1,
height: 1,
}
);
}
#[test]
fn resize_area_collapses_edge_to_one_pixel_instead_of_erroring() {
assert_eq!(
resize_area(
CaptureArea::new(20, 10, 30, 40).unwrap(),
Handle::Left,
(50, 30),
100,
80,
)
.unwrap(),
CaptureArea {
x: 49,
y: 10,
width: 1,
height: 40,
}
);
assert_eq!(
resize_area(
CaptureArea::new(20, 10, 30, 40).unwrap(),
Handle::Top,
(35, 50),
100,
80,
)
.unwrap(),
CaptureArea {
x: 20,
y: 49,
width: 30,
height: 1,
}
);
}
#[test]
fn update_drag_creates_new_selection() {
assert_eq!(
update_drag(Drag::New { start: (10, 20) }, (50, 70), 100, 100).unwrap(),
Some(CaptureArea {
x: 10,
y: 20,
width: 40,
height: 50,
})
);
}
#[test]
fn key_action_maps_arrows_to_move() {
assert_eq!(
key_action_from_keysyms(&[XK_RIGHT], KeyButMask::default()),
KeyAction::Move { dx: 1, dy: 0 }
);
assert_eq!(
key_action_from_keysyms(&[XK_UP], KeyButMask::CONTROL),
KeyAction::Move { dx: 0, dy: -10 }
);
}
#[test]
fn key_action_maps_shift_arrows_to_resize() {
assert_eq!(
key_action_from_keysyms(&[XK_LEFT], KeyButMask::SHIFT),
KeyAction::Resize { dw: -1, dh: 0 }
);
assert_eq!(
key_action_from_keysyms(&[XK_DOWN], KeyButMask::SHIFT | KeyButMask::CONTROL),
KeyAction::Resize { dw: 0, dh: 10 }
);
}
#[test]
fn key_action_maps_confirm_cancel_and_reset() {
assert_eq!(
key_action_from_keysyms(&[XK_RETURN], KeyButMask::default()),
KeyAction::Confirm
);
assert_eq!(
key_action_from_keysyms(&[XK_ESCAPE], KeyButMask::default()),
KeyAction::Cancel
);
assert_eq!(
key_action_from_keysyms(&[XK_DELETE], KeyButMask::default()),
KeyAction::Reset
);
}
#[test]
fn action_buttons_are_clickable_after_selection() {
let area = CaptureArea::new(100, 100, 300, 200).unwrap();
let buttons: Vec<_> = action_button_rectangles(area, 800, 600).collect();
assert_eq!(buttons.len(), 2);
for (button, rectangle) in buttons {
let point = (rectangle.x + 1, rectangle.y + 1);
assert_eq!(action_button_hit(point, area, 800, 600), Some(button));
}
}
#[test]
fn action_buttons_move_above_selection_when_bottom_is_full() {
let area = CaptureArea::new(100, 550, 300, 40).unwrap();
let buttons: Vec<_> = action_button_rectangles(area, 800, 600).collect();
assert!(
buttons
.iter()
.all(|(_, rectangle)| i32::from(rectangle.y) < area.y)
);
}
#[test]
fn confirm_on_release_mode_accepts_selection_immediately() {
let area = CaptureArea::new(10, 20, 160, 120).unwrap();
assert_eq!(
selection_on_release(SelectorMode::ConfirmOnRelease, Some(area)),
Some(area)
);
assert_eq!(
selection_on_release(SelectorMode::Editable, Some(area)),
None
);
assert_eq!(
selection_on_release(SelectorMode::ConfirmOnRelease, None),
None
);
}
#[test]
fn existing_selection_is_clamped_to_current_screen() {
let area = CaptureArea::new(700, 500, 400, 300).unwrap();
assert_eq!(
move_area(area, 0, 0, 800, 600),
CaptureArea {
x: 400,
y: 300,
width: 400,
height: 300,
}
);
}
#[test]
fn label_position_prefers_above_selection() {
let (x, y) = label_position(
CaptureArea::new(100, 100, 300, 200).unwrap(),
1920,
1080,
10,
);
assert_eq!(x, 112);
assert_eq!(y, 88);
}
#[test]
fn label_position_stays_visible_near_top_left() {
let (x, y) = label_position(CaptureArea::new(0, 0, 300, 200).unwrap(), 320, 240, 10);
assert_eq!(x, 12);
assert!(y >= 14);
}
#[test]
fn label_position_stays_visible_near_right_edge() {
let (x, _) = label_position(CaptureArea::new(280, 100, 30, 30).unwrap(), 320, 240, 10);
assert!(x <= 232);
}
#[test]
fn help_label_position_is_centered() {
let (x, y) = help_label_position(1000, 500, HELP_TEXT.len());
assert_eq!(x, 344);
assert_eq!(y, 250);
}
#[test]
fn help_label_position_stays_visible_on_tiny_screen() {
let (x, y) = help_label_position(40, 20, HELP_TEXT.len());
assert_eq!(x, 0);
assert_eq!(y, 14);
}
#[test]
fn label_width_saturates_large_lengths() {
assert_eq!(
label_width((i32::MAX as usize / LABEL_CHAR_WIDTH as usize) + 1),
i32::MAX
);
assert_eq!(label_width(usize::MAX), i32::MAX);
}
#[test]
fn label_box_dimensions_saturate() {
assert_eq!(label_box_width(usize::MAX), i32::MAX);
assert_eq!(
label_box_height(),
LABEL_TEXT_HEIGHT + LABEL_BOX_PADDING_Y * 2
);
}
#[test]
fn label_positions_tolerate_saturated_label_widths() {
let tiny_area = CaptureArea::new(0, 0, 40, 20).unwrap();
assert_eq!(help_label_position(40, 20, usize::MAX), (0, 14));
assert_eq!(
shortcut_label_position(tiny_area, 40, 20, usize::MAX),
(0, 14)
);
assert_eq!(
label_position(
CaptureArea::new(i32::MAX - 1, i32::MAX - 1, 1, 1).unwrap(),
i32::MAX,
i32::MAX,
usize::MAX,
),
(0, i16::MAX)
);
}
#[test]
fn label_overlap_saturates_extreme_geometry() {
assert!(label_baselines_overlap(i32::MAX, i32::MAX));
assert!(label_overlaps_area(
0,
LABEL_TEXT_HEIGHT,
i32::MAX,
CaptureArea::new(100, 0, 1, 1).unwrap(),
));
}
#[test]
fn shortcut_label_position_uses_bottom_when_dimension_is_above() {
let (x, y) = shortcut_label_position(
CaptureArea::new(100, 100, 300, 200).unwrap(),
1000,
500,
SHORTCUT_TEXT.len(),
);
assert_eq!(x, 272);
assert_eq!(y, 488);
}
#[test]
fn shortcut_label_position_uses_top_when_dimension_is_at_bottom() {
let (x, y) = shortcut_label_position(
CaptureArea::new(0, 0, 1000, 500).unwrap(),
1000,
500,
SHORTCUT_TEXT.len(),
);
assert_eq!(x, 272);
assert_eq!(y, 26);
}
#[test]
fn shortcut_label_position_uses_bottom_when_top_would_cover_selection() {
let (x, y) = shortcut_label_position(
CaptureArea::new(20, 10, 30, 40).unwrap(),
100,
80,
SHORTCUT_TEXT.len(),
);
assert_eq!(x, 0);
assert_eq!(y, 68);
}
#[test]
fn shortcut_label_position_uses_top_when_bottom_would_cover_selection() {
let (x, y) = shortcut_label_position(
CaptureArea::new(20, 50, 30, 25).unwrap(),
100,
80,
SHORTCUT_TEXT.len(),
);
assert_eq!(x, 0);
assert_eq!(y, 26);
}
#[test]
fn shortcut_label_position_stays_visible_on_tiny_screen() {
let (x, y) = shortcut_label_position(
CaptureArea::new(0, 0, 40, 20).unwrap(),
40,
20,
SHORTCUT_TEXT.len(),
);
assert_eq!(x, 0);
assert_eq!(y, 14);
}
#[test]
fn shade_rectangles_cut_out_selection() {
let rectangles = shade_rectangles(
100,
80,
Some(CaptureArea {
x: 20,
y: 10,
width: 30,
height: 40,
}),
);
assert_eq!(rectangles.len(), 4);
assert_eq!(rect_tuple(rectangles[0]), (0, 0, 100, 10));
assert_eq!(rect_tuple(rectangles[1]), (0, 50, 100, 30));
assert_eq!(rect_tuple(rectangles[2]), (0, 10, 20, 40));
assert_eq!(rect_tuple(rectangles[3]), (50, 10, 50, 40));
}
#[test]
fn shade_rectangles_skip_zero_sized_edges() {
let rectangles = shade_rectangles(
100,
80,
Some(CaptureArea {
x: 0,
y: 0,
width: 100,
height: 40,
}),
);
assert_eq!(rectangles.len(), 1);
assert_eq!(rect_tuple(rectangles[0]), (0, 40, 100, 40));
}
#[test]
fn overlay_shape_keeps_selection_clear_and_decorations_visible() {
let area = CaptureArea {
x: 20,
y: 10,
width: 60,
height: 50,
};
let rectangles = overlay_shape_rectangles(140, 120, Some(area));
assert!(rectangles_contain_point(&rectangles, (0, 0)));
assert!(rectangles_contain_point(&rectangles, (20, 10)));
assert!(rectangles_contain_point(&rectangles, (40, 35)));
assert!(rectangles_contain_point(&rectangles, (52, 72)));
assert!(!rectangles_contain_point(&rectangles, (55, 36)));
}
#[test]
fn border_shape_saturates_extreme_padded_dimensions() {
let rectangles =
border_shape_rectangles(CaptureArea::new(0, 0, i32::MAX, 1).unwrap(), 100, 10);
assert_eq!(rectangles.len(), 3);
assert!(rectangles.iter().all(|rectangle| rectangle.width > 0));
assert!(rectangles.iter().all(|rectangle| rectangle.height > 0));
assert!(rectangles_contain_point(&rectangles, (0, 0)));
}
#[test]
fn overlay_shape_keeps_full_screen_label_visible() {
let rectangles = overlay_shape_rectangles(
100,
80,
Some(CaptureArea {
x: 0,
y: 0,
width: 100,
height: 80,
}),
);
assert!(rectangles_contain_point(&rectangles, (8, 72)));
assert!(rectangles_contain_point(&rectangles, (90, 22)));
assert!(!rectangles_contain_point(&rectangles, (90, 50)));
}
#[test]
fn selection_grid_adds_subtle_interior_guides() {
let rectangles = selection_grid_rectangles(CaptureArea::new(20, 10, 60, 50).unwrap());
assert_eq!(rectangles.len(), 4);
assert!(rectangles_contain_point(&rectangles, (40, 35)));
assert!(rectangles_contain_point(&rectangles, (55, 26)));
assert!(!rectangles_contain_point(&rectangles, (55, 36)));
}
fn rect_tuple(rect: Rectangle) -> (i16, i16, u16, u16) {
(rect.x, rect.y, rect.width, rect.height)
}
fn rectangles_contain_point(rectangles: &[Rectangle], point: (i16, i16)) -> bool {
rectangles
.iter()
.any(|rectangle| rectangle_contains(*rectangle, point))
}
}