GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
Update system configuration and interface modules
src/color.rs | 26 ++
src/layout.rs | 10 +-
src/main.rs | 6 +-
src/widget.rs | 827 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 857 insertions(+), 12 deletions(-)
diff --git a/src/color.rs b/src/color.rs
index 11fcfd1..0924293 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -48,3 +48,29 @@ pub const TEXT_FG: [f32; 4] = [0.80, 0.80, 0.85, 1.0];
pub const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
pub const TEXT_HEADER: [f32; 4] = [0.90, 0.90, 0.95, 1.0];
pub const TEXT_ACCENT: [f32; 4] = [0.56, 0.83, 0.56, 1.0];
+
+pub fn srgb_to_linear(c: f32) -> f32 {
+ c.powf(2.2)
+}
+
+pub fn linear_to_srgb(c: f32) -> f32 {
+ c.powf(1.0 / 2.2)
+}
+
+pub fn to_linear(color: [f32; 4]) -> [f32; 4] {
+ [
+ srgb_to_linear(color[0]),
+ srgb_to_linear(color[1]),
+ srgb_to_linear(color[2]),
+ color[3],
+ ]
+}
+
+pub fn to_srgb(color: [f32; 4]) -> [f32; 4] {
+ [
+ linear_to_srgb(color[0]),
+ linear_to_srgb(color[1]),
+ linear_to_srgb(color[2]),
+ color[3],
+ ]
+}
diff --git a/src/layout.rs b/src/layout.rs
index bcb8022..ed07b20 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -191,7 +191,15 @@ impl Section {
}
pub fn finish(&mut self, pc: &mut dyn RenderTarget) -> f32 {
- let border: [f32; 4] = [0.25, 0.25, 0.35, 1.0];
+ self.finish_focused(pc, false)
+ }
+
+ pub fn finish_focused(&mut self, pc: &mut dyn RenderTarget, focused: bool) -> f32 {
+ let border: [f32; 4] = if focused {
+ [0.30, 0.50, 0.32, 1.0] // Focused green
+ } else {
+ [0.25, 0.25, 0.35, 1.0] // Default gray
+ };
let x = self.left + 8.0;
let y = self.top + 22.0;
let w = self.cw - 16.0;
diff --git a/src/main.rs b/src/main.rs
index a898a69..ab280c9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -539,6 +539,7 @@ struct AppState {
state: Option<State>,
exit: bool,
redraw: bool,
+ ctrl_pressed: bool,
}
impl CompositorHandler for AppState {
@@ -844,9 +845,10 @@ impl KeyboardHandler for AppState {
_qh: &QueueHandle<Self>,
_keyboard: &wl_keyboard::WlKeyboard,
_serial: u32,
- _modifiers: smithay_client_toolkit::seat::keyboard::Modifiers,
+ modifiers: smithay_client_toolkit::seat::keyboard::Modifiers,
_layout: u32,
) {
+ self.ctrl_pressed = modifiers.ctrl;
}
}
@@ -878,6 +880,7 @@ impl AppState {
logical_key,
text: event.utf8.clone(),
repeat: false,
+ ctrl: self.ctrl_pressed,
};
if let Some(st) = &mut self.state {
@@ -982,6 +985,7 @@ fn main() {
state: None,
exit: false,
redraw: true,
+ ctrl_pressed: false,
};
// Perform a roundtrip to populate output_state with active output scales
diff --git a/src/widget.rs b/src/widget.rs
index 415d1da..b4d91d7 100644
--- a/src/widget.rs
+++ b/src/widget.rs
@@ -56,10 +56,157 @@ pub struct KeyEvent {
pub logical_key: Key,
pub text: Option<String>,
pub repeat: bool,
+ pub ctrl: bool,
}
use crate::colors;
+pub mod focus {
+ use super::Widget;
+ use std::cell::Cell;
+
+ thread_local! {
+ static FOCUSED_WIDGET: Cell<Option<*mut (dyn Widget + 'static)>> = Cell::new(None);
+ }
+
+ pub fn set_focused(w: &mut dyn Widget) {
+ FOCUSED_WIDGET.with(|cell| {
+ let new_ptr = unsafe {
+ std::mem::transmute::<*mut dyn Widget, *mut (dyn Widget + 'static)>(w as *mut dyn Widget)
+ };
+ if let Some(old_ptr) = cell.get() {
+ let old_data = old_ptr as *mut () as usize;
+ let new_data = new_ptr as *mut () as usize;
+ if old_data != new_data {
+ unsafe {
+ (*old_ptr).unfocus();
+ }
+ cell.set(Some(new_ptr));
+ }
+ } else {
+ cell.set(Some(new_ptr));
+ }
+ });
+ }
+
+ pub fn is_focused(w: &dyn Widget) -> bool {
+ FOCUSED_WIDGET.with(|cell| {
+ if let Some(ptr) = cell.get() {
+ let current_data = ptr as *const () as usize;
+ let query_data = w as *const dyn Widget as *const () as usize;
+ current_data == query_data
+ } else {
+ false
+ }
+ })
+ }
+
+ pub fn clear_focus() {
+ FOCUSED_WIDGET.with(|cell| {
+ if let Some(ptr) = cell.take() {
+ unsafe {
+ (*ptr).unfocus();
+ }
+ }
+ });
+ }
+
+ pub fn clear_if_matches(w: &dyn Widget) {
+ FOCUSED_WIDGET.with(|cell| {
+ if let Some(ptr) = cell.get() {
+ let current_data = ptr as *const () as usize;
+ let query_data = w as *const dyn Widget as *const () as usize;
+ if current_data == query_data {
+ cell.set(None);
+ }
+ }
+ });
+ }
+
+ pub fn has_focus() -> bool {
+ FOCUSED_WIDGET.with(|cell| cell.get().is_some())
+ }
+
+ pub fn link_parent_child(parent: &mut dyn Widget, child: &mut dyn Widget) {
+ let parent_ptr = unsafe {
+ std::mem::transmute::<*mut dyn Widget, *mut (dyn Widget + 'static)>(parent as *mut dyn Widget)
+ };
+ let child_ptr = unsafe {
+ std::mem::transmute::<*mut dyn Widget, *mut (dyn Widget + 'static)>(child as *mut dyn Widget)
+ };
+ parent.add_child(child_ptr);
+ child.set_parent(Some(parent_ptr));
+ }
+
+ pub fn navigate_focus(key: &super::Key, ctrl: bool) -> bool {
+ FOCUSED_WIDGET.with(|cell| {
+ let ptr = match cell.get() {
+ Some(p) => p,
+ None => return false,
+ };
+
+ unsafe {
+ match (key, ctrl) {
+ (super::Key::Character(c), true) if c == "u" || c == "U" => {
+ if let Some(parent_ptr) = (*ptr).parent() {
+ let parent_ref = &mut *parent_ptr;
+ set_focused(parent_ref);
+ parent_ref.focus();
+ return true;
+ }
+ }
+ (super::Key::Character(c), true) if c == "i" || c == "I" => {
+ let mut children = (*ptr).children();
+ if !children.is_empty() {
+ let child_ref = &mut *children[0];
+ set_focused(child_ref);
+ child_ref.focus();
+ return true;
+ }
+ }
+ (super::Key::Character(c), true) if c == "j" || c == "J" => {
+ if let Some(parent_ptr) = (*ptr).parent() {
+ let mut siblings = (*parent_ptr).children();
+ let current_idx = siblings.iter().position(|&x| {
+ let a = x as *mut () as usize;
+ let b = ptr as *mut () as usize;
+ a == b
+ });
+ if let Some(idx) = current_idx {
+ let next_idx = (idx + 1) % siblings.len();
+ let sibling_ref = &mut *siblings[next_idx];
+ set_focused(sibling_ref);
+ sibling_ref.focus();
+ return true;
+ }
+ }
+ }
+ (super::Key::Character(c), true) if c == "k" || c == "K" => {
+ if let Some(parent_ptr) = (*ptr).parent() {
+ let mut siblings = (*parent_ptr).children();
+ let current_idx = siblings.iter().position(|&x| {
+ let a = x as *mut () as usize;
+ let b = ptr as *mut () as usize;
+ a == b
+ });
+ if let Some(idx) = current_idx {
+ let prev_idx = if idx == 0 { siblings.len() - 1 } else { idx - 1 };
+ let sibling_ref = &mut *siblings[prev_idx];
+ set_focused(sibling_ref);
+ sibling_ref.focus();
+ return true;
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ false
+ })
+ }
+}
+
+
#[derive(Debug, Clone)]
pub struct TextLabel {
pub text: String,
@@ -139,8 +286,50 @@ pub trait Widget {
fn take_geom_toggle(&mut self) -> bool { false }
fn set_spreadsheet_data(&mut self, _headers: Vec<String>, _rows: Vec<Vec<String>>) {}
fn tick(&mut self, _dt: f32) -> bool { false }
+
+ fn parent(&self) -> Option<*mut (dyn Widget + 'static)> { None }
+ fn set_parent(&mut self, _parent: Option<*mut (dyn Widget + 'static)>) {}
+ fn children(&self) -> Vec<*mut (dyn Widget + 'static)> { vec![] }
+ fn add_child(&mut self, _child: *mut (dyn Widget + 'static)) {}
+ fn clear_children(&mut self) {}
+}
+
+#[derive(Clone)]
+pub struct Container {
+ pub parent: Option<*mut (dyn Widget + 'static)>,
+ pub children: Vec<*mut (dyn Widget + 'static)>,
+}
+
+impl Container {
+ pub fn new() -> Self {
+ Self { parent: None, children: Vec::new() }
+ }
+}
+
+impl Widget for Container {
+ fn rect(&self) -> (f32, f32, f32, f32) { (0.0, 0.0, 0.0, 0.0) }
+ fn set_rect(&mut self, _x: f32, _y: f32, _w: f32, _h: f32) {}
+ fn color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] }
+
+ fn focus(&mut self) {
+ focus::set_focused(self);
+ }
+ fn unfocus(&mut self) {}
+
+ fn parent(&self) -> Option<*mut (dyn Widget + 'static)> { self.parent }
+ fn set_parent(&mut self, parent: Option<*mut (dyn Widget + 'static)>) { self.parent = parent; }
+ fn children(&self) -> Vec<*mut (dyn Widget + 'static)> { self.children.clone() }
+ fn add_child(&mut self, child: *mut (dyn Widget + 'static)) { self.children.push(child); }
+ fn clear_children(&mut self) { self.children.clear(); }
}
+impl Drop for Container {
+ fn drop(&mut self) {
+ focus::clear_if_matches(self);
+ }
+}
+
+
pub struct Header {
x: f32, y: f32, w: f32, h: f32,
hovered: bool,
@@ -1015,6 +1204,7 @@ impl Widget for MenuBar {
fn focus(&mut self) {
self.focused = true;
+ focus::set_focused(self);
}
fn unfocus(&mut self) {
@@ -1140,6 +1330,12 @@ impl Widget for MenuBar {
}
}
+impl Drop for MenuBar {
+ fn drop(&mut self) {
+ focus::clear_if_matches(self);
+ }
+}
+
pub enum ButtonKind {
Primary,
Reset,
@@ -1392,7 +1588,10 @@ impl Widget for Node {
fn set_hovered(&mut self, v: bool) { self.hovered = v; }
fn hovered(&self) -> bool { self.hovered }
- fn focus(&mut self) { self.selected = true; }
+ fn focus(&mut self) {
+ self.selected = true;
+ focus::set_focused(self);
+ }
fn unfocus(&mut self) { self.selected = false; }
fn node_params(&self) -> Vec<(String, String, String)> { self.parameters.clone() }
@@ -1513,6 +1712,12 @@ impl Widget for Node {
fn take_geom_toggle(&mut self) -> bool { std::mem::take(&mut self.geom_toggled) }
}
+impl Drop for Node {
+ fn drop(&mut self) {
+ focus::clear_if_matches(self);
+ }
+}
+
fn menu_item_x(title: &str, menu_items: &[String], idx: usize) -> f32 {
let mut x = 8.0;
if !title.is_empty() {
@@ -1979,11 +2184,13 @@ pub struct Spinbox {
row_x: f32,
row_w: f32,
pub decimals: u32,
+ pub parent: Option<*mut (dyn Widget + 'static)>,
+ pub children: Vec<*mut (dyn Widget + 'static)>,
}
impl Spinbox {
pub fn new(value: i32, min: i32, max: i32, step: i32) -> Self {
- Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, value, min, max, step, editing: false, edit_buffer: String::new(), hovered: false, hover_dec: false, hover_inc: false, label: None, unit: None, row_x: 0.0, row_w: 0.0, decimals: 0 }
+ Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, value, min, max, step, editing: false, edit_buffer: String::new(), hovered: false, hover_dec: false, hover_inc: false, label: None, unit: None, row_x: 0.0, row_w: 0.0, decimals: 0, parent: None, children: Vec::new() }
}
pub fn with_label(mut self, label: &str) -> Self {
@@ -2085,6 +2292,7 @@ impl Widget for Spinbox {
} else {
self.edit_buffer = self.value.to_string();
}
+ focus::set_focused(self);
}
fn unfocus(&mut self) {
@@ -2254,6 +2462,18 @@ impl Widget for Spinbox {
});
labels
}
+
+ fn parent(&self) -> Option<*mut (dyn Widget + 'static)> { self.parent }
+ fn set_parent(&mut self, parent: Option<*mut (dyn Widget + 'static)>) { self.parent = parent; }
+ fn children(&self) -> Vec<*mut (dyn Widget + 'static)> { self.children.clone() }
+ fn add_child(&mut self, child: *mut (dyn Widget + 'static)) { self.children.push(child); }
+ fn clear_children(&mut self) { self.children.clear(); }
+}
+
+impl Drop for Spinbox {
+ fn drop(&mut self) {
+ focus::clear_if_matches(self);
+ }
}
#[derive(Debug, Clone)]
@@ -2268,6 +2488,8 @@ pub struct ColorSelector {
row_x: f32,
row_w: f32,
pub command: String,
+ pub parent: Option<*mut (dyn Widget + 'static)>,
+ pub children: Vec<*mut (dyn Widget + 'static)>,
}
impl ColorSelector {
@@ -2283,6 +2505,8 @@ impl ColorSelector {
row_x: 0.0,
row_w: 0.0,
command: "clear-color-interface".to_string(),
+ parent: None,
+ children: Vec::new(),
}
}
@@ -2319,7 +2543,12 @@ impl Widget for ColorSelector {
fn top_room(&self) -> f32 { if self.label.is_some() { 16.0 } else { 0.0 } }
fn color(&self) -> [f32; 4] {
- [self.color[0] as f32 / 255.0, self.color[1] as f32 / 255.0, self.color[2] as f32 / 255.0, 1.0]
+ colors::to_linear([
+ self.color[0] as f32 / 255.0,
+ self.color[1] as f32 / 255.0,
+ self.color[2] as f32 / 255.0,
+ 1.0,
+ ])
}
fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
@@ -2339,12 +2568,17 @@ impl Widget for ColorSelector {
.arg(&hex)
.output()
{
- if output.status.success() {
- let stdout_str = String::from_utf8_lossy(&output.stdout);
- if let Some(new_color) = parse_hex(stdout_str.trim()) {
- self.color = new_color;
+ let stdout_str = String::from_utf8_lossy(&output.stdout);
+ let mut found_color = None;
+ for line in stdout_str.lines().rev() {
+ if let Some(c) = parse_hex(line.trim()) {
+ found_color = Some(c);
+ break;
}
}
+ if let Some(new_color) = found_color {
+ self.color = new_color;
+ }
}
return true;
}
@@ -2359,6 +2593,7 @@ impl Widget for ColorSelector {
fn focus(&mut self) {
self.editing = true;
self.edit_buffer = format!("#{:02x}{:02x}{:02x}", self.color[0], self.color[1], self.color[2]);
+ focus::set_focused(self);
}
fn unfocus(&mut self) {
@@ -2426,8 +2661,13 @@ impl Widget for ColorSelector {
}
let pick_x = self.x + self.w * 0.65;
let pick_w = self.w * 0.35;
- let (r, g, b, _) = (self.color[0] as f32 / 255.0, self.color[1] as f32 / 255.0, self.color[2] as f32 / 255.0, 1.0);
- quads.push((pick_x, self.y, pick_w, self.h, [r, g, b, 1.0]));
+ let linear_c = colors::to_linear([
+ self.color[0] as f32 / 255.0,
+ self.color[1] as f32 / 255.0,
+ self.color[2] as f32 / 255.0,
+ 1.0,
+ ]);
+ quads.push((pick_x, self.y, pick_w, self.h, linear_c));
quads
}
@@ -2452,6 +2692,18 @@ impl Widget for ColorSelector {
});
labels
}
+
+ fn parent(&self) -> Option<*mut (dyn Widget + 'static)> { self.parent }
+ fn set_parent(&mut self, parent: Option<*mut (dyn Widget + 'static)>) { self.parent = parent; }
+ fn children(&self) -> Vec<*mut (dyn Widget + 'static)> { self.children.clone() }
+ fn add_child(&mut self, child: *mut (dyn Widget + 'static)) { self.children.push(child); }
+ fn clear_children(&mut self) { self.children.clear(); }
+}
+
+impl Drop for ColorSelector {
+ fn drop(&mut self) {
+ focus::clear_if_matches(self);
+ }
}
const BREADCRUMB_PADDING: f32 = 8.0;
@@ -2964,6 +3216,8 @@ pub struct ScrollBox {
pub viewport_y: f32,
pub viewport_h: f32,
hovered: bool,
+ pub parent: Option<*mut (dyn Widget + 'static)>,
+ pub children: Vec<*mut (dyn Widget + 'static)>,
}
impl ScrollBox {
@@ -2975,6 +3229,8 @@ impl ScrollBox {
viewport_y: 0.0,
viewport_h: 0.0,
hovered: false,
+ parent: None,
+ children: Vec::new(),
}
}
@@ -3004,6 +3260,21 @@ impl Widget for ScrollBox {
fn hovered(&self) -> bool { self.hovered }
fn hover_highlight(&self) -> Option<[f32; 4]> { None }
+ fn focus(&mut self) {
+ focus::set_focused(self);
+ }
+ fn unfocus(&mut self) {}
+
+ fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+ if button == MouseButton::Left && state == ElementState::Pressed {
+ if self.hit_test(px, py) {
+ self.focus();
+ return true;
+ }
+ }
+ false
+ }
+
fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
let was = self.hovered;
self.hovered = self.hit_test(px, py);
@@ -3033,7 +3304,13 @@ impl Widget for ScrollBox {
quads.push((self.x, self.y, self.w, self.h, [0.08, 0.08, 0.12, 0.3]));
// Border lines
- let box_border_color = [0.18, 0.18, 0.24, 1.0];
+ let box_border_color = if focus::is_focused(self) {
+ [0.30, 0.50, 0.32, 1.0] // Focused green
+ } else if self.hovered {
+ [0.25, 0.25, 0.35, 1.0] // Hovered
+ } else {
+ [0.18, 0.18, 0.24, 1.0] // Default
+ };
quads.push((self.x, self.y, self.w, 1.0, box_border_color)); // Top
quads.push((self.x, self.y + self.h - 1.0, self.w, 1.0, box_border_color)); // Bottom
quads.push((self.x, self.y, 1.0, self.h, box_border_color)); // Left
@@ -3061,6 +3338,60 @@ impl Widget for ScrollBox {
quads
}
+
+ fn keyboard_input(&mut self, event: &KeyEvent) -> bool {
+ if !focus::is_focused(self) {
+ return false;
+ }
+ if event.state != ElementState::Pressed {
+ return false;
+ }
+ if event.ctrl {
+ match &event.logical_key {
+ Key::Character(c) if c == "n" || c == "N" => {
+ let old_scroll = self.scroll_y;
+ let max_scroll = (self.content_h - self.viewport_h).max(0.0);
+ self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max_scroll);
+ (self.scroll_y - old_scroll).abs() > 0.01
+ }
+ Key::Character(c) if c == "p" || c == "P" => {
+ let old_scroll = self.scroll_y;
+ let max_scroll = (self.content_h - self.viewport_h).max(0.0);
+ self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max_scroll);
+ (self.scroll_y - old_scroll).abs() > 0.01
+ }
+ _ => false,
+ }
+ } else {
+ match &event.logical_key {
+ Key::Named(NamedKey::ArrowDown) => {
+ let old_scroll = self.scroll_y;
+ let max_scroll = (self.content_h - self.viewport_h).max(0.0);
+ self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max_scroll);
+ (self.scroll_y - old_scroll).abs() > 0.01
+ }
+ Key::Named(NamedKey::ArrowUp) => {
+ let old_scroll = self.scroll_y;
+ let max_scroll = (self.content_h - self.viewport_h).max(0.0);
+ self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max_scroll);
+ (self.scroll_y - old_scroll).abs() > 0.01
+ }
+ _ => false,
+ }
+ }
+ }
+
+ fn parent(&self) -> Option<*mut (dyn Widget + 'static)> { self.parent }
+ fn set_parent(&mut self, parent: Option<*mut (dyn Widget + 'static)>) { self.parent = parent; }
+ fn children(&self) -> Vec<*mut (dyn Widget + 'static)> { self.children.clone() }
+ fn add_child(&mut self, child: *mut (dyn Widget + 'static)) { self.children.push(child); }
+ fn clear_children(&mut self) { self.children.clear(); }
+}
+
+impl Drop for ScrollBox {
+ fn drop(&mut self) {
+ focus::clear_if_matches(self);
+ }
}
#[cfg(test)]
@@ -3346,6 +3677,36 @@ mod tests {
// 30 >= 22.0 and 30 + 24 <= 118.0, so it should return Some(30.0)
assert_eq!(sb.get_item_draw_y(60.0, 24.0), Some(30.0));
}
+
+ #[test]
+ fn test_dropdown_widget_interaction() {
+ let options = vec!["Option A".to_string(), "Option B".to_string(), "Option C".to_string()];
+ let mut dd = Dropdown::new(options, 0);
+ dd.set_rect(10.0, 10.0, 100.0, 24.0);
+
+ // 1. Initial State
+ assert!(!dd.open);
+ assert_eq!(dd.selected, 0);
+
+ // 2. Click trigger area opens dropdown
+ let input_changed = dd.mouse_input(MouseButton::Left, ElementState::Pressed, 50.0, 20.0);
+ assert!(input_changed);
+ assert!(dd.open);
+
+ // 3. Hovering options inside popover
+ // Popover starts at y = 10 + 24 = 34. Options are of height 24 each.
+ // Hover option B at y = 34 + 24 + 12 = 70.0
+ let move_changed = dd.cursor_moved(50.0, 70.0);
+ assert!(move_changed);
+ assert_eq!(dd.hovered_item, Some(1));
+
+ // 4. Click option B selects it and closes dropdown
+ let select_changed = dd.mouse_input(MouseButton::Left, ElementState::Pressed, 50.0, 70.0);
+ assert!(select_changed);
+ assert!(!dd.open);
+ assert_eq!(dd.selected, 1);
+ assert!(dd.take_change());
+ }
}
// Generic text item layout wrapper
@@ -3427,3 +3788,449 @@ impl StyledLabel {
}
}
+#[derive(Debug, Clone)]
+pub struct ScrollingList {
+ pub scroll_box: ScrollBox,
+ pub item_height: f32,
+ pub item_gap: f32,
+}
+
+impl ScrollingList {
+ pub fn new(item_height: f32, item_gap: f32) -> Self {
+ Self {
+ scroll_box: ScrollBox::new(),
+ item_height,
+ item_gap,
+ }
+ }
+
+ pub fn update_bounds(&mut self, count: usize, viewport_y: f32, viewport_h: f32) {
+ let item_height_full = self.item_height + self.item_gap;
+ let content_h = count as f32 * item_height_full;
+ self.scroll_box.update_bounds(content_h, viewport_y, viewport_h);
+ }
+
+ pub fn get_item_draw_y(&self, idx: usize, offset: f32) -> Option<f32> {
+ let item_height_full = self.item_height + self.item_gap;
+ let virtual_y = idx as f32 * item_height_full + offset;
+ self.scroll_box.get_item_draw_y(virtual_y, self.item_height)
+ }
+
+ pub fn scroll_y(&self) -> f32 {
+ self.scroll_box.scroll_y
+ }
+
+ pub fn set_scroll_y(&mut self, val: f32) {
+ self.scroll_box.scroll_y = val;
+ }
+}
+
+impl Default for ScrollingList {
+ fn default() -> Self {
+ Self::new(24.0, 4.0)
+ }
+}
+
+impl Widget for ScrollingList {
+ fn rect(&self) -> (f32, f32, f32, f32) {
+ self.scroll_box.rect()
+ }
+
+ fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
+ self.scroll_box.set_rect(x, y, w, h);
+ }
+
+ fn color(&self) -> [f32; 4] {
+ self.scroll_box.color()
+ }
+
+ fn set_hovered(&mut self, v: bool) {
+ self.scroll_box.set_hovered(v);
+ }
+
+ fn hovered(&self) -> bool {
+ self.scroll_box.hovered()
+ }
+
+ fn hover_highlight(&self) -> Option<[f32; 4]> {
+ self.scroll_box.hover_highlight()
+ }
+
+ fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+ self.scroll_box.cursor_moved(px, py)
+ }
+
+ fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
+ self.scroll_box.mouse_wheel(delta, px, py)
+ }
+
+ fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+ self.scroll_box.mouse_input(button, state, px, py)
+ }
+
+ fn focus(&mut self) {
+ self.scroll_box.focus();
+ }
+
+ fn unfocus(&mut self) {
+ self.scroll_box.unfocus();
+ }
+
+ fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ self.scroll_box.extra_quads()
+ }
+
+ fn keyboard_input(&mut self, event: &KeyEvent) -> bool {
+ self.scroll_box.keyboard_input(event)
+ }
+
+ fn parent(&self) -> Option<*mut (dyn Widget + 'static)> { self.scroll_box.parent() }
+ fn set_parent(&mut self, parent: Option<*mut (dyn Widget + 'static)>) { self.scroll_box.set_parent(parent); }
+ fn children(&self) -> Vec<*mut (dyn Widget + 'static)> { self.scroll_box.children() }
+ fn add_child(&mut self, child: *mut (dyn Widget + 'static)) { self.scroll_box.add_child(child); }
+ fn clear_children(&mut self) { self.scroll_box.clear_children(); }
+}
+
+unsafe impl Send for Container {}
+unsafe impl Sync for Container {}
+unsafe impl Send for ScrollBox {}
+unsafe impl Sync for ScrollBox {}
+unsafe impl Send for Spinbox {}
+unsafe impl Sync for Spinbox {}
+unsafe impl Send for ColorSelector {}
+unsafe impl Sync for ColorSelector {}
+unsafe impl Send for ScrollingList {}
+unsafe impl Sync for ScrollingList {}
+
+// ── Dropdown Widget ──
+
+#[derive(Debug, Clone)]
+pub struct Dropdown {
+ x: f32, y: f32, w: f32, h: f32,
+ pub options: Vec<String>,
+ pub selected: usize,
+ pub open: bool,
+ hovered: bool,
+ hovered_item: Option<usize>,
+ just_changed: bool,
+ label: Option<String>,
+ row_x: f32,
+ row_w: f32,
+ pub parent: Option<*mut (dyn Widget + 'static)>,
+ pub children: Vec<*mut (dyn Widget + 'static)>,
+}
+
+impl Dropdown {
+ pub fn new(options: Vec<String>, selected: usize) -> Self {
+ Self {
+ x: 0.0, y: 0.0, w: 0.0, h: 0.0,
+ options,
+ selected,
+ open: false,
+ hovered: false,
+ hovered_item: None,
+ just_changed: false,
+ label: None,
+ row_x: 0.0,
+ row_w: 0.0,
+ parent: None,
+ children: Vec::new(),
+ }
+ }
+
+ pub fn with_label(mut self, label: &str) -> Self {
+ self.label = Some(label.to_string());
+ self
+ }
+
+ pub fn set_label(&mut self, label: &str) {
+ self.label = Some(label.to_string());
+ }
+
+ pub fn take_change(&mut self) -> bool {
+ let changed = self.just_changed;
+ self.just_changed = false;
+ changed
+ }
+
+ pub fn render_popover(&self, pc: &mut dyn crate::layout::RenderTarget) {
+ if !self.open { return; }
+
+ let dy = self.y + self.h;
+ let dh = self.options.len() as f32 * 24.0;
+
+ pc.rect([0.22, 0.22, 0.28, 1.0], self.x, dy, self.w, dh); // border
+ pc.rect([0.06, 0.06, 0.09, 1.0], self.x + 1.0, dy + 1.0, self.w - 2.0, dh - 2.0); // bg
+
+ if let Some(h_idx) = self.hovered_item {
+ let iy = dy + h_idx as f32 * 24.0;
+ pc.rect([0.20, 0.40, 0.65, 0.6], self.x + 2.0, iy + 2.0, self.w - 4.0, 20.0);
+ }
+
+ for (idx, opt) in self.options.iter().enumerate() {
+ let iy = dy + idx as f32 * 24.0 + (24.0 - 12.0) / 2.0;
+ let text_color = if self.hovered_item == Some(idx) {
+ [0xff, 0xff, 0xff]
+ } else if self.selected == idx {
+ [0x3a, 0x9a, 0xff]
+ } else {
+ [0xcc, 0xcc, 0xd4]
+ };
+
+ pc.text(
+ opt,
+ self.x + 8.0,
+ iy,
+ 12.0,
+ [
+ text_color[0] as f32 / 255.0,
+ text_color[1] as f32 / 255.0,
+ text_color[2] as f32 / 255.0,
+ 1.0,
+ ],
+ );
+ }
+ }
+}
+
+impl Default for Dropdown {
+ fn default() -> Self {
+ Self::new(Vec::new(), 0)
+ }
+}
+
+impl Widget for Dropdown {
+ fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+ fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+ fn set_row_rect(&mut self, x: f32, w: f32) { self.row_x = x; self.row_w = w; }
+ fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
+
+ fn color(&self) -> [f32; 4] {
+ [0.08, 0.08, 0.12, 1.0]
+ }
+
+ fn hit_test(&self, px: f32, py: f32) -> bool {
+ let (x, y, w, h) = self.rect();
+ let hx = if self.row_w > 0.0 { self.row_x } else { x };
+ let hw = if self.row_w > 0.0 { self.row_w } else { w };
+ let (hy, hh) = if self.label.is_some() {
+ (y - 18.0, h + 18.0)
+ } else {
+ (y, h)
+ };
+ if self.open {
+ let dy = y + h;
+ let dh = self.options.len() as f32 * 24.0;
+ let hit_trigger = px >= hx && px <= hx + hw && py >= hy && py <= hy + hh;
+ let hit_popover = px >= x && px <= x + w && py >= dy && py <= dy + dh;
+ hit_trigger || hit_popover
+ } else {
+ px >= hx && px <= hx + hw && py >= hy && py <= hy + hh
+ }
+ }
+
+ fn top_room(&self) -> f32 { if self.label.is_some() { 18.0 } else { 0.0 } }
+
+ fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+ let was_hovered = self.hovered;
+ let was_hovered_item = self.hovered_item;
+
+ self.hovered = self.hit_test(px, py);
+ self.hovered_item = None;
+
+ if self.open {
+ let (x, y, w, h) = self.rect();
+ let dy = y + h;
+ let dh = self.options.len() as f32 * 24.0;
+ if px >= x && px <= x + w && py >= dy && py <= dy + dh {
+ let idx = ((py - dy) / 24.0) as usize;
+ if idx < self.options.len() {
+ self.hovered_item = Some(idx);
+ }
+ }
+ }
+
+ self.hovered != was_hovered || self.hovered_item != was_hovered_item
+ }
+
+ fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+ if button != MouseButton::Left || state != ElementState::Pressed { return false; }
+
+ let (x, y, w, h) = self.rect();
+ let (hy, hh) = if self.label.is_some() {
+ (y - 18.0, h + 18.0)
+ } else {
+ (y, h)
+ };
+ let dy = y + h;
+ let dh = self.options.len() as f32 * 24.0;
+
+ let inside_trigger = px >= x && px <= x + w && py >= hy && py <= hy + hh;
+ let inside_popover = self.open && px >= x && px <= x + w && py >= dy && py <= dy + dh;
+
+ if inside_popover {
+ let idx = ((py - dy) / 24.0) as usize;
+ if idx < self.options.len() {
+ if self.selected != idx {
+ self.selected = idx;
+ self.just_changed = true;
+ }
+ }
+ self.open = false;
+ return true;
+ }
+
+ if inside_trigger {
+ self.open = !self.open;
+ if self.open {
+ self.focus();
+ } else {
+ self.unfocus();
+ }
+ return true;
+ }
+
+ if self.open {
+ self.open = false;
+ return true;
+ }
+
+ false
+ }
+
+ fn focus(&mut self) {
+ focus::set_focused(self);
+ }
+
+ fn unfocus(&mut self) {
+ self.open = false;
+ }
+
+ fn keyboard_input(&mut self, event: &KeyEvent) -> bool {
+ if event.state != ElementState::Pressed { return false; }
+ if !self.open {
+ if let Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) = event.logical_key {
+ self.open = true;
+ self.hovered_item = Some(self.selected);
+ return true;
+ }
+ return false;
+ }
+
+ match event.logical_key {
+ Key::Named(NamedKey::ArrowDown) => {
+ let current = self.hovered_item.unwrap_or(self.selected);
+ if current + 1 < self.options.len() {
+ self.hovered_item = Some(current + 1);
+ } else {
+ self.hovered_item = Some(0);
+ }
+ true
+ }
+ Key::Named(NamedKey::ArrowUp) => {
+ let current = self.hovered_item.unwrap_or(self.selected);
+ if current > 0 {
+ self.hovered_item = Some(current - 1);
+ } else {
+ self.hovered_item = Some(self.options.len() - 1);
+ }
+ true
+ }
+ Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
+ if let Some(idx) = self.hovered_item {
+ if self.selected != idx {
+ self.selected = idx;
+ self.just_changed = true;
+ }
+ }
+ self.open = false;
+ true
+ }
+ Key::Named(NamedKey::Escape) => {
+ self.open = false;
+ true
+ }
+ _ => false
+ }
+ }
+
+ fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ let mut quads = Vec::new();
+
+ if self.hovered && !self.open {
+ let (hy, hh) = if self.label.is_some() {
+ (self.y - 18.0, self.h + 18.0)
+ } else {
+ (self.y, self.h)
+ };
+ let hx = if self.row_w > 0.0 { self.row_x } else { self.x };
+ let hw = if self.row_w > 0.0 { self.row_w } else { self.w };
+ quads.push((hx, hy, hw, hh, [1.0, 1.0, 1.0, 0.04]));
+ }
+
+ let bg_color = [0.08, 0.08, 0.12, 1.0];
+ let border_color = if self.open {
+ [0.30, 0.50, 0.32, 1.0]
+ } else if self.hovered {
+ [0.25, 0.25, 0.35, 1.0]
+ } else {
+ [0.18, 0.18, 0.24, 1.0]
+ };
+
+ quads.push((self.x, self.y, self.w, self.h, border_color));
+ quads.push((self.x + 1.0, self.y + 1.0, self.w - 2.0, self.h - 2.0, bg_color));
+
+ quads
+ }
+
+ fn text_labels(&self) -> Vec<TextLabel> {
+ let mut labels = Vec::new();
+
+ if let Some(ref label) = self.label {
+ labels.push(TextLabel {
+ text: label.clone(),
+ x: self.x + 4.0,
+ y: self.y - 14.0,
+ font_size: 11.0,
+ color: [0x83, 0x83, 0x8a],
+ });
+ }
+
+ let selected_text = self.options.get(self.selected).cloned().unwrap_or_default();
+ labels.push(TextLabel {
+ text: selected_text,
+ x: self.x + 8.0,
+ y: self.y + (self.h - 12.0) / 2.0,
+ font_size: 12.0,
+ color: [0xdd, 0xdd, 0xe2],
+ });
+
+ labels.push(TextLabel {
+ text: "▼".to_string(),
+ x: self.x + self.w - 18.0,
+ y: self.y + (self.h - 10.0) / 2.0,
+ font_size: 10.0,
+ color: [0x83, 0x83, 0x8a],
+ });
+
+ labels
+ }
+
+ fn parent(&self) -> Option<*mut (dyn Widget + 'static)> { self.parent }
+ fn set_parent(&mut self, parent: Option<*mut (dyn Widget + 'static)>) { self.parent = parent; }
+ fn children(&self) -> Vec<*mut (dyn Widget + 'static)> { self.children.clone() }
+ fn add_child(&mut self, child: *mut (dyn Widget + 'static)) { self.children.push(child); }
+ fn clear_children(&mut self) { self.children.clear(); }
+}
+
+impl Drop for Dropdown {
+ fn drop(&mut self) {
+ focus::clear_if_matches(self);
+ }
+}
+
+unsafe impl Send for Dropdown {}
+unsafe impl Sync for Dropdown {}
+
+