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:
2026-03-26 07:32:55 +09:00
parent 9f5f2df07c
commit 63e59c0544
4 changed files with 567 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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