use std::path::PathBuf;
use eframe::egui::{
self, CentralPanel, Color32, ColorImage, Context, Key, KeyboardShortcut, Modifiers, Pos2,
Rect, TextureHandle, TextureOptions, Vec2, ViewportCommand,
};
use image::RgbaImage;
use crate::annotate::{Annotation, Shape, Style, Tool, composite_pixelates, pixelate_rects};
use crate::document::Document;
use crate::editor::{Editor, EscapeOutcome};
use crate::ui::{TOOLS, canvas, text_overlay, toolbar};
use crate::{clipboard, export, prefs};
const PALETTE: [Color32; 6] = [
Color32::from_rgb(0xe0, 0x2d, 0x2d), Color32::from_rgb(0xf2, 0xd0, 0x2e), Color32::from_rgb(0x2f, 0xb3, 0x44), Color32::from_rgb(0x2f, 0x52, 0xe0), Color32::WHITE,
Color32::BLACK,
];
struct Toast {
message: String,
until: f64,
is_error: bool,
}
pub struct ScreencapApp {
editor: Editor,
texture: Option<TextureHandle>,
baked_pixelates: Vec<Rect>,
toolbar: toolbar::Toolbar,
toast: Option<Toast>,
last_canvas_size: Vec2,
out_dir: PathBuf,
shot_path: Option<PathBuf>,
shot_frames: u32,
demo: bool,
}
impl ScreencapApp {
pub fn new(
img: RgbaImage,
out_dir: PathBuf,
select_full: bool,
demo_mode: Option<String>,
) -> Self {
let min_dim = img.width().min(img.height());
let saved = if demo_mode.is_some() { prefs::Prefs::default() } else { prefs::load() };
let width = (f64::from(min_dim) / 450.0).clamp(2.0, 8.0).round();
let stroke_width = saved.width.unwrap_or(width as f32);
let font_size = saved.font_size.unwrap_or(stroke_width * 8.0);
let persist_style = saved.width.is_some() || saved.font_size.is_some();
let mut palette = saved.palette.unwrap_or_else(|| PALETTE.to_vec());
palette.truncate(6);
let color = palette.first().copied().unwrap_or(PALETTE[0]);
let mut doc = Document::new(img);
if select_full {
doc.region = Some(doc.image_rect());
}
let style = Style { color, width: stroke_width, font_size };
let editor = Editor::new(doc, style, palette, persist_style);
let mut app = Self {
editor,
texture: None,
baked_pixelates: Vec::new(),
toolbar: toolbar::Toolbar::new(),
toast: None,
last_canvas_size: Vec2::new(1400.0, 850.0),
out_dir,
shot_path: std::env::var_os("SCRANNOTATE_SHOT").map(PathBuf::from),
shot_frames: 0,
demo: demo_mode.is_some(),
};
if let Some(mode) = demo_mode {
app.seed_demo(&mode);
}
app
}
fn seed_demo(&mut self, mode: &str) {
let style = |color, width: f32, font_size: f32| Style { color, width, font_size };
let red = PALETTE[0];
self.editor.style = style(red, 5.0, 36.0);
self.editor.doc.region =
Some(Rect::from_min_max(Pos2::new(430.0, 70.0), Pos2::new(1560.0, 950.0)));
let annotations = vec![
Annotation::new(
Shape::Rect {
rect: Rect::from_min_max(Pos2::new(1108.0, 740.0), Pos2::new(1372.0, 850.0)),
},
style(red, 5.0, 36.0),
),
Annotation::new(
Shape::Arrow { a: Pos2::new(880.0, 560.0), b: Pos2::new(1090.0, 760.0) },
style(red, 5.0, 36.0),
),
Annotation::new(
Shape::Highlight {
rect: Rect::from_min_max(Pos2::new(532.0, 276.0), Pos2::new(1180.0, 322.0)),
},
style(PALETTE[1], 5.0, 36.0),
),
Annotation::new(
Shape::Pixelate {
rect: Rect::from_min_max(Pos2::new(532.0, 404.0), Pos2::new(1010.0, 464.0)),
},
style(red, 5.0, 36.0),
),
Annotation::new(
Shape::Marker {
pos: Pos2::new(492.0, 232.0),
number: 1,
target: Some(Pos2::new(556.0, 292.0)),
},
style(PALETTE[3], 4.0, 26.0),
),
Annotation::new(
Shape::Text { pos: Pos2::new(560.0, 596.0), text: "Ship this build!".to_owned() },
style(red, 5.0, 44.0),
),
];
let mut first = None;
for ann in annotations {
let id = self.editor.doc.push(ann);
first.get_or_insert(id);
}
self.editor.doc.marker_next = 2;
self.editor.tool = Tool::Select;
if let Some(id) = first {
self.editor.selected.insert(id);
}
if mode == "picker" {
self.editor.color_picker = Some(self.editor.style.color);
}
}
fn set_toast(&mut self, ctx: &Context, message: impl Into<String>, is_error: bool) {
self.toast = Some(Toast {
message: message.into(),
until: ctx.input(|i| i.time) + 5.0,
is_error,
});
}
fn rendered(&self) -> anyhow::Result<RgbaImage> {
let doc = &self.editor.doc;
export::render_to_image(&doc.base, doc.shapes(), doc.region)
}
fn save(&mut self, ctx: &Context, close: bool) {
let result =
self.rendered().and_then(|img| export::save_timestamped(&img, &self.out_dir));
match result {
Ok(path) => {
println!("{}", path.display());
if close {
ctx.send_viewport_cmd(ViewportCommand::Close);
} else {
self.set_toast(ctx, format!("Saved {}", path.display()), false);
}
}
Err(err) => self.set_toast(ctx, format!("Save failed: {err:#}"), true),
}
}
fn copy(&mut self, ctx: &Context, close: bool) {
match self.rendered().and_then(|img| clipboard::copy_image(&img)) {
Ok(()) => {
if close {
ctx.send_viewport_cmd(ViewportCommand::Close);
} else {
self.set_toast(ctx, "Copied to the clipboard", false);
}
}
Err(err) => self.set_toast(ctx, format!("Copy failed: {err:#}"), true),
}
}
fn sync_texture(&mut self, ctx: &Context) {
let doc = &self.editor.doc;
let current = pixelate_rects(doc.shapes());
if self.texture.is_some() && current == self.baked_pixelates {
return;
}
let mut composited = doc.base.clone();
composite_pixelates(&mut composited, doc.shapes());
let max_side = u32::try_from(ctx.input(|i| i.max_texture_side))
.unwrap_or(u32::MAX)
.max(64);
let (w, h) = composited.dimensions();
if w > max_side || h > max_side {
let long = u64::from(w.max(h));
let scaled =
|d: u32| u32::try_from(u64::from(d) * u64::from(max_side) / long).unwrap_or(1).max(1);
composited = image::imageops::resize(
&composited,
scaled(w),
scaled(h),
image::imageops::FilterType::Triangle,
);
}
let size = [composited.width() as usize, composited.height() as usize];
let color_image = ColorImage::from_rgba_unmultiplied(size, composited.as_raw());
match &mut self.texture {
Some(tex) => tex.set(color_image, TextureOptions::LINEAR),
None => {
self.texture =
Some(ctx.load_texture("screenshot", color_image, TextureOptions::LINEAR));
}
}
self.baked_pixelates = current;
}
fn handle_shortcuts(&mut self, ctx: &Context, canvas_size: Vec2) {
if self.editor.state.is_text_editing() {
return;
}
let focused = ctx.memory(|m| m.focused().is_some());
let cmd = |key| KeyboardShortcut::new(Modifiers::COMMAND, key);
let cmd_shift = |key| KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, key);
let (save, copy, undo, redo, quit, fit, escape, reset, delete, select_all, space, tool) =
ctx.input_mut(|i| {
let save = i.consume_shortcut(&cmd(Key::S));
let copy = !focused
&& (i.events.iter().any(|e| matches!(e, egui::Event::Copy))
|| i.key_pressed(Key::Enter));
let undo = i.consume_shortcut(&cmd(Key::Z));
let redo =
i.consume_shortcut(&cmd_shift(Key::Z)) || i.consume_shortcut(&cmd(Key::Y));
let quit = i.consume_shortcut(&cmd(Key::Q)) || i.consume_shortcut(&cmd(Key::W));
let fit =
(!focused && i.key_pressed(Key::F)) || i.consume_shortcut(&cmd(Key::Num0));
let escape = !focused && i.key_pressed(Key::Escape) && !i.modifiers.shift;
let reset = i.key_pressed(Key::Escape) && i.modifiers.shift;
let delete =
!focused && (i.key_pressed(Key::Delete) || i.key_pressed(Key::Backspace));
let select_all = !focused && i.consume_shortcut(&cmd(Key::A));
let space = !focused && i.key_released(Key::Space);
let mut tool = None;
if !focused && !i.modifiers.command {
for (t, key) in TOOLS {
if i.key_pressed(key) {
tool = Some(t);
}
}
}
(save, copy, undo, redo, quit, fit, escape, reset, delete, select_all, space, tool)
});
if let Some(tool) = tool {
self.editor.set_tool(tool);
}
if space {
self.editor.space_released();
}
if save {
self.save(ctx, true);
}
if copy {
self.copy(ctx, true);
}
if redo {
self.editor.redo();
} else if undo {
self.editor.undo();
}
if fit {
self.editor.view.fit(self.editor.doc.image_size(), canvas_size);
}
if reset {
self.editor.reset_all();
}
if select_all {
self.editor.select_all();
}
if delete {
self.editor.delete_selected();
}
if escape {
let now = ctx.input(|i| i.time);
match self.editor.escape(now) {
EscapeOutcome::Consumed => {}
EscapeOutcome::Quit => ctx.send_viewport_cmd(ViewportCommand::Close),
}
}
if quit {
ctx.send_viewport_cmd(ViewportCommand::Close);
}
}
fn poll_shot_hook(&mut self, ctx: &Context) {
if self.shot_path.is_none() {
return;
}
self.shot_frames += 1;
if self.shot_frames == 15 {
ctx.send_viewport_cmd(ViewportCommand::Screenshot(Default::default()));
}
let shot = ctx.input(|i| {
i.events.iter().find_map(|e| match e {
egui::Event::Screenshot { image, .. } => Some(image.clone()),
_ => None,
})
});
if let Some(image) = shot
&& let Some(path) = self.shot_path.take()
{
let (w, h) = (
u32::try_from(image.size[0]).unwrap_or(0),
u32::try_from(image.size[1]).unwrap_or(0),
);
let bytes: Vec<u8> = image.pixels.iter().flat_map(|p| p.to_array()).collect();
match RgbaImage::from_raw(w, h, bytes) {
Some(shot) => {
if let Err(err) = shot.save(&path) {
eprintln!("screenshot save failed: {err}");
} else {
println!("{}", path.display());
}
}
None => eprintln!("screenshot buffer size mismatch"),
}
ctx.send_viewport_cmd(ViewportCommand::Close);
}
ctx.request_repaint();
}
}
impl eframe::App for ScreencapApp {
fn on_exit(&mut self) {
if self.demo {
return;
}
let color = self.editor.style.color;
self.editor.promote_color(color);
prefs::save(
&self.editor.palette,
self.editor.persist_style.then_some(&self.editor.style),
);
}
fn ui(&mut self, root: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = root.ctx().clone();
let ctx = &ctx;
self.sync_texture(ctx);
self.poll_shot_hook(ctx);
let now = ctx.input(|i| i.time);
self.editor.tick(now);
if self.editor.state.is_confirm_discard() {
ctx.request_repaint_after(std::time::Duration::from_millis(150));
}
self.handle_shortcuts(ctx, self.last_canvas_size);
CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::BLACK)).show(
root,
|ui| {
let canvas = canvas::show(ui, &mut self.editor, self.texture.as_ref());
self.last_canvas_size = canvas.size();
if self.toast.as_ref().is_some_and(|t| t.until <= now) {
self.toast = None;
}
let status_override = self.toast.as_ref().map(|t| toolbar::StatusOverride {
message: t.message.clone(),
is_error: t.is_error,
});
match self.toolbar.show(ctx, &mut self.editor, canvas, status_override) {
Some(toolbar::ToolbarAction::Copy { close }) => {
self.editor.commit_text();
self.copy(ctx, close);
}
Some(toolbar::ToolbarAction::Save { close }) => {
self.editor.commit_text();
self.save(ctx, close);
}
Some(toolbar::ToolbarAction::Close) => {
ctx.send_viewport_cmd(ViewportCommand::Close);
}
None => {}
}
text_overlay::show(ctx, &mut self.editor, canvas);
},
);
}
}