use rdom_core::{Dom, ListenerOptions, NodeId};
use crate::ext::TuiExt;
use crate::render::Style;
use crate::runtime::builtins::canvas;
use crate::style::Color;
use crate::tui_event::TuiDispatchExt;
use crate::{TuiDom, TuiEvent};
const TRACK_GLYPH: &str = "\u{2500}"; const THUMB_GLYPH: &str = "\u{25CF}";
pub fn install(dom: &mut TuiDom) {
let root = dom.root();
dom.add_event_listener(root, "keydown", ListenerOptions::default(), move |ctx| {
if ctx.event.default_prevented() {
return;
}
let Some(focused) = ctx.dom.focused() else {
return;
};
if !is_range(ctx.dom, focused) {
return;
}
if ctx.dom.node(focused).has_attribute("disabled") {
return;
}
let Some(key) = ctx.event.detail.as_keyboard() else {
return;
};
if key.modifiers.ctrl || key.modifiers.meta || key.modifiers.alt {
return;
}
let step = step_of(ctx.dom, focused);
let (min, max) = range_of(ctx.dom, focused);
let current = value_of(ctx.dom, focused);
let new = match key.key.as_str() {
"ArrowRight" | "ArrowUp" => current + step,
"ArrowLeft" | "ArrowDown" => current - step,
"PageUp" => current + step * 10.0,
"PageDown" => current - step * 10.0,
"Home" => min,
"End" => max,
_ => return,
};
set_value(ctx.dom, focused, new.clamp(min, max));
})
.expect("range keydown install");
}
pub fn attach_all(dom: &mut TuiDom) {
let ranges = collect_ranges(dom);
for id in ranges {
attach(dom, id);
}
}
pub fn attach(dom: &mut TuiDom, input: NodeId) {
if !is_range(dom, input) {
return;
}
canvas::set_paint(dom, input, move |dom, ctx| {
paint_track(dom, input, ctx);
});
}
pub fn value_of(dom: &TuiDom, input: NodeId) -> f64 {
parse_attr::<f64>(dom, input, "value").unwrap_or_else(|| {
let (min, max) = range_of(dom, input);
if max < min {
min
} else {
min + (max - min) / 2.0
}
})
}
pub fn range_of(dom: &TuiDom, input: NodeId) -> (f64, f64) {
let min = parse_attr::<f64>(dom, input, "min").unwrap_or(0.0);
let max = parse_attr::<f64>(dom, input, "max").unwrap_or(100.0);
(min, max)
}
pub fn step_of(dom: &TuiDom, input: NodeId) -> f64 {
match dom.node(input).get_attribute("step") {
Some("any") | None => 1.0,
Some(v) => v.parse::<f64>().ok().filter(|n| *n > 0.0).unwrap_or(1.0),
}
}
pub fn set_value(dom: &mut TuiDom, input: NodeId, v: f64) {
let (min, max) = range_of(dom, input);
let clamped = v.clamp(min, max);
let current = value_of(dom, input);
if (clamped - current).abs() < f64::EPSILON {
return;
}
let _ = dom.set_attribute(input, "value", &format_number(clamped));
let mut input_ev = TuiEvent::input(rdom_core::InputType::InsertReplacementText, None);
let _ = dom.dispatch_tui_event(input, &mut input_ev);
let mut change_ev = TuiEvent::new("change");
let _ = dom.dispatch_tui_event(input, &mut change_ev);
}
fn paint_track(dom: &Dom<TuiExt>, input: NodeId, ctx: &mut canvas::RenderContext<'_>) {
let style = style_from_dom(dom, input);
let w = ctx.width();
if w == 0 || ctx.height() == 0 {
return;
}
for x in 0..w {
ctx.set(x, 0, TRACK_GLYPH.chars().next().unwrap(), style);
}
let (min, max) = range_of_generic(dom, input);
let value = value_of_generic(dom, input);
let ratio = if max <= min {
0.0
} else {
((value - min) / (max - min)).clamp(0.0, 1.0)
};
let thumb_x = (ratio * (w as f64 - 1.0)).round() as u16;
ctx.set(
thumb_x.min(w - 1),
0,
THUMB_GLYPH.chars().next().unwrap(),
style,
);
}
fn style_from_dom(dom: &Dom<TuiExt>, id: NodeId) -> Style {
let fg = dom
.node(id)
.ext()
.and_then(|e| e.computed.as_ref())
.map(|c| c.fg)
.unwrap_or(Color::Reset);
if fg == Color::Reset {
Style::new()
} else {
Style::new().fg(fg)
}
}
fn is_range(dom: &TuiDom, id: NodeId) -> bool {
dom.node(id).tag_name() == Some("input") && dom.node(id).get_attribute("type") == Some("range")
}
fn collect_ranges(dom: &TuiDom) -> Vec<NodeId> {
let mut out = Vec::new();
walk(dom, dom.root(), &mut out);
out
}
fn walk(dom: &TuiDom, id: NodeId, out: &mut Vec<NodeId>) {
if is_range(dom, id) {
out.push(id);
}
for child in dom.node(id).child_nodes() {
walk(dom, child.id(), out);
}
}
fn parse_attr<T: std::str::FromStr>(dom: &TuiDom, id: NodeId, name: &str) -> Option<T> {
dom.node(id)
.get_attribute(name)
.and_then(|s| s.parse().ok())
}
fn value_of_generic(dom: &Dom<TuiExt>, id: NodeId) -> f64 {
dom.node(id)
.get_attribute("value")
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| {
let (min, max) = range_of_generic(dom, id);
if max < min {
min
} else {
min + (max - min) / 2.0
}
})
}
fn range_of_generic(dom: &Dom<TuiExt>, id: NodeId) -> (f64, f64) {
let min = dom
.node(id)
.get_attribute("min")
.and_then(|s| s.parse().ok())
.unwrap_or(0.0);
let max = dom
.node(id)
.get_attribute("max")
.and_then(|s| s.parse().ok())
.unwrap_or(100.0);
(min, max)
}
fn format_number(n: f64) -> String {
if n == n.trunc() && n.is_finite() && n.abs() < 1e16 {
format!("{}", n as i64)
} else {
format!("{}", n)
}
}
#[cfg(test)]
mod tests;