1use ratatui::{
2 layout::Rect,
3 style::{Modifier, Style},
4 text::{Line, Span},
5 widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
6 Frame,
7};
8
9use super::markdown::{parse_markdown, wrap_styled_lines, wrap_text_lines, StyledLine};
10
11pub mod input;
12use super::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
13use crate::primitives::grammar::GrammarRegistry;
14
15fn clamp_rect_to_bounds(rect: Rect, bounds: Rect) -> Rect {
18 let x = rect.x.min(bounds.x + bounds.width.saturating_sub(1));
20 let y = rect.y.min(bounds.y + bounds.height.saturating_sub(1));
22
23 let max_width = (bounds.x + bounds.width).saturating_sub(x);
25 let max_height = (bounds.y + bounds.height).saturating_sub(y);
26
27 Rect {
28 x,
29 y,
30 width: rect.width.min(max_width),
31 height: rect.height.min(max_height),
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum PopupPosition {
38 AtCursor,
40 BelowCursor,
42 AboveCursor,
44 Fixed { x: u16, y: u16 },
46 Centered,
48 CenteredOverlay { width_pct: u8, height_pct: u8 },
54 BottomRight,
56 AboveStatusBarAt { x: u16, status_row: u16 },
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum PopupKind {
68 Completion,
70 Hover,
72 Action,
74 List,
76 Text,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Default)]
90pub enum PopupResolver {
91 #[default]
94 None,
95 Completion,
97 LspConfirm { language: String },
101 LspStatus,
104 CodeAction,
108 PluginAction { popup_id: String },
112 RemoteIndicator,
117 WorkspaceTrust,
122}
123
124#[derive(Debug, Clone, PartialEq)]
126pub enum PopupContent {
127 Text(Vec<String>),
129 Markdown(Vec<StyledLine>),
131 List {
133 items: Vec<PopupListItem>,
134 selected: usize,
135 },
136 Custom(Vec<String>),
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub struct PopupTextSelection {
143 pub start: (usize, usize),
145 pub end: (usize, usize),
147}
148
149impl PopupTextSelection {
150 pub fn normalized(&self) -> ((usize, usize), (usize, usize)) {
152 if self.start.0 < self.end.0 || (self.start.0 == self.end.0 && self.start.1 <= self.end.1) {
153 (self.start, self.end)
154 } else {
155 (self.end, self.start)
156 }
157 }
158
159 pub fn contains(&self, line: usize, col: usize) -> bool {
161 let ((start_line, start_col), (end_line, end_col)) = self.normalized();
162 if line < start_line || line > end_line {
163 return false;
164 }
165 if line == start_line && line == end_line {
166 col >= start_col && col < end_col
167 } else if line == start_line {
168 col >= start_col
169 } else if line == end_line {
170 col < end_col
171 } else {
172 true
173 }
174 }
175}
176
177#[derive(Debug, Clone, PartialEq)]
179pub struct PopupListItem {
180 pub text: String,
182 pub detail: Option<String>,
184 pub icon: Option<String>,
186 pub data: Option<String>,
188 pub disabled: bool,
190}
191
192impl PopupListItem {
193 pub fn new(text: String) -> Self {
194 Self {
195 text,
196 detail: None,
197 icon: None,
198 data: None,
199 disabled: false,
200 }
201 }
202
203 pub fn with_detail(mut self, detail: String) -> Self {
204 self.detail = Some(detail);
205 self
206 }
207
208 pub fn with_icon(mut self, icon: String) -> Self {
209 self.icon = Some(icon);
210 self
211 }
212
213 pub fn with_data(mut self, data: String) -> Self {
214 self.data = Some(data);
215 self
216 }
217
218 pub fn disabled(mut self) -> Self {
219 self.disabled = true;
220 self
221 }
222}
223
224#[derive(Debug, Clone, PartialEq)]
233pub struct Popup {
234 pub kind: PopupKind,
236
237 pub title: Option<String>,
239
240 pub description: Option<String>,
242
243 pub transient: bool,
245
246 pub content: PopupContent,
248
249 pub position: PopupPosition,
251
252 pub width: u16,
254
255 pub max_height: u16,
257
258 pub bordered: bool,
260
261 pub border_style: Style,
263
264 pub background_style: Style,
266
267 pub scroll_offset: usize,
269
270 pub text_selection: Option<PopupTextSelection>,
272
273 pub accept_key_hint: Option<String>,
275
276 pub resolver: PopupResolver,
279
280 pub focused: bool,
289
290 pub focus_key_hint: Option<String>,
296}
297
298impl Popup {
299 pub fn text(content: Vec<String>, theme: &crate::view::theme::Theme) -> Self {
301 Self {
302 kind: PopupKind::Text,
303 title: None,
304 description: None,
305 transient: false,
306 content: PopupContent::Text(content),
307 position: PopupPosition::AtCursor,
308 width: 50,
309 max_height: 15,
310 bordered: true,
311 border_style: Style::default().fg(theme.popup_border_fg),
312 background_style: Style::default().bg(theme.popup_bg),
313 scroll_offset: 0,
314 text_selection: None,
315 accept_key_hint: None,
316 resolver: PopupResolver::None,
317 focused: false,
318 focus_key_hint: None,
319 }
320 }
321
322 pub fn markdown(
327 markdown_text: &str,
328 theme: &crate::view::theme::Theme,
329 registry: Option<&GrammarRegistry>,
330 ) -> Self {
331 let styled_lines = parse_markdown(markdown_text, theme, registry);
332 Self {
333 kind: PopupKind::Text,
334 title: None,
335 description: None,
336 transient: false,
337 content: PopupContent::Markdown(styled_lines),
338 position: PopupPosition::AtCursor,
339 width: 60, max_height: 20, bordered: true,
342 border_style: Style::default().fg(theme.popup_border_fg),
343 background_style: Style::default().bg(theme.popup_bg),
344 scroll_offset: 0,
345 text_selection: None,
346 accept_key_hint: None,
347 resolver: PopupResolver::None,
348 focused: false,
349 focus_key_hint: None,
350 }
351 }
352
353 pub fn list(items: Vec<PopupListItem>, theme: &crate::view::theme::Theme) -> Self {
355 Self {
356 kind: PopupKind::List,
357 title: None,
358 description: None,
359 transient: false,
360 content: PopupContent::List { items, selected: 0 },
361 position: PopupPosition::AtCursor,
362 width: 50,
363 max_height: 15,
364 bordered: true,
365 border_style: Style::default().fg(theme.popup_border_fg),
366 background_style: Style::default().bg(theme.popup_bg),
367 scroll_offset: 0,
368 text_selection: None,
369 accept_key_hint: None,
370 resolver: PopupResolver::None,
371 focused: false,
372 focus_key_hint: None,
373 }
374 }
375
376 pub fn with_title(mut self, title: String) -> Self {
378 self.title = Some(title);
379 self
380 }
381
382 pub fn with_kind(mut self, kind: PopupKind) -> Self {
384 self.kind = kind;
385 self
386 }
387
388 pub fn with_transient(mut self, transient: bool) -> Self {
390 self.transient = transient;
391 self
392 }
393
394 pub fn with_position(mut self, position: PopupPosition) -> Self {
396 self.position = position;
397 self
398 }
399
400 pub fn with_width(mut self, width: u16) -> Self {
402 self.width = width;
403 self
404 }
405
406 pub fn with_max_height(mut self, max_height: u16) -> Self {
408 self.max_height = max_height;
409 self
410 }
411
412 pub fn with_border_style(mut self, style: Style) -> Self {
414 self.border_style = style;
415 self
416 }
417
418 pub fn with_resolver(mut self, resolver: PopupResolver) -> Self {
421 self.resolver = resolver;
422 self
423 }
424
425 pub fn with_focused(mut self, focused: bool) -> Self {
429 self.focused = focused;
430 self
431 }
432
433 pub fn with_focus_key_hint(mut self, hint: String) -> Self {
436 self.focus_key_hint = Some(hint);
437 self
438 }
439
440 pub fn render_title(&self) -> Option<String> {
448 let hint_label = if !self.focused {
449 let hint = self
450 .focus_key_hint
451 .clone()
452 .unwrap_or_else(|| "Alt+T".to_string());
453 Some(format!("[{} to focus]", hint))
454 } else {
455 None
456 };
457 match (&self.title, hint_label) {
458 (Some(title), Some(hint)) => Some(format!("{} {}", title, hint)),
459 (Some(title), None) => Some(title.clone()),
460 (None, Some(hint)) => Some(hint),
461 (None, None) => None,
462 }
463 }
464
465 pub fn selected_item(&self) -> Option<&PopupListItem> {
467 match &self.content {
468 PopupContent::List { items, selected } => items.get(*selected),
469 _ => None,
470 }
471 }
472
473 fn visible_height(&self) -> usize {
475 let border_offset = if self.bordered { 2 } else { 0 };
476 (self.max_height as usize).saturating_sub(border_offset)
477 }
478
479 pub fn select_next(&mut self) {
481 let visible = self.visible_height();
482 if let PopupContent::List { items, selected } = &mut self.content {
483 if *selected < items.len().saturating_sub(1) {
484 *selected += 1;
485 if *selected >= self.scroll_offset + visible {
487 self.scroll_offset = (*selected + 1).saturating_sub(visible);
488 }
489 }
490 }
491 }
492
493 pub fn select_prev(&mut self) {
495 if let PopupContent::List { items: _, selected } = &mut self.content {
496 if *selected > 0 {
497 *selected -= 1;
498 if *selected < self.scroll_offset {
500 self.scroll_offset = *selected;
501 }
502 }
503 }
504 }
505
506 pub fn select_index(&mut self, index: usize) -> bool {
508 let visible = self.visible_height();
509 if let PopupContent::List { items, selected } = &mut self.content {
510 if index < items.len() {
511 *selected = index;
512 if *selected >= self.scroll_offset + visible {
514 self.scroll_offset = (*selected + 1).saturating_sub(visible);
515 } else if *selected < self.scroll_offset {
516 self.scroll_offset = *selected;
517 }
518 return true;
519 }
520 }
521 false
522 }
523
524 pub fn page_down(&mut self) {
526 let visible = self.visible_height();
527 if let PopupContent::List { items, selected } = &mut self.content {
528 *selected = (*selected + visible).min(items.len().saturating_sub(1));
529 self.scroll_offset = (*selected + 1).saturating_sub(visible);
530 } else {
531 self.scroll_offset += visible;
532 }
533 }
534
535 pub fn page_up(&mut self) {
537 let visible = self.visible_height();
538 if let PopupContent::List { items: _, selected } = &mut self.content {
539 *selected = selected.saturating_sub(visible);
540 self.scroll_offset = *selected;
541 } else {
542 self.scroll_offset = self.scroll_offset.saturating_sub(visible);
543 }
544 }
545
546 pub fn select_first(&mut self) {
548 if let PopupContent::List { items: _, selected } = &mut self.content {
549 *selected = 0;
550 self.scroll_offset = 0;
551 } else {
552 self.scroll_offset = 0;
553 }
554 }
555
556 pub fn select_last(&mut self) {
558 let visible = self.visible_height();
559 if let PopupContent::List { items, selected } = &mut self.content {
560 *selected = items.len().saturating_sub(1);
561 if *selected >= visible {
563 self.scroll_offset = (*selected + 1).saturating_sub(visible);
564 }
565 } else {
566 let content_height = self.item_count();
568 if content_height > visible {
569 self.scroll_offset = content_height.saturating_sub(visible);
570 }
571 }
572 }
573
574 pub fn scroll_by(&mut self, delta: i32) {
577 let content_len = self.wrapped_item_count();
578 let visible = self.visible_height();
579 let max_scroll = content_len.saturating_sub(visible);
580
581 if delta < 0 {
582 self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
584 } else {
585 self.scroll_offset = (self.scroll_offset + delta as usize).min(max_scroll);
587 }
588
589 if let PopupContent::List { items, selected } = &mut self.content {
591 let visible_start = self.scroll_offset;
592 let visible_end = (self.scroll_offset + visible).min(items.len());
593
594 if *selected < visible_start {
595 *selected = visible_start;
596 } else if *selected >= visible_end {
597 *selected = visible_end.saturating_sub(1);
598 }
599 }
600 }
601
602 pub fn item_count(&self) -> usize {
604 match &self.content {
605 PopupContent::Text(lines) => lines.len(),
606 PopupContent::Markdown(lines) => lines.len(),
607 PopupContent::List { items, .. } => items.len(),
608 PopupContent::Custom(lines) => lines.len(),
609 }
610 }
611
612 fn wrapped_item_count(&self) -> usize {
617 let border_width = if self.bordered { 2 } else { 0 };
619 let scrollbar_width = 2; let wrap_width = (self.width as usize)
621 .saturating_sub(border_width)
622 .saturating_sub(scrollbar_width);
623
624 if wrap_width == 0 {
625 return self.item_count();
626 }
627
628 match &self.content {
629 PopupContent::Text(lines) => wrap_text_lines(lines, wrap_width).len(),
630 PopupContent::Markdown(styled_lines) => {
631 wrap_styled_lines(styled_lines, wrap_width).len()
632 }
633 PopupContent::List { items, .. } => items.len(),
635 PopupContent::Custom(lines) => lines.len(),
636 }
637 }
638
639 pub fn start_selection(&mut self, line: usize, col: usize) {
641 self.text_selection = Some(PopupTextSelection {
642 start: (line, col),
643 end: (line, col),
644 });
645 }
646
647 pub fn extend_selection(&mut self, line: usize, col: usize) {
649 if let Some(ref mut sel) = self.text_selection {
650 sel.end = (line, col);
651 }
652 }
653
654 pub fn clear_selection(&mut self) {
656 self.text_selection = None;
657 }
658
659 pub fn has_selection(&self) -> bool {
661 if let Some(sel) = &self.text_selection {
662 sel.start != sel.end
663 } else {
664 false
665 }
666 }
667
668 fn content_wrap_width(&self) -> usize {
671 let border_width: u16 = if self.bordered { 2 } else { 0 };
672 let inner_width = self.width.saturating_sub(border_width);
673 let scrollbar_reserved: u16 = 2;
674 let conservative_width = inner_width.saturating_sub(scrollbar_reserved) as usize;
675
676 if conservative_width == 0 {
677 return 0;
678 }
679
680 let visible_height = self.max_height.saturating_sub(border_width) as usize;
681 let line_count = match &self.content {
682 PopupContent::Text(lines) => wrap_text_lines(lines, conservative_width).len(),
683 PopupContent::Markdown(styled_lines) => {
684 wrap_styled_lines(styled_lines, conservative_width).len()
685 }
686 _ => self.item_count(),
687 };
688
689 let needs_scrollbar = line_count > visible_height && inner_width > scrollbar_reserved;
690
691 if needs_scrollbar {
692 conservative_width
693 } else {
694 inner_width as usize
695 }
696 }
697
698 fn get_text_lines(&self) -> Vec<String> {
703 let wrap_width = self.content_wrap_width();
704
705 match &self.content {
706 PopupContent::Text(lines) => {
707 if wrap_width > 0 {
708 wrap_text_lines(lines, wrap_width)
709 } else {
710 lines.clone()
711 }
712 }
713 PopupContent::Markdown(styled_lines) => {
714 if wrap_width > 0 {
715 wrap_styled_lines(styled_lines, wrap_width)
716 .iter()
717 .map(|sl| sl.plain_text())
718 .collect()
719 } else {
720 styled_lines.iter().map(|sl| sl.plain_text()).collect()
721 }
722 }
723 PopupContent::List { items, .. } => items.iter().map(|i| i.text.clone()).collect(),
724 PopupContent::Custom(lines) => lines.clone(),
725 }
726 }
727
728 pub fn get_selected_text(&self) -> Option<String> {
730 let sel = self.text_selection.as_ref()?;
731 if sel.start == sel.end {
732 return None;
733 }
734
735 let ((start_line, start_col), (end_line, end_col)) = sel.normalized();
736 let lines = self.get_text_lines();
737
738 if start_line >= lines.len() {
739 return None;
740 }
741
742 if start_line == end_line {
743 let line = &lines[start_line];
744 let end_col = end_col.min(line.len());
745 let start_col = start_col.min(end_col);
746 Some(line[start_col..end_col].to_string())
747 } else {
748 let mut result = String::new();
749 let first_line = &lines[start_line];
751 result.push_str(&first_line[start_col.min(first_line.len())..]);
752 result.push('\n');
753 for line in lines.iter().take(end_line).skip(start_line + 1) {
755 result.push_str(line);
756 result.push('\n');
757 }
758 if end_line < lines.len() {
760 let last_line = &lines[end_line];
761 result.push_str(&last_line[..end_col.min(last_line.len())]);
762 }
763 Some(result)
764 }
765 }
766
767 pub fn needs_scrollbar(&self) -> bool {
769 self.item_count() > self.visible_height()
770 }
771
772 pub fn scroll_state(&self) -> (usize, usize, usize) {
774 let total = self.item_count();
775 let visible = self.visible_height();
776 (total, visible, self.scroll_offset)
777 }
778
779 pub fn link_at_position(&self, relative_col: usize, relative_row: usize) -> Option<String> {
785 let PopupContent::Markdown(styled_lines) = &self.content else {
786 return None;
787 };
788
789 let border_width = if self.bordered { 2 } else { 0 };
791 let scrollbar_reserved = 2;
792 let content_width = self
793 .width
794 .saturating_sub(border_width)
795 .saturating_sub(scrollbar_reserved) as usize;
796
797 let wrapped_lines = wrap_styled_lines(styled_lines, content_width);
799
800 let line_index = self.scroll_offset + relative_row;
802
803 let line = wrapped_lines.get(line_index)?;
805
806 line.link_at_column(relative_col).map(|s| s.to_string())
808 }
809
810 pub fn description_height(&self) -> u16 {
813 if let Some(desc) = &self.description {
814 let border_width = if self.bordered { 2 } else { 0 };
815 let scrollbar_reserved = 2;
816 let content_width = self
817 .width
818 .saturating_sub(border_width)
819 .saturating_sub(scrollbar_reserved) as usize;
820 let desc_vec = vec![desc.clone()];
821 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
822 wrapped.len() as u16 + 1 } else {
824 0
825 }
826 }
827
828 fn content_height(&self) -> u16 {
830 self.content_height_for_width(self.width)
832 }
833
834 fn content_height_for_width(&self, popup_width: u16) -> u16 {
836 let border_width = if self.bordered { 2 } else { 0 };
838 let scrollbar_reserved = 2; let content_width = popup_width
840 .saturating_sub(border_width)
841 .saturating_sub(scrollbar_reserved) as usize;
842
843 let description_lines = if let Some(desc) = &self.description {
845 let desc_vec = vec![desc.clone()];
846 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
847 wrapped.len() as u16 + 1 } else {
849 0
850 };
851
852 let content_lines = match &self.content {
853 PopupContent::Text(lines) => {
854 wrap_text_lines(lines, content_width).len() as u16
856 }
857 PopupContent::Markdown(styled_lines) => {
858 wrap_styled_lines(styled_lines, content_width).len() as u16
860 }
861 PopupContent::List { items, .. } => items.len() as u16,
862 PopupContent::Custom(lines) => lines.len() as u16,
863 };
864
865 let border_height = if self.bordered { 2 } else { 0 };
867
868 description_lines + content_lines + border_height
869 }
870
871 pub fn calculate_area(&self, terminal_area: Rect, cursor_pos: Option<(u16, u16)>) -> Rect {
873 match self.position {
874 PopupPosition::AtCursor | PopupPosition::BelowCursor | PopupPosition::AboveCursor => {
875 let (cursor_x, cursor_y) =
876 cursor_pos.unwrap_or((terminal_area.width / 2, terminal_area.height / 2));
877
878 let width = self.width.min(terminal_area.width);
879 let height = self
881 .content_height()
882 .min(self.max_height)
883 .min(terminal_area.height);
884
885 let x = if cursor_x + width > terminal_area.width {
886 terminal_area.width.saturating_sub(width)
887 } else {
888 cursor_x
889 };
890
891 let y = match self.position {
892 PopupPosition::AtCursor => cursor_y,
893 PopupPosition::BelowCursor => {
894 if cursor_y + 1 + height > terminal_area.height {
895 cursor_y.saturating_sub(height)
897 } else {
898 cursor_y + 1
900 }
901 }
902 PopupPosition::AboveCursor => {
903 (cursor_y + 1).saturating_sub(height)
905 }
906 _ => cursor_y,
907 };
908
909 Rect {
910 x,
911 y,
912 width,
913 height,
914 }
915 }
916 PopupPosition::Fixed { x, y } => {
917 let width = self.width.min(terminal_area.width);
918 let height = self
919 .content_height()
920 .min(self.max_height)
921 .min(terminal_area.height);
922 let x = if x + width > terminal_area.width {
924 terminal_area.width.saturating_sub(width)
925 } else {
926 x
927 };
928 let y = if y + height > terminal_area.height {
929 terminal_area.height.saturating_sub(height)
930 } else {
931 y
932 };
933 Rect {
934 x,
935 y,
936 width,
937 height,
938 }
939 }
940 PopupPosition::Centered => {
941 let width = self.width.min(terminal_area.width);
942 let height = self
943 .content_height()
944 .min(self.max_height)
945 .min(terminal_area.height);
946 let x = (terminal_area.width.saturating_sub(width)) / 2;
947 let y = (terminal_area.height.saturating_sub(height)) / 2;
948 Rect {
949 x,
950 y,
951 width,
952 height,
953 }
954 }
955 PopupPosition::CenteredOverlay {
956 width_pct,
957 height_pct,
958 } => {
959 let w_pct = width_pct.clamp(1, 100) as u32;
960 let h_pct = height_pct.clamp(1, 100) as u32;
961 let width = ((terminal_area.width as u32 * w_pct) / 100) as u16;
962 let height = ((terminal_area.height as u32 * h_pct) / 100) as u16;
963 let width = width.max(1).min(terminal_area.width);
964 let height = height.max(1).min(terminal_area.height);
965 let x = (terminal_area.width.saturating_sub(width)) / 2;
966 let y = (terminal_area.height.saturating_sub(height)) / 2;
967 Rect {
968 x,
969 y,
970 width,
971 height,
972 }
973 }
974 PopupPosition::BottomRight => {
975 let width = self.width.min(terminal_area.width);
976 let height = self
977 .content_height()
978 .min(self.max_height)
979 .min(terminal_area.height);
980 let x = terminal_area.width.saturating_sub(width);
982 let y = terminal_area
983 .height
984 .saturating_sub(height)
985 .saturating_sub(2);
986 Rect {
987 x,
988 y,
989 width,
990 height,
991 }
992 }
993 PopupPosition::AboveStatusBarAt { x, status_row } => {
994 let width = self.width.min(terminal_area.width);
995 let height = self
996 .content_height()
997 .min(self.max_height)
998 .min(terminal_area.height);
999 let max_x = terminal_area.width.saturating_sub(width).saturating_sub(1);
1005 let x = x.min(max_x);
1006 let y = status_row.saturating_sub(height);
1013 Rect {
1014 x,
1015 y,
1016 width,
1017 height,
1018 }
1019 }
1020 }
1021 }
1022
1023 pub fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::view::theme::Theme) {
1025 self.render_with_hover(frame, area, theme, None);
1026 }
1027
1028 pub fn render_with_hover(
1030 &self,
1031 frame: &mut Frame,
1032 area: Rect,
1033 theme: &crate::view::theme::Theme,
1034 hover_target: Option<&crate::app::HoverTarget>,
1035 ) {
1036 let frame_area = frame.area();
1038 let area = clamp_rect_to_bounds(area, frame_area);
1039
1040 if area.width == 0 || area.height == 0 {
1042 return;
1043 }
1044
1045 frame.render_widget(Clear, area);
1047
1048 let rendered_title = self.render_title();
1049 let block = if self.bordered {
1050 let mut block = Block::default()
1051 .borders(Borders::ALL)
1052 .border_style(self.border_style)
1053 .style(self.background_style);
1054
1055 if let Some(title) = rendered_title.as_deref() {
1056 block = block.title(title);
1057 }
1058
1059 block
1060 } else {
1061 Block::default().style(self.background_style)
1062 };
1063
1064 let inner_area = block.inner(area);
1065 frame.render_widget(block, area);
1066
1067 let dismissible = !matches!(self.resolver, PopupResolver::WorkspaceTrust);
1074 if self.bordered && area.width >= 5 && dismissible {
1075 let close_x = area.x + area.width - 4;
1076 let close_area = Rect {
1077 x: close_x,
1078 y: area.y,
1079 width: 3,
1080 height: 1,
1081 };
1082 frame.render_widget(Paragraph::new("[×]").style(self.border_style), close_area);
1083 }
1084
1085 let content_start_y;
1087 if let Some(desc) = &self.description {
1088 let desc_wrap_width = inner_area.width.saturating_sub(2) as usize; let desc_vec = vec![desc.clone()];
1091 let wrapped_desc = wrap_text_lines(&desc_vec, desc_wrap_width);
1092 let desc_lines: usize = wrapped_desc.len();
1093
1094 for (i, line) in wrapped_desc.iter().enumerate() {
1096 if i >= inner_area.height as usize {
1097 break;
1098 }
1099 let line_area = Rect {
1100 x: inner_area.x,
1101 y: inner_area.y + i as u16,
1102 width: inner_area.width,
1103 height: 1,
1104 };
1105 let desc_style = Style::default().fg(theme.help_separator_fg);
1106 frame.render_widget(Paragraph::new(line.as_str()).style(desc_style), line_area);
1107 }
1108
1109 content_start_y = inner_area.y + (desc_lines as u16).min(inner_area.height) + 1;
1111 } else {
1112 content_start_y = inner_area.y;
1113 }
1114
1115 let inner_area = Rect {
1117 x: inner_area.x,
1118 y: content_start_y,
1119 width: inner_area.width,
1120 height: inner_area
1121 .height
1122 .saturating_sub(content_start_y - area.y - if self.bordered { 1 } else { 0 }),
1123 };
1124
1125 let scrollbar_reserved_width = 2; let wrap_width = inner_area.width.saturating_sub(scrollbar_reserved_width) as usize;
1129 let visible_lines_count = inner_area.height as usize;
1130
1131 let (wrapped_total_lines, needs_scrollbar) = match &self.content {
1133 PopupContent::Text(lines) => {
1134 let wrapped = wrap_text_lines(lines, wrap_width);
1135 let count = wrapped.len();
1136 (
1137 count,
1138 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1139 )
1140 }
1141 PopupContent::Markdown(styled_lines) => {
1142 let wrapped = wrap_styled_lines(styled_lines, wrap_width);
1143 let count = wrapped.len();
1144 (
1145 count,
1146 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1147 )
1148 }
1149 PopupContent::List { items, .. } => {
1150 let count = items.len();
1151 (
1152 count,
1153 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1154 )
1155 }
1156 PopupContent::Custom(lines) => {
1157 let count = lines.len();
1158 (
1159 count,
1160 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1161 )
1162 }
1163 };
1164
1165 let content_area = if needs_scrollbar {
1167 Rect {
1168 x: inner_area.x,
1169 y: inner_area.y,
1170 width: inner_area.width.saturating_sub(scrollbar_reserved_width),
1171 height: inner_area.height,
1172 }
1173 } else {
1174 inner_area
1175 };
1176
1177 match &self.content {
1178 PopupContent::Text(lines) => {
1179 let wrapped_lines = wrap_text_lines(lines, content_area.width as usize);
1181 let selection_style = Style::default().bg(theme.selection_bg);
1182
1183 let visible_lines: Vec<Line> = wrapped_lines
1184 .iter()
1185 .enumerate()
1186 .skip(self.scroll_offset)
1187 .take(content_area.height as usize)
1188 .map(|(line_idx, line)| {
1189 if let Some(ref sel) = self.text_selection {
1190 let chars: Vec<char> = line.chars().collect();
1192 let spans: Vec<Span> = chars
1193 .iter()
1194 .enumerate()
1195 .map(|(col, ch)| {
1196 if sel.contains(line_idx, col) {
1197 Span::styled(ch.to_string(), selection_style)
1198 } else {
1199 Span::raw(ch.to_string())
1200 }
1201 })
1202 .collect();
1203 Line::from(spans)
1204 } else {
1205 Line::from(line.as_str())
1206 }
1207 })
1208 .collect();
1209
1210 let paragraph = Paragraph::new(visible_lines);
1211 frame.render_widget(paragraph, content_area);
1212 }
1213 PopupContent::Markdown(styled_lines) => {
1214 let wrapped_lines = wrap_styled_lines(styled_lines, content_area.width as usize);
1216 let selection_style = Style::default().bg(theme.selection_bg);
1217
1218 let mut link_overlays: Vec<(usize, usize, String, String)> = Vec::new();
1221
1222 let visible_lines: Vec<Line> = wrapped_lines
1223 .iter()
1224 .enumerate()
1225 .skip(self.scroll_offset)
1226 .take(content_area.height as usize)
1227 .map(|(line_idx, styled_line)| {
1228 let mut col = 0usize;
1229 let spans: Vec<Span> = styled_line
1230 .spans
1231 .iter()
1232 .flat_map(|s| {
1233 let span_start_col = col;
1234 let span_width =
1235 unicode_width::UnicodeWidthStr::width(s.text.as_str());
1236 if let Some(url) = &s.link_url {
1237 link_overlays.push((
1238 line_idx - self.scroll_offset,
1239 col,
1240 s.text.clone(),
1241 url.clone(),
1242 ));
1243 }
1244 col += span_width;
1245
1246 if let Some(ref sel) = self.text_selection {
1248 let chars: Vec<char> = s.text.chars().collect();
1250 chars
1251 .iter()
1252 .enumerate()
1253 .map(|(i, ch)| {
1254 let char_col = span_start_col + i;
1255 if sel.contains(line_idx, char_col) {
1256 Span::styled(ch.to_string(), selection_style)
1257 } else {
1258 Span::styled(ch.to_string(), s.style)
1259 }
1260 })
1261 .collect::<Vec<_>>()
1262 } else {
1263 vec![Span::styled(s.text.clone(), s.style)]
1264 }
1265 })
1266 .collect();
1267 Line::from(spans)
1268 })
1269 .collect();
1270
1271 let paragraph = Paragraph::new(visible_lines);
1272 frame.render_widget(paragraph, content_area);
1273
1274 let buffer = frame.buffer_mut();
1276 let max_x = content_area.x + content_area.width;
1277 for (line_idx, col_start, text, url) in link_overlays {
1278 let y = content_area.y + line_idx as u16;
1279 if y >= content_area.y + content_area.height {
1280 continue;
1281 }
1282 let start_x = content_area.x + col_start as u16;
1283 apply_hyperlink_overlay(buffer, start_x, y, max_x, &text, &url);
1284 }
1285 }
1286 PopupContent::List { items, selected } => {
1287 let list_items: Vec<ListItem> = items
1288 .iter()
1289 .enumerate()
1290 .skip(self.scroll_offset)
1291 .take(content_area.height as usize)
1292 .map(|(idx, item)| {
1293 let is_hovered = matches!(
1295 hover_target,
1296 Some(crate::app::HoverTarget::PopupListItem(_, hovered_idx)) if *hovered_idx == idx
1297 );
1298 let is_selected = idx == *selected;
1299
1300 let mut spans = Vec::new();
1301
1302 if let Some(icon) = &item.icon {
1304 spans.push(Span::raw(format!("{} ", icon)));
1305 }
1306
1307 let text = &item.text;
1315 let trimmed = text.trim_start();
1316 let indent_len = text.len() - trimmed.len();
1317 if indent_len > 0 {
1318 spans.push(Span::raw(&text[..indent_len]));
1319 }
1320 let is_clickable = item.data.is_some() && !item.disabled;
1321 let mut text_style = Style::default();
1322 if is_selected {
1323 text_style = text_style.add_modifier(Modifier::BOLD);
1324 }
1325 if is_clickable {
1326 text_style = text_style.add_modifier(Modifier::UNDERLINED);
1327 }
1328 if item.disabled {
1329 text_style = text_style
1330 .fg(theme.help_separator_fg)
1331 .add_modifier(Modifier::DIM);
1332 }
1333 spans.push(Span::styled(trimmed, text_style));
1334
1335 if let Some(detail) = &item.detail {
1337 spans.push(Span::styled(
1338 format!(" {}", detail),
1339 Style::default().fg(theme.help_separator_fg),
1340 ));
1341 }
1342
1343 spans.push(Span::raw(""));
1346
1347 if is_selected {
1349 if let Some(ref hint) = self.accept_key_hint {
1350 let hint_text = format!("({})", hint);
1351 let used_width: usize = spans
1353 .iter()
1354 .map(|s| {
1355 unicode_width::UnicodeWidthStr::width(s.content.as_ref())
1356 })
1357 .sum();
1358 let available = content_area.width as usize;
1359 let hint_len = hint_text.len();
1360 if used_width + hint_len + 1 < available {
1361 let padding = available - used_width - hint_len;
1362 spans.push(Span::raw(" ".repeat(padding)));
1363 spans.push(Span::styled(
1364 hint_text,
1365 Style::default().fg(theme.help_separator_fg),
1366 ));
1367 }
1368 }
1369 }
1370
1371 let row_style = if is_selected {
1373 Style::default().bg(theme.popup_selection_bg)
1374 } else if is_hovered {
1375 Style::default()
1376 .bg(theme.menu_hover_bg)
1377 .fg(theme.menu_hover_fg)
1378 } else {
1379 Style::default()
1380 };
1381
1382 ListItem::new(Line::from(spans)).style(row_style)
1383 })
1384 .collect();
1385
1386 let list = List::new(list_items);
1387 frame.render_widget(list, content_area);
1388 }
1389 PopupContent::Custom(lines) => {
1390 let visible_lines: Vec<Line> = lines
1391 .iter()
1392 .skip(self.scroll_offset)
1393 .take(content_area.height as usize)
1394 .map(|line| Line::from(line.as_str()))
1395 .collect();
1396
1397 let paragraph = Paragraph::new(visible_lines);
1398 frame.render_widget(paragraph, content_area);
1399 }
1400 }
1401
1402 if needs_scrollbar {
1404 let scrollbar_area = Rect {
1405 x: inner_area.x + inner_area.width - 1,
1406 y: inner_area.y,
1407 width: 1,
1408 height: inner_area.height,
1409 };
1410
1411 let scrollbar_state =
1412 ScrollbarState::new(wrapped_total_lines, visible_lines_count, self.scroll_offset);
1413 let scrollbar_colors = ScrollbarColors::from_theme(theme);
1414 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
1415 }
1416 }
1417}
1418
1419#[derive(Debug, Clone)]
1421pub struct PopupManager {
1422 popups: Vec<Popup>,
1424}
1425
1426impl PopupManager {
1427 pub fn new() -> Self {
1428 Self { popups: Vec::new() }
1429 }
1430
1431 pub fn show(&mut self, popup: Popup) {
1433 self.popups.push(popup);
1434 }
1435
1436 pub fn show_or_replace(&mut self, popup: Popup) {
1440 if let Some(pos) = self.popups.iter().position(|p| p.kind == popup.kind) {
1441 self.popups[pos] = popup;
1442 } else {
1443 self.popups.push(popup);
1444 }
1445 }
1446
1447 pub fn hide(&mut self) -> Option<Popup> {
1449 self.popups.pop()
1450 }
1451
1452 pub fn clear(&mut self) {
1454 self.popups.clear();
1455 }
1456
1457 pub fn top(&self) -> Option<&Popup> {
1459 self.popups.last()
1460 }
1461
1462 pub fn top_mut(&mut self) -> Option<&mut Popup> {
1464 self.popups.last_mut()
1465 }
1466
1467 pub fn get(&self, index: usize) -> Option<&Popup> {
1469 self.popups.get(index)
1470 }
1471
1472 pub fn get_mut(&mut self, index: usize) -> Option<&mut Popup> {
1474 self.popups.get_mut(index)
1475 }
1476
1477 pub fn is_visible(&self) -> bool {
1479 !self.popups.is_empty()
1480 }
1481
1482 pub fn is_completion_popup(&self) -> bool {
1484 self.top()
1485 .map(|p| p.kind == PopupKind::Completion)
1486 .unwrap_or(false)
1487 }
1488
1489 pub fn is_hover_popup(&self) -> bool {
1491 self.top()
1492 .map(|p| p.kind == PopupKind::Hover)
1493 .unwrap_or(false)
1494 }
1495
1496 pub fn is_action_popup(&self) -> bool {
1498 self.top()
1499 .map(|p| p.kind == PopupKind::Action)
1500 .unwrap_or(false)
1501 }
1502
1503 pub fn all(&self) -> &[Popup] {
1505 &self.popups
1506 }
1507
1508 pub fn dismiss_transient(&mut self) -> bool {
1512 let is_transient = self.popups.last().is_some_and(|p| p.transient);
1513
1514 if is_transient {
1515 self.popups.pop();
1516 true
1517 } else {
1518 false
1519 }
1520 }
1521}
1522
1523impl Default for PopupManager {
1524 fn default() -> Self {
1525 Self::new()
1526 }
1527}
1528
1529fn apply_hyperlink_overlay(
1534 buffer: &mut ratatui::buffer::Buffer,
1535 start_x: u16,
1536 y: u16,
1537 max_x: u16,
1538 text: &str,
1539 url: &str,
1540) {
1541 let mut chunk_index = 0u16;
1542 let mut chars = text.chars();
1543
1544 loop {
1545 let mut chunk = String::new();
1546 for _ in 0..2 {
1547 if let Some(ch) = chars.next() {
1548 chunk.push(ch);
1549 } else {
1550 break;
1551 }
1552 }
1553
1554 if chunk.is_empty() {
1555 break;
1556 }
1557
1558 let x = start_x + chunk_index * 2;
1559 if x >= max_x {
1560 break;
1561 }
1562
1563 let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, chunk);
1564 buffer[(x, y)].set_symbol(&hyperlink);
1565
1566 chunk_index += 1;
1567 }
1568}
1569
1570#[cfg(test)]
1571mod tests {
1572 use super::*;
1573 use crate::view::theme;
1574
1575 #[test]
1576 fn test_popup_list_item() {
1577 let item = PopupListItem::new("test".to_string())
1578 .with_detail("detail".to_string())
1579 .with_icon("📄".to_string());
1580
1581 assert_eq!(item.text, "test");
1582 assert_eq!(item.detail, Some("detail".to_string()));
1583 assert_eq!(item.icon, Some("📄".to_string()));
1584 }
1585
1586 #[test]
1587 fn test_popup_selection() {
1588 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1589 let items = vec![
1590 PopupListItem::new("item1".to_string()),
1591 PopupListItem::new("item2".to_string()),
1592 PopupListItem::new("item3".to_string()),
1593 ];
1594
1595 let mut popup = Popup::list(items, &theme);
1596
1597 assert_eq!(popup.selected_item().unwrap().text, "item1");
1598
1599 popup.select_next();
1600 assert_eq!(popup.selected_item().unwrap().text, "item2");
1601
1602 popup.select_next();
1603 assert_eq!(popup.selected_item().unwrap().text, "item3");
1604
1605 popup.select_next(); assert_eq!(popup.selected_item().unwrap().text, "item3");
1607
1608 popup.select_prev();
1609 assert_eq!(popup.selected_item().unwrap().text, "item2");
1610
1611 popup.select_prev();
1612 assert_eq!(popup.selected_item().unwrap().text, "item1");
1613
1614 popup.select_prev(); assert_eq!(popup.selected_item().unwrap().text, "item1");
1616 }
1617
1618 #[test]
1619 fn test_popup_manager() {
1620 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1621 let mut manager = PopupManager::new();
1622
1623 assert!(!manager.is_visible());
1624 assert_eq!(manager.top(), None);
1625
1626 let popup1 = Popup::text(vec!["test1".to_string()], &theme);
1627 manager.show(popup1);
1628
1629 assert!(manager.is_visible());
1630 assert_eq!(manager.all().len(), 1);
1631
1632 let popup2 = Popup::text(vec!["test2".to_string()], &theme);
1633 manager.show(popup2);
1634
1635 assert_eq!(manager.all().len(), 2);
1636
1637 manager.hide();
1638 assert_eq!(manager.all().len(), 1);
1639
1640 manager.clear();
1641 assert!(!manager.is_visible());
1642 assert_eq!(manager.all().len(), 0);
1643 }
1644
1645 #[test]
1646 fn test_popup_area_calculation() {
1647 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1648 let terminal_area = Rect {
1649 x: 0,
1650 y: 0,
1651 width: 100,
1652 height: 50,
1653 };
1654
1655 let popup = Popup::text(vec!["test".to_string()], &theme)
1656 .with_width(30)
1657 .with_max_height(10);
1658
1659 let popup_centered = popup.clone().with_position(PopupPosition::Centered);
1661 let area = popup_centered.calculate_area(terminal_area, None);
1662 assert_eq!(area.width, 30);
1663 assert_eq!(area.height, 3);
1665 assert_eq!(area.x, (100 - 30) / 2);
1666 assert_eq!(area.y, (50 - 3) / 2);
1667
1668 let popup_below = popup.clone().with_position(PopupPosition::BelowCursor);
1670 let area = popup_below.calculate_area(terminal_area, Some((20, 10)));
1671 assert_eq!(area.x, 20);
1672 assert_eq!(area.y, 11); }
1674
1675 #[test]
1676 fn test_popup_fixed_position_clamping() {
1677 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1678 let terminal_area = Rect {
1679 x: 0,
1680 y: 0,
1681 width: 100,
1682 height: 50,
1683 };
1684
1685 let popup = Popup::text(vec!["test".to_string()], &theme)
1686 .with_width(30)
1687 .with_max_height(10);
1688
1689 let popup_fixed = popup
1691 .clone()
1692 .with_position(PopupPosition::Fixed { x: 10, y: 20 });
1693 let area = popup_fixed.calculate_area(terminal_area, None);
1694 assert_eq!(area.x, 10);
1695 assert_eq!(area.y, 20);
1696
1697 let popup_right_edge = popup
1699 .clone()
1700 .with_position(PopupPosition::Fixed { x: 99, y: 20 });
1701 let area = popup_right_edge.calculate_area(terminal_area, None);
1702 assert_eq!(area.x, 70);
1704 assert_eq!(area.y, 20);
1705
1706 let popup_beyond = popup
1708 .clone()
1709 .with_position(PopupPosition::Fixed { x: 199, y: 20 });
1710 let area = popup_beyond.calculate_area(terminal_area, None);
1711 assert_eq!(area.x, 70);
1713 assert_eq!(area.y, 20);
1714
1715 let popup_bottom = popup
1717 .clone()
1718 .with_position(PopupPosition::Fixed { x: 10, y: 49 });
1719 let area = popup_bottom.calculate_area(terminal_area, None);
1720 assert_eq!(area.x, 10);
1721 assert_eq!(area.y, 47);
1723 }
1724
1725 #[test]
1726 fn test_clamp_rect_to_bounds() {
1727 let bounds = Rect {
1728 x: 0,
1729 y: 0,
1730 width: 100,
1731 height: 50,
1732 };
1733
1734 let rect = Rect {
1736 x: 10,
1737 y: 20,
1738 width: 30,
1739 height: 10,
1740 };
1741 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1742 assert_eq!(clamped, rect);
1743
1744 let rect = Rect {
1746 x: 99,
1747 y: 20,
1748 width: 30,
1749 height: 10,
1750 };
1751 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1752 assert_eq!(clamped.x, 99); assert_eq!(clamped.width, 1); let rect = Rect {
1757 x: 199,
1758 y: 60,
1759 width: 30,
1760 height: 10,
1761 };
1762 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1763 assert_eq!(clamped.x, 99); assert_eq!(clamped.y, 49); assert_eq!(clamped.width, 1); assert_eq!(clamped.height, 1); }
1768
1769 #[test]
1770 fn hyperlink_overlay_chunks_pairs() {
1771 use ratatui::{buffer::Buffer, layout::Rect};
1772
1773 let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1774 buffer[(0, 0)].set_symbol("P");
1775 buffer[(1, 0)].set_symbol("l");
1776 buffer[(2, 0)].set_symbol("a");
1777 buffer[(3, 0)].set_symbol("y");
1778
1779 apply_hyperlink_overlay(&mut buffer, 0, 0, 10, "Play", "https://example.com");
1780
1781 let first = buffer[(0, 0)].symbol().to_string();
1782 let second = buffer[(2, 0)].symbol().to_string();
1783
1784 assert!(
1785 first.contains("Pl"),
1786 "first chunk should contain 'Pl', got {first:?}"
1787 );
1788 assert!(
1789 second.contains("ay"),
1790 "second chunk should contain 'ay', got {second:?}"
1791 );
1792 }
1793
1794 #[test]
1795 fn test_popup_text_selection() {
1796 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1797 let mut popup = Popup::text(
1798 vec![
1799 "Line 0: Hello".to_string(),
1800 "Line 1: World".to_string(),
1801 "Line 2: Test".to_string(),
1802 ],
1803 &theme,
1804 );
1805
1806 assert!(!popup.has_selection());
1808 assert_eq!(popup.get_selected_text(), None);
1809
1810 popup.start_selection(0, 8);
1812 assert!(!popup.has_selection()); popup.extend_selection(1, 8);
1816 assert!(popup.has_selection());
1817
1818 let selected = popup.get_selected_text().unwrap();
1820 assert_eq!(selected, "Hello\nLine 1: ");
1821
1822 popup.clear_selection();
1824 assert!(!popup.has_selection());
1825 assert_eq!(popup.get_selected_text(), None);
1826
1827 popup.start_selection(1, 8);
1829 popup.extend_selection(1, 13); let selected = popup.get_selected_text().unwrap();
1831 assert_eq!(selected, "World");
1832 }
1833
1834 #[test]
1835 fn test_popup_text_selection_contains() {
1836 let sel = PopupTextSelection {
1837 start: (1, 5),
1838 end: (2, 10),
1839 };
1840
1841 assert!(!sel.contains(0, 5));
1843
1844 assert!(!sel.contains(1, 4)); assert!(sel.contains(1, 5)); assert!(sel.contains(1, 10)); assert!(sel.contains(2, 0)); assert!(sel.contains(2, 9)); assert!(!sel.contains(2, 10)); assert!(!sel.contains(2, 11)); assert!(!sel.contains(3, 0));
1857 }
1858
1859 #[test]
1860 fn test_popup_text_selection_normalized() {
1861 let sel = PopupTextSelection {
1863 start: (1, 5),
1864 end: (2, 10),
1865 };
1866 let ((s_line, s_col), (e_line, e_col)) = sel.normalized();
1867 assert_eq!((s_line, s_col), (1, 5));
1868 assert_eq!((e_line, e_col), (2, 10));
1869
1870 let sel_backward = PopupTextSelection {
1872 start: (2, 10),
1873 end: (1, 5),
1874 };
1875 let ((s_line, s_col), (e_line, e_col)) = sel_backward.normalized();
1876 assert_eq!((s_line, s_col), (1, 5));
1877 assert_eq!((e_line, e_col), (2, 10));
1878 }
1879}