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:
7
crates/voltex_editor/Cargo.toml
Normal file
7
crates/voltex_editor/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "voltex_editor"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
bytemuck = { workspace = true }
|
||||
123
crates/voltex_editor/src/draw_list.rs
Normal file
123
crates/voltex_editor/src/draw_list.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
135
crates/voltex_editor/src/font.rs
Normal file
135
crates/voltex_editor/src/font.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
32
crates/voltex_editor/src/layout.rs
Normal file
32
crates/voltex_editor/src/layout.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
10
crates/voltex_editor/src/lib.rs
Normal file
10
crates/voltex_editor/src/lib.rs
Normal 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;
|
||||
81
crates/voltex_editor/src/ui_context.rs
Normal file
81
crates/voltex_editor/src/ui_context.rs
Normal 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
|
||||
}
|
||||
}
|
||||
281
crates/voltex_editor/src/widgets.rs
Normal file
281
crates/voltex_editor/src/widgets.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user