|
#!/usr/bin/env -S rust-script |
|
//! ```cargo |
|
//! [dependencies] |
|
//! eframe = "0.29" |
|
//! facet = "0.30" |
|
//! facet-json = "0.30" |
|
//! facet-toml = "0.30" |
|
//! ``` |
|
|
|
use eframe::egui; |
|
|
|
mod config { |
|
use facet::Facet; |
|
use eframe::egui; |
|
|
|
#[derive(Facet, Clone)] |
|
pub struct Config { |
|
#[facet(default = "#FFFFFF".to_string())] |
|
pub bg_color: String, |
|
#[facet(default = "#000000".to_string())] |
|
pub point_color: String, |
|
#[facet(default = "#FF0000".to_string())] |
|
pub selected_color: String, |
|
#[facet(default = "#0000FF".to_string())] |
|
pub selection_box_color: String, |
|
#[facet(default = true)] |
|
pub grid_enabled: bool, |
|
#[facet(default = 40.0)] |
|
pub grid_spacing: f32, |
|
#[facet(default = "#CCCCCC".to_string())] |
|
pub grid_color: String, |
|
#[facet(default = 20.0)] |
|
pub point_radius: f32, |
|
#[facet(default = 1.0)] |
|
pub move_step: f32, |
|
#[facet(default = 20.0)] |
|
pub move_step_large: f32, |
|
} |
|
|
|
impl Config { |
|
pub fn load() -> Self { |
|
if let Ok(contents) = std::fs::read_to_string("config.toml") { |
|
if let Ok(config) = facet_toml::from_str::<Config>(&contents) { |
|
return config; |
|
} |
|
} |
|
facet_toml::from_str::<Config>("").unwrap() |
|
} |
|
|
|
pub fn parse_color(&self, hex: &str) -> egui::Color32 { |
|
let hex = hex.trim_start_matches('#'); |
|
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); |
|
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); |
|
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); |
|
egui::Color32::from_rgb(r, g, b) |
|
} |
|
} |
|
} |
|
|
|
mod persistence { |
|
use facet::Facet; |
|
use std::fs; |
|
|
|
const POINTS_FILE: &str = "points.json"; |
|
|
|
#[derive(Facet, Clone)] |
|
#[repr(u8)] |
|
pub enum PointShape { |
|
Circle, |
|
Square, |
|
} |
|
|
|
#[derive(Facet, Clone)] |
|
pub struct Point { |
|
pub id: u64, |
|
pub x: f32, |
|
pub y: f32, |
|
pub shape: PointShape, |
|
} |
|
|
|
#[derive(Facet, Clone)] |
|
struct Points { |
|
points: Vec<Point>, |
|
} |
|
|
|
pub fn load_points() -> Vec<Point> { |
|
let _ = fs::remove_file(POINTS_FILE); |
|
vec![ |
|
Point { id: 1, x: 100.0, y: 100.0, shape: PointShape::Circle }, |
|
Point { id: 2, x: 200.0, y: 200.0, shape: PointShape::Circle }, |
|
Point { id: 3, x: 300.0, y: 150.0, shape: PointShape::Square }, |
|
] |
|
} |
|
|
|
pub fn save_points(points: &[Point]) { |
|
let wrapped = Points { points: points.to_vec() }; |
|
let json = facet_json::to_string(&wrapped); |
|
let _ = fs::write(POINTS_FILE, json); |
|
} |
|
} |
|
|
|
mod state { |
|
use eframe::egui; |
|
use crate::persistence::{Point, PointShape}; |
|
use facet::Facet; |
|
|
|
#[derive(Clone, Facet)] |
|
#[repr(u8)] |
|
pub enum Selection { |
|
None, |
|
Single(usize), |
|
Multiple(Vec<usize>), |
|
} |
|
|
|
pub struct AppState { |
|
pub points: Vec<Point>, |
|
pub selection: Selection, |
|
pub dragging: Option<usize>, |
|
pub pending_clone: bool, |
|
pub pending_shape: bool, |
|
pub pending_view: bool, |
|
pub show_help: bool, |
|
pub next_id: u64, |
|
pub box_select_start: Option<egui::Pos2>, |
|
pub box_select_end: Option<egui::Pos2>, |
|
pub box_select_mode: bool, |
|
pub snap_to_grid: bool, |
|
pub zoom: f32, |
|
pub paintbrush_mode: bool, |
|
pub last_paint_pos: Option<egui::Pos2>, |
|
} |
|
|
|
impl AppState { |
|
pub fn new(points: Vec<Point>) -> Self { |
|
let next_id = points.iter().map(|p| p.id).max().unwrap_or(0) + 1; |
|
let selection = if points.is_empty() { |
|
Selection::None |
|
} else { |
|
Selection::Single(0) |
|
}; |
|
Self { |
|
points, |
|
selection, |
|
dragging: None, |
|
pending_clone: false, |
|
pending_shape: false, |
|
pending_view: false, |
|
show_help: false, |
|
next_id, |
|
box_select_start: None, |
|
box_select_end: None, |
|
box_select_mode: false, |
|
snap_to_grid: false, |
|
zoom: 1.0, |
|
paintbrush_mode: false, |
|
last_paint_pos: None, |
|
} |
|
} |
|
|
|
pub fn point_at_pos(&self, pos: egui::Pos2, radius: f32) -> Option<usize> { |
|
self.points.iter().position(|pt| { |
|
let dx = pos.x - pt.x; |
|
let dy = pos.y - pt.y; |
|
(dx * dx + dy * dy).sqrt() < radius * 2.0 |
|
}) |
|
} |
|
|
|
pub fn selected_indices(&self) -> Vec<usize> { |
|
match &self.selection { |
|
Selection::None => vec![], |
|
Selection::Single(idx) => vec![*idx], |
|
Selection::Multiple(indices) => indices.clone(), |
|
} |
|
} |
|
|
|
pub fn move_selected(&mut self, dx: f32, dy: f32) { |
|
for idx in self.selected_indices() { |
|
self.points[idx].x += dx; |
|
self.points[idx].y += dy; |
|
} |
|
} |
|
|
|
pub fn snap_to_grid(&mut self, grid_spacing: f32, radius: f32) { |
|
for idx in self.selected_indices() { |
|
let pt = &mut self.points[idx]; |
|
let left = pt.x - radius; |
|
let right = pt.x + radius; |
|
let top = pt.y - radius; |
|
let bottom = pt.y + radius; |
|
|
|
let left_snap = (left / grid_spacing).round() * grid_spacing; |
|
let right_snap = (right / grid_spacing).round() * grid_spacing; |
|
let top_snap = (top / grid_spacing).round() * grid_spacing; |
|
let bottom_snap = (bottom / grid_spacing).round() * grid_spacing; |
|
|
|
let left_dist = (left - left_snap).abs(); |
|
let right_dist = (right - right_snap).abs(); |
|
let top_dist = (top - top_snap).abs(); |
|
let bottom_dist = (bottom - bottom_snap).abs(); |
|
|
|
if left_dist < right_dist { |
|
pt.x = left_snap + radius; |
|
} else { |
|
pt.x = right_snap - radius; |
|
} |
|
|
|
if top_dist < bottom_dist { |
|
pt.y = top_snap + radius; |
|
} else { |
|
pt.y = bottom_snap - radius; |
|
} |
|
} |
|
} |
|
|
|
pub fn quantize_position(pos: f32, step: f32) -> f32 { |
|
(pos / step).round() * step |
|
} |
|
|
|
pub fn clone_selected(&mut self, dx: f32, dy: f32) { |
|
let indices = self.selected_indices(); |
|
let mut new_points = Vec::new(); |
|
|
|
for idx in indices { |
|
let pt = &self.points[idx]; |
|
new_points.push(Point { |
|
id: self.next_id, |
|
x: pt.x + dx, |
|
y: pt.y + dy, |
|
shape: pt.shape.clone(), |
|
}); |
|
self.next_id += 1; |
|
} |
|
|
|
let start_idx = self.points.len(); |
|
self.points.extend(new_points); |
|
|
|
if self.points.len() - start_idx == 1 { |
|
self.selection = Selection::Single(start_idx); |
|
} else { |
|
self.selection = Selection::Multiple((start_idx..self.points.len()).collect()); |
|
} |
|
} |
|
|
|
pub fn set_selected_shape(&mut self, shape: PointShape) { |
|
for idx in self.selected_indices() { |
|
self.points[idx].shape = shape.clone(); |
|
} |
|
} |
|
|
|
pub fn delete_selected(&mut self) { |
|
let indices = self.selected_indices(); |
|
if indices.is_empty() { |
|
return; |
|
} |
|
|
|
let mut indices_sorted = indices.clone(); |
|
indices_sorted.sort_by(|a, b| b.cmp(a)); |
|
|
|
for idx in indices_sorted { |
|
self.points.remove(idx); |
|
} |
|
|
|
if self.points.is_empty() { |
|
self.selection = Selection::None; |
|
} else { |
|
let max_id = self.points.iter().map(|p| p.id).max().unwrap(); |
|
let max_idx = self.points.iter().position(|p| p.id == max_id).unwrap(); |
|
self.selection = Selection::Single(max_idx); |
|
} |
|
} |
|
|
|
pub fn point_in_box(&self, idx: usize, rect: egui::Rect, radius: f32) -> bool { |
|
let pt = &self.points[idx]; |
|
match pt.shape { |
|
PointShape::Circle => { |
|
rect.contains(egui::pos2(pt.x - radius, pt.y - radius)) && |
|
rect.contains(egui::pos2(pt.x + radius, pt.y + radius)) && |
|
rect.contains(egui::pos2(pt.x - radius, pt.y + radius)) && |
|
rect.contains(egui::pos2(pt.x + radius, pt.y - radius)) |
|
} |
|
PointShape::Square => { |
|
rect.contains(egui::pos2(pt.x - radius, pt.y - radius)) && |
|
rect.contains(egui::pos2(pt.x + radius, pt.y + radius)) && |
|
rect.contains(egui::pos2(pt.x - radius, pt.y + radius)) && |
|
rect.contains(egui::pos2(pt.x + radius, pt.y - radius)) |
|
} |
|
} |
|
} |
|
|
|
pub fn select_in_box(&mut self, rect: egui::Rect, radius: f32) { |
|
let mut selected = Vec::new(); |
|
for (idx, _) in self.points.iter().enumerate() { |
|
if self.point_in_box(idx, rect, radius) { |
|
selected.push(idx); |
|
} |
|
} |
|
|
|
self.selection = if selected.is_empty() { |
|
Selection::None |
|
} else if selected.len() == 1 { |
|
Selection::Single(selected[0]) |
|
} else { |
|
Selection::Multiple(selected) |
|
}; |
|
} |
|
|
|
pub fn convex_hull_offset(&self, direction: (f32, f32), radius: f32) -> (f32, f32) { |
|
let indices = self.selected_indices(); |
|
if indices.is_empty() { |
|
return (0.0, 0.0); |
|
} |
|
|
|
let (dx, dy) = direction; |
|
|
|
if dx.abs() > 0.0 { |
|
let mut min_x = f32::MAX; |
|
let mut max_x = f32::MIN; |
|
for idx in &indices { |
|
let pt = &self.points[*idx]; |
|
min_x = min_x.min(pt.x - radius); |
|
max_x = max_x.max(pt.x + radius); |
|
} |
|
let width = max_x - min_x; |
|
(dx * width, 0.0) |
|
} else { |
|
let mut min_y = f32::MAX; |
|
let mut max_y = f32::MIN; |
|
for idx in &indices { |
|
let pt = &self.points[*idx]; |
|
min_y = min_y.min(pt.y - radius); |
|
max_y = max_y.max(pt.y + radius); |
|
} |
|
let height = max_y - min_y; |
|
(0.0, dy * height) |
|
} |
|
} |
|
|
|
pub fn expand_selection_box(&mut self, direction: (f32, f32), radius: f32) { |
|
let current = self.selected_indices(); |
|
if current.is_empty() { |
|
return; |
|
} |
|
|
|
let mut candidates = Vec::new(); |
|
for idx in current { |
|
let pt = &self.points[idx]; |
|
let search_pos = egui::pos2( |
|
pt.x + direction.0 * radius * 2.0, |
|
pt.y + direction.1 * radius * 2.0, |
|
); |
|
|
|
for (i, other) in self.points.iter().enumerate() { |
|
let dist_sq = (other.x - search_pos.x).powi(2) + (other.y - search_pos.y).powi(2); |
|
if dist_sq < (radius * 2.5).powi(2) { |
|
candidates.push(i); |
|
} |
|
} |
|
} |
|
|
|
let mut all_selected = self.selected_indices(); |
|
for c in candidates { |
|
if !all_selected.contains(&c) { |
|
all_selected.push(c); |
|
} |
|
} |
|
|
|
self.selection = if all_selected.len() == 1 { |
|
Selection::Single(all_selected[0]) |
|
} else { |
|
Selection::Multiple(all_selected) |
|
}; |
|
} |
|
|
|
pub fn status_text(&self) -> Option<String> { |
|
if self.paintbrush_mode { |
|
Some("Paintbrush".to_string()) |
|
} else if self.box_select_mode { |
|
Some("Box Select".to_string()) |
|
} else if self.pending_clone { |
|
Some("Clone mode".to_string()) |
|
} else if self.pending_shape { |
|
Some("Shape mode".to_string()) |
|
} else if self.snap_to_grid { |
|
Some("Snap to Grid".to_string()) |
|
} else { |
|
None |
|
} |
|
} |
|
|
|
pub fn get_paint_shape(&self) -> PointShape { |
|
match &self.selection { |
|
Selection::Single(idx) => self.points[*idx].shape.clone(), |
|
Selection::Multiple(indices) => { |
|
if let Some(idx) = indices.first() { |
|
self.points[*idx].shape.clone() |
|
} else { |
|
PointShape::Circle |
|
} |
|
} |
|
Selection::None => PointShape::Circle, |
|
} |
|
} |
|
|
|
pub fn paint_point(&mut self, pos: egui::Pos2, radius: f32, move_step: f32, grid_spacing: f32, snap: bool) { |
|
let quantized_x = Self::quantize_position(pos.x, move_step); |
|
let quantized_y = Self::quantize_position(pos.y, move_step); |
|
|
|
if let Some(last_pos) = self.last_paint_pos { |
|
let dx = (quantized_x - last_pos.x).abs(); |
|
let dy = (quantized_y - last_pos.y).abs(); |
|
|
|
if dx < radius * 2.0 && dy < radius * 2.0 { |
|
return; |
|
} |
|
} |
|
|
|
let shape = self.get_paint_shape(); |
|
let mut new_point = Point { |
|
id: self.next_id, |
|
x: quantized_x, |
|
y: quantized_y, |
|
shape, |
|
}; |
|
|
|
self.next_id += 1; |
|
self.points.push(new_point.clone()); |
|
|
|
if snap { |
|
let idx = self.points.len() - 1; |
|
let temp_selection = self.selection.clone(); |
|
self.selection = Selection::Single(idx); |
|
self.snap_to_grid(grid_spacing, radius); |
|
self.selection = temp_selection; |
|
} |
|
|
|
self.last_paint_pos = Some(egui::pos2(quantized_x, quantized_y)); |
|
} |
|
} |
|
} |
|
|
|
mod drawing { |
|
use crate::config::Config; |
|
use crate::state::AppState; |
|
use crate::persistence::PointShape; |
|
use eframe::egui; |
|
|
|
pub fn draw_canvas( |
|
ui: &mut egui::Ui, |
|
state: &AppState, |
|
config: &Config, |
|
) -> egui::Response { |
|
let (response, painter) = ui.allocate_painter( |
|
ui.available_size(), |
|
egui::Sense::click_and_drag(), |
|
); |
|
|
|
let bg = config.parse_color(&config.bg_color); |
|
painter.rect_filled(response.rect, 0.0, bg); |
|
|
|
if config.grid_enabled { |
|
draw_grid(&painter, &response.rect, config); |
|
} |
|
|
|
draw_points(&painter, state, config); |
|
|
|
response |
|
} |
|
|
|
fn draw_grid(painter: &egui::Painter, rect: &egui::Rect, config: &Config) { |
|
let grid_color = config.parse_color(&config.grid_color); |
|
let spacing = config.grid_spacing; |
|
|
|
let mut x = (rect.min.x / spacing).ceil() * spacing; |
|
while x < rect.max.x { |
|
painter.line_segment( |
|
[egui::pos2(x, rect.min.y), egui::pos2(x, rect.max.y)], |
|
egui::Stroke::new(1.0, grid_color), |
|
); |
|
x += spacing; |
|
} |
|
|
|
let mut y = (rect.min.y / spacing).ceil() * spacing; |
|
while y < rect.max.y { |
|
painter.line_segment( |
|
[egui::pos2(rect.min.x, y), egui::pos2(rect.max.x, y)], |
|
egui::Stroke::new(1.0, grid_color), |
|
); |
|
y += spacing; |
|
} |
|
} |
|
|
|
fn draw_points(painter: &egui::Painter, state: &AppState, config: &Config) { |
|
let point_color = config.parse_color(&config.point_color); |
|
let selected_color = config.parse_color(&config.selected_color); |
|
let selected_indices = state.selected_indices(); |
|
|
|
for (i, pt) in state.points.iter().enumerate() { |
|
let pos = egui::pos2(pt.x, pt.y); |
|
let color = if selected_indices.contains(&i) || state.dragging == Some(i) { |
|
selected_color |
|
} else { |
|
point_color |
|
}; |
|
|
|
match pt.shape { |
|
PointShape::Circle => { |
|
painter.circle_filled(pos, config.point_radius, color); |
|
} |
|
PointShape::Square => { |
|
let half = config.point_radius; |
|
let rect = egui::Rect::from_center_size( |
|
pos, |
|
egui::vec2(half * 2.0, half * 2.0), |
|
); |
|
painter.rect_filled(rect, 0.0, color); |
|
} |
|
} |
|
} |
|
|
|
if let (Some(start), Some(end)) = (state.box_select_start, state.box_select_end) { |
|
let box_color = config.parse_color(&config.selection_box_color); |
|
let rect = egui::Rect::from_two_pos(start, end); |
|
painter.rect_stroke(rect, 0.0, egui::Stroke::new(2.0, box_color)); |
|
} |
|
} |
|
} |
|
|
|
mod ui { |
|
use crate::config::Config; |
|
use crate::persistence::{self, PointShape}; |
|
use crate::state::AppState; |
|
use eframe::egui; |
|
|
|
pub fn show_menu(ctx: &egui::Context, state: &mut AppState) { |
|
egui::TopBottomPanel::top("menu").show(ctx, |ui| { |
|
egui::menu::bar(ui, |ui| { |
|
ui.menu_button("File", |ui| { |
|
if ui.button("Save").clicked() { |
|
persistence::save_points(&state.points); |
|
ui.close_menu(); |
|
} |
|
if ui.button("Load").clicked() { |
|
state.points = persistence::load_points(); |
|
ui.close_menu(); |
|
} |
|
if ui.button("Reset").clicked() { |
|
state.points = persistence::load_points(); |
|
ui.close_menu(); |
|
} |
|
if ui.button("Quit").clicked() { |
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close); |
|
} |
|
}); |
|
ui.menu_button("Help", |ui| { |
|
if ui.button("Keyboard Shortcuts").clicked() { |
|
state.show_help = !state.show_help; |
|
ui.close_menu(); |
|
} |
|
}); |
|
}); |
|
}); |
|
} |
|
|
|
pub fn show_tool_panel(ctx: &egui::Context, config: &Config, _state: &mut AppState) { |
|
egui::SidePanel::left("tools").show(ctx, |ui| { |
|
ui.heading("Parameters"); |
|
ui.separator(); |
|
|
|
ui.label("Movement"); |
|
ui.label(format!("Move Step: {} (Arrow)", config.move_step)); |
|
ui.label(format!("Large Step: {} (Shift + Arrow)", config.move_step_large)); |
|
ui.separator(); |
|
|
|
ui.label("Appearance"); |
|
ui.label(format!("Point Radius: {}", config.point_radius)); |
|
ui.label(format!("Grid Spacing: {}", config.grid_spacing)); |
|
ui.separator(); |
|
|
|
ui.label("Colors"); |
|
show_color_swatch(ui, "Background", &config.bg_color, config); |
|
show_color_swatch(ui, "Point", &config.point_color, config); |
|
show_color_swatch(ui, "Selected", &config.selected_color, config); |
|
show_color_swatch(ui, "Selection Box", &config.selection_box_color, config); |
|
show_color_swatch(ui, "Grid", &config.grid_color, config); |
|
}); |
|
} |
|
|
|
fn show_color_swatch(ui: &mut egui::Ui, label: &str, hex: &str, config: &Config) { |
|
ui.horizontal(|ui| { |
|
let color = config.parse_color(hex); |
|
ui.label(format!("{}: ", label)); |
|
let size = egui::vec2(16.0, 16.0); |
|
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); |
|
ui.painter().rect_filled(rect, 2.0, color); |
|
ui.label(hex); |
|
}); |
|
} |
|
|
|
pub fn show_help_window(ctx: &egui::Context, state: &mut AppState) { |
|
if state.show_help { |
|
egui::Window::new("Keyboard Shortcuts") |
|
.open(&mut state.show_help) |
|
.show(ctx, |ui| { |
|
ui.label("B: Toggle box select"); |
|
ui.label(" In box mode: Arrow keys expand selection"); |
|
ui.label("P: Toggle paintbrush mode"); |
|
ui.label(" In paintbrush: Click/drag to paint points"); |
|
ui.label("Arrow Keys: Move selected point(s)"); |
|
ui.label("Shift + Arrow: Move by large step"); |
|
ui.label("C then C: Clone on top"); |
|
ui.label("C then Arrow: Clone adjacent"); |
|
ui.label("S then C: Set shape to Circle"); |
|
ui.label("S then S: Set shape to Square"); |
|
ui.label("D: Delete selected"); |
|
ui.label("G: Toggle snap-to-grid"); |
|
ui.label("V then G: Toggle grid visibility"); |
|
ui.label("Ctrl + Scroll: Zoom"); |
|
ui.label("?: Show help"); |
|
ui.label("Ctrl+S: Save"); |
|
ui.label("Ctrl+O: Load"); |
|
ui.label("Ctrl+R: Reset"); |
|
ui.label("Q or Escape: Quit"); |
|
}); |
|
} |
|
} |
|
|
|
pub fn handle_keyboard(ctx: &egui::Context, state: &mut AppState, config: &mut Config) { |
|
let shift = ctx.input(|i| i.modifiers.shift); |
|
let step = if shift { config.move_step_large } else { config.move_step }; |
|
|
|
if ctx.input(|i| i.key_pressed(egui::Key::G)) { |
|
if state.pending_view { |
|
config.grid_enabled = !config.grid_enabled; |
|
state.pending_view = false; |
|
} else { |
|
state.snap_to_grid = !state.snap_to_grid; |
|
} |
|
} |
|
|
|
if ctx.input(|i| i.key_pressed(egui::Key::V)) { |
|
state.pending_view = true; |
|
} else if !ctx.input(|i| i.key_down(egui::Key::G)) { |
|
state.pending_view = false; |
|
} |
|
|
|
if ctx.input(|i| i.key_pressed(egui::Key::Questionmark)) { |
|
state.show_help = !state.show_help; |
|
} |
|
|
|
if ctx.input(|i| i.key_pressed(egui::Key::Q) || i.key_pressed(egui::Key::Escape)) { |
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close); |
|
} |
|
|
|
if ctx.input(|i| i.modifiers.ctrl && i.key_pressed(egui::Key::S)) { |
|
persistence::save_points(&state.points); |
|
} |
|
|
|
if ctx.input(|i| i.modifiers.ctrl && i.key_pressed(egui::Key::O)) { |
|
state.points = persistence::load_points(); |
|
} |
|
|
|
if ctx.input(|i| i.modifiers.ctrl && i.key_pressed(egui::Key::R)) { |
|
state.points = persistence::load_points(); |
|
} |
|
|
|
if ctx.input(|i| i.key_pressed(egui::Key::D)) { |
|
state.delete_selected(); |
|
} |
|
|
|
if ctx.input(|i| i.key_pressed(egui::Key::B)) { |
|
state.box_select_mode = !state.box_select_mode; |
|
if !state.box_select_mode { |
|
state.box_select_start = None; |
|
state.box_select_end = None; |
|
} |
|
} |
|
|
|
if ctx.input(|i| i.key_pressed(egui::Key::P)) { |
|
state.paintbrush_mode = !state.paintbrush_mode; |
|
state.last_paint_pos = None; |
|
} |
|
|
|
if state.box_select_mode { |
|
if ctx.input(|i| i.key_pressed(egui::Key::ArrowLeft)) { |
|
state.expand_selection_box((-1.0, 0.0), config.point_radius); |
|
} |
|
if ctx.input(|i| i.key_pressed(egui::Key::ArrowRight)) { |
|
state.expand_selection_box((1.0, 0.0), config.point_radius); |
|
} |
|
if ctx.input(|i| i.key_pressed(egui::Key::ArrowUp)) { |
|
state.expand_selection_box((0.0, -1.0), config.point_radius); |
|
} |
|
if ctx.input(|i| i.key_pressed(egui::Key::ArrowDown)) { |
|
state.expand_selection_box((0.0, 1.0), config.point_radius); |
|
} |
|
} else if ctx.input(|i| i.key_pressed(egui::Key::S)) { |
|
if state.pending_shape { |
|
state.set_selected_shape(PointShape::Square); |
|
state.pending_shape = false; |
|
} else { |
|
state.pending_shape = true; |
|
} |
|
} else if state.pending_shape { |
|
if ctx.input(|i| i.key_pressed(egui::Key::C)) { |
|
state.set_selected_shape(PointShape::Circle); |
|
state.pending_shape = false; |
|
} |
|
} else if ctx.input(|i| i.key_pressed(egui::Key::C)) { |
|
if state.pending_clone { |
|
state.clone_selected(0.0, 0.0); |
|
state.pending_clone = false; |
|
} else { |
|
state.pending_clone = true; |
|
} |
|
} else if state.pending_clone { |
|
if ctx.input(|i| i.key_pressed(egui::Key::ArrowLeft)) { |
|
let (dx, dy) = state.convex_hull_offset((-1.0, 0.0), config.point_radius); |
|
state.clone_selected(dx, dy); |
|
state.pending_clone = false; |
|
} else if ctx.input(|i| i.key_pressed(egui::Key::ArrowRight)) { |
|
let (dx, dy) = state.convex_hull_offset((1.0, 0.0), config.point_radius); |
|
state.clone_selected(dx, dy); |
|
state.pending_clone = false; |
|
} else if ctx.input(|i| i.key_pressed(egui::Key::ArrowUp)) { |
|
let (dx, dy) = state.convex_hull_offset((0.0, -1.0), config.point_radius); |
|
state.clone_selected(dx, dy); |
|
state.pending_clone = false; |
|
} else if ctx.input(|i| i.key_pressed(egui::Key::ArrowDown)) { |
|
let (dx, dy) = state.convex_hull_offset((0.0, 1.0), config.point_radius); |
|
state.clone_selected(dx, dy); |
|
state.pending_clone = false; |
|
} |
|
} else { |
|
if ctx.input(|i| i.key_pressed(egui::Key::ArrowLeft)) { |
|
state.move_selected(-step, 0.0); |
|
if state.snap_to_grid { |
|
state.snap_to_grid(config.grid_spacing, config.point_radius); |
|
} |
|
} |
|
if ctx.input(|i| i.key_pressed(egui::Key::ArrowRight)) { |
|
state.move_selected(step, 0.0); |
|
if state.snap_to_grid { |
|
state.snap_to_grid(config.grid_spacing, config.point_radius); |
|
} |
|
} |
|
if ctx.input(|i| i.key_pressed(egui::Key::ArrowUp)) { |
|
state.move_selected(0.0, -step); |
|
if state.snap_to_grid { |
|
state.snap_to_grid(config.grid_spacing, config.point_radius); |
|
} |
|
} |
|
if ctx.input(|i| i.key_pressed(egui::Key::ArrowDown)) { |
|
state.move_selected(0.0, step); |
|
if state.snap_to_grid { |
|
state.snap_to_grid(config.grid_spacing, config.point_radius); |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
struct PointDragger { |
|
state: state::AppState, |
|
config: config::Config, |
|
} |
|
|
|
impl PointDragger { |
|
fn new() -> Self { |
|
let config = config::Config::load(); |
|
let points = persistence::load_points(); |
|
Self { |
|
state: state::AppState::new(points), |
|
config, |
|
} |
|
} |
|
} |
|
|
|
impl eframe::App for PointDragger { |
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { |
|
ui::show_menu(ctx, &mut self.state); |
|
ui::show_tool_panel(ctx, &self.config, &mut self.state); |
|
ui::show_help_window(ctx, &mut self.state); |
|
ui::handle_keyboard(ctx, &mut self.state, &mut self.config); |
|
|
|
egui::CentralPanel::default().show(ctx, |ui| { |
|
let response = drawing::draw_canvas(ui, &self.state, &self.config); |
|
|
|
if ctx.input(|i| i.modifiers.ctrl) { |
|
let scroll_delta = ctx.input(|i| i.smooth_scroll_delta.y); |
|
if scroll_delta != 0.0 { |
|
let zoom_delta = scroll_delta * 0.001; |
|
self.state.zoom = (self.state.zoom + zoom_delta).clamp(0.1, 10.0); |
|
} |
|
} |
|
|
|
if self.state.box_select_mode { |
|
if response.drag_started() { |
|
if let Some(pos) = response.interact_pointer_pos() { |
|
self.state.box_select_start = Some(pos); |
|
self.state.box_select_end = Some(pos); |
|
} |
|
} |
|
|
|
if response.dragged() { |
|
if let Some(pos) = response.interact_pointer_pos() { |
|
self.state.box_select_end = Some(pos); |
|
} |
|
} |
|
|
|
if response.drag_stopped() { |
|
if let (Some(start), Some(end)) = (self.state.box_select_start, self.state.box_select_end) { |
|
let rect = egui::Rect::from_two_pos(start, end); |
|
self.state.select_in_box(rect, self.config.point_radius); |
|
} |
|
self.state.box_select_start = None; |
|
self.state.box_select_end = None; |
|
} |
|
} else if self.state.paintbrush_mode { |
|
if response.clicked() || response.dragged() { |
|
if let Some(pos) = response.interact_pointer_pos() { |
|
self.state.paint_point( |
|
pos, |
|
self.config.point_radius, |
|
self.config.move_step, |
|
self.config.grid_spacing, |
|
self.state.snap_to_grid, |
|
); |
|
} |
|
} |
|
|
|
if response.drag_stopped() { |
|
self.state.last_paint_pos = None; |
|
persistence::save_points(&self.state.points); |
|
} |
|
} else { |
|
if response.drag_started() { |
|
if let Some(pos) = response.interact_pointer_pos() { |
|
if let Some(idx) = self.state.point_at_pos(pos, self.config.point_radius) { |
|
let selected_indices = self.state.selected_indices(); |
|
if selected_indices.contains(&idx) { |
|
self.state.dragging = Some(idx); |
|
} else { |
|
self.state.selection = crate::state::Selection::Single(idx); |
|
self.state.dragging = Some(idx); |
|
} |
|
} |
|
} |
|
} |
|
|
|
if response.dragged() { |
|
if self.state.dragging.is_some() { |
|
if let Some(pos) = response.interact_pointer_pos() { |
|
let selected = self.state.selected_indices(); |
|
if let Some(drag_idx) = self.state.dragging { |
|
let old_pos = (self.state.points[drag_idx].x, self.state.points[drag_idx].y); |
|
let quantized_x = state::AppState::quantize_position(pos.x, self.config.move_step); |
|
let quantized_y = state::AppState::quantize_position(pos.y, self.config.move_step); |
|
let dx = quantized_x - old_pos.0; |
|
let dy = quantized_y - old_pos.1; |
|
|
|
for idx in selected { |
|
self.state.points[idx].x += dx; |
|
self.state.points[idx].y += dy; |
|
} |
|
|
|
if self.state.snap_to_grid { |
|
self.state.snap_to_grid(self.config.grid_spacing, self.config.point_radius); |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
if response.drag_stopped() { |
|
if self.state.dragging.is_some() { |
|
persistence::save_points(&self.state.points); |
|
self.state.dragging = None; |
|
} |
|
} |
|
|
|
if response.clicked() { |
|
if let Some(pos) = response.interact_pointer_pos() { |
|
if let Some(idx) = self.state.point_at_pos(pos, self.config.point_radius) { |
|
self.state.selection = crate::state::Selection::Single(idx); |
|
} else { |
|
self.state.selection = crate::state::Selection::None; |
|
} |
|
} |
|
} |
|
} |
|
}); |
|
} |
|
} |
|
|
|
fn main() -> eframe::Result<()> { |
|
let options = eframe::NativeOptions::default(); |
|
eframe::run_native( |
|
"Point Dragger", |
|
options, |
|
Box::new(|_cc| Ok(Box::new(PointDragger::new()))), |
|
) |
|
} |