Skip to main content

a2ui_tui/components/
slider.rs

1//! Slider component — renders a progress-bar style slider.
2
3use ratatui::{
4    Frame,
5    layout::Rect,
6    style::{Color, Style},
7    text::{Line, Span},
8    widgets::Paragraph,
9};
10
11use a2ui_base::model::component_context::ComponentContext;
12use a2ui_base::protocol::common_types::{DynamicNumber, DynamicString};
13use crate::component_impl::TuiComponent;
14
15/// Slider component implementation.
16///
17/// Renders a progress-bar style slider: `[=====>      ] 50`.
18/// Uses `━` for the filled portion and `─` for the unfilled portion.
19/// Applies a default 1-cell margin.
20pub struct SliderComponent;
21
22impl TuiComponent for SliderComponent {
23    fn name(&self) -> &'static str {
24        "Slider"
25    }
26
27    fn render(
28        &self,
29        ctx: &ComponentContext,
30        area: Rect,
31        frame: &mut Frame,
32        _render_child: &mut dyn FnMut(&str, Rect, &mut Frame, &str),
33        _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
34    ) {
35        let comp_model = match ctx.components.get(&ctx.component_id) {
36            Some(m) => m,
37            None => return,
38        };
39
40        // Apply default 1-cell margin on all sides (never collapses to zero).
41        let inner = crate::layout_engine::padded_content(area);
42
43        if inner.width == 0 || inner.height == 0 {
44            return;
45        }
46
47        // Resolve label.
48        let label = match comp_model.get_property::<DynamicString>("label") {
49            Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
50            None => String::new(),
51        };
52
53        // Resolve value, min, max.
54        let value = match comp_model.get_property::<DynamicNumber>("value") {
55            Some(dn) => ctx.data_context.resolve_dynamic_number(&dn),
56            None => 0.0,
57        };
58        let min = comp_model
59            .get_property::<DynamicNumber>("min")
60            .map(|dn| ctx.data_context.resolve_dynamic_number(&dn))
61            .unwrap_or(0.0);
62        let max = comp_model
63            .get_property::<DynamicNumber>("max")
64            .map(|dn| ctx.data_context.resolve_dynamic_number(&dn))
65            .unwrap_or(100.0);
66
67        // Calculate fill ratio.
68        let range = max - min;
69        let ratio = if range.abs() < f64::EPSILON {
70            0.0
71        } else {
72            ((value - min) / range).clamp(0.0, 1.0)
73        };
74
75        // Build the slider bar string.
76        // Reserve space for: label + space + brackets + value text
77        let value_text = format!("{:.0}", value);
78        // Total available width for the bar inside brackets
79        let label_width = if label.is_empty() { 0 } else { label.len() + 1 };
80        let value_width = value_text.len() + 1; // space before value
81        let overhead = 2 + label_width + value_width; // [ ]
82        let bar_width = if (inner.width as usize) > overhead {
83            inner.width as usize - overhead
84        } else {
85            0
86        };
87
88        let filled = (bar_width as f64 * ratio).round() as usize;
89        let unfilled = bar_width.saturating_sub(filled);
90
91        // Determine if this slider has keyboard focus.
92        let is_focused = ctx.focused_id.as_deref() == Some(ctx.component_id.as_str());
93        let bar_color = if is_focused { Color::Yellow } else { Color::Cyan };
94
95        // Resolve steps for discrete step markers.
96        let steps = comp_model
97            .get_property::<DynamicNumber>("steps")
98            .map(|dn| ctx.data_context.resolve_dynamic_number(&dn) as usize);
99
100        let bar_str = if bar_width > 0 {
101            if let Some(step_count) = steps {
102                if step_count > 0 && step_count <= bar_width {
103                    // Draw slider with step markers
104                    let mut bar: Vec<char> = vec!['─'; bar_width];
105                    for i in 0..=step_count {
106                        let pos = (bar_width as f64 * i as f64 / step_count as f64).round() as usize;
107                        if pos < bar_width {
108                            bar[pos] = '┬';
109                        }
110                    }
111                    // Fill portion
112                    for j in 0..filled {
113                        if j < bar.len() {
114                            bar[j] = '━';
115                        }
116                    }
117                    format!("[{}]", bar.into_iter().collect::<String>())
118                } else {
119                    let filled_str: String = "━".repeat(filled);
120                    let unfilled_str: String = "─".repeat(unfilled);
121                    format!("[{}{}]", filled_str, unfilled_str)
122                }
123            } else {
124                let filled_str: String = "━".repeat(filled);
125                let unfilled_str: String = "─".repeat(unfilled);
126                format!("[{}{}]", filled_str, unfilled_str)
127            }
128        } else {
129            String::new()
130        };
131
132        // Build the display line.
133        let mut spans = Vec::new();
134        if !label.is_empty() {
135            spans.push(Span::styled(
136                format!("{} ", label),
137                Style::default().fg(Color::White),
138            ));
139        }
140        spans.push(Span::styled(
141            bar_str,
142            Style::default().fg(bar_color),
143        ));
144        spans.push(Span::styled(
145            format!(" {}", value_text),
146            Style::default().fg(Color::White),
147        ));
148
149        let paragraph = Paragraph::new(Line::from(spans));
150        frame.render_widget(paragraph, inner);
151    }
152
153    fn natural_height(
154        &self,
155        _ctx: &ComponentContext,
156        _available_width: u16,
157        _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
158    ) -> Option<u16> {
159        // Single-line slider bar + 2 margin.
160        Some(3)
161    }
162
163    fn handle_event(
164        &self,
165        ctx: &ComponentContext,
166        event: &a2ui_base::event::InputEvent,
167    ) -> Option<a2ui_base::event::EventResult> {
168        a2ui_base::components::slider::handle_event(ctx, event)
169    }
170}