1use super::*;
2
3impl Context {
4 pub fn grid(&mut self, cols: u32, f: impl FnOnce(&mut Context)) -> Response {
21 slt_assert(cols > 0, "grid() requires at least 1 column");
22 let interaction_id = self.next_interaction_id();
23 let border = self.theme.border;
24
25 self.commands.push(Command::BeginContainer {
26 direction: Direction::Column,
27 gap: 0,
28 align: Align::Start,
29 align_self: None,
30 justify: Justify::Start,
31 border: None,
32 border_sides: BorderSides::all(),
33 border_style: Style::new().fg(border),
34 bg_color: None,
35 padding: Padding::default(),
36 margin: Margin::default(),
37 constraints: Constraints::default(),
38 title: None,
39 grow: 0,
40 group_name: None,
41 });
42
43 let children_start = self.commands.len();
44 f(self);
45 let child_commands: Vec<Command> = self.commands.drain(children_start..).collect();
46
47 let mut elements: Vec<Vec<Command>> = Vec::new();
48 let mut iter = child_commands.into_iter().peekable();
49 while let Some(cmd) = iter.next() {
50 match cmd {
51 Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
52 let mut depth = 1_u32;
53 let mut element = vec![cmd];
54 for next in iter.by_ref() {
55 match next {
56 Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
57 depth += 1;
58 }
59 Command::EndContainer => {
60 depth = depth.saturating_sub(1);
61 }
62 _ => {}
63 }
64 let at_end = matches!(next, Command::EndContainer) && depth == 0;
65 element.push(next);
66 if at_end {
67 break;
68 }
69 }
70 elements.push(element);
71 }
72 Command::EndContainer => {}
73 _ => elements.push(vec![cmd]),
74 }
75 }
76
77 let cols = cols.max(1) as usize;
78 for row in elements.chunks(cols) {
79 self.skip_interaction_slot();
80 self.commands.push(Command::BeginContainer {
81 direction: Direction::Row,
82 gap: 0,
83 align: Align::Start,
84 align_self: None,
85 justify: Justify::Start,
86 border: None,
87 border_sides: BorderSides::all(),
88 border_style: Style::new().fg(border),
89 bg_color: None,
90 padding: Padding::default(),
91 margin: Margin::default(),
92 constraints: Constraints::default(),
93 title: None,
94 grow: 0,
95 group_name: None,
96 });
97
98 for element in row {
99 self.skip_interaction_slot();
100 self.commands.push(Command::BeginContainer {
101 direction: Direction::Column,
102 gap: 0,
103 align: Align::Start,
104 align_self: None,
105 justify: Justify::Start,
106 border: None,
107 border_sides: BorderSides::all(),
108 border_style: Style::new().fg(border),
109 bg_color: None,
110 padding: Padding::default(),
111 margin: Margin::default(),
112 constraints: Constraints::default(),
113 title: None,
114 grow: 1,
115 group_name: None,
116 });
117 self.commands.extend(element.iter().cloned());
118 self.commands.push(Command::EndContainer);
119 }
120
121 self.commands.push(Command::EndContainer);
122 }
123
124 self.commands.push(Command::EndContainer);
125 self.rollback.last_text_idx = None;
126
127 self.response_for(interaction_id)
128 }
129
130 pub fn list(&mut self, state: &mut ListState) -> Response {
136 self.list_colored(state, &WidgetColors::new())
137 }
138
139 pub fn list_colored(&mut self, state: &mut ListState, colors: &WidgetColors) -> Response {
141 let visible = state.visible_indices().to_vec();
142 if visible.is_empty() && state.items.is_empty() {
143 state.selected = 0;
144 return Response::none();
145 }
146
147 if !visible.is_empty() {
148 state.selected = state.selected.min(visible.len().saturating_sub(1));
149 }
150
151 let old_selected = state.selected;
152 let focused = self.register_focusable();
153 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
154
155 if focused {
156 let mut consumed_indices = Vec::new();
157 for (i, key) in self.available_key_presses() {
158 match key.code {
159 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
160 let _ = handle_vertical_nav(
161 &mut state.selected,
162 visible.len().saturating_sub(1),
163 key.code.clone(),
164 );
165 consumed_indices.push(i);
166 }
167 _ => {}
168 }
169 }
170 self.consume_indices(consumed_indices);
171 }
172
173 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
174 let mut consumed = Vec::new();
175 for (i, mouse) in clicks {
176 let clicked_idx = (mouse.y - rect.y) as usize;
177 if clicked_idx < visible.len() {
178 state.selected = clicked_idx;
179 consumed.push(i);
180 }
181 }
182 self.consume_indices(consumed);
183 }
184
185 self.commands.push(Command::BeginContainer {
186 direction: Direction::Column,
187 gap: 0,
188 align: Align::Start,
189 align_self: None,
190 justify: Justify::Start,
191 border: None,
192 border_sides: BorderSides::all(),
193 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
194 bg_color: None,
195 padding: Padding::default(),
196 margin: Margin::default(),
197 constraints: Constraints::default(),
198 title: None,
199 grow: 0,
200 group_name: None,
201 });
202
203 for (view_idx, &item_idx) in visible.iter().enumerate() {
204 let item = &state.items[item_idx];
205 if view_idx == state.selected {
206 let mut selected_style = Style::new()
207 .bg(colors.accent.unwrap_or(self.theme.selected_bg))
208 .fg(colors.fg.unwrap_or(self.theme.selected_fg));
209 if focused {
210 selected_style = selected_style.bold();
211 }
212 let mut row = String::with_capacity(2 + item.len());
213 row.push_str("▸ ");
214 row.push_str(item);
215 self.styled(row, selected_style);
216 } else {
217 let mut row = String::with_capacity(2 + item.len());
218 row.push_str(" ");
219 row.push_str(item);
220 self.styled(row, Style::new().fg(colors.fg.unwrap_or(self.theme.text)));
221 }
222 }
223
224 self.commands.push(Command::EndContainer);
225 self.rollback.last_text_idx = None;
226
227 response.changed = state.selected != old_selected;
228 response
229 }
230
231 pub fn calendar(&mut self, state: &mut CalendarState) -> Response {
233 let focused = self.register_focusable();
234 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
235
236 let month_days = CalendarState::days_in_month(state.year, state.month);
237 state.cursor_day = state.cursor_day.clamp(1, month_days);
238 if let Some(day) = state.selected_day {
239 state.selected_day = Some(day.min(month_days));
240 }
241 let old_selected = state.selected_day;
242
243 if focused {
244 let mut consumed_indices = Vec::new();
245 for (i, key) in self.available_key_presses() {
246 match key.code {
247 KeyCode::Left => {
248 calendar_move_cursor_by_days(state, -1);
249 consumed_indices.push(i);
250 }
251 KeyCode::Right => {
252 calendar_move_cursor_by_days(state, 1);
253 consumed_indices.push(i);
254 }
255 KeyCode::Up => {
256 calendar_move_cursor_by_days(state, -7);
257 consumed_indices.push(i);
258 }
259 KeyCode::Down => {
260 calendar_move_cursor_by_days(state, 7);
261 consumed_indices.push(i);
262 }
263 KeyCode::Char('h') => {
264 state.prev_month();
265 consumed_indices.push(i);
266 }
267 KeyCode::Char('l') => {
268 state.next_month();
269 consumed_indices.push(i);
270 }
271 KeyCode::Enter | KeyCode::Char(' ') => {
272 state.selected_day = Some(state.cursor_day);
273 consumed_indices.push(i);
274 }
275 _ => {}
276 }
277 }
278 self.consume_indices(consumed_indices);
279 }
280
281 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
282 let mut consumed = Vec::new();
283 for (i, mouse) in clicks {
284 let rel_x = mouse.x.saturating_sub(rect.x);
285 let rel_y = mouse.y.saturating_sub(rect.y);
286 if rel_y == 0 {
287 if rel_x <= 2 {
288 state.prev_month();
289 consumed.push(i);
290 continue;
291 }
292 if rel_x + 3 >= rect.width {
293 state.next_month();
294 consumed.push(i);
295 continue;
296 }
297 }
298
299 if !(2..8).contains(&rel_y) {
300 continue;
301 }
302 if rel_x >= 21 {
303 continue;
304 }
305
306 let week = rel_y - 2;
307 let col = rel_x / 3;
308 let day_index = week * 7 + col;
309 let first = CalendarState::first_weekday(state.year, state.month);
310 let days = CalendarState::days_in_month(state.year, state.month);
311 if day_index < first {
312 continue;
313 }
314 let day = day_index - first + 1;
315 if day == 0 || day > days {
316 continue;
317 }
318 state.cursor_day = day;
319 state.selected_day = Some(day);
320 consumed.push(i);
321 }
322 self.consume_indices(consumed);
323 }
324
325 let title = {
326 let month_name = calendar_month_name(state.month);
327 let mut s = String::with_capacity(16);
328 s.push_str(&state.year.to_string());
329 s.push(' ');
330 s.push_str(month_name);
331 s
332 };
333
334 self.commands.push(Command::BeginContainer {
335 direction: Direction::Column,
336 gap: 0,
337 align: Align::Start,
338 align_self: None,
339 justify: Justify::Start,
340 border: None,
341 border_sides: BorderSides::all(),
342 border_style: Style::new().fg(self.theme.border),
343 bg_color: None,
344 padding: Padding::default(),
345 margin: Margin::default(),
346 constraints: Constraints::default(),
347 title: None,
348 grow: 0,
349 group_name: None,
350 });
351
352 self.commands.push(Command::BeginContainer {
353 direction: Direction::Row,
354 gap: 1,
355 align: Align::Start,
356 align_self: None,
357 justify: Justify::Start,
358 border: None,
359 border_sides: BorderSides::all(),
360 border_style: Style::new().fg(self.theme.border),
361 bg_color: None,
362 padding: Padding::default(),
363 margin: Margin::default(),
364 constraints: Constraints::default(),
365 title: None,
366 grow: 0,
367 group_name: None,
368 });
369 self.styled("◀", Style::new().fg(self.theme.text));
370 self.styled(title, Style::new().bold().fg(self.theme.text));
371 self.styled("▶", Style::new().fg(self.theme.text));
372 self.commands.push(Command::EndContainer);
373
374 self.commands.push(Command::BeginContainer {
375 direction: Direction::Row,
376 gap: 0,
377 align: Align::Start,
378 align_self: None,
379 justify: Justify::Start,
380 border: None,
381 border_sides: BorderSides::all(),
382 border_style: Style::new().fg(self.theme.border),
383 bg_color: None,
384 padding: Padding::default(),
385 margin: Margin::default(),
386 constraints: Constraints::default(),
387 title: None,
388 grow: 0,
389 group_name: None,
390 });
391 for wd in ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] {
392 self.styled(
393 format!("{wd:>2} "),
394 Style::new().fg(self.theme.text_dim).bold(),
395 );
396 }
397 self.commands.push(Command::EndContainer);
398
399 let first = CalendarState::first_weekday(state.year, state.month);
400 let days = CalendarState::days_in_month(state.year, state.month);
401 for week in 0..6_u32 {
402 self.commands.push(Command::BeginContainer {
403 direction: Direction::Row,
404 gap: 0,
405 align: Align::Start,
406 align_self: None,
407 justify: Justify::Start,
408 border: None,
409 border_sides: BorderSides::all(),
410 border_style: Style::new().fg(self.theme.border),
411 bg_color: None,
412 padding: Padding::default(),
413 margin: Margin::default(),
414 constraints: Constraints::default(),
415 title: None,
416 grow: 0,
417 group_name: None,
418 });
419
420 for col in 0..7_u32 {
421 let idx = week * 7 + col;
422 if idx < first || idx >= first + days {
423 self.styled(" ", Style::new().fg(self.theme.text_dim));
424 continue;
425 }
426 let day = idx - first + 1;
427 let text = format!("{day:>2} ");
428 let style = if state.selected_day == Some(day) {
429 Style::new()
430 .bg(self.theme.selected_bg)
431 .fg(self.theme.selected_fg)
432 } else if state.cursor_day == day {
433 Style::new().fg(self.theme.primary).bold()
434 } else {
435 Style::new().fg(self.theme.text)
436 };
437 self.styled(text, style);
438 }
439
440 self.commands.push(Command::EndContainer);
441 }
442
443 self.commands.push(Command::EndContainer);
444 self.rollback.last_text_idx = None;
445 response.changed = state.selected_day != old_selected;
446 response
447 }
448
449 pub fn file_picker(&mut self, state: &mut FilePickerState) -> Response {
451 if state.dirty {
452 state.refresh();
453 }
454 if !state.entries.is_empty() {
455 state.selected = state.selected.min(state.entries.len().saturating_sub(1));
456 }
457
458 let focused = self.register_focusable();
459 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
460 let mut file_selected = false;
461
462 if focused {
463 let mut consumed_indices = Vec::new();
464 for (i, key) in self.available_key_presses() {
465 match key.code {
466 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
467 if !state.entries.is_empty() {
468 let _ = handle_vertical_nav(
469 &mut state.selected,
470 state.entries.len().saturating_sub(1),
471 key.code.clone(),
472 );
473 }
474 consumed_indices.push(i);
475 }
476 KeyCode::Enter => {
477 if let Some(entry) = state.entries.get(state.selected).cloned() {
478 if entry.is_dir {
479 state.current_dir = entry.path;
480 state.selected = 0;
481 state.selected_file = None;
482 state.dirty = true;
483 } else {
484 state.selected_file = Some(entry.path);
485 file_selected = true;
486 }
487 }
488 consumed_indices.push(i);
489 }
490 KeyCode::Backspace => {
491 if let Some(parent) = state.current_dir.parent().map(|p| p.to_path_buf()) {
492 state.current_dir = parent;
493 state.selected = 0;
494 state.selected_file = None;
495 state.dirty = true;
496 }
497 consumed_indices.push(i);
498 }
499 KeyCode::Char('h') => {
500 state.show_hidden = !state.show_hidden;
501 state.selected = 0;
502 state.dirty = true;
503 consumed_indices.push(i);
504 }
505 KeyCode::Esc => {
506 state.selected_file = None;
507 consumed_indices.push(i);
508 }
509 _ => {}
510 }
511 }
512 self.consume_indices(consumed_indices);
513 }
514
515 if state.dirty {
516 state.refresh();
517 }
518
519 self.commands.push(Command::BeginContainer {
520 direction: Direction::Column,
521 gap: 0,
522 align: Align::Start,
523 align_self: None,
524 justify: Justify::Start,
525 border: None,
526 border_sides: BorderSides::all(),
527 border_style: Style::new().fg(self.theme.border),
528 bg_color: None,
529 padding: Padding::default(),
530 margin: Margin::default(),
531 constraints: Constraints::default(),
532 title: None,
533 grow: 0,
534 group_name: None,
535 });
536
537 let dir_text = {
538 let dir = state.current_dir.display().to_string();
539 let mut text = String::with_capacity(5 + dir.len());
540 text.push_str("Dir: ");
541 text.push_str(&dir);
542 text
543 };
544 self.styled(dir_text, Style::new().fg(self.theme.text_dim).dim());
545
546 if state.entries.is_empty() {
547 self.styled("(empty)", Style::new().fg(self.theme.text_dim).dim());
548 } else {
549 for (idx, entry) in state.entries.iter().enumerate() {
550 let icon = if entry.is_dir { "▸ " } else { " " };
551 let row = if entry.is_dir {
552 let mut row = String::with_capacity(icon.len() + entry.name.len());
553 row.push_str(icon);
554 row.push_str(&entry.name);
555 row
556 } else {
557 let size_text = entry.size.to_string();
558 let mut row =
559 String::with_capacity(icon.len() + entry.name.len() + size_text.len() + 4);
560 row.push_str(icon);
561 row.push_str(&entry.name);
562 row.push_str(" ");
563 row.push_str(&size_text);
564 row.push_str(" B");
565 row
566 };
567
568 let style = if idx == state.selected {
569 if focused {
570 Style::new().bold().fg(self.theme.primary)
571 } else {
572 Style::new().fg(self.theme.primary)
573 }
574 } else {
575 Style::new().fg(self.theme.text)
576 };
577 self.styled(row, style);
578 }
579 }
580
581 self.commands.push(Command::EndContainer);
582 self.rollback.last_text_idx = None;
583
584 response.changed = file_selected;
585 response
586 }
587}