Skip to content

Instantly share code, notes, and snippets.

@lmmx
Last active October 30, 2025 16:23
Show Gist options
  • Save lmmx/9ce5af70012f33fc4a45ae3abf21caeb to your computer and use it in GitHub Desktop.
Save lmmx/9ce5af70012f33fc4a45ae3abf21caeb to your computer and use it in GitHub Desktop.
Point viewer with JSON storage (rust-script MVP in facet, egui)

Point Dragger

A GUI application for creating, manipulating, and arranging points on a canvas. Points can be circles or squares, and supports multi-selection, cloning, and grid-based layout.

Features

  • Draw circles and squares on a canvas
  • Select single or multiple points
  • Drag points with mouse (snapped to grid)
  • Clone points in any direction
  • Snap to grid lines
  • Keyboard-driven workflow
  • Configurable via TOML file
  • Auto-save to JSON

Installation

Requires Rust and rust-script.

chmod +x main.rs
./main.rs

Configuration

Create config.toml in the working directory:

bg_color = "#FFFFFF"
point_color = "#000000"
selected_color = "#FF0000"
selection_box_color = "#0000FF"
grid_enabled = true
grid_spacing = 50.0
grid_color = "#CCCCCC"
point_radius = 8.0
move_step = 1.0
move_step_large = 8.0

Controls

Selection

  • Click point: Select single point
  • Click empty: Deselect all
  • B: Toggle box select mode
  • Arrow keys (in box mode): Expand selection to adjacent points
  • Drag box: Select all points entirely within box

Movement

  • Arrow keys: Move selected points by move_step
  • Shift + Arrow: Move by move_step_large
  • Mouse drag: Move selected points (quantized to move_step)

Cloning

  • C then C: Clone selected points on top
  • C then Arrow: Clone adjacent (offset by bounding box size)

Shapes

  • S then S: Set selected points to square
  • S then C: Set selected points to circle

View

  • G: Toggle snap-to-grid mode
  • V then G: Toggle grid visibility
  • Ctrl + Scroll: Zoom (0.1x to 10x)

Other

  • D: Delete selected points
  • ?: Show help window
  • Ctrl+S: Save points to points.json
  • Ctrl+O: Load points from file
  • Ctrl+R: Reset to defaults
  • Q or Escape: Quit

File Format

Points are saved to points.json in the working directory:

{
  "points": [
    {"id": 1, "x": 100.0, "y": 100.0, "shape": "Circle"},
    {"id": 2, "x": 200.0, "y": 200.0, "shape": "Square"}
  ]
}

Snap to Grid

When snap-to-grid mode is enabled (G), point boundaries snap to the nearest grid lines. The closest edge of each point aligns with grid spacing.

Multi-Selection Behavior

  • Dragging a selected point moves all selected points together
  • Cloning creates copies of all selected points
  • Shape changes apply to all selected points
  • Delete removes all selected points, then selects most recently created remaining point

Dependencies

  • eframe 0.29
  • facet 0.30
  • facet-json 0.30
  • facet-toml 0.30
#!/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()))),
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment