1use crate::copy_modal::{CopyFocus, CopyModal};
5use crate::render::context::RenderContext;
6use crate::widgets::ui::{FormRow, FormValue, HintBar, Picker, Surface};
7use ratatui::buffer::Buffer;
8use ratatui::layout::Rect;
9use ratatui::style::Style;
10use ratatui::widgets::{Paragraph, Widget};
11
12const LABEL_WIDTH: u16 = 9;
14
15fn row_label(focus: CopyFocus) -> &'static str {
16 match focus {
17 CopyFocus::Scope => "Scope:",
18 CopyFocus::Column => "Column:",
19 CopyFocus::Format => "Format:",
20 CopyFocus::Header => "Header:",
21 }
22}
23
24pub fn render_copy_modal(area: Rect, buf: &mut Buffer, modal: &mut CopyModal, ctx: &RenderContext) {
25 let g = crate::glyphs::get();
26 let footer = match &modal.picker {
27 Some(_) => HintBar::from_ctx(ctx)
28 .hint_weighted("Enter", "Choose", 3)
29 .hint_weighted("type", "Narrow", 1)
30 .hint_weighted("Esc", "Back", 4),
31 None => {
32 let (key, label) = match modal.focus {
33 CopyFocus::Header => ("Space", "Toggle"),
34 CopyFocus::Column => ("Space", "Pick"),
35 CopyFocus::Scope | CopyFocus::Format => (g.updown_lr, "Change"),
36 };
37 HintBar::from_ctx(ctx)
38 .hint_weighted("Enter", "Copy", 3)
39 .hint_weighted(key, label, 2)
40 .hint_weighted("Tab", "Next", 1)
41 .hint_weighted("Esc", "Cancel", 4)
42 }
43 };
44 crate::pointer::record(area, crate::pointer::Hit::Modal);
45 let content = Surface::new("Copy").footer(&footer).render(area, buf, ctx);
46 if content.height < 3 || content.width < 10 {
47 return;
48 }
49
50 let spec_y = content.y + content.height - 1;
52
53 let rows = modal.row_order();
54 let mut y = content.y;
55 for &row in &rows {
56 if y >= spec_y {
57 break;
58 }
59 let value = match row {
60 CopyFocus::Scope => FormValue::Choice(modal.scope.as_str()),
61 CopyFocus::Format => FormValue::Choice(modal.format.as_str()),
62 CopyFocus::Header => FormValue::Toggle(modal.header()),
63 CopyFocus::Column => match modal.column.as_deref() {
64 Some(column) => FormValue::Choice(column),
65 None => FormValue::Placeholder("none"),
66 },
67 };
68 let row_area = Rect {
69 y,
70 height: 1,
71 ..content
72 };
73 FormRow {
74 label: row_label(row),
75 value,
76 focused: modal.focus == row,
77 label_width: LABEL_WIDTH,
78 }
79 .render_picking(row_area, buf, ctx, modal.picker.is_some());
80 crate::pointer::record_field::<CopyModal>(row_area, row);
81 y += 1;
82 }
83
84 if let Some(state) = &modal.picker {
87 crate::pointer::record(content, crate::pointer::Hit::Picker);
89 let picker_y = y + 1;
90 if picker_y < spec_y {
91 let picker_area = Rect {
92 x: content.x + 2,
93 y: picker_y,
94 width: content.width.saturating_sub(2),
95 height: spec_y - picker_y,
96 };
97 Picker::from_state(state, true).render(picker_area, buf, ctx);
98 }
99 }
100
101 let (text, style) = match modal.spec_line() {
103 Ok(line) => (line, Style::default().fg(ctx.text_primary)),
104 Err(gap) if modal.attention => (gap, Style::default().fg(ctx.warning)),
105 Err(gap) => (gap, Style::default().fg(ctx.dimmed)),
106 };
107 Paragraph::new(text).style(style).render(
108 Rect {
109 y: spec_y,
110 height: 1,
111 ..content
112 },
113 buf,
114 );
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::copy_modal::{CopyContext, CopyScope};
121
122 fn render_to_text(modal: &mut CopyModal) -> String {
123 let area = Rect::new(0, 0, 46, 14);
124 let mut buf = Buffer::empty(area);
125 let ctx = RenderContext::for_test();
126 render_copy_modal(area, &mut buf, modal, &ctx);
127 (0..area.height)
128 .map(|y| {
129 (0..area.width)
130 .map(|x| buf[(x, y)].symbol().to_string())
131 .collect::<String>()
132 })
133 .collect::<Vec<_>>()
134 .join("\n")
135 }
136
137 #[test]
138 fn the_dialog_echoes_every_choice_and_the_spec() {
139 let mut modal = CopyModal::new();
140 modal.open(
141 vec!["city".into(), "pop".into()],
142 None,
143 CopyContext {
144 row_number: 1235,
145 view_rows: 42,
146 view_cols: 8,
147 total_rows: Some(1_000_000),
148 },
149 );
150 let text = render_to_text(&mut modal);
151 assert!(text.contains("Scope:"), "{text}");
152 assert!(text.contains("Row"), "{text}");
153 assert!(text.contains("Format:"), "{text}");
154 assert!(text.contains("Copy row 1,235 as TSV"), "{text}");
155 }
156
157 #[test]
158 fn the_python_scope_shows_only_the_scope_row() {
159 let mut modal = CopyModal::new();
160 modal.open(vec!["city".into()], None, CopyContext::default());
161 modal.scope = CopyScope::Python;
162 let text = render_to_text(&mut modal);
163 assert!(text.contains("Python (Polars)"), "{text}");
164 assert!(!text.contains("Format:"), "{text}");
165 assert!(!text.contains("Header:"), "{text}");
166 assert!(
167 text.contains("Copy the view as a Python (Polars) script"),
168 "{text}"
169 );
170 }
171
172 #[test]
173 fn the_open_picker_lists_and_narrows() {
174 let mut modal = CopyModal::new();
175 modal.open(
176 vec!["city".into(), "population".into()],
177 None,
178 CopyContext::default(),
179 );
180 modal.scope = CopyScope::Cell;
181 modal.focus = CopyFocus::Column;
182 modal.open_picker();
183 let text = render_to_text(&mut modal);
184 assert!(text.contains("city"), "{text}");
185 assert!(text.contains("population"), "{text}");
186 modal
187 .picker
188 .as_mut()
189 .unwrap()
190 .filter_key('p', crossterm::event::KeyModifiers::NONE);
191 let text = render_to_text(&mut modal);
192 assert!(text.contains("population"), "{text}");
193 assert!(!text.contains("\n city"), "{text}");
194 }
195}