use crate::selection::Speed;
use std::borrow::Cow;
use std::time::Instant;
use crate::message::Message;
use crate::screenshot::RgbaHandle;
use crate::selection::selection_lock::OptionalSelectionExt;
use crate::theme::THEME;
use iced::alignment::Vertical;
use iced::mouse::Cursor;
use iced::widget::canvas::Path;
use iced::widget::text::Shaping;
use iced::widget::{self, Column, Space, Stack, canvas, column, container, row};
use iced::{Background, Color, Element, Font, Length, Point, Rectangle, Size, Task};
use crate::background_image::BackgroundImage;
use crate::corners::{Side, SideOrCorner};
use crate::rectangle::RectangleExt;
use crate::selection::{Selection, SelectionStatus};
pub static SAVED_IMAGE: std::sync::OnceLock<image::DynamicImage> = std::sync::OnceLock::new();
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd)]
pub struct ErrorMessage {
message: Cow<'static, str>,
timestamp: Instant,
}
impl ErrorMessage {
pub fn new<T: Into<Cow<'static, str>>>(message: T) -> Self {
Self {
message: message.into(),
timestamp: Instant::now(),
}
}
}
#[derive(Debug)]
pub struct App {
pub selections_created: usize,
pub screenshot: RgbaHandle,
pub selection: Option<Selection>,
pub errors: Vec<ErrorMessage>,
}
impl Default for App {
fn default() -> Self {
let screenshot =
crate::screenshot::screenshot().expect("Failed to take a screenshot of the desktop");
Self {
screenshot,
selection: None,
selections_created: 0,
errors: vec![],
}
}
}
impl App {
pub fn view(&self) -> iced::Element<Message> {
Stack::new()
.push(BackgroundImage::new(self.screenshot.clone().into()))
.push(canvas(self).width(Length::Fill).height(Length::Fill))
.push_maybe(
self.selection
.is_none()
.then(|| self.render_welcome_message()),
)
.push(self.render_errors())
.push_maybe(self.selection.filter(|sel| sel.is_idle()).map(|sel| {
let (image_width, image_height, _) = self.screenshot.raw();
sel.render_icons(image_width as f32, image_height as f32)
}))
.push_maybe(self.selection.get().map(|(sel, key)| {
let (image_width, image_height, _) = self.screenshot.raw();
crate::widgets::size_indicator(image_height, image_width, sel.norm().rect, key)
}))
.into()
}
#[expect(clippy::needless_pass_by_value, reason = "trait function")]
pub fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::ResizeVertically {
new_height,
sel_is_some,
} => {
let sel = self.selection.unlock(sel_is_some);
let new_height =
new_height.min((sel.norm().rect.y + sel.norm().rect.height) as u32);
let dy = new_height as f32 - sel.norm().rect.height;
*sel = sel
.norm()
.with_height(|_| new_height as f32)
.with_y(|y| y - dy);
}
Message::ResizeHorizontally {
new_width,
sel_is_some,
} => {
let sel = self.selection.unlock(sel_is_some);
let new_width = new_width.min((sel.norm().rect.x + sel.norm().rect.width) as u32);
let dx = new_width as f32 - sel.norm().rect.width;
*sel = sel
.norm()
.with_width(|_| new_width as f32)
.with_x(|x| x - dx);
}
Message::NoOp => (),
Message::Exit => return Self::exit(),
Message::LeftMouseDown(cursor) => {
if let Some((cursor, side, rect)) = cursor.position().and_then(|cursor_pos| {
self.selection.as_mut().and_then(|selected_region| {
selected_region
.corners()
.side_at(cursor_pos)
.map(|l| (cursor_pos, l, selected_region))
})
}) {
let resized = SelectionStatus::Resize {
initial_rect: rect.norm().rect,
initial_cursor_pos: cursor,
resize_side: side,
};
rect.status = resized;
} else if let Some((cursor, selected_region)) = self.cursor_in_selection_mut(cursor)
{
let dragged = SelectionStatus::Move {
initial_rect_pos: selected_region.norm().pos(),
initial_cursor_pos: cursor,
};
selected_region.status = dragged;
} else if let Some(cursor_position) = cursor.position() {
self.create_selection_at(cursor_position);
}
}
Message::EnterIdle => {
if let Some(selection) = self.selection.as_mut() {
selection.status = SelectionStatus::Idle;
}
}
Message::MoveSelection {
current_cursor_pos,
initial_cursor_pos,
current_selection,
initial_rect_pos,
speed,
} => {
let (image_width, image_height, _) = self.screenshot.raw();
let mut new_selection = current_selection.with_pos(|_| {
initial_rect_pos + ((current_cursor_pos - initial_cursor_pos) * speed.speed())
});
let old_x = new_selection.rect.x as u32;
let old_y = new_selection.rect.y as u32;
new_selection.rect.x = new_selection
.rect
.x
.min(image_width as f32 - new_selection.rect.width)
.max(0.0);
new_selection.rect.y = new_selection
.rect
.y
.min(image_height as f32 - new_selection.rect.height)
.max(0.0);
if new_selection.rect.y as u32 != old_y || new_selection.rect.x as u32 != old_x {
new_selection.status = SelectionStatus::Move {
initial_rect_pos: new_selection.pos(),
initial_cursor_pos: current_cursor_pos,
}
}
if speed
== (Speed::Slow {
has_speed_changed: true,
})
{
new_selection.status = SelectionStatus::Move {
initial_rect_pos: current_selection.pos(),
initial_cursor_pos: current_cursor_pos,
}
}
self.selection = Some(new_selection);
}
Message::ExtendNewSelection(new_mouse_position) => {
self.update_selection(new_mouse_position);
}
Message::CopyToClipboard => {
let Some(selection) = self.selection.map(Selection::norm) else {
self.error("There is no selection to copy");
return Task::none();
};
let (width, height, pixels) = self.screenshot.raw();
let cropped_image = selection.process_image(width, height, pixels);
let image_data = arboard::ImageData {
width: cropped_image.width() as usize,
height: cropped_image.height() as usize,
bytes: std::borrow::Cow::Borrowed(cropped_image.as_bytes()),
};
#[cfg_attr(
target_os = "macos",
expect(unused_variables, reason = "it is used on other platforms")
)]
match crate::clipboard::set_image(image_data) {
Ok(img_path) => {
let mut notify = notify_rust::Notification::new();
notify
.summary(&format!("Copied image to clipboard {width}px * {height}px"));
#[cfg(not(target_os = "macos"))]
notify.image_path(&img_path.to_string_lossy());
let _ = notify.show();
return Self::exit();
}
Err(err) => {
self.error(format!("Could not copy the image: {err}"));
}
}
}
Message::SaveScreenshot => {
let Some(selection) = self.selection.as_ref().map(|sel| Selection::norm(*sel))
else {
self.error("Selection does not exist. There is nothing to copy!");
return Task::none();
};
let (width, height, pixels) = self.screenshot.raw();
let cropped_image = selection.process_image(width, height, pixels);
let _ = SAVED_IMAGE.set(cropped_image);
return Self::exit();
}
Message::Resize {
current_cursor_pos,
initial_cursor_pos,
resize_side,
initial_rect,
sel_is_some,
speed,
} => {
let selected_region = self.selection.unlock(sel_is_some);
let resize_speed = speed.speed();
let dy = (current_cursor_pos.y - initial_cursor_pos.y) * resize_speed;
let dx = (current_cursor_pos.x - initial_cursor_pos.x) * resize_speed;
selected_region.rect = match resize_side {
SideOrCorner::Side(side) => match side {
Side::Top => initial_rect.with_height(|h| h - dy).with_y(|y| y + dy),
Side::Right => initial_rect.with_width(|w| w + dx),
Side::Bottom => initial_rect.with_height(|h| h + dy),
Side::Left => initial_rect.with_width(|w| w - dx).with_x(|x| x + dx),
},
SideOrCorner::Corner(corner) => corner.resize_rect(initial_rect, dy, dx),
};
if speed
== (Speed::Slow {
has_speed_changed: true,
})
{
selected_region.status = SelectionStatus::Resize {
initial_rect: selected_region.rect,
initial_cursor_pos: current_cursor_pos,
resize_side,
}
}
}
Message::ResizeToCursor {
cursor_pos,
selection,
sel_is_some,
} => {
let (corner_point, corners) = selection.corners().nearest_corner(cursor_pos);
let sel = self.selection.unlock(sel_is_some);
sel.rect = corners.resize_rect(
selection.rect,
cursor_pos.y - corner_point.y,
cursor_pos.x - corner_point.x,
);
sel.status = SelectionStatus::Resize {
initial_rect: sel.rect,
initial_cursor_pos: cursor_pos,
resize_side: SideOrCorner::Corner(corners),
};
}
Message::SelectFullScreen => {
let (width, height, _) = self.screenshot.raw();
{
self.selection = Some(Selection::new(Point { x: 0.0, y: 0.0 }).with_size(
|_| Size {
width: width as f32,
height: height as f32,
},
));
}
}
}
Task::none()
}
pub fn render_shade(&self, frame: &mut canvas::Frame, bounds: Rectangle) {
let Some(selection) = self.selection.map(Selection::norm) else {
frame.fill_rectangle(bounds.position(), bounds.size(), THEME.non_selected_region);
return;
};
let outside = Path::new(|p| {
p.move_to(bounds.top_left());
p.line_to(bounds.top_right());
p.line_to(bounds.bottom_right());
p.line_to(bounds.bottom_left());
p.move_to(bounds.top_left());
p.move_to(selection.top_left());
p.line_to(selection.bottom_left());
p.line_to(selection.bottom_right());
p.line_to(selection.top_right());
p.move_to(selection.top_left());
});
frame.fill(&outside, THEME.non_selected_region);
}
fn render_welcome_message<'a>(&self) -> Element<'a, Message> {
use iced::widget::text;
const WIDTH: u32 = 380;
const HEIGHT: u32 = 160;
const FONT_SIZE: f32 = 13.0;
let (width, height, _) = self.screenshot.raw();
let vertical_space = Space::with_height(height / 2 - HEIGHT / 2);
let horizontal_space = Space::with_width(width / 2 - WIDTH / 2);
let bold = Font {
weight: iced::font::Weight::Bold,
..Font::default()
};
let keys = |key: &'static str, action: &'static str| {
row![
row![
Space::with_width(Length::Fill),
text(key)
.size(FONT_SIZE)
.font(bold)
.shaping(Shaping::Advanced)
.align_y(Vertical::Bottom)
]
.width(100.0),
Space::with_width(Length::Fixed(20.0)),
text(action).size(FONT_SIZE).align_y(Vertical::Bottom),
]
};
let stuff = iced::widget::container(
column![
keys("Mouse", "Select screenshot area"),
keys("Ctrl + S", "Save screenshot to a file"),
keys("Enter", "Copy screenshot to clipboard"),
keys("Right Click", "Snap closest corner to mouse"),
keys("Shift + Mouse", "Slowly resize / move area"),
keys("Esc", "Exit"),
]
.spacing(8.0)
.height(HEIGHT)
.width(WIDTH)
.padding(10.0),
)
.style(|_| iced::widget::container::Style {
text_color: Some(THEME.fg_on_accent_bg),
background: Some(Background::Color(THEME.accent.scale_alpha(0.95))),
border: iced::Border::default()
.color(Color::WHITE)
.rounded(6.0)
.width(1.5),
shadow: iced::Shadow::default(),
});
column![vertical_space, row![horizontal_space, stuff]].into()
}
fn render_errors(&self) -> iced::Element<Message> {
const ERROR_WIDTH: u32 = 300;
let errors = self
.errors()
.into_iter()
.take(3)
.map(|error| {
container(widget::text!("Error: {error}"))
.height(80)
.width(ERROR_WIDTH)
.style(|_| container::Style {
text_color: Some(THEME.fg),
background: Some(Background::Color(THEME.error_bg)),
border: iced::Border {
color: THEME.drop_shadow,
width: 4.0,
radius: 2.0.into(),
},
shadow: iced::Shadow::default(),
})
.padding(10.0)
.into()
})
.collect::<Column<_>>()
.width(ERROR_WIDTH)
.spacing(30);
let (image_width, _, _) = self.screenshot.raw();
row![Space::with_width(image_width - ERROR_WIDTH), errors].into()
}
pub fn cursor_in_selection(&self, cursor: Cursor) -> Option<(Point, Selection)> {
self.selection.and_then(|sel| {
cursor
.position()
.and_then(|cursor_pos| sel.contains(cursor_pos).then_some((cursor_pos, sel)))
})
}
fn cursor_in_selection_mut(&mut self, cursor: Cursor) -> Option<(Point, &mut Selection)> {
self.selection.as_mut().and_then(|sel| {
cursor
.position()
.and_then(|cursor_pos| sel.norm().contains(cursor_pos).then_some((cursor_pos, sel)))
})
}
pub fn create_selection_at(&mut self, create_selection_at: Point) {
let mut selection = Selection::new(create_selection_at);
selection.status = SelectionStatus::Create;
self.selections_created += 1;
self.selection = Some(selection);
}
pub fn update_selection(&mut self, other: Point) {
self.selection = self.selection.take().map(|selected_region| {
#[rustfmt::skip]
{
};
let width = other.x - selected_region.rect.x;
let height = other.y - selected_region.rect.y;
selected_region.with_size(|_| Size { width, height })
});
}
fn exit() -> Task<Message> {
iced::window::get_latest().then(|id| iced::window::close(id.expect("window to exist")))
}
fn error<T: Into<Cow<'static, str>> + std::fmt::Display>(&mut self, error: T) {
log::error!("Status Error: {error}");
self.errors.push(ErrorMessage::new(error));
}
fn errors(&self) -> Vec<String> {
const ERROR_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
let now = Instant::now();
self.errors
.iter()
.rev()
.map_while(|err| {
let time_passed = now - err.timestamp;
log::error!("{:#?}, {:#?}", now, err.timestamp);
(time_passed <= ERROR_TIMEOUT).then_some(err.message.to_string())
})
.collect()
}
}