use std::sync::mpsc;
use crate::anim::{Anim, Spring, SpringValue};
use crate::draw::{ClipRect, Color, Scene, TextAlign, TextDraw, grid_width};
use crate::input::Input;
use crate::items::UiItem;
use crate::loader::UiMsg;
use crate::styles::{DrawLabel, StyleCtx, StyleId, StyleRegistry, Transition, push_component};
use crate::textures::TextureInfo;
pub struct TickCtx<'a> {
pub input: &'a crate::input::Input,
pub dt: f32,
pub registry: &'a StyleRegistry,
pub scene: &'a mut Scene,
pub tex_registry: &'a dyn TextureInfo,
pub tx: &'a mpsc::Sender<UiMsg>,
pub default_style: Option<StyleId>,
pub clip: Option<ClipRect>,
pub pw: f32,
pub ph: f32,
pub click_consumed: bool,
pub osk_open: bool,
pub pane: Option<&'a mut crate::draw::Pane>,
pub tab_pages: &'a std::collections::HashMap<String, usize>,
}
#[derive(Clone, Copy)]
pub struct KeyNav {
pub nav_x: f32,
pub enter: bool,
pub space: bool,
pub escape: bool,
}
impl KeyNav {
const fn from_input(input: &crate::input::Input) -> Self {
Self {
nav_x: if input.arrow_right {
1.0
} else if input.arrow_left {
-1.0
} else {
0.0
},
enter: input.enter,
space: input.space,
escape: input.escape,
}
}
}
const Z_CONTAINER: f32 = 0.1; const Z_WIDGET: f32 = 0.5; const Z_BAR: f32 = 0.7; const Z_OVERLAY: f32 = 0.9; const Z_POPOUT: f32 = 2.0; const Z_POPOUT_W: f32 = 2.5; const Z_DROPDOWN: f32 = 3.0; const Z_DROPDOWN_W: f32 = 3.5;
pub use crate::draw::Rect;
#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub struct WidgetState {
pub hovered: bool, pub pressed: bool, pub focused: bool, pub disabled: bool, pub checked: bool, pub grabbed: bool, pub open: bool, }
#[derive(Clone, Copy)]
pub enum Axis {
Horizontal,
}
pub struct ClickResult {
pub clicked: bool,
pub hovered: bool,
pub pressed: bool,
pub visual: WidgetState,
}
pub struct DragResult {
pub changed: bool,
pub value: f32, pub visual: WidgetState,
}
pub struct TextResult {
pub changed: bool,
pub value: String,
pub submitted: bool,
pub visual: WidgetState,
pub new_cursor: usize,
pub new_sel_anchor: Option<usize>,
}
pub fn click(rect: Rect, input: &Input, focused: bool, disabled: bool) -> ClickResult {
click_blocked(rect, input, focused, disabled, false)
}
pub fn click_blocked(
rect: Rect,
input: &Input,
focused: bool,
disabled: bool,
blocked: bool,
) -> ClickResult {
if disabled {
return ClickResult {
clicked: false,
hovered: false,
pressed: false,
visual: WidgetState {
disabled: true,
..Default::default()
},
};
}
let mouse_hovered = !blocked && rect.contains(input.mouse_x, input.mouse_y);
let hovered = mouse_hovered || focused;
let pressed = (mouse_hovered && input.left_pressed) || (focused && input.space);
let clicked =
(mouse_hovered && input.left_just_released) || (focused && (input.space || input.enter));
let visual = WidgetState {
hovered,
pressed,
focused: false,
..Default::default()
};
ClickResult {
clicked,
hovered,
pressed,
visual,
}
}
pub fn drag(
rect: Rect,
axis: Axis,
current: f32,
grabbing: bool,
input: &Input,
focused: bool,
disabled: bool,
) -> (DragResult, bool) {
if disabled {
return (
DragResult {
changed: false,
value: current,
visual: WidgetState {
disabled: true,
..Default::default()
},
},
false,
);
}
let mouse_hovered = rect.contains(input.mouse_x, input.mouse_y);
let hovered = mouse_hovered || focused;
let new_grab = (grabbing && input.left_pressed) || (mouse_hovered && input.left_just_pressed);
let visual = WidgetState {
hovered,
pressed: new_grab,
focused: false,
grabbed: new_grab,
..Default::default()
};
let (changed, value) = if new_grab {
let norm = match axis {
Axis::Horizontal => ((input.mouse_x - rect.x) / rect.w).clamp(0.0, 1.0),
};
(true, norm)
} else {
(false, current)
};
(
DragResult {
changed,
value,
visual,
},
new_grab,
)
}
pub fn scroll(current: f32, total: f32, visible: f32, rect: Rect, input: &Input) -> f32 {
if !rect.contains(input.mouse_x, input.mouse_y) {
return current;
}
let max = (total - visible).max(0.0);
input.scroll_delta.mul_add(40.0, current).clamp(0.0, max)
}
fn delete_char_range(s: &str, range: std::ops::Range<usize>) -> (String, usize) {
let mut result = String::with_capacity(s.len());
for (i, ch) in s.chars().enumerate() {
if !range.contains(&i) {
result.push(ch);
}
}
(result, range.start)
}
pub fn text_input(
rect: Rect,
value: &str,
focused: bool,
max_len: Option<usize>,
input: &Input,
disabled: bool,
cursor_pos: usize,
sel_anchor: Option<usize>,
multiline: bool,
) -> TextResult {
if disabled {
return TextResult {
changed: false,
value: value.to_string(),
submitted: false,
visual: WidgetState {
disabled: true,
..Default::default()
},
new_cursor: cursor_pos,
new_sel_anchor: None,
};
}
let hovered = rect.contains(input.mouse_x, input.mouse_y);
let visual = WidgetState {
hovered,
pressed: hovered && input.left_pressed,
focused,
..Default::default()
};
if !focused {
return TextResult {
changed: false,
value: value.to_string(),
submitted: false,
visual,
new_cursor: cursor_pos,
new_sel_anchor: None,
};
}
let mut s = value.to_string();
let mut changed = false;
let mut cursor = cursor_pos.min(s.chars().count());
let mut anchor = sel_anchor;
let sel_range = |cur: usize, anc: Option<usize>| -> Option<std::ops::Range<usize>> {
anc.and_then(|a| {
if a == cur {
None
} else {
Some(cur.min(a)..cur.max(a))
}
})
};
if !input.text_input.is_empty() {
if let Some(range) = sel_range(cursor, anchor) {
(s, cursor) = delete_char_range(&s, range);
anchor = None;
changed = true;
}
for &ch in &input.text_input {
let under_limit = max_len.is_none_or(|m| s.chars().count() < m);
if under_limit {
let byte_off = s.char_indices().nth(cursor).map_or(s.len(), |(i, _)| i);
s.insert(byte_off, ch);
cursor += 1;
changed = true;
}
}
}
if input.backspace {
if let Some(range) = sel_range(cursor, anchor) {
(s, cursor) = delete_char_range(&s, range);
anchor = None;
changed = true;
} else if cursor > 0 {
let byte_off = s.char_indices().nth(cursor - 1).map_or(0, |(i, _)| i);
s.remove(byte_off);
cursor -= 1;
changed = true;
}
}
let submitted = if multiline {
input.ctrl && input.enter
} else {
input.enter
};
if multiline && input.enter && !input.ctrl {
if let Some(range) = sel_range(cursor, anchor) {
(s, cursor) = delete_char_range(&s, range);
anchor = None;
}
let byte_off = s.char_indices().nth(cursor).map_or(s.len(), |(i, _)| i);
s.insert(byte_off, '\n');
cursor += 1;
changed = true;
}
TextResult {
changed,
value: s,
submitted,
visual,
new_cursor: cursor,
new_sel_anchor: anchor,
}
}
#[derive(Default)]
pub struct TickResult {
pub consumes_nav: bool,
pub expired: bool,
pub request_focus: Option<String>,
}
pub enum ItemState {
Button(ButtonState),
Toggle(ButtonState),
Slider(SliderState),
TextBox(TextBoxState),
ScrollList(ScrollState),
ScrollPane(ScrollPaneState),
Dropdown(DropdownState),
RadioGroup(RadioGroupState),
Bar(BarState),
Popout(PopoutState),
Actor(ActorState),
Toast(ToastState),
Tab(TabState),
None,
}
impl ItemState {
pub fn visual(&self) -> WidgetState {
match self {
Self::Button(s) | Self::Toggle(s) => s.visual,
Self::Slider(s) => s.visual,
Self::TextBox(s) => s.visual,
Self::ScrollList(s) => s.visual,
Self::ScrollPane(s) => s.visual,
Self::Dropdown(s) => s.visual,
Self::Popout(s) => s.visual,
Self::Actor(s) => s.visual,
Self::Toast(s) => s.visual,
Self::RadioGroup(_) | Self::Bar(_) | Self::Tab(_) | Self::None => {
WidgetState::default()
} }
}
}
pub fn item_state_for(item: &UiItem) -> ItemState {
match item {
UiItem::Button(_) => ItemState::Button(ButtonState::new()),
UiItem::Toggle(t) => ItemState::Toggle(ButtonState::for_toggle(t)),
UiItem::Slider(s) => ItemState::Slider(SliderState::for_slider(s)),
UiItem::TextBox(tb) => ItemState::TextBox(TextBoxState::for_textbox(tb)),
UiItem::ScrollList(l) => ItemState::ScrollList(ScrollState::with_children(l.items.len())),
UiItem::ScrollPane(p) => ItemState::ScrollPane(ScrollPaneState::with_children(&p.items)),
UiItem::Dropdown(dd) => ItemState::Dropdown(DropdownState::for_dropdown(dd)),
UiItem::RadioGroup(rg) => ItemState::RadioGroup(RadioGroupState::for_radio(rg)),
UiItem::Bar(b) => ItemState::Bar(BarState::with_children(&b.items)),
UiItem::Popout(p) => ItemState::Popout(PopoutState::with_children(p.items.len())),
UiItem::Actor(a) => ItemState::Actor(ActorState::new(a, 0.0, 0.0)),
UiItem::Toast(t) => ItemState::Toast(ToastState::new(t.duration)),
UiItem::Tab(t) => ItemState::Tab(TabState::with_pages(t)),
_ => ItemState::None,
}
}
pub struct ButtonState {
pub visual: WidgetState,
pub anim: Anim,
pub hover_timer: f32,
pub checked: bool,
}
impl ButtonState {
pub fn new() -> Self {
Self {
visual: WidgetState::default(),
anim: Anim::new(Spring::BOUNCY),
hover_timer: 0.0,
checked: false,
}
}
pub fn for_toggle(item: &crate::items::Toggle) -> Self {
Self {
checked: item.default_checked,
..Self::new()
}
}
}
pub struct SliderState {
pub visual: WidgetState,
pub anim: Anim,
pub grabbing: bool,
pub nav_grabbed: bool,
pub hover_timer: f32,
pub value: f32,
}
impl SliderState {
pub fn new() -> Self {
Self {
visual: WidgetState::default(),
anim: Anim::new(Spring::BOUNCY),
grabbing: false,
nav_grabbed: false,
hover_timer: 0.0,
value: 0.0,
}
}
pub fn for_slider(item: &crate::items::Slider) -> Self {
Self {
value: item.default_value,
..Self::new()
}
}
}
pub struct TextBoxState {
pub visual: WidgetState,
pub anim: Anim,
pub blink: f32,
pub cursor_pos: usize,
pub hover_timer: f32,
pub nav_grabbed: bool,
pub sel_anchor: Option<usize>,
pub scroll_offset: f32,
pub scroll_y: f32,
pub text: String,
}
impl TextBoxState {
pub fn new() -> Self {
Self {
visual: WidgetState::default(),
anim: Anim::new(Spring::BOUNCY),
blink: 0.0,
cursor_pos: 0,
hover_timer: 0.0,
nav_grabbed: false,
sel_anchor: None,
scroll_offset: 0.0,
scroll_y: 0.0,
text: String::new(),
}
}
pub fn for_textbox(item: &crate::items::TextBox) -> Self {
let text = item.default_text.clone();
let cursor_pos = text.chars().count();
Self {
cursor_pos,
text,
..Self::new()
}
}
}
pub struct ScrollState {
pub visual: WidgetState,
pub scroll: f32,
pub max_scroll: f32,
pub children: Vec<ButtonState>,
}
impl ScrollState {
pub fn new() -> Self {
Self {
visual: WidgetState::default(),
scroll: 0.0,
max_scroll: 0.0,
children: Vec::new(),
}
}
pub fn with_children(count: usize) -> Self {
Self {
children: (0..count).map(|_| ButtonState::new()).collect(),
..Self::new()
}
}
}
pub struct ScrollPaneState {
pub visual: WidgetState,
pub scroll: f32,
pub children: Vec<ItemState>,
pub manual_positions: Vec<(f32, f32)>,
}
impl ScrollPaneState {
pub fn with_children(items: &[crate::items::UiItem]) -> Self {
let manual_positions = items
.iter()
.map(|item| (crate::logic::item_x(item), crate::logic::item_y(item)))
.collect();
Self {
visual: WidgetState::default(),
scroll: 0.0,
children: items.iter().map(item_state_for).collect(),
manual_positions,
}
}
}
pub struct TabState {
pub active_page: usize,
pub page_scrolls: Vec<f32>,
pub children: Vec<Vec<ItemState>>,
pub last_page: usize,
pub slide_x: f32,
pub slide_vel: f32,
}
impl TabState {
pub fn with_pages(tab: &crate::items::Tab) -> Self {
Self {
active_page: 0,
page_scrolls: vec![0.0; tab.pages.len()],
children: tab
.pages
.iter()
.map(|p| p.items.iter().map(item_state_for).collect())
.collect(),
last_page: 0,
slide_x: 0.0,
slide_vel: 0.0,
}
}
}
pub struct BarState {
pub children: Vec<ButtonState>, pub button_centers: Vec<(f32, f32)>,
pub manual_positions: Vec<(f32, f32)>,
}
impl BarState {
pub fn with_children(items: &[crate::items::UiItem]) -> Self {
let manual_positions = items
.iter()
.map(|item| (crate::logic::item_x(item), crate::logic::item_y(item)))
.collect();
let count = items
.iter()
.filter(|i| matches!(i, crate::items::UiItem::Button(_)))
.count();
Self {
children: (0..count).map(|_| ButtonState::new()).collect(),
button_centers: Vec::new(),
manual_positions,
}
}
}
pub struct DropdownState {
pub visual: WidgetState,
pub open: bool,
pub hover_timer: f32,
pub children: Vec<ButtonState>,
pub selected: usize,
}
impl DropdownState {
pub fn new() -> Self {
Self {
visual: WidgetState::default(),
open: false,
hover_timer: 0.0,
children: Vec::new(),
selected: 0,
}
}
pub fn for_dropdown(item: &crate::items::Dropdown) -> Self {
Self {
children: (0..item.items.len()).map(|_| ButtonState::new()).collect(),
selected: item
.default_selected
.min(item.items.len().saturating_sub(1)),
..Self::new()
}
}
}
pub struct PopoutState {
pub visual: WidgetState,
pub open: bool,
pub spring: SpringValue,
pub hover_timer: f32,
pub children: Vec<ButtonState>,
pub child_centers: Vec<(f32, f32)>,
}
impl PopoutState {
pub fn new() -> Self {
Self {
visual: WidgetState::default(),
open: false,
spring: SpringValue::new(0.0, Spring::BOUNCY),
hover_timer: 0.0,
children: Vec::new(),
child_centers: Vec::new(),
}
}
pub fn with_children(count: usize) -> Self {
Self {
children: (0..count).map(|_| ButtonState::new()).collect(),
..Self::new()
}
}
}
pub struct RadioGroupState {
pub children: Vec<ButtonState>,
pub selected: usize,
}
impl RadioGroupState {
pub fn for_radio(item: &crate::items::RadioGroup) -> Self {
Self {
children: (0..item.items.len()).map(|_| ButtonState::new()).collect(),
selected: item
.default_selected
.min(item.items.len().saturating_sub(1)),
}
}
}
pub struct ActorState {
pub visual: WidgetState,
pub pos_x: f32,
pub pos_y: f32,
pub trail: crate::anim::TrailBuffer,
pub active_override: Option<ActiveOverride>,
pub time: f32,
}
pub struct ActiveOverride {
pub trigger: crate::items::Trigger,
pub action: crate::items::Action,
pub override_time: f32,
pub latched: bool,
}
impl ActorState {
pub fn new(actor: &crate::items::Actor, ox: f32, oy: f32) -> Self {
Self {
visual: WidgetState::default(),
pos_x: actor.origin_x + ox,
pos_y: actor.origin_y + oy,
trail: crate::anim::TrailBuffer::new(actor.trail_capacity),
active_override: None,
time: 0.0,
}
}
}
fn draw_tooltip(
tooltip: Option<&str>,
ctx: &mut TickCtx,
rect: Rect,
hovered: bool,
hover_timer: &mut f32,
) {
if let (Some(text), Some(style)) = (tooltip, ctx.default_style) {
if hovered {
*hover_timer += ctx.dt;
} else {
*hover_timer = 0.0;
}
if *hover_timer > 0.5 {
let tooltip_rect = Rect::new(rect.x, rect.y + rect.h + 8.0, 200.0, 36.0);
ctx.registry.draw(
style,
tooltip_rect,
WidgetState::default(),
DrawLabel {
label: Some(text),
z: 0.9,
clip: None,
alpha: 1.0,
},
ctx.scene,
ctx.tex_registry,
);
}
}
}
#[inline]
fn default_font_size(h: f32) -> f32 {
(h * 0.45).clamp(10.0, 48.0)
}
fn push_widget_text(
scene: &mut Scene,
rect: Rect,
vs: &crate::styles::VisualState,
text: &str,
clip: Option<ClipRect>,
z: f32,
) {
let font_size = vs.font_size.unwrap_or_else(|| default_font_size(rect.h));
let color = vs.text_color.unwrap_or(Color::WHITE);
scene.push_text(&TextDraw {
text,
x: rect.x + vs.text_offset_x,
y: (rect.h - font_size).mul_add(0.5, rect.y) + vs.text_offset_y,
w: rect.w,
size: font_size,
color,
align: vs.text_align,
font: vs.font.as_deref(),
bold: vs.bold,
italic: vs.italic,
clip,
z,
});
}
fn apply_visual(visual: &mut WidgetState, anim: &mut Anim, new_visual: WidgetState, dt: f32) {
*visual = new_visual;
anim.transition(*visual);
anim.update(dt);
}
pub fn tick_button(
btn: &crate::items::Button,
state: &mut ButtonState,
ctx: &mut TickCtx,
focused: bool,
) -> TickResult {
let rect = Rect::new(btn.x, btn.y, btn.width, btn.height);
let r = click_blocked(rect, ctx.input, focused, btn.disabled, ctx.click_consumed);
apply_visual(
&mut state.visual,
&mut state.anim,
WidgetState {
hovered: r.hovered,
pressed: r.pressed,
focused,
disabled: btn.disabled,
..Default::default()
},
ctx.dt,
);
if r.clicked {
crate::logic::send_press(ctx.tx, &btn.action, &btn.id, false);
}
let vs = push_component(
ctx.scene,
StyleCtx {
registry: ctx.registry,
tex_registry: ctx.tex_registry,
},
btn.style,
Transition {
from: state.anim.prev_state(),
to: state.visual,
t: state.anim.t(),
},
rect,
Z_WIDGET,
ctx.clip,
);
push_widget_text(ctx.scene, rect, &vs, &btn.text, ctx.clip, Z_WIDGET);
draw_tooltip(
btn.tooltip.as_deref(),
ctx,
rect,
r.hovered,
&mut state.hover_timer,
);
TickResult::default()
}
pub fn tick_toggle(
toggle: &crate::items::Toggle,
state: &mut ButtonState,
ctx: &mut TickCtx,
focused: bool,
) -> TickResult {
let rect = Rect::new(toggle.x, toggle.y, toggle.width, toggle.height);
let r = click_blocked(
rect,
ctx.input,
focused,
toggle.disabled,
ctx.click_consumed,
);
if r.clicked {
state.checked = !state.checked;
match &toggle.action {
crate::loader::ToggleAction::Custom(tag) => {
let _ = ctx.tx.send(UiMsg::Toggle(tag.clone(), state.checked));
}
crate::loader::ToggleAction::Print => println!("{}: {}", toggle.id, state.checked),
}
}
apply_visual(
&mut state.visual,
&mut state.anim,
WidgetState {
hovered: r.hovered,
pressed: r.pressed,
focused,
disabled: toggle.disabled,
checked: state.checked,
..Default::default()
},
ctx.dt,
);
let style = if state.checked {
toggle.style_on
} else {
toggle.style_off
};
let vs = push_component(
ctx.scene,
StyleCtx {
registry: ctx.registry,
tex_registry: ctx.tex_registry,
},
style,
Transition {
from: state.anim.prev_state(),
to: state.visual,
t: state.anim.t(),
},
rect,
Z_WIDGET,
ctx.clip,
);
push_widget_text(ctx.scene, rect, &vs, &toggle.text, ctx.clip, Z_WIDGET);
draw_tooltip(
toggle.tooltip.as_deref(),
ctx,
rect,
r.hovered,
&mut state.hover_timer,
);
TickResult::default()
}
pub fn tick_slider(
slider: &crate::items::Slider,
state: &mut SliderState,
ctx: &mut TickCtx,
focused: bool,
nav: KeyNav,
) -> TickResult {
let rect = Rect::new(slider.x, slider.y, slider.width, slider.height);
let (min, max) = (slider.min, slider.max);
let current = ((state.value - min) / (max - min)).clamp(0.0, 1.0);
let (r, new_grabbing) = drag(
rect,
Axis::Horizontal,
current,
state.grabbing,
ctx.input,
focused,
false,
);
state.grabbing = new_grabbing;
if r.changed {
let raw = r.value.mul_add(slider.max - slider.min, slider.min);
let stepped = slider.step.map_or(raw, |step| (raw / step).round() * step);
let new_val = stepped.clamp(slider.min, slider.max);
if (new_val - state.value).abs() > f32::EPSILON {
state.value = new_val;
match &slider.action {
crate::loader::SliderAction::Custom(tag) => {
let _ = ctx.tx.send(UiMsg::Slider(tag.clone(), new_val));
}
crate::loader::SliderAction::Print => println!("{}: {}", slider.id, new_val),
}
}
}
if focused {
if nav.space || nav.enter {
state.nav_grabbed = !state.nav_grabbed;
}
if state.nav_grabbed && nav.nav_x != 0.0 {
let step = slider.step.unwrap_or((slider.max - slider.min) / 20.0);
let new_val = nav
.nav_x
.mul_add(step, state.value)
.clamp(slider.min, slider.max);
if (new_val - state.value).abs() > f32::EPSILON {
state.value = new_val;
match &slider.action {
crate::loader::SliderAction::Custom(tag) => {
let _ = ctx.tx.send(UiMsg::Slider(tag.clone(), new_val));
}
crate::loader::SliderAction::Print => println!("{}: {}", slider.id, new_val),
}
}
}
} else {
state.nav_grabbed = false;
}
apply_visual(
&mut state.visual,
&mut state.anim,
WidgetState {
hovered: r.visual.hovered,
pressed: r.visual.pressed || state.nav_grabbed,
focused,
grabbed: state.grabbing || state.nav_grabbed,
..Default::default()
},
ctx.dt,
);
let track_rect = rect;
let thumb_size = slider.height;
let thumb_x = slider.x + current * (slider.width - thumb_size);
let thumb_rect = Rect::new(thumb_x, slider.y, thumb_size, thumb_size);
ctx.registry.draw(
slider.style_track,
track_rect,
state.visual,
DrawLabel {
label: None,
z: Z_WIDGET,
clip: ctx.clip,
alpha: 1.0,
},
ctx.scene,
ctx.tex_registry,
);
push_component(
ctx.scene,
StyleCtx {
registry: ctx.registry,
tex_registry: ctx.tex_registry,
},
slider.style_thumb,
Transition {
from: state.anim.prev_state(),
to: state.visual,
t: state.anim.t(),
},
thumb_rect,
Z_WIDGET,
ctx.clip,
);
draw_tooltip(
slider.tooltip.as_deref(),
ctx,
rect,
r.visual.hovered,
&mut state.hover_timer,
);
TickResult {
consumes_nav: state.nav_grabbed,
..Default::default()
}
}
fn text_align_start(
rect_x: f32,
rect_w: f32,
char_count: usize,
ch_w: f32,
align: TextAlign,
) -> f32 {
let text_w = char_count as f32 * ch_w;
match align {
TextAlign::Left => rect_x,
TextAlign::Center => (rect_w - text_w).mul_add(0.5, rect_x),
TextAlign::Right => rect_x + rect_w - text_w,
}
}
fn update_textbox_cursor(
state: &mut TextBoxState,
value: &str,
nav_x: f32,
dt: f32,
focused: bool,
shift: bool,
) {
if focused {
let char_count = value.chars().count();
if nav_x < 0.0 && state.cursor_pos > 0 {
if shift {
if state.sel_anchor.is_none() {
state.sel_anchor = Some(state.cursor_pos);
}
} else {
state.sel_anchor = None;
}
state.cursor_pos -= 1;
state.blink = 0.0;
}
if nav_x > 0.0 && state.cursor_pos < char_count {
if shift {
if state.sel_anchor.is_none() {
state.sel_anchor = Some(state.cursor_pos);
}
} else {
state.sel_anchor = None;
}
state.cursor_pos += 1;
state.blink = 0.0;
}
state.blink = (state.blink + dt) % 1.0;
} else {
state.blink = 0.0;
state.sel_anchor = None;
state.cursor_pos = state.cursor_pos.min(value.chars().count());
}
}
fn cursor_to_line_col(value: &str, cursor_pos: usize) -> (usize, usize) {
let mut remaining = cursor_pos;
for (i, line) in value.split('\n').enumerate() {
let line_chars = line.chars().count();
if remaining <= line_chars {
return (i, remaining);
}
remaining -= line_chars + 1; }
let lines: Vec<&str> = value.split('\n').collect();
(
lines.len().saturating_sub(1),
lines.last().map_or(0, |l| l.chars().count()),
)
}
fn line_col_to_cursor(value: &str, line: usize, col: usize) -> usize {
let mut pos = 0usize;
for (i, l) in value.split('\n').enumerate() {
if i == line {
return pos + col.min(l.chars().count());
}
pos += l.chars().count() + 1;
}
value.chars().count()
}
fn merge_clips(outer: Option<ClipRect>, inner: Option<ClipRect>) -> Option<ClipRect> {
match (outer, inner) {
(None, i) => i,
(o, None) => o,
(Some(a), Some(b)) => {
let x1 = a.x.max(b.x);
let y1 = a.y.max(b.y);
let x2 = (a.x + a.w).min(b.x + b.w);
let y2 = (a.y + a.h).min(b.y + b.h);
if x2 > x1 && y2 > y1 {
Some(ClipRect {
x: x1,
y: y1,
w: x2 - x1,
h: y2 - y1,
})
} else {
Some(ClipRect {
x: x1,
y: y1,
w: 0.0,
h: 0.0,
})
}
}
}
}
fn draw_textbox_text_clipped(
ctx: &mut TickCtx,
rect: Rect,
label: &str,
vs: &crate::styles::VisualState,
font_size_override: Option<f32>,
scroll_offset: f32,
align: TextAlign,
clip: Option<ClipRect>,
) {
let font_size = font_size_override
.or(vs.font_size)
.unwrap_or_else(|| default_font_size(rect.h));
let color = vs.text_color.unwrap_or(Color::WHITE);
ctx.scene.push_text(&TextDraw {
text: label,
x: rect.x + vs.text_offset_x - scroll_offset,
y: (rect.h - font_size).mul_add(0.5, rect.y) + vs.text_offset_y,
w: rect.w,
size: font_size,
color,
align,
font: vs.font.as_deref(),
bold: vs.bold,
italic: vs.italic,
clip,
z: Z_WIDGET,
});
}
pub fn tick_textbox(
tb: &crate::items::TextBox,
state: &mut TextBoxState,
ctx: &mut TickCtx,
focused: bool,
nav: KeyNav,
) -> TickResult {
let rect = Rect::new(tb.x, tb.y, tb.width, tb.height);
if !ctx.osk_open {
if focused {
if !state.nav_grabbed && (nav.enter || nav.space) {
state.nav_grabbed = true;
state.blink = 0.0;
} else if state.nav_grabbed && nav.escape {
state.nav_grabbed = false;
}
} else {
state.nav_grabbed = false;
}
} else if !focused {
state.nav_grabbed = false;
}
let font_size_approx = tb.font_size.unwrap_or_else(|| default_font_size(rect.h));
let ch_w = font_size_approx * 0.55;
let char_count = state.text.chars().count();
let style_ref = ctx.registry.get(if focused {
tb.style_focus
} else {
tb.style_idle
});
let text_offset_x_approx = style_ref.idle.text_offset_x;
let text_start_base = rect.x + text_offset_x_approx - state.scroll_offset;
let hovered = rect.contains(ctx.input.mouse_x, ctx.input.mouse_y);
let request_focus = if ctx.input.left_just_pressed && hovered && !ctx.click_consumed {
let offset = (ctx.input.mouse_x - text_start_base).max(0.0);
state.cursor_pos = ((offset / ch_w).round() as usize).min(char_count);
state.sel_anchor = None;
state.blink = 0.0;
state.nav_grabbed = true;
Some(tb.id.clone())
} else {
None
};
if focused && ctx.input.left_pressed && !ctx.input.left_just_pressed && hovered {
if state.sel_anchor.is_none() {
state.sel_anchor = Some(state.cursor_pos);
}
let offset = (ctx.input.mouse_x - text_start_base).max(0.0);
let drag_pos = ((offset / ch_w).round() as usize).min(char_count);
if drag_pos != state.cursor_pos {
state.cursor_pos = drag_pos;
state.blink = 0.0;
}
}
let editing = focused && state.nav_grabbed;
let r = text_input(
rect,
&state.text,
editing && !ctx.osk_open,
tb.max_len,
ctx.input,
false,
state.cursor_pos,
state.sel_anchor,
tb.multiline,
);
state.sel_anchor = r.new_sel_anchor;
if r.changed {
state.text.clone_from(&r.value);
state.cursor_pos = r.new_cursor;
state.blink = 0.0;
match &tb.on_change {
crate::loader::TextBoxAction::Custom(tag) => {
let _ = ctx.tx.send(UiMsg::TextChanged(tag.clone(), r.value));
}
crate::loader::TextBoxAction::Print => println!("{}: {}", tb.id, state.text),
crate::loader::TextBoxAction::None => {}
}
}
if r.submitted {
match &tb.on_submit {
crate::loader::TextBoxAction::Custom(tag) => {
let _ = ctx
.tx
.send(UiMsg::TextSubmitted(tag.clone(), state.text.clone()));
}
crate::loader::TextBoxAction::Print => println!("{}: submitted {}", tb.id, state.text),
crate::loader::TextBoxAction::None => {}
}
}
if editing && !ctx.osk_open && ctx.input.ctrl && ctx.input.key_a {
state.sel_anchor = Some(0);
state.cursor_pos = state.text.chars().count();
state.blink = 0.0;
}
if editing && !ctx.osk_open && ctx.input.ctrl && !tb.password {
let sel_range: Option<std::ops::Range<usize>> = state.sel_anchor.and_then(|a| {
let lo = state.cursor_pos.min(a);
let hi = state.cursor_pos.max(a);
if lo < hi { Some(lo..hi) } else { None }
});
if (ctx.input.key_c || ctx.input.key_x)
&& let Some(ref range) = sel_range
&& let Ok(mut cb) = arboard::Clipboard::new()
{
let selected: String = state
.text
.chars()
.skip(range.start)
.take(range.end - range.start)
.collect();
let _ = cb.set_text(selected);
}
if ctx.input.key_x
&& let Some(range) = sel_range
{
let (new_val, new_cur) = delete_char_range(&state.text, range);
state.text = new_val;
state.cursor_pos = new_cur;
state.sel_anchor = None;
state.blink = 0.0;
match &tb.on_change {
crate::loader::TextBoxAction::Custom(tag) => {
let _ = ctx
.tx
.send(UiMsg::TextChanged(tag.clone(), state.text.clone()));
}
crate::loader::TextBoxAction::Print => println!("{}: {}", tb.id, state.text),
crate::loader::TextBoxAction::None => {}
}
}
if ctx.input.key_v
&& let Ok(mut cb) = arboard::Clipboard::new()
&& let Ok(text) = cb.get_text()
{
if let Some(a) = state.sel_anchor {
let lo = state.cursor_pos.min(a);
let hi = state.cursor_pos.max(a);
if lo < hi {
let (new_val, new_cur) = delete_char_range(&state.text, lo..hi);
state.text = new_val;
state.cursor_pos = new_cur;
state.sel_anchor = None;
}
}
for ch in text.chars() {
if ch.is_control() && ch != '\n' {
continue;
}
if !tb.multiline && ch == '\n' {
continue;
}
let under_limit = tb.max_len.is_none_or(|m| state.text.chars().count() < m);
if under_limit {
let byte_off = state
.text
.char_indices()
.nth(state.cursor_pos)
.map_or(state.text.len(), |(i, _)| i);
state.text.insert(byte_off, ch);
state.cursor_pos += 1;
}
}
state.blink = 0.0;
match &tb.on_change {
crate::loader::TextBoxAction::Custom(tag) => {
let _ = ctx
.tx
.send(UiMsg::TextChanged(tag.clone(), state.text.clone()));
}
crate::loader::TextBoxAction::Print => println!("{}: {}", tb.id, state.text),
crate::loader::TextBoxAction::None => {}
}
}
}
if editing && !tb.multiline {
let cursor_x = state.cursor_pos as f32 * ch_w;
let pad = 4.0;
if cursor_x < state.scroll_offset {
state.scroll_offset = cursor_x;
} else if cursor_x > 2.0f32.mul_add(-pad, state.scroll_offset + rect.w) {
state.scroll_offset = cursor_x - 2.0f32.mul_add(-pad, rect.w);
}
state.scroll_offset = state.scroll_offset.max(0.0);
} else if !focused || tb.multiline {
state.scroll_offset = 0.0;
}
let cursor_nav = if ctx.osk_open { 0.0 } else { nav.nav_x };
let text_clone = state.text.clone();
update_textbox_cursor(
state,
&text_clone,
cursor_nav,
ctx.dt,
editing,
ctx.input.shift,
);
if tb.multiline && editing && !ctx.osk_open {
let moved = if ctx.input.arrow_up || ctx.input.arrow_down {
let (cur_line, cur_col) = cursor_to_line_col(&state.text, state.cursor_pos);
let total_lines = state.text.split('\n').count();
let new_line = if ctx.input.arrow_up {
cur_line.saturating_sub(1)
} else {
(cur_line + 1).min(total_lines.saturating_sub(1))
};
if new_line == cur_line {
false
} else {
let new_pos = line_col_to_cursor(&state.text, new_line, cur_col);
if ctx.input.shift {
if state.sel_anchor.is_none() {
state.sel_anchor = Some(state.cursor_pos);
}
} else {
state.sel_anchor = None;
}
state.cursor_pos = new_pos;
state.blink = 0.0;
true
}
} else {
false
};
let line_height = font_size_approx * 1.2;
let v_pad = 4.0;
let (cursor_line, _) = cursor_to_line_col(&state.text, state.cursor_pos);
let total_lines = state.text.split('\n').count();
let max_scroll_y = 2.0f32
.mul_add(v_pad, (total_lines as f32).mul_add(line_height, -rect.h))
.max(0.0);
let cursor_y_abs = cursor_line as f32 * line_height;
if cursor_y_abs < state.scroll_y || moved {
state.scroll_y = cursor_y_abs.min(state.scroll_y);
}
let scroll_bottom = 2.0f32.mul_add(-v_pad, state.scroll_y + rect.h);
if cursor_y_abs + line_height > scroll_bottom {
state.scroll_y = cursor_y_abs + line_height - 2.0f32.mul_add(-v_pad, rect.h);
}
state.scroll_y = state.scroll_y.clamp(0.0, max_scroll_y);
if r.visual.hovered && ctx.input.scroll_delta.abs() > 0.0 {
state.scroll_y = ctx
.input
.scroll_delta
.mul_add(-line_height, state.scroll_y)
.clamp(0.0, max_scroll_y);
}
}
apply_visual(
&mut state.visual,
&mut state.anim,
WidgetState {
hovered: r.visual.hovered,
pressed: r.visual.pressed,
focused,
..Default::default()
},
ctx.dt,
);
let style = if focused {
tb.style_focus
} else {
tb.style_idle
};
let display_value: String = if tb.password {
state.text.chars().map(|_| '•').collect()
} else {
state.text.clone()
};
let vs = push_component(
ctx.scene,
StyleCtx {
registry: ctx.registry,
tex_registry: ctx.tex_registry,
},
style,
Transition {
from: state.anim.prev_state(),
to: state.visual,
t: state.anim.t(),
},
rect,
Z_WIDGET,
ctx.clip,
);
let text_clip = crate::draw::ClipRect {
x: rect.x,
y: rect.y,
w: rect.w,
h: rect.h,
};
let text_clip_merged = merge_clips(ctx.clip, Some(text_clip));
let font_size = tb
.font_size
.or(vs.font_size)
.unwrap_or_else(|| default_font_size(rect.h));
let ch_w = font_size * 0.55;
let shader = ctx.registry.get(style).shader;
let color = vs.text_color.unwrap_or(Color::WHITE);
let render_align = vs.text_align;
let line_height = if tb.multiline {
font_size * 1.2
} else {
rect.h
};
let v_pad = if tb.multiline { 4.0 } else { 0.0 };
let lines: Vec<&str> = display_value.split('\n').collect();
let first_visible = if tb.multiline {
(state.scroll_y / line_height) as usize
} else {
0
};
let visible_count = if tb.multiline {
(rect.h / line_height).ceil() as usize + 1
} else {
1
};
let (cursor_line, cursor_col) = cursor_to_line_col(&state.text, state.cursor_pos);
for (i, line_text) in lines
.iter()
.enumerate()
.skip(first_visible)
.take(visible_count)
{
let line_y = if tb.multiline {
((i - first_visible) as f32).mul_add(line_height, rect.y + v_pad)
- (state.scroll_y % line_height)
} else {
rect.y
};
let line_rect = Rect::new(rect.x, line_y, rect.w, line_height);
let h_scroll = if tb.multiline {
0.0
} else {
state.scroll_offset
};
if focused && let Some(anchor) = state.sel_anchor {
let sel_lo = state.cursor_pos.min(anchor);
let sel_hi = state.cursor_pos.max(anchor);
let line_start_flat = line_col_to_cursor(&state.text, i, 0);
let line_char_count = line_text.chars().count();
let line_end_flat = line_start_flat + line_char_count;
if sel_lo < line_end_flat && sel_hi > line_start_flat {
let lo_in_line = sel_lo.saturating_sub(line_start_flat);
let hi_in_line = (sel_hi - line_start_flat).min(line_char_count);
let text_start = text_align_start(
rect.x + vs.text_offset_x - h_scroll,
rect.w,
line_char_count,
ch_w,
render_align,
);
let sx = (lo_in_line as f32).mul_add(ch_w, text_start);
let sw = (hi_in_line - lo_in_line) as f32 * ch_w;
ctx.scene.push_full(
vec![
[sx, line_y],
[sx + sw, line_y],
[sx, line_y + line_height],
[sx + sw, line_y],
[sx + sw, line_y + line_height],
[sx, line_y + line_height],
],
shader,
Color::rgba(0.3, 0.5, 1.0, 0.4),
Z_WIDGET - 0.05,
text_clip_merged,
0.0,
);
}
}
let line_label = if state.text.is_empty() && i == 0 {
&tb.placeholder as &str
} else {
line_text
};
draw_textbox_text_clipped(
ctx,
line_rect,
line_label,
&vs,
Some(font_size),
h_scroll,
render_align,
text_clip_merged,
);
}
if focused
&& state.blink < 0.5
&& cursor_line >= first_visible
&& cursor_line < first_visible + visible_count
{
let caret_line_y = if tb.multiline {
((cursor_line - first_visible) as f32).mul_add(line_height, rect.y + v_pad)
- (state.scroll_y % line_height)
} else {
rect.y
};
let line_text = state.text.split('\n').nth(cursor_line).unwrap_or("");
let cursor_byte_in_line = line_text
.char_indices()
.nth(cursor_col)
.map_or(line_text.len(), |(i, _)| i);
let h_scroll = if tb.multiline {
0.0
} else {
state.scroll_offset
};
let caret_x = if let Some(p) = ctx.pane.as_mut() {
let off = p.measure_cursor(
line_text,
font_size,
rect.w,
render_align,
ctx.pw,
ctx.ph,
cursor_byte_in_line,
vs.bold,
vs.italic,
vs.font.as_deref(),
);
let unit = ctx.ph / 1080.0;
let raw = rect.x + vs.text_offset_x - h_scroll + off;
((raw * unit).round() / unit).clamp(rect.x, rect.x + rect.w - 2.0)
} else {
let text_start = text_align_start(
rect.x + vs.text_offset_x - h_scroll,
rect.w,
line_text.chars().count(),
ch_w,
render_align,
);
(cursor_col as f32)
.mul_add(ch_w, text_start)
.clamp(rect.x, rect.x + rect.w - 2.0)
};
let caret_y = line_height.mul_add(0.15, caret_line_y);
let caret_h = line_height * 0.70;
ctx.scene.push_full(
vec![
[caret_x, caret_y],
[caret_x + 2.0, caret_y],
[caret_x, caret_y + caret_h],
[caret_x + 2.0, caret_y],
[caret_x + 2.0, caret_y + caret_h],
[caret_x, caret_y + caret_h],
],
shader,
color,
Z_WIDGET + 0.1,
text_clip_merged,
0.0,
);
}
draw_tooltip(
tb.tooltip.as_deref(),
ctx,
rect,
r.visual.hovered,
&mut state.hover_timer,
);
TickResult {
consumes_nav: editing,
request_focus,
..Default::default()
}
}
fn tick_scroll_list_horizontal(
list: &mut crate::items::ScrollList,
state: &mut ScrollState,
ctx: &mut TickCtx,
focused_path: &[usize],
container: Rect,
clip: crate::draw::ClipRect,
total_gap: f32,
) {
let item_h = list.height - list.pad_top - list.pad_bottom;
let total_w = list
.items
.iter()
.map(|i| {
if let crate::items::UiItem::Button(b) = i {
b.width
} else {
0.0
}
})
.sum::<f32>()
+ total_gap
+ list.pad_left
+ list.pad_right;
state.max_scroll = (total_w - list.width).max(0.0);
state.scroll = scroll(state.scroll, total_w, list.width, container, ctx.input);
if let Some(&ci) = focused_path.first() {
let mut item_x = list.pad_left;
for (idx, item) in list.items.iter().enumerate() {
let w = if let crate::items::UiItem::Button(b) = item {
b.width
} else {
0.0
};
if idx == ci {
let target = list
.width
.mul_add(-0.5, item_x + w * 0.5)
.clamp(0.0, state.max_scroll);
state.scroll += (target - state.scroll) * 0.2;
break;
}
item_x += w + list.gap;
}
}
let mut cx = list.x + list.pad_left - state.scroll;
for (ci, (item, child_state)) in list
.items
.iter_mut()
.zip(state.children.iter_mut())
.enumerate()
{
if let crate::items::UiItem::Button(btn) = item {
btn.height = item_h;
let bx = cx;
let by = list.y + list.pad_top;
let rect = Rect::new(bx, by, btn.width, btn.height);
let visible = bx + btn.width > list.x && bx < list.x + list.width;
let child_focused = focused_path.first() == Some(&ci);
let cr = click(rect, ctx.input, child_focused, !visible);
apply_visual(
&mut child_state.visual,
&mut child_state.anim,
WidgetState {
hovered: cr.hovered,
pressed: cr.pressed,
..Default::default()
},
ctx.dt,
);
if cr.clicked {
crate::logic::send_press(ctx.tx, &btn.action, &btn.id, false);
}
if visible {
let vs = push_component(
ctx.scene,
StyleCtx {
registry: ctx.registry,
tex_registry: ctx.tex_registry,
},
btn.style,
Transition {
from: child_state.anim.prev_state(),
to: child_state.visual,
t: child_state.anim.t(),
},
rect,
Z_WIDGET,
Some(clip),
);
push_widget_text(ctx.scene, rect, &vs, &btn.text, Some(clip), Z_WIDGET);
}
cx += btn.width + list.gap;
}
}
}
fn tick_scroll_list_vertical(
list: &mut crate::items::ScrollList,
state: &mut ScrollState,
ctx: &mut TickCtx,
focused_path: &[usize],
container: Rect,
clip: crate::draw::ClipRect,
total_gap: f32,
) {
let item_w = list.width - list.pad_left - list.pad_right;
let total_h = list
.items
.iter()
.map(|i| {
if let crate::items::UiItem::Button(b) = i {
b.height
} else {
0.0
}
})
.sum::<f32>()
+ total_gap
+ list.pad_top
+ list.pad_bottom;
state.max_scroll = (total_h - list.height).max(0.0);
state.scroll = scroll(state.scroll, total_h, list.height, container, ctx.input);
if let Some(&ci) = focused_path.first() {
let mut item_y = list.pad_top;
for (idx, item) in list.items.iter().enumerate() {
let h = if let crate::items::UiItem::Button(b) = item {
b.height
} else {
0.0
};
if idx == ci {
let target = list
.height
.mul_add(-0.5, item_y + h * 0.5)
.clamp(0.0, state.max_scroll);
state.scroll += (target - state.scroll) * 0.2;
break;
}
item_y += h + list.gap;
}
}
let mut cy = list.y + list.pad_top - state.scroll;
for (ci, (item, child_state)) in list
.items
.iter_mut()
.zip(state.children.iter_mut())
.enumerate()
{
if let crate::items::UiItem::Button(btn) = item {
btn.width = item_w;
let bx = list.x + list.pad_left;
let by = cy;
let rect = Rect::new(bx, by, btn.width, btn.height);
let visible = by + btn.height > list.y && by < list.y + list.height;
let child_focused = focused_path.first() == Some(&ci);
let cr = click(rect, ctx.input, child_focused, !visible);
apply_visual(
&mut child_state.visual,
&mut child_state.anim,
WidgetState {
hovered: cr.hovered,
pressed: cr.pressed,
..Default::default()
},
ctx.dt,
);
if cr.clicked {
crate::logic::send_press(ctx.tx, &btn.action, &btn.id, false);
}
if visible {
let vs = push_component(
ctx.scene,
StyleCtx {
registry: ctx.registry,
tex_registry: ctx.tex_registry,
},
btn.style,
Transition {
from: child_state.anim.prev_state(),
to: child_state.visual,
t: child_state.anim.t(),
},
rect,
Z_WIDGET,
Some(clip),
);
push_widget_text(ctx.scene, rect, &vs, &btn.text, Some(clip), Z_WIDGET);
}
cy += btn.height + list.gap;
}
}
}
pub fn tick_scroll_list(
list: &mut crate::items::ScrollList,
state: &mut ScrollState,
ctx: &mut TickCtx,
focused_path: &[usize],
) -> TickResult {
if list.full_span {
let gw = grid_width(ctx.pw, ctx.ph);
if list.horizontal {
list.x = -gw * 0.5;
list.width = gw;
} else {
list.y = -540.0;
list.height = 1080.0;
}
}
let container = Rect::new(list.x, list.y, list.width, list.height);
let clip = crate::draw::ClipRect {
x: list.x,
y: list.y,
w: list.width,
h: list.height,
};
let n = list.items.len();
let total_gap = if n > 1 {
list.gap * (n as f32 - 1.0)
} else {
0.0
};
state.visual = WidgetState {
hovered: container.contains(ctx.input.mouse_x, ctx.input.mouse_y),
..Default::default()
};
if let Some(style) = list.style {
ctx.registry.draw(
style,
container,
state.visual,
DrawLabel {
label: None,
z: Z_CONTAINER,
clip: None,
alpha: 1.0,
},
ctx.scene,
ctx.tex_registry,
);
}
if list.horizontal {
tick_scroll_list_horizontal(list, state, ctx, focused_path, container, clip, total_gap);
} else {
tick_scroll_list_vertical(list, state, ctx, focused_path, container, clip, total_gap);
}
TickResult::default()
}
fn tick_scroll_pane_horizontal(
pane: &mut crate::items::ScrollPane,
state: &mut ScrollPaneState,
ctx: &mut TickCtx,
focused_id: Option<&String>,
focused_path: &[usize],
total_gap: f32,
) -> bool {
let container = Rect::new(pane.x, pane.y, pane.width, pane.height);
let pane_clip = ClipRect {
x: pane.x,
y: pane.y,
w: pane.width,
h: pane.height,
};
let item_h = pane.height - pane.pad_top - pane.pad_bottom;
if pane.manual {
let max_right = state
.manual_positions
.iter()
.zip(pane.items.iter())
.map(|(&(rx, _), item)| rx + crate::logic::item_width(item))
.fold(f32::NEG_INFINITY, f32::max);
let max_scroll = (max_right - (pane.x + pane.width)).max(0.0);
state.scroll = scroll(
state.scroll,
max_scroll + pane.width,
pane.width,
container,
ctx.input,
)
.min(max_scroll);
if let Some(&ci) = focused_path.first()
&& let Some(&(rx, _)) = state.manual_positions.get(ci)
{
let w = crate::logic::item_width(&pane.items[ci]);
let target = w
.mul_add(0.5, rx)
.mul_add(1.0, -pane.width * 0.5)
.clamp(0.0, max_scroll);
state.scroll += (target - state.scroll) * 0.2;
}
let mut consumed_nav = false;
let positions: Vec<(f32, f32)> = state.manual_positions.clone();
for (ci, ((item, child_state), &(rx, ry))) in pane
.items
.iter_mut()
.zip(state.children.iter_mut())
.zip(positions.iter())
.enumerate()
{
let w = crate::logic::item_width(item);
let ox = rx - state.scroll;
let oy = ry;
let visible = ox + w > pane.x && ox < pane.x + pane.width;
set_item_pos_h(item, ox, oy);
let child_focused = focused_path.first() == Some(&ci);
let child_path = if child_focused {
&focused_path[1..]
} else {
&[]
};
let r = if visible {
let old_clip = ctx.clip;
ctx.clip = merge_clips(ctx.clip, Some(pane_clip));
let res = tick_item(
item,
child_state,
ctx,
focused_id,
child_focused,
child_path,
);
ctx.clip = old_clip;
res
} else {
TickResult::default()
};
if child_focused && r.consumes_nav {
consumed_nav = true;
}
}
return consumed_nav;
}
let total_w = pane.items.iter().map(crate::logic::item_width).sum::<f32>()
+ total_gap
+ pane.pad_left
+ pane.pad_right;
let max_scroll = (total_w - pane.width).max(0.0);
state.scroll = scroll(state.scroll, total_w, pane.width, container, ctx.input).min(max_scroll);
if let Some(&ci) = focused_path.first() {
let mut item_x = pane.pad_left;
for (idx, item) in pane.items.iter().enumerate() {
let w = crate::logic::item_width(item);
if idx == ci {
let target = pane
.width
.mul_add(-0.5, w.mul_add(0.5, item_x))
.clamp(0.0, max_scroll);
state.scroll += (target - state.scroll) * 0.2;
break;
}
item_x += w + pane.gap;
}
}
let mut cx = pane.x + pane.pad_left - state.scroll;
let mut consumed_nav = false;
for (ci, (item, child_state)) in pane
.items
.iter_mut()
.zip(state.children.iter_mut())
.enumerate()
{
let w = crate::logic::item_width(item);
let visible = cx + w > pane.x && cx < pane.x + pane.width;
let h = crate::logic::item_height(item);
let ox = cx;
let oy = (item_h - h).max(0.0).mul_add(0.5, pane.y + pane.pad_top);
set_item_pos_h(item, ox, oy);
let child_focused = focused_path.first() == Some(&ci);
let child_path = if child_focused {
&focused_path[1..]
} else {
&[]
};
let r = if visible {
let old_clip = ctx.clip;
ctx.clip = merge_clips(ctx.clip, Some(pane_clip));
let res = tick_item(
item,
child_state,
ctx,
focused_id,
child_focused,
child_path,
);
ctx.clip = old_clip;
res
} else {
TickResult::default()
};
if child_focused && r.consumes_nav {
consumed_nav = true;
}
cx += w + pane.gap;
}
consumed_nav
}
fn tick_scroll_pane_vertical(
pane: &mut crate::items::ScrollPane,
state: &mut ScrollPaneState,
ctx: &mut TickCtx,
focused_id: Option<&String>,
focused_path: &[usize],
container: Rect,
pane_clip: ClipRect,
total_gap: f32,
) -> bool {
let item_w = pane.width - pane.pad_left - pane.pad_right;
if pane.manual {
let max_bottom = state
.manual_positions
.iter()
.zip(pane.items.iter())
.map(|(&(_, ry), item)| ry + crate::logic::item_height(item))
.fold(f32::NEG_INFINITY, f32::max);
let max_scroll = (max_bottom - (pane.y + pane.height)).max(0.0);
state.scroll = scroll(
state.scroll,
max_scroll + pane.height,
pane.height,
container,
ctx.input,
)
.min(max_scroll);
if let Some(&ci) = focused_path.first()
&& let Some(&(_, ry)) = state.manual_positions.get(ci)
{
let h = crate::logic::item_height(&pane.items[ci]);
let target = h
.mul_add(0.5, ry)
.mul_add(1.0, -pane.height * 0.5)
.clamp(0.0, max_scroll);
state.scroll += (target - state.scroll) * 0.2;
}
let mut consumed_nav = false;
let positions: Vec<(f32, f32)> = state.manual_positions.clone();
for (ci, ((item, child_state), &(rx, ry))) in pane
.items
.iter_mut()
.zip(state.children.iter_mut())
.zip(positions.iter())
.enumerate()
{
let h = crate::logic::item_height(item);
let ox = rx;
let oy = ry - state.scroll;
let visible = oy + h > pane.y && oy < pane.y + pane.height;
set_item_pos(item, ox, oy, crate::logic::item_width(item));
let child_focused = focused_path.first() == Some(&ci);
let child_path = if child_focused {
&focused_path[1..]
} else {
&[]
};
let r = if visible {
let old_clip = ctx.clip;
ctx.clip = merge_clips(ctx.clip, Some(pane_clip));
let res = tick_item(
item,
child_state,
ctx,
focused_id,
child_focused,
child_path,
);
ctx.clip = old_clip;
res
} else {
TickResult::default()
};
if child_focused && r.consumes_nav {
consumed_nav = true;
}
}
return consumed_nav;
}
let total_h = pane
.items
.iter()
.map(crate::logic::item_height)
.sum::<f32>()
+ total_gap
+ pane.pad_top
+ pane.pad_bottom;
let max_scroll = (total_h - pane.height).max(0.0);
state.scroll = scroll(state.scroll, total_h, pane.height, container, ctx.input).min(max_scroll);
if let Some(&ci) = focused_path.first() {
let mut item_y = pane.pad_top;
for (idx, item) in pane.items.iter().enumerate() {
let h = crate::logic::item_height(item);
if idx == ci {
let target = pane
.height
.mul_add(-0.5, h.mul_add(0.5, item_y))
.clamp(0.0, max_scroll);
state.scroll += (target - state.scroll) * 0.2;
break;
}
item_y += h + pane.gap;
}
}
let mut cy = pane.y + pane.pad_top - state.scroll;
let mut consumed_nav = false;
for (ci, (item, child_state)) in pane
.items
.iter_mut()
.zip(state.children.iter_mut())
.enumerate()
{
let h = crate::logic::item_height(item);
let visible = cy + h > pane.y && cy < pane.y + pane.height;
let ox = pane.x + pane.pad_left;
let oy = cy;
set_item_pos(item, ox, oy, item_w);
let child_focused = focused_path.first() == Some(&ci);
let child_path = if child_focused {
&focused_path[1..]
} else {
&[]
};
let r = if visible {
let old_clip = ctx.clip;
ctx.clip = merge_clips(ctx.clip, Some(pane_clip));
let res = tick_item(
item,
child_state,
ctx,
focused_id,
child_focused,
child_path,
);
ctx.clip = old_clip;
res
} else {
TickResult::default()
};
if child_focused && r.consumes_nav {
consumed_nav = true;
}
cy += h + pane.gap;
}
consumed_nav
}
pub fn tick_scroll_pane(
pane: &mut crate::items::ScrollPane,
state: &mut ScrollPaneState,
ctx: &mut TickCtx,
focused_id: Option<&String>,
focused_path: &[usize],
) -> TickResult {
if pane.full_span {
let gw = grid_width(ctx.pw, ctx.ph);
if pane.horizontal {
pane.x = -gw * 0.5;
pane.width = gw;
} else {
pane.x = -gw * 0.5;
pane.width = gw;
if pane.height == 0.0 {
pane.y = -540.0;
pane.height = 1080.0;
}
}
}
let container = Rect::new(pane.x, pane.y, pane.width, pane.height);
let pane_clip = ClipRect {
x: pane.x,
y: pane.y,
w: pane.width,
h: pane.height,
};
let n = pane.items.len();
let total_gap = if n > 1 {
pane.gap * (n as f32 - 1.0)
} else {
0.0
};
state.visual = WidgetState {
hovered: container.contains(ctx.input.mouse_x, ctx.input.mouse_y),
..Default::default()
};
if let Some(style) = pane.style {
ctx.registry.draw(
style,
container,
state.visual,
DrawLabel {
label: None,
z: Z_CONTAINER,
clip: None,
alpha: 1.0,
},
ctx.scene,
ctx.tex_registry,
);
}
let consumed_nav = if pane.horizontal {
tick_scroll_pane_horizontal(pane, state, ctx, focused_id, focused_path, total_gap)
} else {
tick_scroll_pane_vertical(
pane,
state,
ctx,
focused_id,
focused_path,
container,
pane_clip,
total_gap,
)
};
if consumed_nav {
TickResult {
consumes_nav: true,
..Default::default()
}
} else {
TickResult::default()
}
}
const fn set_item_pos(item: &mut crate::items::UiItem, x: f32, y: f32, w: f32) {
use crate::items::UiItem;
match item {
UiItem::Button(b) => {
b.x = x;
b.y = y;
b.width = w;
}
UiItem::Toggle(t) => {
t.x = x;
t.y = y;
t.width = w;
}
UiItem::Slider(s) => {
s.x = x;
s.y = y;
s.width = w;
}
UiItem::TextBox(tb) => {
tb.x = x;
tb.y = y;
tb.width = w;
}
UiItem::Label(l) => {
l.x = x;
l.y = y;
}
UiItem::ProgressBar(p) => {
p.x = x;
p.y = y;
p.width = w;
}
UiItem::Divider(d) => {
d.x = x;
d.y = y;
d.width = w;
}
UiItem::Image(img) => {
img.x = x;
img.y = y;
img.width = w;
}
UiItem::Tab(t) => {
t.x = x;
t.y = y;
t.width = w;
}
UiItem::ScrollPane(s) => {
s.x = x;
s.y = y;
s.width = w;
}
_ => {}
}
}
const fn set_item_pos_h(item: &mut crate::items::UiItem, x: f32, y: f32) {
use crate::items::UiItem;
match item {
UiItem::Button(b) => {
b.x = x;
b.y = y;
}
UiItem::Toggle(t) => {
t.x = x;
t.y = y;
}
UiItem::Slider(s) => {
s.x = x;
s.y = y;
}
UiItem::TextBox(tb) => {
tb.x = x;
tb.y = y;
}
UiItem::Label(l) => {
l.x = x;
l.y = y;
}
UiItem::ProgressBar(p) => {
p.x = x;
p.y = y;
}
UiItem::Divider(d) => {
d.x = x;
d.y = y;
}
UiItem::Image(img) => {
img.x = x;
img.y = y;
}
UiItem::Tab(t) => {
t.x = x;
t.y = y;
}
UiItem::ScrollPane(s) => {
s.x = x;
s.y = y;
}
_ => {}
}
}
fn draw_dropdown_option(
ctx: &mut TickCtx,
btn: &crate::items::Button,
child_state: &ButtonState,
opt_rect: Rect,
) {
let vs = push_component(
ctx.scene,
StyleCtx {
registry: ctx.registry,
tex_registry: ctx.tex_registry,
},
btn.style,
Transition {
from: child_state.anim.prev_state(),
to: child_state.visual,
t: child_state.anim.t(),
},
opt_rect,
Z_DROPDOWN_W,
None,
);
push_widget_text(ctx.scene, opt_rect, &vs, &btn.text, None, Z_DROPDOWN_W);
}
pub fn tick_dropdown(
dd: &mut crate::items::Dropdown,
state: &mut DropdownState,
ctx: &mut TickCtx,
focused: bool,
focused_path: &[usize],
nav: KeyNav,
) -> TickResult {
let rect = Rect::new(dd.x, dd.y, dd.width, dd.height);
let header_focused = focused && focused_path.is_empty();
let r = click(rect, ctx.input, header_focused, false);
let nav_confirm = header_focused && (nav.enter || nav.space);
if r.clicked || (nav_confirm && !state.open) {
state.open = !state.open;
}
if nav.escape {
state.open = false;
}
state.visual = WidgetState {
hovered: r.visual.hovered,
pressed: r.visual.pressed,
focused,
open: state.open,
..Default::default()
};
let selected_label = dd
.options
.get(state.selected)
.map_or("", std::string::String::as_str);
ctx.registry.draw(
dd.style,
rect,
state.visual,
DrawLabel {
label: Some(selected_label),
z: Z_WIDGET,
clip: None,
alpha: 1.0,
},
ctx.scene,
ctx.tex_registry,
);
draw_tooltip(None, ctx, rect, r.visual.hovered, &mut state.hover_timer);
if state.open {
let list_h = dd.height * dd.items.len() as f32;
let list_rect = Rect::new(dd.x, dd.y + dd.height, dd.width, list_h);
let box_style = dd.style_list.unwrap_or(dd.style);
ctx.registry.draw(
box_style,
list_rect,
WidgetState::default(),
DrawLabel {
label: None,
z: Z_DROPDOWN,
clip: None,
alpha: 1.0,
},
ctx.scene,
ctx.tex_registry,
);
}
let mut clicked_outside = state.open && ctx.input.left_just_pressed;
for (idx, (btn, child_state)) in dd
.items
.iter_mut()
.zip(state.children.iter_mut())
.enumerate()
{
let opt_rect = Rect::new(dd.x + btn.x, dd.y + btn.y, btn.width, btn.height);
let child_focused = focused_path.first() == Some(&idx);
let cr = click(opt_rect, ctx.input, child_focused, !state.open);
if cr.hovered {
clicked_outside = false;
}
apply_visual(
&mut child_state.visual,
&mut child_state.anim,
WidgetState {
hovered: cr.hovered,
pressed: cr.pressed,
..Default::default()
},
ctx.dt,
);
if (cr.clicked || (child_focused && (nav.enter || nav.space))) && state.open {
state.selected = idx;
state.open = false;
match &dd.action {
crate::loader::DropdownAction::Custom(tag) => {
let label = dd.options.get(idx).cloned().unwrap_or_default();
let _ = ctx.tx.send(UiMsg::Dropdown(tag.clone(), idx, label));
}
crate::loader::DropdownAction::Print => println!("{}: {}", dd.id, idx),
}
}
if state.open {
draw_dropdown_option(ctx, btn, child_state, opt_rect);
}
}
if clicked_outside {
state.open = false;
}
TickResult::default()
}
pub fn tick_radio_group(
rg: &mut crate::items::RadioGroup,
state: &mut RadioGroupState,
ctx: &mut TickCtx,
focused_path: &[usize],
) -> TickResult {
for (i, (btn, child_state)) in rg
.items
.iter_mut()
.zip(state.children.iter_mut())
.enumerate()
{
btn.style = if i == state.selected {
rg.style_selected
} else {
rg.style_idle
};
let rect = Rect::new(rg.x + btn.x, rg.y + btn.y, btn.width, btn.height);
let cr = click(rect, ctx.input, focused_path.first() == Some(&i), false);
apply_visual(
&mut child_state.visual,
&mut child_state.anim,
WidgetState {
hovered: cr.hovered,
pressed: cr.pressed,
..Default::default()
},
ctx.dt,
);
if cr.clicked && i != state.selected {
state.selected = i;
match &rg.action {
crate::loader::RadioAction::Custom(tag) => {
let label = rg.options.get(i).cloned().unwrap_or_default();
let _ = ctx.tx.send(UiMsg::Radio(tag.clone(), i, label));
}
crate::loader::RadioAction::Print => println!("{}: {}", rg.id, i),
}
}
let vs = push_component(
ctx.scene,
StyleCtx {
registry: ctx.registry,
tex_registry: ctx.tex_registry,
},
btn.style,
Transition {
from: child_state.anim.prev_state(),
to: child_state.visual,
t: child_state.anim.t(),
},
rect,
Z_WIDGET,
None,
);
push_widget_text(ctx.scene, rect, &vs, &btn.text, None, Z_WIDGET);
}
TickResult::default()
}
fn tick_bar_button(
btn: &crate::items::Button,
child_state: &mut ButtonState,
ctx: &mut TickCtx,
rect: Rect,
nav: KeyNav,
btn_focused: bool,
button_centers: &mut Vec<(f32, f32)>,
) {
button_centers.push((rect.w.mul_add(0.5, rect.x), rect.h.mul_add(0.5, rect.y)));
let cr = click(rect, ctx.input, btn_focused, btn.disabled);
apply_visual(
&mut child_state.visual,
&mut child_state.anim,
WidgetState {
hovered: cr.hovered,
pressed: cr.pressed,
focused: btn_focused,
disabled: btn.disabled,
..Default::default()
},
ctx.dt,
);
if cr.clicked || (btn_focused && (nav.enter || nav.space)) {
crate::logic::send_press(ctx.tx, &btn.action, &btn.id, false);
}
let vs = push_component(
ctx.scene,
StyleCtx {
registry: ctx.registry,
tex_registry: ctx.tex_registry,
},
btn.style,
Transition {
from: child_state.anim.prev_state(),
to: child_state.visual,
t: child_state.anim.t(),
},
rect,
Z_BAR,
None,
);
push_widget_text(ctx.scene, rect, &vs, &btn.text, None, Z_BAR);
draw_tooltip(
btn.tooltip.as_deref(),
ctx,
rect,
cr.hovered,
&mut child_state.hover_timer,
);
}
pub fn tick_bar(
bar: &crate::items::Bar,
state: &mut BarState,
ctx: &mut TickCtx,
focused_path: &[usize],
) -> TickResult {
use crate::items::{BarEdge, UiItem};
let gw = grid_width(ctx.pw, ctx.ph);
let hw = gw * 0.5;
let hh = 540.0_f32;
let horizontal = matches!(bar.edge, BarEdge::Top | BarEdge::Bottom | BarEdge::Free)
&& !(bar.height > 0.0 && bar.width == 0.0);
let (bar_x, bar_y, bar_main) = match bar.edge {
BarEdge::Top => (-hw, -hh, gw),
BarEdge::Bottom => (-hw, hh - bar.thickness, gw),
BarEdge::Left => (
-hw,
-hh + bar.top_inset,
1080.0 - bar.top_inset - bar.bottom_inset,
),
BarEdge::Right => (
hw - bar.thickness,
-hh + bar.top_inset,
1080.0 - bar.top_inset - bar.bottom_inset,
),
BarEdge::Free => {
let w = if bar.width > 0.0 {
bar.width
} else {
bar.thickness
};
let h = if bar.height > 0.0 {
bar.height
} else {
bar.thickness
};
let main = if h > w { h } else { w };
(bar.x, bar.y, main)
}
};
let bar_w = if bar.edge == BarEdge::Free {
if bar.width > 0.0 {
bar.width
} else {
bar.thickness
}
} else if horizontal {
gw
} else {
bar.thickness
};
let bar_h = if bar.edge == BarEdge::Free {
if bar.height > 0.0 {
bar.height
} else {
bar.thickness
}
} else if horizontal {
bar.thickness
} else {
bar_main
};
if let Some(style) = bar.style {
ctx.registry.draw(
style,
Rect::new(bar_x, bar_y, bar_w, bar_h),
WidgetState::default(),
DrawLabel {
label: None,
z: Z_BAR,
clip: None,
alpha: 1.0,
},
ctx.scene,
ctx.tex_registry,
);
}
let item_cross = bar.pad.mul_add(-2.0, bar.thickness);
let cross_center = if horizontal {
bar.thickness.mul_add(0.5, bar_y)
} else {
bar.thickness.mul_add(0.5, bar_x)
};
let n = bar.items.len();
let fixed_total: f32 = bar.gap.mul_add(
n.saturating_sub(1) as f32,
bar.pad.mul_add(
2.0,
bar.items
.iter()
.map(|item| match item {
UiItem::Button(b) => b.height,
UiItem::Label(lbl) => lbl.width,
UiItem::ScrollList(sl) => {
if horizontal {
sl.width
} else {
sl.height
}
}
_ => 0.0,
})
.sum::<f32>(),
),
);
let spacer_count = bar
.items
.iter()
.filter(|i| matches!(i, UiItem::Spacer))
.count();
let spacer_size = if spacer_count > 0 {
((bar_main - fixed_total) / spacer_count as f32).max(0.0)
} else {
0.0
};
let nav = KeyNav::from_input(ctx.input);
state.button_centers.clear();
if bar.manual {
let mut btn_idx = 0_usize;
for (pos_idx, item) in bar.items.iter().enumerate() {
let (rx, ry) = state
.manual_positions
.get(pos_idx)
.copied()
.unwrap_or((0.0, 0.0));
if let UiItem::Button(btn) = item {
let rect = Rect::new(
btn.width.mul_add(-0.5, rx),
btn.height.mul_add(-0.5, ry),
btn.width,
btn.height,
);
let btn_focused = focused_path.first() == Some(&btn_idx);
tick_bar_button(
btn,
&mut state.children[btn_idx],
ctx,
rect,
nav,
btn_focused,
&mut state.button_centers,
);
btn_idx += 1;
}
}
return TickResult::default();
}
let mut cursor = bar.pad;
let mut btn_idx = 0_usize;
for item in &bar.items {
match item {
UiItem::Button(btn) => {
let item_main = btn.height;
let (x, y, w, h) = if horizontal {
(
bar_x + cursor,
cross_center - item_cross * 0.5,
item_main,
item_cross,
)
} else {
(
cross_center - item_cross * 0.5,
bar_y + cursor,
item_cross,
item_main,
)
};
let rect = Rect::new(x, y, w, h);
let btn_focused = focused_path.first() == Some(&btn_idx);
tick_bar_button(
btn,
&mut state.children[btn_idx],
ctx,
rect,
nav,
btn_focused,
&mut state.button_centers,
);
btn_idx += 1;
cursor += item_main + bar.gap;
}
UiItem::Label(lbl) => {
let (x, y) = if horizontal {
(bar_x + cursor, cross_center - item_cross * 0.5)
} else {
(cross_center - item_cross * 0.5, bar_y + cursor)
};
ctx.scene.push_text(&TextDraw {
text: &lbl.text,
x,
y,
w: lbl.width,
size: lbl.size,
color: lbl.color,
align: crate::draw::TextAlign::Left,
font: None,
bold: false,
italic: false,
clip: None,
z: Z_WIDGET,
});
cursor += lbl.width + bar.gap;
}
UiItem::Spacer => {
cursor += spacer_size + bar.gap;
}
_ => {}
}
}
TickResult::default()
}
fn tick_popout_buttons_horizontal(
popout: &crate::items::Popout,
open: &mut bool,
children: &mut [ButtonState],
child_centers: &mut Vec<(f32, f32)>,
ctx: &mut TickCtx,
focused_path: &[usize],
draw_x: f32,
draw_h: f32,
apy: f32,
nav: KeyNav,
) {
let mut cursor = popout.gap;
for (ci, (item, child_state)) in popout.items.iter().zip(children.iter_mut()).enumerate() {
if let crate::items::UiItem::Button(btn) = item {
let bx = draw_x + cursor;
let by = (draw_h - btn.height).mul_add(0.5, apy);
let shifted_rect = Rect::new(bx, by, btn.width, btn.height);
child_centers.push((btn.width.mul_add(0.5, bx), btn.height.mul_add(0.5, by)));
let btn_focused = focused_path.first() == Some(&ci);
let cr = click(shifted_rect, ctx.input, btn_focused, btn.disabled);
let nav_press = btn_focused && (nav.enter || nav.space);
apply_visual(
&mut child_state.visual,
&mut child_state.anim,
WidgetState {
hovered: cr.hovered,
pressed: cr.pressed || nav_press,
focused: btn_focused,
..Default::default()
},
ctx.dt,
);
if cr.clicked || nav_press {
if btn.id == popout.toggle_id {
*open = !*open;
} else {
crate::logic::send_press(ctx.tx, &btn.action, &btn.id, false);
}
}
let vs = push_component(
ctx.scene,
StyleCtx {
registry: ctx.registry,
tex_registry: ctx.tex_registry,
},
btn.style,
Transition {
from: child_state.anim.prev_state(),
to: child_state.visual,
t: child_state.anim.t(),
},
shifted_rect,
Z_POPOUT_W,
None,
);
push_widget_text(ctx.scene, shifted_rect, &vs, &btn.text, None, Z_POPOUT_W);
cursor += btn.width + popout.gap;
}
}
}
fn tick_popout_buttons_vertical(
popout: &crate::items::Popout,
open: &mut bool,
children: &mut [ButtonState],
child_centers: &mut Vec<(f32, f32)>,
ctx: &mut TickCtx,
focused_path: &[usize],
apx: f32,
apy: f32,
nav: KeyNav,
) {
for (ci, (item, child_state)) in popout.items.iter().zip(children.iter_mut()).enumerate() {
if let crate::items::UiItem::Button(btn) = item {
let shifted_rect = Rect::new(btn.x + apx, btn.y + apy, btn.width, btn.height);
child_centers.push((
shifted_rect.w.mul_add(0.5, shifted_rect.x),
shifted_rect.h.mul_add(0.5, shifted_rect.y),
));
let btn_focused = focused_path.first() == Some(&ci);
let cr = click(shifted_rect, ctx.input, btn_focused, btn.disabled);
let nav_press = btn_focused && (nav.enter || nav.space);
apply_visual(
&mut child_state.visual,
&mut child_state.anim,
WidgetState {
hovered: cr.hovered,
pressed: cr.pressed || nav_press,
focused: btn_focused,
..Default::default()
},
ctx.dt,
);
if cr.clicked || nav_press {
if btn.id == popout.toggle_id {
*open = !*open;
} else {
crate::logic::send_press(ctx.tx, &btn.action, &btn.id, false);
}
}
let vs = push_component(
ctx.scene,
StyleCtx {
registry: ctx.registry,
tex_registry: ctx.tex_registry,
},
btn.style,
Transition {
from: child_state.anim.prev_state(),
to: child_state.visual,
t: child_state.anim.t(),
},
shifted_rect,
Z_POPOUT_W,
None,
);
push_widget_text(ctx.scene, shifted_rect, &vs, &btn.text, None, Z_POPOUT_W);
}
}
}
pub fn tick_popout(
popout: &crate::items::Popout,
state: &mut PopoutState,
ctx: &mut TickCtx,
focused: bool,
focused_path: &[usize],
nav: KeyNav,
) -> TickResult {
let (px, py) = if state.open {
(popout.open_x, popout.open_y)
} else {
(popout.closed_x, popout.closed_y)
};
let rect = Rect::new(px, py, popout.width, popout.height);
let r = click(rect, ctx.input, focused, false);
if focused && !state.open && (nav.enter || nav.space) {
state.open = true;
}
if nav.escape && state.open {
state.open = false;
}
if popout.home_toggles && ctx.input.home {
state.open = !state.open;
}
state
.spring
.update(if state.open { 1.0 } else { 0.0 }, ctx.dt);
state.visual = WidgetState {
hovered: r.visual.hovered,
pressed: r.visual.pressed,
focused,
open: state.open,
..Default::default()
};
let t = state.spring.t();
let apx = (popout.open_x - popout.closed_x).mul_add(t, popout.closed_x);
let apy = (popout.open_y - popout.closed_y).mul_add(t, popout.closed_y);
let (draw_x, draw_w, draw_h) = if popout.full_span {
let gw = grid_width(ctx.pw, ctx.ph);
match popout.edge {
Some(crate::items::PopoutEdge::Left | crate::items::PopoutEdge::Right) => {
(apx, popout.width, 1080.0)
}
_ => (-gw * 0.5, gw, popout.height),
}
} else {
(apx, popout.width, popout.height)
};
if let Some(style) = popout.style {
ctx.registry.draw(
style,
Rect::new(draw_x, apy, draw_w, draw_h),
state.visual,
DrawLabel {
label: None,
z: Z_POPOUT,
clip: None,
alpha: 1.0,
},
ctx.scene,
ctx.tex_registry,
);
}
state.child_centers.clear();
if popout.horizontal {
tick_popout_buttons_horizontal(
popout,
&mut state.open,
&mut state.children,
&mut state.child_centers,
ctx,
focused_path,
draw_x,
draw_h,
apy,
nav,
);
} else {
tick_popout_buttons_vertical(
popout,
&mut state.open,
&mut state.children,
&mut state.child_centers,
ctx,
focused_path,
apx,
apy,
nav,
);
}
draw_tooltip(None, ctx, rect, r.visual.hovered, &mut state.hover_timer);
TickResult::default()
}
pub struct ToastState {
pub visual: WidgetState,
pub remaining: f32,
pub duration: f32,
}
impl ToastState {
pub fn new(duration: f32) -> Self {
Self {
visual: WidgetState::default(),
remaining: duration,
duration,
}
}
pub fn alpha(&self) -> f32 {
let fade_in = (self.duration - self.remaining) / 0.3;
let fade_out = self.remaining / 0.3;
fade_in.min(fade_out).clamp(0.0, 1.0)
}
}
pub fn tick_toast(
toast: &crate::items::Toast,
state: &mut ToastState,
ctx: &mut TickCtx,
) -> TickResult {
state.remaining -= ctx.dt;
let alpha = state.alpha();
if let Some(style) = toast.shape {
let rect = Rect::new(toast.x, toast.y, toast.width, toast.height);
ctx.registry.draw(
style,
rect,
state.visual,
DrawLabel {
label: Some(&toast.message),
z: Z_OVERLAY,
clip: None,
alpha,
},
ctx.scene,
ctx.tex_registry,
);
}
TickResult {
expired: state.remaining <= 0.0,
..Default::default()
}
}
fn update_actor_triggers(
actor: &crate::items::Actor,
state: &mut ActorState,
hovered: bool,
pressed_self: bool,
clicked_self: bool,
clicked_any: bool,
) {
use crate::items::Trigger;
let mut next_t = state
.active_override
.as_ref()
.map_or(0.0, |o| o.override_time);
for b in &actor.behaviours {
if b.trigger == Trigger::Always {
continue;
}
let firing = match b.trigger {
Trigger::OnHoverSelf => hovered,
Trigger::OnPressSelf => pressed_self,
Trigger::OnClickSelf => clicked_self,
Trigger::OnClickAnywhere => clicked_any,
Trigger::Always => unreachable!(),
};
if firing {
next_t += 1.0;
let latched = matches!(b.trigger, Trigger::OnClickSelf | Trigger::OnClickAnywhere);
state.active_override = Some(ActiveOverride {
trigger: b.trigger,
action: b.action.clone(),
override_time: next_t,
latched,
});
}
}
let still_active = state.active_override.as_ref().is_some_and(|ov| {
if ov.latched {
return true;
}
match ov.trigger {
Trigger::OnHoverSelf => hovered,
Trigger::OnPressSelf => pressed_self,
Trigger::Always => true,
_ => unreachable!(),
}
});
if !still_active {
state.active_override = None;
}
}
pub fn tick_actor(
actor: &crate::items::Actor,
state: &mut ActorState,
ctx: &mut TickCtx,
ox: f32,
oy: f32,
) -> TickResult {
use crate::items::{Action, Trigger};
state.time += ctx.dt;
state
.trail
.push(state.time, ctx.input.mouse_x, ctx.input.mouse_y);
let ax = actor.origin_x + ox;
let ay = actor.origin_y + oy;
let rect = Rect::new(ax, ay, actor.width, actor.height);
let hovered = rect.contains(ctx.input.mouse_x, ctx.input.mouse_y);
let pressed_self = hovered && ctx.input.left_pressed;
let clicked_self = hovered && ctx.input.left_just_released;
let clicked_any = ctx.input.left_just_released;
update_actor_triggers(
actor,
state,
hovered,
pressed_self,
clicked_self,
clicked_any,
);
state.visual = WidgetState {
hovered,
pressed: pressed_self,
..Default::default()
};
let active_action: Option<&Action> =
state
.active_override
.as_ref()
.map(|o| &o.action)
.or_else(|| {
actor
.behaviours
.iter()
.find(|b| b.trigger == Trigger::Always)
.map(|b| &b.action)
});
match active_action {
Some(Action::FollowCursor { speed, trail }) => {
let (tx, ty) = state.trail.sample(state.time, *trail);
let f = (speed * ctx.dt).clamp(0.0, 1.0);
state.pos_x += (tx - state.pos_x) * f;
state.pos_y += (ty - state.pos_y) * f;
}
Some(Action::MoveTo { x, y, speed }) => {
let f = (speed * ctx.dt).clamp(0.0, 1.0);
state.pos_x += (x + ox - state.pos_x) * f;
state.pos_y += (y + oy - state.pos_y) * f;
}
Some(Action::SwapGif { .. }) | None => {
if actor.return_on_end || active_action.is_none() {
let f = (8.0 * ctx.dt).clamp(0.0, 1.0);
state.pos_x += (ax - state.pos_x) * f;
state.pos_y += (ay - state.pos_y) * f;
}
}
}
let draw_rect = Rect::new(state.pos_x, state.pos_y, actor.width, actor.height);
let z = if actor.z_front { Z_OVERLAY } else { Z_WIDGET };
match active_action {
Some(Action::SwapGif { texture, shader }) => {
ctx.scene.push_image(draw_rect, *shader, *texture, z, None);
}
_ => {
if let Some(tex) = actor.gif {
ctx.scene
.push_image(draw_rect, actor.gif_shader, tex, z, None);
} else {
let style = actor.style.or(ctx.default_style);
if let Some(style) = style {
ctx.registry.draw(
style,
draw_rect,
state.visual,
DrawLabel {
label: None,
z,
clip: None,
alpha: 1.0,
},
ctx.scene,
ctx.tex_registry,
);
}
}
}
}
TickResult::default()
}
pub fn tick_tab(
tab: &mut crate::items::Tab,
state: &mut TabState,
ctx: &mut TickCtx,
focused_id: Option<&String>,
focused_path: &[usize],
) -> TickResult {
if tab.full_span {
let gw = grid_width(ctx.pw, ctx.ph);
tab.x = -gw * 0.5;
tab.width = gw;
}
let container = Rect::new(tab.x, tab.y, tab.width, tab.height);
let tab_clip = ClipRect {
x: tab.x,
y: tab.y,
w: tab.width,
h: tab.height,
};
if let Some(style) = tab.style {
ctx.registry.draw(
style,
container,
WidgetState::default(),
DrawLabel {
label: None,
z: Z_CONTAINER,
clip: ctx.clip,
alpha: 1.0,
},
ctx.scene,
ctx.tex_registry,
);
}
let page = ctx
.tab_pages
.get(&tab.id)
.copied()
.unwrap_or(0)
.min(tab.pages.len().saturating_sub(1));
if page != state.last_page {
let dir = if page > state.last_page {
1.0_f32
} else {
-1.0_f32
};
state.slide_x = dir * tab.width;
state.slide_vel = 0.0;
state.last_page = page;
}
let accel = (-400.0_f32).mul_add(state.slide_x, -(30.0 * state.slide_vel));
state.slide_vel += accel * ctx.dt;
state.slide_x += state.slide_vel * ctx.dt;
if state.slide_x.abs() < 0.5 && state.slide_vel.abs() < 1.0 {
state.slide_x = 0.0;
state.slide_vel = 0.0;
}
let Some(page_def) = tab.pages.get_mut(page) else {
return TickResult::default();
};
let page_children = &mut state.children[page];
let scroll_offset = &mut state.page_scrolls[page];
let item_w = tab.width - tab.pad_left - tab.pad_right;
let n = page_def.items.len();
let total_gap = if n > 1 {
tab.gap * (n as f32 - 1.0)
} else {
0.0
};
let total_h = page_def
.items
.iter()
.map(crate::logic::item_height)
.sum::<f32>()
+ total_gap
+ tab.pad_top
+ tab.pad_bottom;
let max_scroll = (total_h - tab.height).max(0.0);
*scroll_offset =
scroll(*scroll_offset, total_h, tab.height, container, ctx.input).min(max_scroll);
if let Some(&ci) = focused_path.first() {
let mut item_y = tab.pad_top;
for (idx, item) in page_def.items.iter().enumerate() {
let h = crate::logic::item_height(item);
if idx == ci {
let target = tab
.height
.mul_add(-0.5, h.mul_add(0.5, item_y))
.clamp(0.0, max_scroll);
*scroll_offset += (target - *scroll_offset) * 0.2;
break;
}
item_y += h + tab.gap;
}
}
let slide = state.slide_x;
let mut cy = tab.y + tab.pad_top - *scroll_offset;
let mut consumed_nav = false;
for (ci, (item, child_state)) in page_def
.items
.iter_mut()
.zip(page_children.iter_mut())
.enumerate()
{
let h = crate::logic::item_height(item);
let visible = cy + h > tab.y && cy < tab.y + tab.height;
set_item_pos(item, tab.x + tab.pad_left + slide, cy, item_w);
let child_focused = focused_path.first() == Some(&ci);
let child_path = if child_focused {
&focused_path[1..]
} else {
&[]
};
let r = if visible {
let old_clip = ctx.clip;
ctx.clip = merge_clips(ctx.clip, Some(tab_clip));
let res = tick_item(
item,
child_state,
ctx,
focused_id,
child_focused,
child_path,
);
ctx.clip = old_clip;
res
} else {
TickResult::default()
};
if child_focused && r.consumes_nav {
consumed_nav = true;
}
cy += h + tab.gap;
}
if consumed_nav {
TickResult {
consumes_nav: true,
..Default::default()
}
} else {
TickResult::default()
}
}
pub fn tick_item(
item: &mut UiItem,
state: &mut ItemState,
ctx: &mut TickCtx,
focused_id: Option<&String>,
focused: bool,
focused_path: &[usize],
) -> TickResult {
let nav = KeyNav::from_input(ctx.input);
match (item, state) {
(UiItem::Button(b), ItemState::Button(s)) => tick_button(b, s, ctx, focused),
(UiItem::Toggle(t), ItemState::Toggle(s)) => tick_toggle(t, s, ctx, focused),
(UiItem::Slider(sl), ItemState::Slider(st)) => tick_slider(sl, st, ctx, focused, nav),
(UiItem::TextBox(tb), ItemState::TextBox(s)) => {
let tb_focused = focused_id.map(String::as_str) == Some(tb.id.as_str());
tick_textbox(tb, s, ctx, tb_focused, nav)
}
(UiItem::ScrollList(l), ItemState::ScrollList(s)) => {
tick_scroll_list(l, s, ctx, focused_path)
}
(UiItem::ScrollPane(p), ItemState::ScrollPane(s)) => {
tick_scroll_pane(p, s, ctx, focused_id, focused_path)
}
(UiItem::Dropdown(dd), ItemState::Dropdown(s)) => {
tick_dropdown(dd, s, ctx, focused, focused_path, nav)
}
(UiItem::Popout(p), ItemState::Popout(s)) => {
tick_popout(p, s, ctx, focused, focused_path, nav)
}
(UiItem::Tab(t), ItemState::Tab(s)) => tick_tab(t, s, ctx, focused_id, focused_path),
(UiItem::Actor(a), ItemState::Actor(s)) => tick_actor(a, s, ctx, 0.0, 0.0),
(UiItem::Toast(t), ItemState::Toast(s)) => tick_toast(t, s, ctx),
(UiItem::Bar(b), ItemState::Bar(s)) => tick_bar(b, s, ctx, focused_path),
(UiItem::RadioGroup(rg), ItemState::RadioGroup(s)) => {
tick_radio_group(rg, s, ctx, focused_path)
}
(UiItem::Label(l), ItemState::None) => {
ctx.scene.push_text(&TextDraw {
text: &l.text,
x: l.x,
y: l.y,
w: 0.0,
size: l.size,
color: l.color,
align: crate::draw::TextAlign::Left,
font: None,
bold: false,
italic: false,
clip: ctx.clip,
z: Z_WIDGET,
});
TickResult::default()
}
(UiItem::Divider(d), ItemState::None) => {
if d.full_span {
let gw = crate::draw::grid_width(ctx.pw, ctx.ph);
d.x = -gw * 0.5;
d.width = gw;
}
let rect = Rect::new(d.x, d.y, d.width, d.height);
ctx.registry.draw(
d.style,
rect,
WidgetState::default(),
DrawLabel {
label: None,
z: Z_WIDGET,
clip: ctx.clip,
alpha: 1.0,
},
ctx.scene,
ctx.tex_registry,
);
TickResult::default()
}
(UiItem::Image(img), ItemState::None) => {
ctx.scene.push_image(
Rect::new(img.x, img.y, img.width, img.height),
img.shader,
img.texture,
Z_WIDGET,
ctx.clip,
);
TickResult::default()
}
(UiItem::ProgressBar(pb), ItemState::None) => {
let track = Rect::new(pb.x, pb.y, pb.width, pb.height);
let fill = Rect::new(pb.x, pb.y, pb.width * pb.value.clamp(0.0, 1.0), pb.height);
ctx.registry.draw(
pb.style_track,
track,
WidgetState::default(),
DrawLabel {
label: None,
z: Z_WIDGET,
clip: ctx.clip,
alpha: 1.0,
},
ctx.scene,
ctx.tex_registry,
);
ctx.registry.draw(
pb.style_fill,
fill,
WidgetState::default(),
DrawLabel {
label: None,
z: Z_WIDGET,
clip: ctx.clip,
alpha: 1.0,
},
ctx.scene,
ctx.tex_registry,
);
TickResult::default()
}
_ => TickResult::default(),
}
}