feat(editor): add voltex_editor crate with IMGUI core (font, draw_list, widgets)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 15:10:53 +09:00
parent 87b9b7c1bd
commit 19db4dd390
8 changed files with 671 additions and 0 deletions

View File

@@ -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"] }

View File

@@ -0,0 +1,7 @@
[package]
name = "voltex_editor"
version = "0.1.0"
edition = "2021"
[dependencies]
bytemuck = { workspace = true }

View File

@@ -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<DrawVertex>,
pub indices: Vec<u16>,
pub commands: Vec<DrawCommand>,
}
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());
}
}

View File

@@ -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<u8>, // 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -0,0 +1,81 @@
use crate::draw_list::DrawList;
use crate::font::FontAtlas;
use crate::layout::LayoutState;
pub struct UiContext {
pub hot: Option<u64>,
pub active: Option<u64>,
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
}
}

View File

@@ -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);
}
}