diff --git a/Cargo.toml b/Cargo.toml index 6b20561..34f9e60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "crates/voltex_ai", "crates/voltex_net", "crates/voltex_script", + "crates/voltex_editor", ] [workspace.dependencies] @@ -35,6 +36,7 @@ voltex_audio = { path = "crates/voltex_audio" } voltex_ai = { path = "crates/voltex_ai" } voltex_net = { path = "crates/voltex_net" } voltex_script = { path = "crates/voltex_script" } +voltex_editor = { path = "crates/voltex_editor" } wgpu = "28.0" winit = "0.30" bytemuck = { version = "1", features = ["derive"] } diff --git a/crates/voltex_editor/Cargo.toml b/crates/voltex_editor/Cargo.toml new file mode 100644 index 0000000..cbe80b7 --- /dev/null +++ b/crates/voltex_editor/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "voltex_editor" +version = "0.1.0" +edition = "2021" + +[dependencies] +bytemuck = { workspace = true } diff --git a/crates/voltex_editor/src/draw_list.rs b/crates/voltex_editor/src/draw_list.rs new file mode 100644 index 0000000..51fce06 --- /dev/null +++ b/crates/voltex_editor/src/draw_list.rs @@ -0,0 +1,123 @@ +use bytemuck::{Pod, Zeroable}; +use crate::font::FontAtlas; + +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct DrawVertex { + pub position: [f32; 2], + pub uv: [f32; 2], + pub color: [u8; 4], +} + +pub struct DrawCommand { + pub index_offset: u32, + pub index_count: u32, +} + +pub struct DrawList { + pub vertices: Vec, + pub indices: Vec, + pub commands: Vec, +} + +impl DrawList { + pub fn new() -> Self { + DrawList { + vertices: Vec::new(), + indices: Vec::new(), + commands: Vec::new(), + } + } + + pub fn clear(&mut self) { + self.vertices.clear(); + self.indices.clear(); + self.commands.clear(); + } + + /// Add a solid-color rectangle. UV is (0,0) for solid color rendering. + pub fn add_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: [u8; 4]) { + self.add_rect_uv(x, y, w, h, 0.0, 0.0, 0.0, 0.0, color); + } + + /// Add a textured quad with explicit UV coordinates. + pub fn add_rect_uv( + &mut self, + x: f32, y: f32, w: f32, h: f32, + u0: f32, v0: f32, u1: f32, v1: f32, + color: [u8; 4], + ) { + let index_offset = self.indices.len() as u32; + let base_vertex = self.vertices.len() as u16; + + // 4 vertices: top-left, top-right, bottom-right, bottom-left + self.vertices.push(DrawVertex { position: [x, y ], uv: [u0, v0], color }); + self.vertices.push(DrawVertex { position: [x + w, y ], uv: [u1, v0], color }); + self.vertices.push(DrawVertex { position: [x + w, y + h], uv: [u1, v1], color }); + self.vertices.push(DrawVertex { position: [x, y + h], uv: [u0, v1], color }); + + // 2 triangles = 6 indices + self.indices.push(base_vertex); + self.indices.push(base_vertex + 1); + self.indices.push(base_vertex + 2); + self.indices.push(base_vertex); + self.indices.push(base_vertex + 2); + self.indices.push(base_vertex + 3); + + self.commands.push(DrawCommand { + index_offset, + index_count: 6, + }); + } + + /// Add text at the given position. One quad per character, using glyph UVs from the atlas. + pub fn add_text(&mut self, font: &FontAtlas, text: &str, x: f32, y: f32, color: [u8; 4]) { + let gw = font.glyph_width as f32; + let gh = font.glyph_height as f32; + let mut cx = x; + for ch in text.chars() { + let (u0, v0, u1, v1) = font.glyph_uv(ch); + self.add_rect_uv(cx, y, gw, gh, u0, v0, u1, v1, color); + cx += gw; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::font::FontAtlas; + + #[test] + fn test_add_rect_vertex_index_count() { + let mut dl = DrawList::new(); + dl.add_rect(0.0, 0.0, 100.0, 50.0, [255, 0, 0, 255]); + assert_eq!(dl.vertices.len(), 4); + assert_eq!(dl.indices.len(), 6); + assert_eq!(dl.commands.len(), 1); + assert_eq!(dl.commands[0].index_count, 6); + assert_eq!(dl.commands[0].index_offset, 0); + } + + #[test] + fn test_add_text_char_count() { + let font = FontAtlas::generate(); + let mut dl = DrawList::new(); + let text = "Hello"; + dl.add_text(&font, text, 0.0, 0.0, [255, 255, 255, 255]); + // 5 chars => 5 quads => 5*4=20 vertices, 5*6=30 indices + assert_eq!(dl.vertices.len(), 5 * 4); + assert_eq!(dl.indices.len(), 5 * 6); + assert_eq!(dl.commands.len(), 5); + } + + #[test] + fn test_clear() { + let mut dl = DrawList::new(); + dl.add_rect(0.0, 0.0, 50.0, 50.0, [0, 0, 0, 255]); + dl.clear(); + assert!(dl.vertices.is_empty()); + assert!(dl.indices.is_empty()); + assert!(dl.commands.is_empty()); + } +} diff --git a/crates/voltex_editor/src/font.rs b/crates/voltex_editor/src/font.rs new file mode 100644 index 0000000..8ea2852 --- /dev/null +++ b/crates/voltex_editor/src/font.rs @@ -0,0 +1,135 @@ +/// Bitmap font atlas for ASCII 32-126. +/// 8x12 pixel glyphs arranged in 16 columns x 6 rows = 128x72 texture. +pub struct FontAtlas { + pub width: u32, + pub height: u32, + pub glyph_width: u32, + pub glyph_height: u32, + pub pixels: Vec, // R8 grayscale +} + +impl FontAtlas { + /// Generate a minimal font atlas. + /// Each glyph is 8x12 pixels. Atlas is 16 cols x 6 rows = 128x72. + /// Space (32) = all zeros. Other chars get a simple recognizable pattern. + pub fn generate() -> Self { + let glyph_width: u32 = 8; + let glyph_height: u32 = 12; + let cols: u32 = 16; + let rows: u32 = 6; + let width = cols * glyph_width; // 128 + let height = rows * glyph_height; // 72 + + let mut pixels = vec![0u8; (width * height) as usize]; + + // ASCII 32 (space) through 126 (~) = 95 characters + for code in 32u8..=126u8 { + let index = (code - 32) as u32; + let col = index % cols; + let row = index / cols; + let base_x = col * glyph_width; + let base_y = row * glyph_height; + + if code == 32 { + // Space: all zeros (already zero) + continue; + } + + // Generate a recognizable pattern based on char code. + // Draw a border frame + inner pattern derived from code value. + for py in 0..glyph_height { + for px in 0..glyph_width { + let pixel_idx = ((base_y + py) * width + (base_x + px)) as usize; + + let on_border = px == 0 || px == glyph_width - 1 + || py == 0 || py == glyph_height - 1; + + // Inner pattern: use bits of char code to create variation + let inner_bit = ((code as u32).wrapping_mul(px + 1).wrapping_mul(py + 1)) & 0x3; + let on_inner = inner_bit == 0 && px > 1 && px < glyph_width - 2 + && py > 2 && py < glyph_height - 2; + + // Horizontal bar in middle for letter-like appearance + let mid_y = glyph_height / 2; + let on_midbar = py == mid_y && px > 1 && px < glyph_width - 2; + + // Vertical stem on left side + let on_stem = px == 2 && py > 1 && py < glyph_height - 2; + + if on_border || on_inner || on_midbar || on_stem { + pixels[pixel_idx] = 255; + } + } + } + } + + FontAtlas { + width, + height, + glyph_width, + glyph_height, + pixels, + } + } + + /// Returns (u0, v0, u1, v1) UV coordinates for a given character. + /// Returns coordinates for space if character is out of ASCII range. + pub fn glyph_uv(&self, ch: char) -> (f32, f32, f32, f32) { + let code = ch as u32; + let index = if code >= 32 && code <= 126 { + code - 32 + } else { + 0 // space + }; + + let cols = self.width / self.glyph_width; + let col = index % cols; + let row = index / cols; + + let u0 = (col * self.glyph_width) as f32 / self.width as f32; + let v0 = (row * self.glyph_height) as f32 / self.height as f32; + let u1 = u0 + self.glyph_width as f32 / self.width as f32; + let v1 = v0 + self.glyph_height as f32 / self.height as f32; + + (u0, v0, u1, v1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_size() { + let atlas = FontAtlas::generate(); + assert_eq!(atlas.width, 128); + assert_eq!(atlas.height, 72); + assert_eq!(atlas.pixels.len(), (128 * 72) as usize); + } + + #[test] + fn test_glyph_uv_space() { + let atlas = FontAtlas::generate(); + let (u0, v0, u1, v1) = atlas.glyph_uv(' '); + assert!((u0 - 0.0).abs() < 1e-6); + assert!((v0 - 0.0).abs() < 1e-6); + assert!((u1 - 8.0 / 128.0).abs() < 1e-6); + assert!((v1 - 12.0 / 72.0).abs() < 1e-6); + } + + #[test] + fn test_glyph_uv_a() { + let atlas = FontAtlas::generate(); + // 'A' = ASCII 65, index = 65 - 32 = 33 + // col = 33 % 16 = 1, row = 33 / 16 = 2 + let (u0, v0, u1, v1) = atlas.glyph_uv('A'); + let expected_u0 = 1.0 * 8.0 / 128.0; + let expected_v0 = 2.0 * 12.0 / 72.0; + let expected_u1 = expected_u0 + 8.0 / 128.0; + let expected_v1 = expected_v0 + 12.0 / 72.0; + assert!((u0 - expected_u0).abs() < 1e-6, "u0 mismatch: {} vs {}", u0, expected_u0); + assert!((v0 - expected_v0).abs() < 1e-6, "v0 mismatch: {} vs {}", v0, expected_v0); + assert!((u1 - expected_u1).abs() < 1e-6); + assert!((v1 - expected_v1).abs() < 1e-6); + } +} diff --git a/crates/voltex_editor/src/layout.rs b/crates/voltex_editor/src/layout.rs new file mode 100644 index 0000000..6d3ac75 --- /dev/null +++ b/crates/voltex_editor/src/layout.rs @@ -0,0 +1,32 @@ +/// Simple cursor-based layout state for immediate mode UI. +pub struct LayoutState { + pub cursor_x: f32, + pub cursor_y: f32, + pub indent: f32, + pub line_height: f32, + pub padding: f32, +} + +impl LayoutState { + /// Create a new layout state starting at (x, y). + pub fn new(x: f32, y: f32) -> Self { + LayoutState { + cursor_x: x, + cursor_y: y, + indent: x, + line_height: 12.0, + padding: 4.0, + } + } + + /// Advance to the next line: cursor_y += line_height + padding, cursor_x = indent. + pub fn advance_line(&mut self) { + self.cursor_y += self.line_height + self.padding; + self.cursor_x = self.indent; + } + + /// Advance horizontally: cursor_x += width + padding. + pub fn advance_x(&mut self, width: f32) { + self.cursor_x += width + self.padding; + } +} diff --git a/crates/voltex_editor/src/lib.rs b/crates/voltex_editor/src/lib.rs new file mode 100644 index 0000000..8fef481 --- /dev/null +++ b/crates/voltex_editor/src/lib.rs @@ -0,0 +1,10 @@ +pub mod font; +pub mod draw_list; +pub mod layout; +pub mod ui_context; +pub mod widgets; + +pub use font::FontAtlas; +pub use draw_list::{DrawVertex, DrawCommand, DrawList}; +pub use layout::LayoutState; +pub use ui_context::UiContext; diff --git a/crates/voltex_editor/src/ui_context.rs b/crates/voltex_editor/src/ui_context.rs new file mode 100644 index 0000000..8212718 --- /dev/null +++ b/crates/voltex_editor/src/ui_context.rs @@ -0,0 +1,81 @@ +use crate::draw_list::DrawList; +use crate::font::FontAtlas; +use crate::layout::LayoutState; + +pub struct UiContext { + pub hot: Option, + pub active: Option, + pub draw_list: DrawList, + pub layout: LayoutState, + pub mouse_x: f32, + pub mouse_y: f32, + pub mouse_down: bool, + pub mouse_clicked: bool, + pub mouse_released: bool, + pub screen_width: f32, + pub screen_height: f32, + pub font: FontAtlas, + id_counter: u64, + prev_mouse_down: bool, +} + +impl UiContext { + /// Create a new UiContext for the given screen dimensions. + pub fn new(screen_w: f32, screen_h: f32) -> Self { + UiContext { + hot: None, + active: None, + draw_list: DrawList::new(), + layout: LayoutState::new(0.0, 0.0), + mouse_x: 0.0, + mouse_y: 0.0, + mouse_down: false, + mouse_clicked: false, + mouse_released: false, + screen_width: screen_w, + screen_height: screen_h, + font: FontAtlas::generate(), + id_counter: 0, + prev_mouse_down: false, + } + } + + /// Begin a new frame: clear draw list, reset id counter, update mouse state. + pub fn begin_frame(&mut self, mx: f32, my: f32, mouse_down: bool) { + self.draw_list.clear(); + self.id_counter = 0; + self.hot = None; + + self.mouse_x = mx; + self.mouse_y = my; + + // Compute transitions + self.mouse_clicked = !self.prev_mouse_down && mouse_down; + self.mouse_released = self.prev_mouse_down && !mouse_down; + + self.mouse_down = mouse_down; + self.prev_mouse_down = mouse_down; + + // Reset layout to top-left + self.layout = LayoutState::new(0.0, 0.0); + } + + /// End the current frame. + pub fn end_frame(&mut self) { + // Nothing for now — GPU submission will hook in here later. + } + + /// Generate a new unique ID for this frame. + pub fn gen_id(&mut self) -> u64 { + self.id_counter += 1; + self.id_counter + } + + /// Check if the mouse cursor is inside the given rectangle. + pub fn mouse_in_rect(&self, x: f32, y: f32, w: f32, h: f32) -> bool { + self.mouse_x >= x + && self.mouse_x < x + w + && self.mouse_y >= y + && self.mouse_y < y + h + } +} diff --git a/crates/voltex_editor/src/widgets.rs b/crates/voltex_editor/src/widgets.rs new file mode 100644 index 0000000..bc2c647 --- /dev/null +++ b/crates/voltex_editor/src/widgets.rs @@ -0,0 +1,281 @@ +use crate::ui_context::UiContext; + +// Color palette +const COLOR_BG: [u8; 4] = [0x2B, 0x2B, 0x2B, 0xFF]; +const COLOR_BUTTON: [u8; 4] = [0x44, 0x44, 0x44, 0xFF]; +const COLOR_BUTTON_HOT: [u8; 4] = [0x55, 0x55, 0x55, 0xFF]; +const COLOR_BUTTON_ACTIVE: [u8; 4] = [0x66, 0x66, 0x66, 0xFF]; +const COLOR_TEXT: [u8; 4] = [0xEE, 0xEE, 0xEE, 0xFF]; +const COLOR_PANEL: [u8; 4] = [0x33, 0x33, 0x33, 0xFF]; +const COLOR_SLIDER_BG: [u8; 4] = [0x44, 0x44, 0x44, 0xFF]; +const COLOR_SLIDER_HANDLE: [u8; 4] = [0x88, 0x88, 0xFF, 0xFF]; +const COLOR_CHECK_BG: [u8; 4] = [0x44, 0x44, 0x44, 0xFF]; +const COLOR_CHECK_MARK: [u8; 4] = [0x88, 0xFF, 0x88, 0xFF]; + +impl UiContext { + /// Draw text at the current cursor position and advance to the next line. + pub fn text(&mut self, text: &str) { + let x = self.layout.cursor_x; + let y = self.layout.cursor_y; + // We need to clone font info for the borrow checker + let gw = self.font.glyph_width as f32; + let gh = self.font.glyph_height as f32; + + // Draw each character + let mut cx = x; + for ch in text.chars() { + let (u0, v0, u1, v1) = self.font.glyph_uv(ch); + self.draw_list.add_rect_uv(cx, y, gw, gh, u0, v0, u1, v1, COLOR_TEXT); + cx += gw; + } + + self.layout.advance_line(); + } + + /// Draw a button with the given label. Returns true if clicked this frame. + pub fn button(&mut self, label: &str) -> bool { + let id = self.gen_id(); + let gw = self.font.glyph_width as f32; + let gh = self.font.glyph_height as f32; + let padding = self.layout.padding; + + let text_w = label.len() as f32 * gw; + let btn_w = text_w + padding * 2.0; + let btn_h = gh + padding * 2.0; + + let x = self.layout.cursor_x; + let y = self.layout.cursor_y; + + let hovered = self.mouse_in_rect(x, y, btn_w, btn_h); + + if hovered { + self.hot = Some(id); + if self.mouse_down { + self.active = Some(id); + } + } + + // Determine color + let bg_color = if self.active == Some(id) && hovered { + COLOR_BUTTON_ACTIVE + } else if self.hot == Some(id) { + COLOR_BUTTON_HOT + } else { + COLOR_BUTTON + }; + + // Draw background rect + self.draw_list.add_rect(x, y, btn_w, btn_h, bg_color); + + // Draw text centered inside button + let text_x = x + padding; + let text_y = y + padding; + let mut cx = text_x; + for ch in label.chars() { + let (u0, v0, u1, v1) = self.font.glyph_uv(ch); + self.draw_list.add_rect_uv(cx, text_y, gw, gh, u0, v0, u1, v1, COLOR_TEXT); + cx += gw; + } + + self.layout.advance_line(); + + // Return true if mouse was released over this button while it was active + let clicked = hovered && self.mouse_released && self.active == Some(id); + if self.mouse_released { + if self.active == Some(id) { + self.active = None; + } + } + clicked + } + + /// Draw a horizontal slider. Returns the (possibly new) value after interaction. + pub fn slider(&mut self, label: &str, value: f32, min: f32, max: f32) -> f32 { + let id = self.gen_id(); + let gw = self.font.glyph_width as f32; + let gh = self.font.glyph_height as f32; + let padding = self.layout.padding; + + let slider_w = 150.0_f32; + let slider_h = gh + padding * 2.0; + let handle_w = 10.0_f32; + + let x = self.layout.cursor_x; + let y = self.layout.cursor_y; + + let hovered = self.mouse_in_rect(x, y, slider_w, slider_h); + + if hovered && self.mouse_clicked { + self.active = Some(id); + } + + let mut new_value = value; + + if self.active == Some(id) { + if self.mouse_down { + // Map mouse_x to value + let t = ((self.mouse_x - x - handle_w / 2.0) / (slider_w - handle_w)).clamp(0.0, 1.0); + new_value = min + t * (max - min); + } else if self.mouse_released { + self.active = None; + } + } + + // Clamp value + new_value = new_value.clamp(min, max); + + // Draw background bar + self.draw_list.add_rect(x, y, slider_w, slider_h, COLOR_SLIDER_BG); + + // Draw handle + let t = if (max - min).abs() < 1e-6 { + 0.0 + } else { + (new_value - min) / (max - min) + }; + let handle_x = x + t * (slider_w - handle_w); + self.draw_list.add_rect(handle_x, y, handle_w, slider_h, COLOR_SLIDER_HANDLE); + + // Draw label to the right of the slider + let label_x = x + slider_w + padding; + let label_y = y + padding; + let mut cx = label_x; + for ch in label.chars() { + let (u0, v0, u1, v1) = self.font.glyph_uv(ch); + self.draw_list.add_rect_uv(cx, label_y, gw, gh, u0, v0, u1, v1, COLOR_TEXT); + cx += gw; + } + + self.layout.advance_line(); + + new_value + } + + /// Draw a checkbox. Returns the new checked state (toggled on click). + pub fn checkbox(&mut self, label: &str, checked: bool) -> bool { + let id = self.gen_id(); + let gw = self.font.glyph_width as f32; + let gh = self.font.glyph_height as f32; + let padding = self.layout.padding; + + let box_size = gh; + let x = self.layout.cursor_x; + let y = self.layout.cursor_y; + + let hovered = self.mouse_in_rect(x, y, box_size + padding + label.len() as f32 * gw, gh + padding); + + if hovered { + self.hot = Some(id); + } + + let mut new_checked = checked; + if hovered && self.mouse_clicked { + new_checked = !checked; + } + + // Draw checkbox background + self.draw_list.add_rect(x, y, box_size, box_size, COLOR_CHECK_BG); + + // Draw check mark if checked + if new_checked { + let inner = 3.0; + self.draw_list.add_rect( + x + inner, + y + inner, + box_size - inner * 2.0, + box_size - inner * 2.0, + COLOR_CHECK_MARK, + ); + } + + // Draw label + let label_x = x + box_size + padding; + let label_y = y; + let mut cx = label_x; + for ch in label.chars() { + let (u0, v0, u1, v1) = self.font.glyph_uv(ch); + self.draw_list.add_rect_uv(cx, label_y, gw, gh, u0, v0, u1, v1, COLOR_TEXT); + cx += gw; + } + + self.layout.advance_line(); + + new_checked + } + + /// Begin a panel: draw background and title, set cursor inside panel. + pub fn begin_panel(&mut self, title: &str, x: f32, y: f32, w: f32, h: f32) { + let gh = self.font.glyph_height as f32; + let padding = self.layout.padding; + + // Draw panel background + self.draw_list.add_rect(x, y, w, h, COLOR_PANEL); + + // Draw title bar (slightly darker background handled by same panel color here) + let title_bar_h = gh + padding * 2.0; + self.draw_list.add_rect(x, y, w, title_bar_h, COLOR_BG); + + // Draw title text + let gw = self.font.glyph_width as f32; + let mut cx = x + padding; + let ty = y + padding; + for ch in title.chars() { + let (u0, v0, u1, v1) = self.font.glyph_uv(ch); + self.draw_list.add_rect_uv(cx, ty, gw, gh, u0, v0, u1, v1, COLOR_TEXT); + cx += gw; + } + + // Set cursor to inside the panel (below title bar) + self.layout = crate::layout::LayoutState::new(x + padding, y + title_bar_h + padding); + } + + /// End a panel — currently a no-op; cursor remains where it was. + pub fn end_panel(&mut self) { + // Nothing for now; future could restore outer cursor state. + } +} + +#[cfg(test)] +mod tests { + use crate::ui_context::UiContext; + + #[test] + fn test_button_returns_false_when_not_clicked() { + let mut ctx = UiContext::new(800.0, 600.0); + // Mouse is at (500, 500) — far from any button + ctx.begin_frame(500.0, 500.0, false); + let result = ctx.button("Click Me"); + assert!(!result, "button should return false when mouse is not over it"); + } + + #[test] + fn test_button_returns_true_when_clicked() { + let mut ctx = UiContext::new(800.0, 600.0); + + // Frame 1: mouse over button, pressed down + // Button will be at layout cursor (0, 0) with some width/height + // glyph_width=8, "OK"=2 chars, btn_w = 2*8 + 4*2 = 24, btn_h = 12 + 4*2 = 20 + ctx.begin_frame(10.0, 5.0, true); + let _ = ctx.button("OK"); + + // Frame 2: mouse still over button, released + ctx.begin_frame(10.0, 5.0, false); + let result = ctx.button("OK"); + assert!(result, "button should return true when clicked and released"); + } + + #[test] + fn test_slider_returns_clamped_value() { + let mut ctx = UiContext::new(800.0, 600.0); + ctx.begin_frame(0.0, 0.0, false); + + // Value above max should be clamped + let v = ctx.slider("test", 150.0, 0.0, 100.0); + assert!((v - 100.0).abs() < 1e-6, "slider should clamp to max: got {}", v); + + ctx.begin_frame(0.0, 0.0, false); + // Value below min should be clamped + let v2 = ctx.slider("test", -10.0, 0.0, 100.0); + assert!((v2 - 0.0).abs() < 1e-6, "slider should clamp to min: got {}", v2); + } +}