From f14a8f653ba81872402ba554bc92ede823b380d0 Mon Sep 17 00:00:00 2001 From: grimsace Date: Fri, 6 Mar 2026 11:42:20 -0600 Subject: [PATCH] added export functionality --- Cargo.toml | 3 + README.md | 4 + src/exporter.rs | 770 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 6 + src/ui.rs | 60 ++++ 5 files changed, 843 insertions(+) create mode 100644 src/exporter.rs diff --git a/Cargo.toml b/Cargo.toml index 7037907..a2475a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,3 +8,6 @@ eframe = "0.31" serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "6" +rfd = "0.15" +tiny-skia = "0.11" +image = { version = "0.25", default-features = true } diff --git a/README.md b/README.md index aee6c79..6d1db40 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ A Rust desktop app for generating simple tabletop dungeon layouts. - Generate control: - Master seed input (`u64`) - `Random` seed button + - `Reset` button + - `Export Image` button + - Export format selector: `.png`, `.jpeg`, `.webp`, `.svg` + - Export uses an OS folder picker to choose save location ## Generation Behavior diff --git a/src/exporter.rs b/src/exporter.rs new file mode 100644 index 0000000..510f008 --- /dev/null +++ b/src/exporter.rs @@ -0,0 +1,770 @@ +use std::collections::HashSet; +use std::fmt::Write as _; +use std::path::PathBuf; + +use image::{DynamicImage, ImageFormat, RgbaImage}; +use rfd::FileDialog; +use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Rect, Stroke, StrokeDash, Transform}; + +use crate::layout::{Door, DungeonLayout, Room}; +use crate::ui::{ExportFormat, UiSettings}; + +const BG: (u8, u8, u8, u8) = (24, 24, 26, 255); +const GRID: (u8, u8, u8, u8) = (130, 130, 130, 255); +const ROOM: (u8, u8, u8, u8) = (70, 120, 160, 255); +const CORRIDOR: (u8, u8, u8, u8) = (210, 190, 120, 255); +const BLACK: (u8, u8, u8, u8) = (0, 0, 0, 255); +const OPEN_DOOR: (u8, u8, u8, u8) = (220, 70, 70, 255); +const LOCKED_DOOR: (u8, u8, u8, u8) = (80, 200, 120, 255); +const ARCHWAY: (u8, u8, u8, u8) = (70, 130, 220, 255); + +#[derive(Debug, Clone, Copy)] +enum DoorStyle { + Solid, + LongDash, + ShortDash, + Dotted, +} + +#[derive(Debug, Clone, Copy)] +struct ExportGeometry { + cols: usize, + rows: usize, + cell: f32, + pad: f32, + width: u32, + height: u32, +} + +impl ExportGeometry { + fn new(cols: usize, rows: usize) -> Self { + let cell = 64.0; + let pad = 40.0; + let width = (pad * 2.0 + cols as f32 * cell).round() as u32; + let height = (pad * 2.0 + rows as f32 * cell).round() as u32; + Self { + cols, + rows, + cell, + pad, + width, + height, + } + } + + fn left(&self) -> f32 { + self.pad + } + + fn top(&self) -> f32 { + self.pad + } + + fn cell_rect(&self, col: usize, row: usize) -> Option { + Rect::from_xywh( + self.left() + col as f32 * self.cell, + self.top() + row as f32 * self.cell, + self.cell, + self.cell, + ) + } + + fn cell_center(&self, col: usize, row: usize) -> (f32, f32) { + ( + self.left() + (col as f32 + 0.5) * self.cell, + self.top() + (row as f32 + 0.5) * self.cell, + ) + } +} + +pub fn export_with_dialog( + layout: &DungeonLayout, + settings: &UiSettings, +) -> Result { + let ext = settings.export_format.extension(); + let default_name = format!("dungeon_export.{ext}"); + + let mut dialog = FileDialog::new(); + dialog = dialog + .set_title("Export image") + .set_file_name(&default_name) + .add_filter(settings.export_format.label(), &[ext]); + + let mut path = dialog + .save_file() + .ok_or_else(|| "Export canceled".to_string())?; + + if path.extension().is_none() { + path.set_extension(ext); + } + + match settings.export_format { + ExportFormat::Svg => { + let svg = build_svg(layout, settings); + std::fs::write(&path, svg).map_err(|e| format!("Failed writing SVG: {e}"))?; + } + ExportFormat::Png | ExportFormat::Jpeg | ExportFormat::Webp => { + let pixmap = render_pixmap(layout, settings)?; + let rgba = RgbaImage::from_raw(pixmap.width(), pixmap.height(), pixmap.data().to_vec()) + .ok_or_else(|| "Failed to build image buffer".to_string())?; + let img = DynamicImage::ImageRgba8(rgba); + let format = match settings.export_format { + ExportFormat::Png => ImageFormat::Png, + ExportFormat::Jpeg => ImageFormat::Jpeg, + ExportFormat::Webp => ImageFormat::WebP, + ExportFormat::Svg => unreachable!(), + }; + img.save_with_format(&path, format) + .map_err(|e| format!("Failed writing image: {e}"))?; + } + } + + Ok(path) +} + +fn render_pixmap(layout: &DungeonLayout, settings: &UiSettings) -> Result { + let g = ExportGeometry::new(settings.cols, settings.rows); + let mut pixmap = + Pixmap::new(g.width, g.height).ok_or_else(|| "Failed to allocate canvas".to_string())?; + fill_bg(&mut pixmap, BG); + + let wall_w = (g.cell / 5.0).max(1.0); + let door_w = (g.cell / 10.0).max(1.0); + + draw_grid(&mut pixmap, &g); + + let mut corridor_cells = HashSet::new(); + let mut corridor_edges = HashSet::new(); + let mut room_cells = HashSet::new(); + let mut door_edges = HashSet::new(); + + for door in &layout.doors { + door_edges.insert(norm_edge(door.from, door.to)); + } + + for corridor in &layout.corridors { + for &cell in &corridor.path { + corridor_cells.insert(cell); + } + for pair in corridor.path.windows(2) { + corridor_edges.insert(norm_edge(pair[0], pair[1])); + } + } + + for room in &layout.rooms { + for x in room.x..(room.x + room.width) { + for y in room.y..(room.y + room.height) { + room_cells.insert((x, y)); + } + } + } + + for &(x, y) in &corridor_cells { + fill_rect_cell(&mut pixmap, &g, x, y, CORRIDOR); + if settings.colorblind_mode { + let (cx, cy) = g.cell_center(x, y); + draw_dot(&mut pixmap, cx, cy, g.cell * 0.14, BLACK); + } + } + + for room in &layout.rooms { + fill_rect_room(&mut pixmap, &g, room, ROOM); + if settings.colorblind_mode { + draw_room_crosshatch(&mut pixmap, &g, room, 1.5, BLACK); + } + } + + draw_cell_walls( + &mut pixmap, + &g, + &corridor_cells, + Some(&corridor_edges), + &door_edges, + wall_w, + BLACK, + ); + draw_cell_walls( + &mut pixmap, + &g, + &room_cells, + None, + &door_edges, + wall_w, + BLACK, + ); + + for door in &layout.doors { + let (color, style) = if settings.colorblind_mode { + let s = if door.archway { + DoorStyle::Dotted + } else if door.locked { + DoorStyle::ShortDash + } else { + DoorStyle::LongDash + }; + (BLACK, s) + } else if door.archway { + (ARCHWAY, DoorStyle::Solid) + } else if door.locked { + (LOCKED_DOOR, DoorStyle::Solid) + } else { + (OPEN_DOOR, DoorStyle::Solid) + }; + draw_door(&mut pixmap, &g, door, door_w, color, style); + } + + Ok(pixmap) +} + +fn build_svg(layout: &DungeonLayout, settings: &UiSettings) -> String { + let g = ExportGeometry::new(settings.cols, settings.rows); + let wall_w = (g.cell / 5.0).max(1.0); + let door_w = (g.cell / 10.0).max(1.0); + + let mut s = String::new(); + let _ = writeln!( + s, + "", + g.width, g.height, g.width, g.height + ); + let _ = writeln!( + s, + "", + BG.0, BG.1, BG.2 + ); + + for c in 0..=g.cols { + let x = g.left() + c as f32 * g.cell; + let _ = writeln!( + s, + "", + g.top(), + g.top() + g.rows as f32 * g.cell, + GRID.0, + GRID.1, + GRID.2 + ); + } + for r in 0..=g.rows { + let y = g.top() + r as f32 * g.cell; + let _ = writeln!( + s, + "", + g.left(), + g.left() + g.cols as f32 * g.cell, + GRID.0, + GRID.1, + GRID.2 + ); + } + + let mut corridor_cells = HashSet::new(); + let mut corridor_edges = HashSet::new(); + let mut room_cells = HashSet::new(); + let mut door_edges = HashSet::new(); + + for door in &layout.doors { + door_edges.insert(norm_edge(door.from, door.to)); + } + for corridor in &layout.corridors { + for &cell in &corridor.path { + corridor_cells.insert(cell); + } + for pair in corridor.path.windows(2) { + corridor_edges.insert(norm_edge(pair[0], pair[1])); + } + } + for room in &layout.rooms { + for x in room.x..(room.x + room.width) { + for y in room.y..(room.y + room.height) { + room_cells.insert((x, y)); + } + } + } + + for &(x, y) in &corridor_cells { + let px = g.left() + x as f32 * g.cell; + let py = g.top() + y as f32 * g.cell; + let _ = writeln!( + s, + "", + g.cell, g.cell, CORRIDOR.0, CORRIDOR.1, CORRIDOR.2 + ); + if settings.colorblind_mode { + let (cx, cy) = g.cell_center(x, y); + let _ = writeln!( + s, + "", + g.cell * 0.14 + ); + } + } + + for room in &layout.rooms { + let px = g.left() + room.x as f32 * g.cell; + let py = g.top() + room.y as f32 * g.cell; + let _ = writeln!( + s, + "", + room.width as f32 * g.cell, + room.height as f32 * g.cell, + ROOM.0, + ROOM.1, + ROOM.2 + ); + if settings.colorblind_mode { + for x in room.x..(room.x + room.width) { + for y in room.y..(room.y + room.height) { + let rx = g.left() + x as f32 * g.cell + 2.0; + let ry = g.top() + y as f32 * g.cell + 2.0; + let rr = g.cell - 4.0; + let _ = writeln!( + s, + "", + rx + rr, + ry + rr + ); + let _ = writeln!( + s, + "", + rx + rr, + ry + rr + ); + } + } + } + } + + append_svg_walls( + &mut s, + &g, + &corridor_cells, + Some(&corridor_edges), + &door_edges, + wall_w, + ); + append_svg_walls(&mut s, &g, &room_cells, None, &door_edges, wall_w); + + for door in &layout.doors { + let (color, dash) = if settings.colorblind_mode { + if door.archway { + ("black", Some("2,4")) + } else if door.locked { + ("black", Some("6,4")) + } else { + ("black", Some("14,7")) + } + } else if door.archway { + ("rgb(70,130,220)", None) + } else if door.locked { + ("rgb(80,200,120)", None) + } else { + ("rgb(220,70,70)", None) + }; + + if let Some((x1, y1, x2, y2)) = door_line_points(&g, door.from, door.to) { + if let Some(pattern) = dash { + let _ = writeln!( + s, + "" + ); + } else { + let _ = writeln!( + s, + "" + ); + } + } + } + + s.push_str("\n"); + s +} + +fn fill_bg(pixmap: &mut Pixmap, color: (u8, u8, u8, u8)) { + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + let Some(rect) = Rect::from_xywh(0.0, 0.0, pixmap.width() as f32, pixmap.height() as f32) + else { + return; + }; + pixmap.fill_rect(rect, &paint, Transform::identity(), None); +} + +fn draw_grid(pixmap: &mut Pixmap, g: &ExportGeometry) { + for c in 0..=g.cols { + let x = g.left() + c as f32 * g.cell; + draw_line( + pixmap, + x, + g.top(), + x, + g.top() + g.rows as f32 * g.cell, + 1.0, + GRID, + DoorStyle::Solid, + ); + } + for r in 0..=g.rows { + let y = g.top() + r as f32 * g.cell; + draw_line( + pixmap, + g.left(), + y, + g.left() + g.cols as f32 * g.cell, + y, + 1.0, + GRID, + DoorStyle::Solid, + ); + } +} + +fn fill_rect_cell( + pixmap: &mut Pixmap, + g: &ExportGeometry, + col: usize, + row: usize, + color: (u8, u8, u8, u8), +) { + let Some(rect) = g.cell_rect(col, row) else { + return; + }; + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + pixmap.fill_rect(rect, &paint, Transform::identity(), None); +} + +fn fill_rect_room(pixmap: &mut Pixmap, g: &ExportGeometry, room: &Room, color: (u8, u8, u8, u8)) { + let Some(rect) = Rect::from_xywh( + g.left() + room.x as f32 * g.cell, + g.top() + room.y as f32 * g.cell, + room.width as f32 * g.cell, + room.height as f32 * g.cell, + ) else { + return; + }; + + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + pixmap.fill_rect(rect, &paint, Transform::identity(), None); +} + +fn draw_dot(pixmap: &mut Pixmap, cx: f32, cy: f32, radius: f32, color: (u8, u8, u8, u8)) { + let mut pb = PathBuilder::new(); + pb.push_circle(cx, cy, radius); + let Some(path) = pb.finish() else { + return; + }; + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + pixmap.fill_path( + &path, + &paint, + FillRule::Winding, + Transform::identity(), + None, + ); +} + +fn draw_room_crosshatch( + pixmap: &mut Pixmap, + g: &ExportGeometry, + room: &Room, + width: f32, + color: (u8, u8, u8, u8), +) { + for x in room.x..(room.x + room.width) { + for y in room.y..(room.y + room.height) { + if let Some(rect) = g.cell_rect(x, y) { + let pad = 2.0; + draw_line( + pixmap, + rect.left() + pad, + rect.top() + pad, + rect.right() - pad, + rect.bottom() - pad, + width, + color, + DoorStyle::Solid, + ); + draw_line( + pixmap, + rect.right() - pad, + rect.top() + pad, + rect.left() + pad, + rect.bottom() - pad, + width, + color, + DoorStyle::Solid, + ); + } + } + } +} + +fn draw_cell_walls( + pixmap: &mut Pixmap, + g: &ExportGeometry, + cells: &HashSet<(usize, usize)>, + connected_edges: Option<&HashSet<((usize, usize), (usize, usize))>>, + door_edges: &HashSet<((usize, usize), (usize, usize))>, + width: f32, + color: (u8, u8, u8, u8), +) { + for &(col, row) in cells { + if let Some(rect) = g.cell_rect(col, row) { + let right = (col + 1, row); + let bottom = (col, row + 1); + + let should_left = col == 0 + || (!cells.contains(&(col - 1, row)) + && !door_edges.contains(&norm_edge((col, row), (col - 1, row)))); + + let right_connected = connected_edges + .map(|set| set.contains(&norm_edge((col, row), right))) + .unwrap_or(false); + let should_right = (!cells.contains(&right) || !right_connected) + && !door_edges.contains(&norm_edge((col, row), right)); + + let should_top = row == 0 + || (!cells.contains(&(col, row - 1)) + && !door_edges.contains(&norm_edge((col, row), (col, row - 1)))); + + let bottom_connected = connected_edges + .map(|set| set.contains(&norm_edge((col, row), bottom))) + .unwrap_or(false); + let should_bottom = (!cells.contains(&bottom) || !bottom_connected) + && !door_edges.contains(&norm_edge((col, row), bottom)); + + if should_left { + draw_line( + pixmap, + rect.left(), + rect.top(), + rect.left(), + rect.bottom(), + width, + color, + DoorStyle::Solid, + ); + } + if should_right { + draw_line( + pixmap, + rect.right(), + rect.top(), + rect.right(), + rect.bottom(), + width, + color, + DoorStyle::Solid, + ); + } + if should_top { + draw_line( + pixmap, + rect.left(), + rect.top(), + rect.right(), + rect.top(), + width, + color, + DoorStyle::Solid, + ); + } + if should_bottom { + draw_line( + pixmap, + rect.left(), + rect.bottom(), + rect.right(), + rect.bottom(), + width, + color, + DoorStyle::Solid, + ); + } + } + } +} + +fn append_svg_walls( + out: &mut String, + g: &ExportGeometry, + cells: &HashSet<(usize, usize)>, + connected_edges: Option<&HashSet<((usize, usize), (usize, usize))>>, + door_edges: &HashSet<((usize, usize), (usize, usize))>, + width: f32, +) { + for &(col, row) in cells { + let left = g.left() + col as f32 * g.cell; + let top = g.top() + row as f32 * g.cell; + let right = left + g.cell; + let bottom = top + g.cell; + + let right_cell = (col + 1, row); + let bottom_cell = (col, row + 1); + + let left_edge = col == 0 + || (!cells.contains(&(col - 1, row)) + && !door_edges.contains(&norm_edge((col, row), (col - 1, row)))); + + let right_connected = connected_edges + .map(|set| set.contains(&norm_edge((col, row), right_cell))) + .unwrap_or(false); + let right_edge = (!cells.contains(&right_cell) || !right_connected) + && !door_edges.contains(&norm_edge((col, row), right_cell)); + + let top_edge = row == 0 + || (!cells.contains(&(col, row - 1)) + && !door_edges.contains(&norm_edge((col, row), (col, row - 1)))); + + let bottom_connected = connected_edges + .map(|set| set.contains(&norm_edge((col, row), bottom_cell))) + .unwrap_or(false); + let bottom_edge = (!cells.contains(&bottom_cell) || !bottom_connected) + && !door_edges.contains(&norm_edge((col, row), bottom_cell)); + + if left_edge { + let _ = writeln!( + out, + "" + ); + } + if right_edge { + let _ = writeln!( + out, + "" + ); + } + if top_edge { + let _ = writeln!( + out, + "" + ); + } + if bottom_edge { + let _ = writeln!( + out, + "" + ); + } + } +} + +fn draw_door( + pixmap: &mut Pixmap, + g: &ExportGeometry, + door: &Door, + width: f32, + color: (u8, u8, u8, u8), + style: DoorStyle, +) { + let Some((x1, y1, x2, y2)) = door_line_points(g, door.from, door.to) else { + return; + }; + draw_line(pixmap, x1, y1, x2, y2, width, color, style); +} + +fn door_line_points( + g: &ExportGeometry, + a: (usize, usize), + b: (usize, usize), +) -> Option<(f32, f32, f32, f32)> { + if a.0.abs_diff(b.0) + a.1.abs_diff(b.1) != 1 { + return None; + } + + if a.0 != b.0 { + let x = g.left() + (a.0.max(b.0) as f32) * g.cell; + let row = a.1; + let y0 = g.top() + row as f32 * g.cell; + let y1 = y0 + g.cell; + Some((x, y0, x, y1)) + } else { + let y = g.top() + (a.1.max(b.1) as f32) * g.cell; + let col = a.0; + let x0 = g.left() + col as f32 * g.cell; + let x1 = x0 + g.cell; + Some((x0, y, x1, y)) + } +} + +fn draw_line( + pixmap: &mut Pixmap, + x1: f32, + y1: f32, + x2: f32, + y2: f32, + width: f32, + color: (u8, u8, u8, u8), + style: DoorStyle, +) { + match style { + DoorStyle::Solid => stroke_path(pixmap, x1, y1, x2, y2, width, color, None), + DoorStyle::LongDash => stroke_path(pixmap, x1, y1, x2, y2, width, color, Some((14.0, 7.0))), + DoorStyle::ShortDash => stroke_path(pixmap, x1, y1, x2, y2, width, color, Some((6.0, 4.0))), + DoorStyle::Dotted => dotted_line(pixmap, x1, y1, x2, y2, width, color), + } +} + +fn stroke_path( + pixmap: &mut Pixmap, + x1: f32, + y1: f32, + x2: f32, + y2: f32, + width: f32, + color: (u8, u8, u8, u8), + dash: Option<(f32, f32)>, +) { + let mut pb = PathBuilder::new(); + pb.move_to(x1, y1); + pb.line_to(x2, y2); + let Some(path) = pb.finish() else { + return; + }; + + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + + let mut stroke = Stroke::default(); + stroke.width = width; + if let Some((dash_len, gap_len)) = dash { + stroke.dash = StrokeDash::new(vec![dash_len, gap_len], 0.0); + } + + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); +} + +fn dotted_line( + pixmap: &mut Pixmap, + x1: f32, + y1: f32, + x2: f32, + y2: f32, + width: f32, + color: (u8, u8, u8, u8), +) { + let dx = x2 - x1; + let dy = y2 - y1; + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + return; + } + let ux = dx / len; + let uy = dy / len; + + let mut d = 0.0_f32; + let step = 6.0; + let radius = (width * 0.35).max(1.0); + while d <= len { + draw_dot(pixmap, x1 + ux * d, y1 + uy * d, radius, color); + d += step; + } +} + +fn norm_edge(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) { + if a <= b { (a, b) } else { (b, a) } +} diff --git a/src/main.rs b/src/main.rs index 253eb56..95f7a32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod exporter; mod layout; mod seed; mod settings; @@ -68,6 +69,11 @@ impl eframe::App for DungeonApp { if panel_result.reset_clicked || panel_result.settings_changed { self.regenerate_layout(); } + if panel_result.export_clicked { + if let Err(err) = exporter::export_with_dialog(&self.layout, &self.settings) { + eprintln!("{err}"); + } + } egui::SidePanel::right("legend_panel") .resizable(false) diff --git a/src/ui.rs b/src/ui.rs index 24d11a9..46fd230 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -15,6 +15,40 @@ impl Default for Tab { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExportFormat { + Png, + Jpeg, + Webp, + Svg, +} + +impl Default for ExportFormat { + fn default() -> Self { + Self::Png + } +} + +impl ExportFormat { + pub fn extension(self) -> &'static str { + match self { + ExportFormat::Png => "png", + ExportFormat::Jpeg => "jpeg", + ExportFormat::Webp => "webp", + ExportFormat::Svg => "svg", + } + } + + pub fn label(self) -> &'static str { + match self { + ExportFormat::Png => ".png", + ExportFormat::Jpeg => ".jpeg", + ExportFormat::Webp => ".webp", + ExportFormat::Svg => ".svg", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct UiSettings { @@ -32,6 +66,7 @@ pub struct UiSettings { pub locked_door_percent: usize, pub allow_middle_corridor_doors: bool, pub colorblind_mode: bool, + pub export_format: ExportFormat, active_tab: Tab, } @@ -52,6 +87,7 @@ impl Default for UiSettings { locked_door_percent: 25, allow_middle_corridor_doors: false, colorblind_mode: false, + export_format: ExportFormat::Png, active_tab: Tab::Generate, } } @@ -61,6 +97,7 @@ impl Default for UiSettings { pub struct SidePanelResult { pub settings_changed: bool, pub reset_clicked: bool, + pub export_clicked: bool, } pub fn draw_side_panel(ctx: &egui::Context, settings: &mut UiSettings) -> SidePanelResult { @@ -148,6 +185,29 @@ fn draw_generate_tab(ui: &mut egui::Ui, settings: &mut UiSettings, result: &mut if ui.button("Reset").clicked() { result.reset_clicked = true; } + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + ui.label(RichText::new("Export").strong()); + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label("Format"); + egui::ComboBox::from_id_salt("export_format") + .selected_text(settings.export_format.label()) + .show_ui(ui, |ui| { + ui.selectable_value(&mut settings.export_format, ExportFormat::Png, ".png"); + ui.selectable_value(&mut settings.export_format, ExportFormat::Jpeg, ".jpeg"); + ui.selectable_value(&mut settings.export_format, ExportFormat::Webp, ".webp"); + ui.selectable_value(&mut settings.export_format, ExportFormat::Svg, ".svg"); + }); + }); + + ui.add_space(6.0); + if ui.button("Export Image").clicked() { + result.export_clicked = true; + } } fn draw_layout_tab(ui: &mut egui::Ui, settings: &mut UiSettings, result: &mut SidePanelResult) {