Skip to main content

fresh/view/controls/map_input/
render.rs

1//! Map control rendering functions
2
3use ratatui::layout::Rect;
4use ratatui::style::{Modifier, Style};
5use ratatui::text::{Line, Span};
6use ratatui::widgets::Paragraph;
7use ratatui::Frame;
8
9use super::{FocusState, MapColors, MapEntryLayout, MapLayout, MapState};
10
11/// Render a map control
12pub fn render_map(
13    frame: &mut Frame,
14    area: Rect,
15    state: &MapState,
16    colors: &MapColors,
17    key_width: u16,
18) -> MapLayout {
19    let empty_layout = MapLayout {
20        full_area: area,
21        entry_areas: Vec::new(),
22        add_row_area: None,
23    };
24
25    if area.height == 0 || area.width < 15 {
26        return empty_layout;
27    }
28
29    let label_color = match state.focus {
30        FocusState::Focused => colors.focused_fg,
31        FocusState::Hovered => colors.focused_fg,
32        FocusState::Disabled => colors.disabled,
33        FocusState::Normal => colors.label,
34    };
35
36    // Render label
37    let label_line = Line::from(vec![
38        Span::styled(&state.label, Style::default().fg(label_color)),
39        Span::raw(":"),
40    ]);
41    frame.render_widget(
42        Paragraph::new(label_line),
43        Rect::new(area.x, area.y, area.width, 1),
44    );
45
46    let mut entry_areas = Vec::new();
47    let mut y = area.y + 1;
48    let indent = 2u16;
49    let actual_key_width = key_width.min(area.width.saturating_sub(indent + 8));
50
51    // Render entries
52    for (idx, (key, value)) in state.entries.iter().enumerate() {
53        if y >= area.y + area.height {
54            break;
55        }
56
57        let is_focused = state.focused_entry == Some(idx) && state.focus == FocusState::Focused;
58        let is_expanded = state.is_expanded(idx);
59
60        let arrow = if is_expanded { "▼" } else { ">" };
61
62        // Value preview using display_field if available
63        let value_preview = state.get_display_value(value);
64        // Truncate if too long. Counts characters (not bytes) so that a
65        // preview value containing non-ASCII (e.g. quoted strings with
66        // emoji or CJK) doesn't byte-slice through a multi-byte UTF-8
67        // sequence and panic — same class as #1718.
68        let max_preview_len = 30;
69        let value_preview = if value_preview.chars().count() > max_preview_len {
70            let kept: String = value_preview
71                .chars()
72                .take(max_preview_len.saturating_sub(3))
73                .collect();
74            format!("{}...", kept)
75        } else {
76            value_preview
77        };
78
79        let row_area = Rect::new(area.x, y, area.width, 1);
80
81        // Full row background highlight for focused entry
82        if is_focused {
83            let highlight_style = Style::default().bg(colors.focused);
84            let bg_line = Line::from(Span::styled(
85                " ".repeat(area.width as usize),
86                highlight_style,
87            ));
88            frame.render_widget(Paragraph::new(bg_line), row_area);
89        }
90
91        // Row content with appropriate colors
92        let (arrow_color, key_color, value_color) = if is_focused {
93            // Use focused_fg for text on the focused background
94            (colors.focused_fg, colors.focused_fg, colors.focused_fg)
95        } else {
96            (colors.expand_arrow, colors.key, colors.value_preview)
97        };
98
99        let base_style = if is_focused {
100            Style::default().bg(colors.focused)
101        } else {
102            Style::default()
103        };
104
105        let mut spans = vec![
106            Span::styled(" ".repeat(indent as usize), base_style),
107            Span::styled(arrow, base_style.fg(arrow_color)),
108            Span::raw(" "),
109            Span::styled(
110                format!("{:width$}", key, width = actual_key_width as usize),
111                base_style.fg(key_color),
112            ),
113            Span::raw(" "),
114            Span::styled(value_preview, base_style.fg(value_color)),
115        ];
116
117        // Add [Edit] hint for focused entry
118        if is_focused {
119            spans.push(Span::styled(
120                "  [Enter to edit]",
121                base_style.fg(colors.focused_fg).add_modifier(Modifier::DIM),
122            ));
123        }
124
125        frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
126
127        entry_areas.push(MapEntryLayout {
128            index: idx,
129            row_area,
130            expand_area: Rect::new(area.x + indent, y, 1, 1),
131            key_area: Rect::new(area.x + indent + 2, y, actual_key_width, 1),
132            remove_area: Rect::new(area.x + indent + 2 + actual_key_width + 22, y, 3, 1),
133        });
134
135        y += 1;
136
137        // If expanded, show nested values (simplified view)
138        if is_expanded && y < area.y + area.height {
139            if let Some(obj) = value.as_object() {
140                for (nested_key, nested_value) in obj.iter().take(5) {
141                    if y >= area.y + area.height {
142                        break;
143                    }
144                    let nested_preview = format_value_preview(nested_value, 15);
145                    let nested_line = Line::from(vec![
146                        Span::raw(" ".repeat((indent + 4) as usize)),
147                        Span::styled(
148                            format!("{}: ", nested_key),
149                            Style::default().fg(colors.label),
150                        ),
151                        Span::styled(nested_preview, Style::default().fg(colors.value_preview)),
152                    ]);
153                    frame.render_widget(
154                        Paragraph::new(nested_line),
155                        Rect::new(area.x, y, area.width, 1),
156                    );
157                    y += 1;
158                }
159                if obj.len() > 5 && y < area.y + area.height {
160                    let more_line = Line::from(Span::styled(
161                        format!(
162                            "{}... and {} more",
163                            " ".repeat((indent + 4) as usize),
164                            obj.len() - 5
165                        ),
166                        Style::default()
167                            .fg(colors.value_preview)
168                            .add_modifier(Modifier::ITALIC),
169                    ));
170                    frame.render_widget(
171                        Paragraph::new(more_line),
172                        Rect::new(area.x, y, area.width, 1),
173                    );
174                    y += 1;
175                }
176            }
177        }
178    }
179
180    // Render "add new" row
181    let add_row_area = if y < area.y + area.height {
182        let is_focused = state.focused_entry.is_none() && state.focus == FocusState::Focused;
183        let (border_color, text_color) = if is_focused {
184            (colors.focused, colors.label)
185        } else if state.focus == FocusState::Disabled {
186            (colors.disabled, colors.disabled)
187        } else {
188            (colors.border, colors.label)
189        };
190
191        let inner_width = actual_key_width.saturating_sub(2) as usize;
192        let visible: String = state.new_key_text.chars().take(inner_width).collect();
193        let padded = format!("{:width$}", visible, width = inner_width);
194
195        let line = Line::from(vec![
196            Span::raw(" ".repeat(indent as usize)),
197            Span::styled("[", Style::default().fg(border_color)),
198            Span::styled(padded, Style::default().fg(text_color)),
199            Span::styled("]", Style::default().fg(border_color)),
200            Span::raw(" "),
201            Span::styled("[+]", Style::default().fg(colors.add_button)),
202            Span::raw(" Add entry..."),
203        ]);
204
205        let row_area = Rect::new(area.x, y, area.width, 1);
206        frame.render_widget(Paragraph::new(line), row_area);
207
208        // Render cursor if focused
209        if is_focused && state.cursor <= inner_width {
210            let cursor_x = area.x + indent + 1 + state.cursor as u16;
211            let cursor_char = state.new_key_text.chars().nth(state.cursor).unwrap_or(' ');
212            let cursor_area = Rect::new(cursor_x, y, 1, 1);
213            let cursor_span = Span::styled(
214                cursor_char.to_string(),
215                Style::default()
216                    .fg(colors.cursor)
217                    .add_modifier(Modifier::REVERSED),
218            );
219            frame.render_widget(Paragraph::new(Line::from(vec![cursor_span])), cursor_area);
220        }
221
222        Some(row_area)
223    } else {
224        None
225    };
226
227    MapLayout {
228        full_area: area,
229        entry_areas,
230        add_row_area,
231    }
232}
233
234/// Format a JSON value as a short preview string.
235///
236/// Counts characters (not bytes) when truncating so that a JSON string
237/// containing non-ASCII (e.g. CJK, emoji) doesn't byte-slice through a
238/// multi-byte UTF-8 sequence and panic — same class as #1718.
239pub(super) fn format_value_preview(value: &serde_json::Value, max_len: usize) -> String {
240    let s = match value {
241        serde_json::Value::Null => "null".to_string(),
242        serde_json::Value::Bool(b) => b.to_string(),
243        serde_json::Value::Number(n) => n.to_string(),
244        serde_json::Value::String(s) => format!("\"{}\"", s),
245        serde_json::Value::Array(arr) => format!("[{} items]", arr.len()),
246        serde_json::Value::Object(obj) => format!("{{{} fields}}", obj.len()),
247    };
248    if s.chars().count() > max_len {
249        let kept: String = s.chars().take(max_len.saturating_sub(3)).collect();
250        format!("{}...", kept)
251    } else {
252        s
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::format_value_preview;
259    use serde_json::json;
260
261    #[test]
262    fn format_value_preview_ascii_truncates() {
263        let v = json!("hello world this is a long string");
264        let out = format_value_preview(&v, 10);
265        assert_eq!(out, "\"hello ...");
266    }
267
268    #[test]
269    fn format_value_preview_multibyte_does_not_panic() {
270        // Regression: byte-slicing a JSON string at `max_len - 3` would
271        // land inside the 3-byte UTF-8 sequence for `こ` and panic — same
272        // class as #1718.
273        let v = json!("こんにちは世界これはテストです");
274        let out = format_value_preview(&v, 10);
275        // Must not panic; output must be valid UTF-8.
276        assert!(out.ends_with("..."));
277        assert_eq!(out.chars().count(), 10);
278    }
279
280    #[test]
281    fn format_value_preview_emoji_does_not_panic() {
282        let v = json!("📦📦📦📦📦📦📦📦📦📦");
283        let out = format_value_preview(&v, 5);
284        assert!(out.ends_with("..."));
285        assert_eq!(out.chars().count(), 5);
286    }
287}