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.interaction_count += 1;
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.interaction_count += 1;
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.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 = self.next_interaction_id();
154 let mut response = self.response_for(interaction_id);
155 response.focused = focused;
156
157 if focused {
158 let mut consumed_indices = Vec::new();
159 for (i, event) in self.events.iter().enumerate() {
160 if let Event::Key(key) = event {
161 if key.kind != KeyEventKind::Press {
162 continue;
163 }
164 match key.code {
165 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
166 let _ = handle_vertical_nav(
167 &mut state.selected,
168 visible.len().saturating_sub(1),
169 key.code.clone(),
170 );
171 consumed_indices.push(i);
172 }
173 _ => {}
174 }
175 }
176 }
177
178 for index in consumed_indices {
179 self.consumed[index] = true;
180 }
181 }
182
183 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
184 for (i, event) in self.events.iter().enumerate() {
185 if self.consumed[i] {
186 continue;
187 }
188 if let Event::Mouse(mouse) = event {
189 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
190 continue;
191 }
192 let in_bounds = mouse.x >= rect.x
193 && mouse.x < rect.right()
194 && mouse.y >= rect.y
195 && mouse.y < rect.bottom();
196 if !in_bounds {
197 continue;
198 }
199 let clicked_idx = (mouse.y - rect.y) as usize;
200 if clicked_idx < visible.len() {
201 state.selected = clicked_idx;
202 self.consumed[i] = true;
203 }
204 }
205 }
206 }
207
208 self.commands.push(Command::BeginContainer {
209 direction: Direction::Column,
210 gap: 0,
211 align: Align::Start,
212 align_self: None,
213 justify: Justify::Start,
214 border: None,
215 border_sides: BorderSides::all(),
216 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
217 bg_color: None,
218 padding: Padding::default(),
219 margin: Margin::default(),
220 constraints: Constraints::default(),
221 title: None,
222 grow: 0,
223 group_name: None,
224 });
225
226 for (view_idx, &item_idx) in visible.iter().enumerate() {
227 let item = &state.items[item_idx];
228 if view_idx == state.selected {
229 let mut selected_style = Style::new()
230 .bg(colors.accent.unwrap_or(self.theme.selected_bg))
231 .fg(colors.fg.unwrap_or(self.theme.selected_fg));
232 if focused {
233 selected_style = selected_style.bold();
234 }
235 let mut row = String::with_capacity(2 + item.len());
236 row.push_str("▸ ");
237 row.push_str(item);
238 self.styled(row, selected_style);
239 } else {
240 let mut row = String::with_capacity(2 + item.len());
241 row.push_str(" ");
242 row.push_str(item);
243 self.styled(row, Style::new().fg(colors.fg.unwrap_or(self.theme.text)));
244 }
245 }
246
247 self.commands.push(Command::EndContainer);
248 self.last_text_idx = None;
249
250 response.changed = state.selected != old_selected;
251 response
252 }
253
254 pub fn calendar(&mut self, state: &mut CalendarState) -> Response {
256 let focused = self.register_focusable();
257 let interaction_id = self.next_interaction_id();
258 let mut response = self.response_for(interaction_id);
259 response.focused = focused;
260
261 let month_days = CalendarState::days_in_month(state.year, state.month);
262 state.cursor_day = state.cursor_day.clamp(1, month_days);
263 if let Some(day) = state.selected_day {
264 state.selected_day = Some(day.min(month_days));
265 }
266 let old_selected = state.selected_day;
267
268 if focused {
269 let mut consumed_indices = Vec::new();
270 for (i, event) in self.events.iter().enumerate() {
271 if self.consumed[i] {
272 continue;
273 }
274 if let Event::Key(key) = event {
275 if key.kind != KeyEventKind::Press {
276 continue;
277 }
278 match key.code {
279 KeyCode::Left => {
280 calendar_move_cursor_by_days(state, -1);
281 consumed_indices.push(i);
282 }
283 KeyCode::Right => {
284 calendar_move_cursor_by_days(state, 1);
285 consumed_indices.push(i);
286 }
287 KeyCode::Up => {
288 calendar_move_cursor_by_days(state, -7);
289 consumed_indices.push(i);
290 }
291 KeyCode::Down => {
292 calendar_move_cursor_by_days(state, 7);
293 consumed_indices.push(i);
294 }
295 KeyCode::Char('h') => {
296 state.prev_month();
297 consumed_indices.push(i);
298 }
299 KeyCode::Char('l') => {
300 state.next_month();
301 consumed_indices.push(i);
302 }
303 KeyCode::Enter | KeyCode::Char(' ') => {
304 state.selected_day = Some(state.cursor_day);
305 consumed_indices.push(i);
306 }
307 _ => {}
308 }
309 }
310 }
311
312 for index in consumed_indices {
313 self.consumed[index] = true;
314 }
315 }
316
317 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
318 for (i, event) in self.events.iter().enumerate() {
319 if self.consumed[i] {
320 continue;
321 }
322 if let Event::Mouse(mouse) = event {
323 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
324 continue;
325 }
326 let in_bounds = mouse.x >= rect.x
327 && mouse.x < rect.right()
328 && mouse.y >= rect.y
329 && mouse.y < rect.bottom();
330 if !in_bounds {
331 continue;
332 }
333
334 let rel_x = mouse.x.saturating_sub(rect.x);
335 let rel_y = mouse.y.saturating_sub(rect.y);
336 if rel_y == 0 {
337 if rel_x <= 2 {
338 state.prev_month();
339 self.consumed[i] = true;
340 continue;
341 }
342 if rel_x + 3 >= rect.width {
343 state.next_month();
344 self.consumed[i] = true;
345 continue;
346 }
347 }
348
349 if !(2..8).contains(&rel_y) {
350 continue;
351 }
352 if rel_x >= 21 {
353 continue;
354 }
355
356 let week = rel_y - 2;
357 let col = rel_x / 3;
358 let day_index = week * 7 + col;
359 let first = CalendarState::first_weekday(state.year, state.month);
360 let days = CalendarState::days_in_month(state.year, state.month);
361 if day_index < first {
362 continue;
363 }
364 let day = day_index - first + 1;
365 if day == 0 || day > days {
366 continue;
367 }
368 state.cursor_day = day;
369 state.selected_day = Some(day);
370 self.consumed[i] = true;
371 }
372 }
373 }
374
375 let title = {
376 let month_name = calendar_month_name(state.month);
377 let mut s = String::with_capacity(16);
378 s.push_str(&state.year.to_string());
379 s.push(' ');
380 s.push_str(month_name);
381 s
382 };
383
384 self.commands.push(Command::BeginContainer {
385 direction: Direction::Column,
386 gap: 0,
387 align: Align::Start,
388 align_self: None,
389 justify: Justify::Start,
390 border: None,
391 border_sides: BorderSides::all(),
392 border_style: Style::new().fg(self.theme.border),
393 bg_color: None,
394 padding: Padding::default(),
395 margin: Margin::default(),
396 constraints: Constraints::default(),
397 title: None,
398 grow: 0,
399 group_name: None,
400 });
401
402 self.commands.push(Command::BeginContainer {
403 direction: Direction::Row,
404 gap: 1,
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 self.styled("◀", Style::new().fg(self.theme.text));
420 self.styled(title, Style::new().bold().fg(self.theme.text));
421 self.styled("▶", Style::new().fg(self.theme.text));
422 self.commands.push(Command::EndContainer);
423
424 self.commands.push(Command::BeginContainer {
425 direction: Direction::Row,
426 gap: 0,
427 align: Align::Start,
428 align_self: None,
429 justify: Justify::Start,
430 border: None,
431 border_sides: BorderSides::all(),
432 border_style: Style::new().fg(self.theme.border),
433 bg_color: None,
434 padding: Padding::default(),
435 margin: Margin::default(),
436 constraints: Constraints::default(),
437 title: None,
438 grow: 0,
439 group_name: None,
440 });
441 for wd in ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] {
442 self.styled(
443 format!("{wd:>2} "),
444 Style::new().fg(self.theme.text_dim).bold(),
445 );
446 }
447 self.commands.push(Command::EndContainer);
448
449 let first = CalendarState::first_weekday(state.year, state.month);
450 let days = CalendarState::days_in_month(state.year, state.month);
451 for week in 0..6_u32 {
452 self.commands.push(Command::BeginContainer {
453 direction: Direction::Row,
454 gap: 0,
455 align: Align::Start,
456 align_self: None,
457 justify: Justify::Start,
458 border: None,
459 border_sides: BorderSides::all(),
460 border_style: Style::new().fg(self.theme.border),
461 bg_color: None,
462 padding: Padding::default(),
463 margin: Margin::default(),
464 constraints: Constraints::default(),
465 title: None,
466 grow: 0,
467 group_name: None,
468 });
469
470 for col in 0..7_u32 {
471 let idx = week * 7 + col;
472 if idx < first || idx >= first + days {
473 self.styled(" ", Style::new().fg(self.theme.text_dim));
474 continue;
475 }
476 let day = idx - first + 1;
477 let text = format!("{day:>2} ");
478 let style = if state.selected_day == Some(day) {
479 Style::new()
480 .bg(self.theme.selected_bg)
481 .fg(self.theme.selected_fg)
482 } else if state.cursor_day == day {
483 Style::new().fg(self.theme.primary).bold()
484 } else {
485 Style::new().fg(self.theme.text)
486 };
487 self.styled(text, style);
488 }
489
490 self.commands.push(Command::EndContainer);
491 }
492
493 self.commands.push(Command::EndContainer);
494 self.last_text_idx = None;
495 response.changed = state.selected_day != old_selected;
496 response
497 }
498
499 pub fn file_picker(&mut self, state: &mut FilePickerState) -> Response {
501 if state.dirty {
502 state.refresh();
503 }
504 if !state.entries.is_empty() {
505 state.selected = state.selected.min(state.entries.len().saturating_sub(1));
506 }
507
508 let focused = self.register_focusable();
509 let interaction_id = self.next_interaction_id();
510 let mut response = self.response_for(interaction_id);
511 response.focused = focused;
512 let mut file_selected = false;
513
514 if focused {
515 let mut consumed_indices = Vec::new();
516 for (i, event) in self.events.iter().enumerate() {
517 if self.consumed[i] {
518 continue;
519 }
520 if let Event::Key(key) = event {
521 if key.kind != KeyEventKind::Press {
522 continue;
523 }
524 match key.code {
525 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
526 if !state.entries.is_empty() {
527 let _ = handle_vertical_nav(
528 &mut state.selected,
529 state.entries.len().saturating_sub(1),
530 key.code.clone(),
531 );
532 }
533 consumed_indices.push(i);
534 }
535 KeyCode::Enter => {
536 if let Some(entry) = state.entries.get(state.selected).cloned() {
537 if entry.is_dir {
538 state.current_dir = entry.path;
539 state.selected = 0;
540 state.selected_file = None;
541 state.dirty = true;
542 } else {
543 state.selected_file = Some(entry.path);
544 file_selected = true;
545 }
546 }
547 consumed_indices.push(i);
548 }
549 KeyCode::Backspace => {
550 if let Some(parent) =
551 state.current_dir.parent().map(|p| p.to_path_buf())
552 {
553 state.current_dir = parent;
554 state.selected = 0;
555 state.selected_file = None;
556 state.dirty = true;
557 }
558 consumed_indices.push(i);
559 }
560 KeyCode::Char('h') => {
561 state.show_hidden = !state.show_hidden;
562 state.selected = 0;
563 state.dirty = true;
564 consumed_indices.push(i);
565 }
566 KeyCode::Esc => {
567 state.selected_file = None;
568 consumed_indices.push(i);
569 }
570 _ => {}
571 }
572 }
573 }
574
575 for index in consumed_indices {
576 self.consumed[index] = true;
577 }
578 }
579
580 if state.dirty {
581 state.refresh();
582 }
583
584 self.commands.push(Command::BeginContainer {
585 direction: Direction::Column,
586 gap: 0,
587 align: Align::Start,
588 align_self: None,
589 justify: Justify::Start,
590 border: None,
591 border_sides: BorderSides::all(),
592 border_style: Style::new().fg(self.theme.border),
593 bg_color: None,
594 padding: Padding::default(),
595 margin: Margin::default(),
596 constraints: Constraints::default(),
597 title: None,
598 grow: 0,
599 group_name: None,
600 });
601
602 let dir_text = {
603 let dir = state.current_dir.display().to_string();
604 let mut text = String::with_capacity(5 + dir.len());
605 text.push_str("Dir: ");
606 text.push_str(&dir);
607 text
608 };
609 self.styled(dir_text, Style::new().fg(self.theme.text_dim).dim());
610
611 if state.entries.is_empty() {
612 self.styled("(empty)", Style::new().fg(self.theme.text_dim).dim());
613 } else {
614 for (idx, entry) in state.entries.iter().enumerate() {
615 let icon = if entry.is_dir { "▸ " } else { " " };
616 let row = if entry.is_dir {
617 let mut row = String::with_capacity(icon.len() + entry.name.len());
618 row.push_str(icon);
619 row.push_str(&entry.name);
620 row
621 } else {
622 let size_text = entry.size.to_string();
623 let mut row =
624 String::with_capacity(icon.len() + entry.name.len() + size_text.len() + 4);
625 row.push_str(icon);
626 row.push_str(&entry.name);
627 row.push_str(" ");
628 row.push_str(&size_text);
629 row.push_str(" B");
630 row
631 };
632
633 let style = if idx == state.selected {
634 if focused {
635 Style::new().bold().fg(self.theme.primary)
636 } else {
637 Style::new().fg(self.theme.primary)
638 }
639 } else {
640 Style::new().fg(self.theme.text)
641 };
642 self.styled(row, style);
643 }
644 }
645
646 self.commands.push(Command::EndContainer);
647 self.last_text_idx = None;
648
649 response.changed = file_selected;
650 response
651 }
652}