diff --git a/Cargo.lock b/Cargo.lock index 03e6f5c..e872457 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" name = "advanced_erosion_terrain_heightmap_generation" version = "0.1.0" dependencies = [ + "arboard", "eframe", "egui", "image", diff --git a/Cargo.toml b/Cargo.toml index 87e575b..844f2e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,14 @@ version = "0.1.0" edition = "2024" [dependencies] +arboard = "3.6" eframe = "0.33" egui = "0.33" image = { version = "0.25", default-features = false, features = ["png"] } rand = "0.9" rayon = "1.10" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 diff --git a/src/app.rs b/src/app.rs index 722e293..e3415b0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,9 +1,11 @@ use std::sync::mpsc::{self, Receiver, TryRecvError}; use std::thread; +use arboard::{Clipboard, ImageData}; use eframe::egui::{ - self, Color32, ColorImage, Context, DragValue, Frame, RichText, Sense, Slider, TextureHandle, - TextureOptions, Ui, Vec2, + self, Align2, Color32, ColorImage, Context, CornerRadius, DragValue, Event, Frame, Key, + Response, RichText, Sense, Slider, Stroke, StrokeKind, TextureHandle, TextureOptions, Ui, + Vec2, }; use rand::Rng; @@ -114,6 +116,56 @@ impl TerrainApp { }); } + fn copy_preview(&mut self, preview: PreviewFocus) { + let Some(generated) = &self.generated else { + self.status_message = String::from("No generated image is available to copy."); + return; + }; + + let image = match preview { + PreviewFocus::Heightmap => &generated.heightmap_image, + PreviewFocus::Bumpmap => &generated.bumpmap_image, + }; + + match copy_gray_image_to_clipboard(image) { + Ok(()) => { + self.status_message = match preview { + PreviewFocus::Heightmap => String::from("Heightmap copied to clipboard."), + PreviewFocus::Bumpmap => String::from("Bumpmap copied to clipboard."), + }; + } + Err(error) => { + self.status_message = format!("Failed to copy image: {error}"); + } + } + } + + fn preview_context_menu(&mut self, response: &Response, preview: PreviewFocus) { + response.context_menu(|ui| { + if ui.button("Copy").clicked() { + self.copy_preview(preview); + ui.close(); + } + }); + } + + fn overlay_copy_button(&mut self, ui: &mut Ui, anchor_rect: egui::Rect, preview: PreviewFocus) { + let button_size = Vec2::new(28.0, 28.0); + let button_rect = Align2::RIGHT_TOP + .align_size_within_rect(button_size, anchor_rect.shrink2(Vec2::new(8.0, 8.0))); + + let response = ui.put( + button_rect, + egui::Button::new(RichText::new("🗍").size(14.0)), + ); + + if response.clicked() { + self.copy_preview(preview); + } + + response.on_hover_text("Copy"); + } + fn tab_button(ui: &mut Ui, current: &mut LeftTab, tab: LeftTab, label: &str) { let selected = *current == tab; if ui.selectable_label(selected, label).clicked() { @@ -231,24 +283,29 @@ impl TerrainApp { }); return; }; + let heightmap_texture = textures.heightmap.clone(); + let bumpmap_texture = textures.bumpmap.clone(); let available = ui.available_size(); let bottom_strip_height = (available.y * 0.24).max(120.0).min(220.0); let main_height = (available.y - bottom_strip_height - 8.0).max(100.0); let main_texture = match self.preview_focus { - PreviewFocus::Heightmap => &textures.heightmap, - PreviewFocus::Bumpmap => &textures.bumpmap, + PreviewFocus::Heightmap => &heightmap_texture, + PreviewFocus::Bumpmap => &bumpmap_texture, }; - render_clickable_image(ui, main_texture, Vec2::new(available.x, main_height), false); + let main_response = + render_clickable_image(ui, main_texture, Vec2::new(available.x, main_height), true); + self.preview_context_menu(&main_response, self.preview_focus); + self.overlay_copy_button(ui, main_response.rect, self.preview_focus); ui.add_space(8.0); let thumb_width = (available.x * 0.28).max(120.0).min(240.0); let panel_width = (thumb_width + 32.0).min(available.x); let secondary_texture = match self.preview_focus { - PreviewFocus::Heightmap => &textures.bumpmap, - PreviewFocus::Bumpmap => &textures.heightmap, + PreviewFocus::Heightmap => &bumpmap_texture, + PreviewFocus::Bumpmap => &heightmap_texture, }; let secondary_focus = match self.preview_focus { PreviewFocus::Heightmap => PreviewFocus::Bumpmap, @@ -266,12 +323,14 @@ impl TerrainApp { ui.vertical_centered(|ui| { ui.label(RichText::new(secondary_label).strong()); ui.add_space(6.0); - if render_clickable_image( + let secondary_response = render_clickable_image( ui, secondary_texture, Vec2::new(thumb_width, bottom_strip_height - 32.0), true, - ) { + ); + self.preview_context_menu(&secondary_response, secondary_focus); + if secondary_response.clicked() { self.preview_focus = secondary_focus; } }); @@ -283,6 +342,24 @@ impl TerrainApp { impl eframe::App for TerrainApp { fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) { + let wants_keyboard_input = ctx.wants_keyboard_input(); + let should_copy = ctx.input(|input| { + !wants_keyboard_input + && input.events.iter().any(|event| match event { + Event::Copy => true, + Event::Key { + key: Key::C, + pressed: true, + modifiers, + .. + } => modifiers.ctrl || modifiers.command, + _ => false, + }) + }); + + if should_copy { + self.copy_preview(self.preview_focus); + } self.poll_generation(ctx); self.ui_left_panel(ctx); self.ui_right_panel(ctx); @@ -335,7 +412,7 @@ fn render_clickable_image( texture: &TextureHandle, max_size: Vec2, clickable: bool, -) -> bool { +) -> Response { let image_size = texture.size_vec2(); let scale = (max_size.x / image_size.x).min(max_size.y / image_size.y); let desired_size = image_size * scale.max(0.01); @@ -344,7 +421,35 @@ fn render_clickable_image( .sense(if clickable { Sense::click() } else { - Sense::hover() + Sense::click() }); - ui.add(image).clicked() + let response = ui.add(image); + + if response.hovered() { + ui.painter().rect_stroke( + response.rect.expand(4.0), + CornerRadius::same(8), + Stroke::new(2.0, Color32::from_rgba_premultiplied(180, 220, 255, 180)), + StrokeKind::Outside, + ); + } + + response +} + +fn copy_gray_image_to_clipboard(image: &image::GrayImage) -> Result<(), String> { + let mut rgba = Vec::with_capacity((image.width() * image.height() * 4) as usize); + for pixel in image.pixels() { + let gray = pixel.0[0]; + rgba.extend_from_slice(&[gray, gray, gray, 255]); + } + + let data = ImageData { + width: image.width() as usize, + height: image.height() as usize, + bytes: rgba.into(), + }; + + let mut clipboard = Clipboard::new().map_err(|error| error.to_string())?; + clipboard.set_image(data).map_err(|error| error.to_string()) }