feat(editor): add text input, scroll panel, drag-and-drop widgets
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,15 +9,27 @@ pub struct DrawVertex {
|
|||||||
pub color: [u8; 4],
|
pub color: [u8; 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A scissor rectangle for content clipping, in pixel coordinates.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ScissorRect {
|
||||||
|
pub x: u32,
|
||||||
|
pub y: u32,
|
||||||
|
pub w: u32,
|
||||||
|
pub h: u32,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct DrawCommand {
|
pub struct DrawCommand {
|
||||||
pub index_offset: u32,
|
pub index_offset: u32,
|
||||||
pub index_count: u32,
|
pub index_count: u32,
|
||||||
|
/// Optional scissor rect for clipping. None means no clipping.
|
||||||
|
pub scissor: Option<ScissorRect>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DrawList {
|
pub struct DrawList {
|
||||||
pub vertices: Vec<DrawVertex>,
|
pub vertices: Vec<DrawVertex>,
|
||||||
pub indices: Vec<u16>,
|
pub indices: Vec<u16>,
|
||||||
pub commands: Vec<DrawCommand>,
|
pub commands: Vec<DrawCommand>,
|
||||||
|
scissor_stack: Vec<ScissorRect>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DrawList {
|
impl DrawList {
|
||||||
@@ -26,6 +38,7 @@ impl DrawList {
|
|||||||
vertices: Vec::new(),
|
vertices: Vec::new(),
|
||||||
indices: Vec::new(),
|
indices: Vec::new(),
|
||||||
commands: Vec::new(),
|
commands: Vec::new(),
|
||||||
|
scissor_stack: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +46,23 @@ impl DrawList {
|
|||||||
self.vertices.clear();
|
self.vertices.clear();
|
||||||
self.indices.clear();
|
self.indices.clear();
|
||||||
self.commands.clear();
|
self.commands.clear();
|
||||||
|
self.scissor_stack.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a scissor rect onto the stack. All subsequent draw commands will
|
||||||
|
/// be clipped to this rectangle until `pop_scissor` is called.
|
||||||
|
pub fn push_scissor(&mut self, x: u32, y: u32, w: u32, h: u32) {
|
||||||
|
self.scissor_stack.push(ScissorRect { x, y, w, h });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop the current scissor rect from the stack.
|
||||||
|
pub fn pop_scissor(&mut self) {
|
||||||
|
self.scissor_stack.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current scissor rect (top of stack), or None.
|
||||||
|
fn current_scissor(&self) -> Option<ScissorRect> {
|
||||||
|
self.scissor_stack.last().copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a solid-color rectangle. UV is (0,0) for solid color rendering.
|
/// Add a solid-color rectangle. UV is (0,0) for solid color rendering.
|
||||||
@@ -67,6 +97,7 @@ impl DrawList {
|
|||||||
self.commands.push(DrawCommand {
|
self.commands.push(DrawCommand {
|
||||||
index_offset,
|
index_offset,
|
||||||
index_count: 6,
|
index_count: 6,
|
||||||
|
scissor: self.current_scissor(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -286,8 +286,13 @@ impl UiRenderer {
|
|||||||
pass.set_vertex_buffer(0, vertex_buffer.slice(..));
|
pass.set_vertex_buffer(0, vertex_buffer.slice(..));
|
||||||
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16);
|
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16);
|
||||||
|
|
||||||
// Draw each command
|
// Draw each command (with optional scissor clipping)
|
||||||
for cmd in &draw_list.commands {
|
for cmd in &draw_list.commands {
|
||||||
|
if let Some(scissor) = &cmd.scissor {
|
||||||
|
pass.set_scissor_rect(scissor.x, scissor.y, scissor.w, scissor.h);
|
||||||
|
} else {
|
||||||
|
pass.set_scissor_rect(0, 0, screen_w as u32, screen_h as u32);
|
||||||
|
}
|
||||||
pass.draw_indexed(
|
pass.draw_indexed(
|
||||||
cmd.index_offset..cmd.index_offset + cmd.index_count,
|
cmd.index_offset..cmd.index_offset + cmd.index_count,
|
||||||
0,
|
0,
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use crate::draw_list::DrawList;
|
use crate::draw_list::DrawList;
|
||||||
use crate::font::FontAtlas;
|
use crate::font::FontAtlas;
|
||||||
use crate::layout::LayoutState;
|
use crate::layout::LayoutState;
|
||||||
|
|
||||||
|
/// Key events the UI system understands.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Key {
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Backspace,
|
||||||
|
Delete,
|
||||||
|
Home,
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct UiContext {
|
pub struct UiContext {
|
||||||
pub hot: Option<u64>,
|
pub hot: Option<u64>,
|
||||||
pub active: Option<u64>,
|
pub active: Option<u64>,
|
||||||
@@ -12,11 +24,23 @@ pub struct UiContext {
|
|||||||
pub mouse_down: bool,
|
pub mouse_down: bool,
|
||||||
pub mouse_clicked: bool,
|
pub mouse_clicked: bool,
|
||||||
pub mouse_released: bool,
|
pub mouse_released: bool,
|
||||||
|
pub mouse_scroll: f32,
|
||||||
pub screen_width: f32,
|
pub screen_width: f32,
|
||||||
pub screen_height: f32,
|
pub screen_height: f32,
|
||||||
pub font: FontAtlas,
|
pub font: FontAtlas,
|
||||||
id_counter: u64,
|
id_counter: u64,
|
||||||
prev_mouse_down: bool,
|
prev_mouse_down: bool,
|
||||||
|
// Text input state
|
||||||
|
pub focused_id: Option<u32>,
|
||||||
|
pub cursor_pos: usize,
|
||||||
|
input_chars: Vec<char>,
|
||||||
|
input_keys: Vec<Key>,
|
||||||
|
// Scroll panel state
|
||||||
|
pub scroll_offsets: HashMap<u32, f32>,
|
||||||
|
// Drag and drop state
|
||||||
|
pub dragging: Option<(u32, u64)>,
|
||||||
|
pub drag_start: (f32, f32),
|
||||||
|
pub(crate) drag_started: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UiContext {
|
impl UiContext {
|
||||||
@@ -32,11 +56,20 @@ impl UiContext {
|
|||||||
mouse_down: false,
|
mouse_down: false,
|
||||||
mouse_clicked: false,
|
mouse_clicked: false,
|
||||||
mouse_released: false,
|
mouse_released: false,
|
||||||
|
mouse_scroll: 0.0,
|
||||||
screen_width: screen_w,
|
screen_width: screen_w,
|
||||||
screen_height: screen_h,
|
screen_height: screen_h,
|
||||||
font: FontAtlas::generate(),
|
font: FontAtlas::generate(),
|
||||||
id_counter: 0,
|
id_counter: 0,
|
||||||
prev_mouse_down: false,
|
prev_mouse_down: false,
|
||||||
|
focused_id: None,
|
||||||
|
cursor_pos: 0,
|
||||||
|
input_chars: Vec::new(),
|
||||||
|
input_keys: Vec::new(),
|
||||||
|
scroll_offsets: HashMap::new(),
|
||||||
|
dragging: None,
|
||||||
|
drag_start: (0.0, 0.0),
|
||||||
|
drag_started: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +93,39 @@ impl UiContext {
|
|||||||
self.layout = LayoutState::new(0.0, 0.0);
|
self.layout = LayoutState::new(0.0, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feed a character input event (printable ASCII) for text input widgets.
|
||||||
|
pub fn input_char(&mut self, ch: char) {
|
||||||
|
if ch.is_ascii() && !ch.is_ascii_control() {
|
||||||
|
self.input_chars.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed a key input event for text input widgets.
|
||||||
|
pub fn input_key(&mut self, key: Key) {
|
||||||
|
self.input_keys.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set mouse scroll delta for this frame (positive = scroll up).
|
||||||
|
pub fn set_scroll(&mut self, delta: f32) {
|
||||||
|
self.mouse_scroll = delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain all pending input chars (consumed by text_input widget).
|
||||||
|
pub(crate) fn drain_chars(&mut self) -> Vec<char> {
|
||||||
|
std::mem::take(&mut self.input_chars)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain all pending key events (consumed by text_input widget).
|
||||||
|
pub(crate) fn drain_keys(&mut self) -> Vec<Key> {
|
||||||
|
std::mem::take(&mut self.input_keys)
|
||||||
|
}
|
||||||
|
|
||||||
/// End the current frame.
|
/// End the current frame.
|
||||||
pub fn end_frame(&mut self) {
|
pub fn end_frame(&mut self) {
|
||||||
// Nothing for now — GPU submission will hook in here later.
|
self.mouse_scroll = 0.0;
|
||||||
|
// Clear any unconsumed input
|
||||||
|
self.input_chars.clear();
|
||||||
|
self.input_keys.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a new unique ID for this frame.
|
/// Generate a new unique ID for this frame.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::ui_context::UiContext;
|
use crate::ui_context::{Key, UiContext};
|
||||||
|
|
||||||
// Color palette
|
// Color palette
|
||||||
const COLOR_BG: [u8; 4] = [0x2B, 0x2B, 0x2B, 0xFF];
|
const COLOR_BG: [u8; 4] = [0x2B, 0x2B, 0x2B, 0xFF];
|
||||||
@@ -11,6 +11,13 @@ const COLOR_SLIDER_BG: [u8; 4] = [0x44, 0x44, 0x44, 0xFF];
|
|||||||
const COLOR_SLIDER_HANDLE: [u8; 4] = [0x88, 0x88, 0xFF, 0xFF];
|
const COLOR_SLIDER_HANDLE: [u8; 4] = [0x88, 0x88, 0xFF, 0xFF];
|
||||||
const COLOR_CHECK_BG: [u8; 4] = [0x44, 0x44, 0x44, 0xFF];
|
const COLOR_CHECK_BG: [u8; 4] = [0x44, 0x44, 0x44, 0xFF];
|
||||||
const COLOR_CHECK_MARK: [u8; 4] = [0x88, 0xFF, 0x88, 0xFF];
|
const COLOR_CHECK_MARK: [u8; 4] = [0x88, 0xFF, 0x88, 0xFF];
|
||||||
|
const COLOR_INPUT_BG: [u8; 4] = [0x22, 0x22, 0x22, 0xFF];
|
||||||
|
const COLOR_INPUT_BORDER: [u8; 4] = [0x66, 0x66, 0x66, 0xFF];
|
||||||
|
const COLOR_INPUT_FOCUSED: [u8; 4] = [0x44, 0x88, 0xFF, 0xFF];
|
||||||
|
const COLOR_CURSOR: [u8; 4] = [0xFF, 0xFF, 0xFF, 0xFF];
|
||||||
|
const COLOR_SCROLLBAR_BG: [u8; 4] = [0x33, 0x33, 0x33, 0xFF];
|
||||||
|
const COLOR_SCROLLBAR_THUMB: [u8; 4]= [0x66, 0x66, 0x77, 0xFF];
|
||||||
|
const DRAG_THRESHOLD: f32 = 5.0;
|
||||||
|
|
||||||
impl UiContext {
|
impl UiContext {
|
||||||
/// Draw text at the current cursor position and advance to the next line.
|
/// Draw text at the current cursor position and advance to the next line.
|
||||||
@@ -233,11 +240,231 @@ impl UiContext {
|
|||||||
pub fn end_panel(&mut self) {
|
pub fn end_panel(&mut self) {
|
||||||
// Nothing for now; future could restore outer cursor state.
|
// Nothing for now; future could restore outer cursor state.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Text Input Widget ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Draw an editable single-line text input. Returns true if the buffer changed.
|
||||||
|
///
|
||||||
|
/// `id` must be unique per text input. The widget renders a box at (x, y) with
|
||||||
|
/// the given `width`. Height is determined by the font glyph height + padding.
|
||||||
|
pub fn text_input(&mut self, id: u32, buffer: &mut String, x: f32, y: f32, width: f32) -> bool {
|
||||||
|
let gw = self.font.glyph_width as f32;
|
||||||
|
let gh = self.font.glyph_height as f32;
|
||||||
|
let padding = self.layout.padding;
|
||||||
|
let height = gh + padding * 2.0;
|
||||||
|
|
||||||
|
let hovered = self.mouse_in_rect(x, y, width, height);
|
||||||
|
|
||||||
|
// Click to focus / unfocus
|
||||||
|
if self.mouse_clicked {
|
||||||
|
if hovered {
|
||||||
|
self.focused_id = Some(id);
|
||||||
|
// Place cursor at end or at click position
|
||||||
|
let click_offset = ((self.mouse_x - x - padding) / gw).round() as usize;
|
||||||
|
self.cursor_pos = click_offset.min(buffer.len());
|
||||||
|
} else if self.focused_id == Some(id) {
|
||||||
|
self.focused_id = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
// Process input only if focused
|
||||||
|
if self.focused_id == Some(id) {
|
||||||
|
// Ensure cursor_pos is valid
|
||||||
|
if self.cursor_pos > buffer.len() {
|
||||||
|
self.cursor_pos = buffer.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process character input
|
||||||
|
let chars = self.drain_chars();
|
||||||
|
for ch in chars {
|
||||||
|
buffer.insert(self.cursor_pos, ch);
|
||||||
|
self.cursor_pos += 1;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process key input
|
||||||
|
let keys = self.drain_keys();
|
||||||
|
for key in keys {
|
||||||
|
match key {
|
||||||
|
Key::Backspace => {
|
||||||
|
if self.cursor_pos > 0 {
|
||||||
|
buffer.remove(self.cursor_pos - 1);
|
||||||
|
self.cursor_pos -= 1;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Key::Delete => {
|
||||||
|
if self.cursor_pos < buffer.len() {
|
||||||
|
buffer.remove(self.cursor_pos);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Key::Left => {
|
||||||
|
if self.cursor_pos > 0 {
|
||||||
|
self.cursor_pos -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Key::Right => {
|
||||||
|
if self.cursor_pos < buffer.len() {
|
||||||
|
self.cursor_pos += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Key::Home => {
|
||||||
|
self.cursor_pos = 0;
|
||||||
|
}
|
||||||
|
Key::End => {
|
||||||
|
self.cursor_pos = buffer.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw border
|
||||||
|
let border_color = if self.focused_id == Some(id) {
|
||||||
|
COLOR_INPUT_FOCUSED
|
||||||
|
} else {
|
||||||
|
COLOR_INPUT_BORDER
|
||||||
|
};
|
||||||
|
self.draw_list.add_rect(x, y, width, height, border_color);
|
||||||
|
// Draw inner background (1px border)
|
||||||
|
self.draw_list.add_rect(x + 1.0, y + 1.0, width - 2.0, height - 2.0, COLOR_INPUT_BG);
|
||||||
|
|
||||||
|
// Draw text
|
||||||
|
let text_x = x + padding;
|
||||||
|
let text_y = y + padding;
|
||||||
|
let mut cx = text_x;
|
||||||
|
for ch in buffer.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw cursor if focused
|
||||||
|
if self.focused_id == Some(id) {
|
||||||
|
let cursor_x = text_x + self.cursor_pos as f32 * gw;
|
||||||
|
self.draw_list.add_rect(cursor_x, text_y, 1.0, gh, COLOR_CURSOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.layout.advance_line();
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scroll Panel ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Begin a scrollable panel. Content drawn between begin/end will be clipped
|
||||||
|
/// to the panel bounds. `content_height` is the total height of the content
|
||||||
|
/// inside the panel (used to compute scrollbar size).
|
||||||
|
pub fn begin_scroll_panel(&mut self, id: u32, x: f32, y: f32, w: f32, h: f32, content_height: f32) {
|
||||||
|
let scrollbar_w = 12.0_f32;
|
||||||
|
let panel_inner_w = w - scrollbar_w;
|
||||||
|
|
||||||
|
// Handle mouse wheel when hovering over the panel
|
||||||
|
let hovered = self.mouse_in_rect(x, y, w, h);
|
||||||
|
let scroll_delta = if hovered && self.mouse_scroll.abs() > 0.0 {
|
||||||
|
-self.mouse_scroll * 20.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get or create scroll offset, apply delta and clamp
|
||||||
|
let scroll = self.scroll_offsets.entry(id).or_insert(0.0);
|
||||||
|
*scroll += scroll_delta;
|
||||||
|
let max_scroll = (content_height - h).max(0.0);
|
||||||
|
*scroll = scroll.clamp(0.0, max_scroll);
|
||||||
|
let current_scroll = *scroll;
|
||||||
|
|
||||||
|
// Draw panel background
|
||||||
|
self.draw_list.add_rect(x, y, w, h, COLOR_PANEL);
|
||||||
|
|
||||||
|
// Draw scrollbar track
|
||||||
|
let sb_x = x + panel_inner_w;
|
||||||
|
self.draw_list.add_rect(sb_x, y, scrollbar_w, h, COLOR_SCROLLBAR_BG);
|
||||||
|
|
||||||
|
// Draw scrollbar thumb
|
||||||
|
if content_height > h {
|
||||||
|
let thumb_ratio = h / content_height;
|
||||||
|
let thumb_h = (thumb_ratio * h).max(16.0);
|
||||||
|
let scroll_ratio = if max_scroll > 0.0 { current_scroll / max_scroll } else { 0.0 };
|
||||||
|
let thumb_y = y + scroll_ratio * (h - thumb_h);
|
||||||
|
self.draw_list.add_rect(sb_x, thumb_y, scrollbar_w, thumb_h, COLOR_SCROLLBAR_THUMB);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push scissor rect for content clipping
|
||||||
|
self.draw_list.push_scissor(x as u32, y as u32, panel_inner_w as u32, h as u32);
|
||||||
|
|
||||||
|
// Set cursor inside panel, offset by scroll
|
||||||
|
self.layout = crate::layout::LayoutState::new(x + self.layout.padding, y + self.layout.padding - current_scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End a scrollable panel. Pops the scissor rect.
|
||||||
|
pub fn end_scroll_panel(&mut self) {
|
||||||
|
self.draw_list.pop_scissor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag and Drop ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Begin dragging an item. Call this when the user presses down on a draggable element.
|
||||||
|
/// `id` identifies the source, `payload` is an arbitrary u64 value transferred on drop.
|
||||||
|
pub fn begin_drag(&mut self, id: u32, payload: u64) {
|
||||||
|
if self.mouse_clicked {
|
||||||
|
self.dragging = Some((id, payload));
|
||||||
|
self.drag_start = (self.mouse_x, self.mouse_y);
|
||||||
|
self.drag_started = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if a drag operation is currently in progress (past the threshold).
|
||||||
|
pub fn is_dragging(&self) -> bool {
|
||||||
|
if let Some(_) = self.dragging {
|
||||||
|
self.drag_started
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End the current drag operation. Returns `Some((source_id, payload))` if a drag
|
||||||
|
/// was in progress and the mouse was released, otherwise `None`.
|
||||||
|
pub fn end_drag(&mut self) -> Option<(u32, u64)> {
|
||||||
|
// Update drag started state based on threshold
|
||||||
|
if let Some(_) = self.dragging {
|
||||||
|
if !self.drag_started {
|
||||||
|
let dx = self.mouse_x - self.drag_start.0;
|
||||||
|
let dy = self.mouse_y - self.drag_start.1;
|
||||||
|
if (dx * dx + dy * dy).sqrt() >= DRAG_THRESHOLD {
|
||||||
|
self.drag_started = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.mouse_released {
|
||||||
|
let result = if self.drag_started { self.dragging } else { None };
|
||||||
|
self.dragging = None;
|
||||||
|
self.drag_started = false;
|
||||||
|
result
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declare a drop target region. If a drag is released over this target,
|
||||||
|
/// returns the payload that was dropped. Otherwise returns `None`.
|
||||||
|
pub fn drop_target(&mut self, _id: u32, x: f32, y: f32, w: f32, h: f32) -> Option<u64> {
|
||||||
|
if self.mouse_released && self.drag_started {
|
||||||
|
if self.mouse_in_rect(x, y, w, h) {
|
||||||
|
if let Some((_src_id, payload)) = self.dragging {
|
||||||
|
return Some(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::ui_context::UiContext;
|
use crate::ui_context::{Key, UiContext};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_button_returns_false_when_not_clicked() {
|
fn test_button_returns_false_when_not_clicked() {
|
||||||
@@ -278,4 +505,241 @@ mod tests {
|
|||||||
let v2 = ctx.slider("test", -10.0, 0.0, 100.0);
|
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);
|
assert!((v2 - 0.0).abs() < 1e-6, "slider should clamp to min: got {}", v2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Text Input Tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_input_basic_typing() {
|
||||||
|
let mut ctx = UiContext::new(800.0, 600.0);
|
||||||
|
let mut buf = String::new();
|
||||||
|
|
||||||
|
// Click on the text input to focus it (at x=10, y=10, width=200)
|
||||||
|
ctx.begin_frame(15.0, 15.0, true);
|
||||||
|
ctx.text_input(1, &mut buf, 10.0, 10.0, 200.0);
|
||||||
|
|
||||||
|
// Now type some characters
|
||||||
|
ctx.begin_frame(15.0, 15.0, false);
|
||||||
|
ctx.input_char('H');
|
||||||
|
ctx.input_char('i');
|
||||||
|
let changed = ctx.text_input(1, &mut buf, 10.0, 10.0, 200.0);
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(buf, "Hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_input_backspace() {
|
||||||
|
let mut ctx = UiContext::new(800.0, 600.0);
|
||||||
|
let mut buf = String::from("abc");
|
||||||
|
|
||||||
|
// Focus — click far right so cursor goes to end (padding=4, gw=8, 3 chars → need x > 10+4+24=38)
|
||||||
|
ctx.begin_frame(50.0, 15.0, true);
|
||||||
|
ctx.text_input(1, &mut buf, 10.0, 10.0, 200.0);
|
||||||
|
|
||||||
|
// Backspace
|
||||||
|
ctx.begin_frame(50.0, 15.0, false);
|
||||||
|
ctx.input_key(Key::Backspace);
|
||||||
|
let changed = ctx.text_input(1, &mut buf, 10.0, 10.0, 200.0);
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(buf, "ab");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_input_cursor_movement() {
|
||||||
|
let mut ctx = UiContext::new(800.0, 600.0);
|
||||||
|
let mut buf = String::from("abc");
|
||||||
|
|
||||||
|
// Focus — click far right so cursor goes to end
|
||||||
|
ctx.begin_frame(50.0, 15.0, true);
|
||||||
|
ctx.text_input(1, &mut buf, 10.0, 10.0, 200.0);
|
||||||
|
assert_eq!(ctx.cursor_pos, 3); // cursor at end of "abc"
|
||||||
|
|
||||||
|
// Move cursor to beginning with Home
|
||||||
|
ctx.begin_frame(15.0, 15.0, false);
|
||||||
|
ctx.input_key(Key::Home);
|
||||||
|
ctx.text_input(1, &mut buf, 10.0, 10.0, 200.0);
|
||||||
|
assert_eq!(ctx.cursor_pos, 0);
|
||||||
|
|
||||||
|
// Type 'X' at beginning
|
||||||
|
ctx.begin_frame(15.0, 15.0, false);
|
||||||
|
ctx.input_char('X');
|
||||||
|
let changed = ctx.text_input(1, &mut buf, 10.0, 10.0, 200.0);
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(buf, "Xabc");
|
||||||
|
assert_eq!(ctx.cursor_pos, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_input_delete_key() {
|
||||||
|
let mut ctx = UiContext::new(800.0, 600.0);
|
||||||
|
let mut buf = String::from("abc");
|
||||||
|
|
||||||
|
// Focus
|
||||||
|
ctx.begin_frame(15.0, 15.0, true);
|
||||||
|
ctx.text_input(1, &mut buf, 10.0, 10.0, 200.0);
|
||||||
|
|
||||||
|
// Move to Home, then Delete
|
||||||
|
ctx.begin_frame(15.0, 15.0, false);
|
||||||
|
ctx.input_key(Key::Home);
|
||||||
|
ctx.input_key(Key::Delete);
|
||||||
|
let changed = ctx.text_input(1, &mut buf, 10.0, 10.0, 200.0);
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(buf, "bc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_input_arrow_keys() {
|
||||||
|
let mut ctx = UiContext::new(800.0, 600.0);
|
||||||
|
let mut buf = String::from("hello");
|
||||||
|
|
||||||
|
// Focus — click far right so cursor at end
|
||||||
|
ctx.begin_frame(100.0, 15.0, true);
|
||||||
|
ctx.text_input(1, &mut buf, 10.0, 10.0, 200.0);
|
||||||
|
|
||||||
|
// Left twice from end (pos 5→3)
|
||||||
|
ctx.begin_frame(100.0, 15.0, false);
|
||||||
|
ctx.input_key(Key::Left);
|
||||||
|
ctx.input_key(Key::Left);
|
||||||
|
ctx.text_input(1, &mut buf, 10.0, 10.0, 200.0);
|
||||||
|
assert_eq!(ctx.cursor_pos, 3);
|
||||||
|
|
||||||
|
// Type 'X' at position 3
|
||||||
|
ctx.begin_frame(100.0, 15.0, false);
|
||||||
|
ctx.input_char('X');
|
||||||
|
let changed = ctx.text_input(1, &mut buf, 10.0, 10.0, 200.0);
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(buf, "helXlo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_input_no_change_when_not_focused() {
|
||||||
|
let mut ctx = UiContext::new(800.0, 600.0);
|
||||||
|
let mut buf = String::from("test");
|
||||||
|
|
||||||
|
// Don't click on the input — mouse at (500, 500) far away
|
||||||
|
ctx.begin_frame(500.0, 500.0, true);
|
||||||
|
ctx.input_char('X');
|
||||||
|
let changed = ctx.text_input(1, &mut buf, 10.0, 10.0, 200.0);
|
||||||
|
assert!(!changed);
|
||||||
|
assert_eq!(buf, "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scroll Panel Tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scroll_offset_clamping() {
|
||||||
|
let mut ctx = UiContext::new(800.0, 600.0);
|
||||||
|
|
||||||
|
// Panel at (0,0), 200x100, content_height=300
|
||||||
|
// Scroll down a lot
|
||||||
|
ctx.begin_frame(100.0, 50.0, false);
|
||||||
|
ctx.set_scroll(-100.0); // scroll down
|
||||||
|
ctx.begin_scroll_panel(1, 0.0, 0.0, 200.0, 100.0, 300.0);
|
||||||
|
ctx.end_scroll_panel();
|
||||||
|
|
||||||
|
// max_scroll = 300 - 100 = 200; scroll should be clamped
|
||||||
|
let scroll = ctx.scroll_offsets.get(&1).copied().unwrap_or(0.0);
|
||||||
|
assert!(scroll >= 0.0 && scroll <= 200.0, "scroll={}", scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scroll_offset_does_not_go_negative() {
|
||||||
|
let mut ctx = UiContext::new(800.0, 600.0);
|
||||||
|
|
||||||
|
// Scroll up when already at top
|
||||||
|
ctx.begin_frame(100.0, 50.0, false);
|
||||||
|
ctx.set_scroll(100.0); // scroll up
|
||||||
|
ctx.begin_scroll_panel(1, 0.0, 0.0, 200.0, 100.0, 300.0);
|
||||||
|
ctx.end_scroll_panel();
|
||||||
|
|
||||||
|
let scroll = ctx.scroll_offsets.get(&1).copied().unwrap_or(0.0);
|
||||||
|
assert!((scroll - 0.0).abs() < 1e-6, "scroll should be 0, got {}", scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scroll_panel_content_clipping() {
|
||||||
|
let mut ctx = UiContext::new(800.0, 600.0);
|
||||||
|
|
||||||
|
ctx.begin_frame(100.0, 50.0, false);
|
||||||
|
ctx.begin_scroll_panel(1, 10.0, 20.0, 200.0, 100.0, 300.0);
|
||||||
|
|
||||||
|
// Draw some content inside
|
||||||
|
ctx.text("Inside scroll");
|
||||||
|
// Commands drawn inside should have a scissor rect
|
||||||
|
let has_scissor = ctx.draw_list.commands.iter().any(|c| c.scissor.is_some());
|
||||||
|
assert!(has_scissor, "commands inside scroll panel should have scissor rects");
|
||||||
|
|
||||||
|
ctx.end_scroll_panel();
|
||||||
|
|
||||||
|
// Commands drawn after end_scroll_panel should NOT have scissor
|
||||||
|
let cmds_before = ctx.draw_list.commands.len();
|
||||||
|
ctx.text("Outside scroll");
|
||||||
|
let new_cmds = &ctx.draw_list.commands[cmds_before..];
|
||||||
|
let has_scissor_after = new_cmds.iter().any(|c| c.scissor.is_some());
|
||||||
|
assert!(!has_scissor_after, "commands after end_scroll_panel should not have scissor");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag and Drop Tests ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_drag_start_and_end() {
|
||||||
|
let mut ctx = UiContext::new(800.0, 600.0);
|
||||||
|
|
||||||
|
// Frame 1: mouse down — begin drag
|
||||||
|
ctx.begin_frame(100.0, 100.0, true);
|
||||||
|
ctx.begin_drag(1, 42);
|
||||||
|
assert!(!ctx.is_dragging(), "should not be dragging yet (below threshold)");
|
||||||
|
let _ = ctx.end_drag();
|
||||||
|
|
||||||
|
// Frame 2: mouse moved past threshold, still down
|
||||||
|
ctx.begin_frame(110.0, 100.0, true);
|
||||||
|
let _ = ctx.end_drag();
|
||||||
|
assert!(ctx.is_dragging(), "should be dragging after moving past threshold");
|
||||||
|
|
||||||
|
// Frame 3: mouse released
|
||||||
|
ctx.begin_frame(120.0, 100.0, false);
|
||||||
|
let result = ctx.end_drag();
|
||||||
|
assert!(result.is_some());
|
||||||
|
let (src_id, payload) = result.unwrap();
|
||||||
|
assert_eq!(src_id, 1);
|
||||||
|
assert_eq!(payload, 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_drop_on_target() {
|
||||||
|
let mut ctx = UiContext::new(800.0, 600.0);
|
||||||
|
|
||||||
|
// Frame 1: begin drag
|
||||||
|
ctx.begin_frame(100.0, 100.0, true);
|
||||||
|
ctx.begin_drag(1, 99);
|
||||||
|
let _ = ctx.end_drag();
|
||||||
|
|
||||||
|
// Frame 2: move past threshold
|
||||||
|
ctx.begin_frame(110.0, 100.0, true);
|
||||||
|
let _ = ctx.end_drag();
|
||||||
|
|
||||||
|
// Frame 3: release over drop target at (200, 200, 50, 50)
|
||||||
|
ctx.begin_frame(220.0, 220.0, false);
|
||||||
|
let drop_result = ctx.drop_target(2, 200.0, 200.0, 50.0, 50.0);
|
||||||
|
assert_eq!(drop_result, Some(99));
|
||||||
|
let _ = ctx.end_drag();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_drop_outside_target() {
|
||||||
|
let mut ctx = UiContext::new(800.0, 600.0);
|
||||||
|
|
||||||
|
// Frame 1: begin drag
|
||||||
|
ctx.begin_frame(100.0, 100.0, true);
|
||||||
|
ctx.begin_drag(1, 77);
|
||||||
|
let _ = ctx.end_drag();
|
||||||
|
|
||||||
|
// Frame 2: move past threshold
|
||||||
|
ctx.begin_frame(110.0, 100.0, true);
|
||||||
|
let _ = ctx.end_drag();
|
||||||
|
|
||||||
|
// Frame 3: release far from drop target
|
||||||
|
ctx.begin_frame(500.0, 500.0, false);
|
||||||
|
let drop_result = ctx.drop_target(2, 200.0, 200.0, 50.0, 50.0);
|
||||||
|
assert_eq!(drop_result, None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user