fresh/view/controls/map_input/
render.rs1use 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
11pub 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 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 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 let value_preview = state.get_display_value(value);
64 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 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 let (arrow_color, key_color, value_color) = if is_focused {
93 (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 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 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 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 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
234pub(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 let v = json!("こんにちは世界これはテストです");
274 let out = format_value_preview(&v, 10);
275 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}