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],
|
||||
}
|
||||
|
||||
/// 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 index_offset: u32,
|
||||
pub index_count: u32,
|
||||
/// Optional scissor rect for clipping. None means no clipping.
|
||||
pub scissor: Option<ScissorRect>,
|
||||
}
|
||||
|
||||
pub struct DrawList {
|
||||
pub vertices: Vec<DrawVertex>,
|
||||
pub indices: Vec<u16>,
|
||||
pub commands: Vec<DrawCommand>,
|
||||
scissor_stack: Vec<ScissorRect>,
|
||||
}
|
||||
|
||||
impl DrawList {
|
||||
@@ -26,6 +38,7 @@ impl DrawList {
|
||||
vertices: Vec::new(),
|
||||
indices: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
scissor_stack: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +46,23 @@ impl DrawList {
|
||||
self.vertices.clear();
|
||||
self.indices.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.
|
||||
@@ -67,6 +97,7 @@ impl DrawList {
|
||||
self.commands.push(DrawCommand {
|
||||
index_offset,
|
||||
index_count: 6,
|
||||
scissor: self.current_scissor(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -286,8 +286,13 @@ impl UiRenderer {
|
||||
pass.set_vertex_buffer(0, vertex_buffer.slice(..));
|
||||
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 {
|
||||
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(
|
||||
cmd.index_offset..cmd.index_offset + cmd.index_count,
|
||||
0,
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
use std::collections::HashMap;
|
||||
use crate::draw_list::DrawList;
|
||||
use crate::font::FontAtlas;
|
||||
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 hot: Option<u64>,
|
||||
pub active: Option<u64>,
|
||||
@@ -12,11 +24,23 @@ pub struct UiContext {
|
||||
pub mouse_down: bool,
|
||||
pub mouse_clicked: bool,
|
||||
pub mouse_released: bool,
|
||||
pub mouse_scroll: f32,
|
||||
pub screen_width: f32,
|
||||
pub screen_height: f32,
|
||||
pub font: FontAtlas,
|
||||
id_counter: u64,
|
||||
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 {
|
||||
@@ -32,11 +56,20 @@ impl UiContext {
|
||||
mouse_down: false,
|
||||
mouse_clicked: false,
|
||||
mouse_released: false,
|
||||
mouse_scroll: 0.0,
|
||||
screen_width: screen_w,
|
||||
screen_height: screen_h,
|
||||
font: FontAtlas::generate(),
|
||||
id_counter: 0,
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::ui_context::UiContext;
|
||||
use crate::ui_context::{Key, UiContext};
|
||||
|
||||
// Color palette
|
||||
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_CHECK_BG: [u8; 4] = [0x44, 0x44, 0x44, 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 {
|
||||
/// 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) {
|
||||
// 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)]
|
||||
mod tests {
|
||||
use crate::ui_context::UiContext;
|
||||
use crate::ui_context::{Key, UiContext};
|
||||
|
||||
#[test]
|
||||
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);
|
||||
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