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}
118
119#[derive(Debug, Clone, PartialEq)]
121pub enum PopupContent {
122 Text(Vec<String>),
124 Markdown(Vec<StyledLine>),
126 List {
128 items: Vec<PopupListItem>,
129 selected: usize,
130 },
131 Custom(Vec<String>),
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub struct PopupTextSelection {
138 pub start: (usize, usize),
140 pub end: (usize, usize),
142}
143
144impl PopupTextSelection {
145 pub fn normalized(&self) -> ((usize, usize), (usize, usize)) {
147 if self.start.0 < self.end.0 || (self.start.0 == self.end.0 && self.start.1 <= self.end.1) {
148 (self.start, self.end)
149 } else {
150 (self.end, self.start)
151 }
152 }
153
154 pub fn contains(&self, line: usize, col: usize) -> bool {
156 let ((start_line, start_col), (end_line, end_col)) = self.normalized();
157 if line < start_line || line > end_line {
158 return false;
159 }
160 if line == start_line && line == end_line {
161 col >= start_col && col < end_col
162 } else if line == start_line {
163 col >= start_col
164 } else if line == end_line {
165 col < end_col
166 } else {
167 true
168 }
169 }
170}
171
172#[derive(Debug, Clone, PartialEq)]
174pub struct PopupListItem {
175 pub text: String,
177 pub detail: Option<String>,
179 pub icon: Option<String>,
181 pub data: Option<String>,
183 pub disabled: bool,
185}
186
187impl PopupListItem {
188 pub fn new(text: String) -> Self {
189 Self {
190 text,
191 detail: None,
192 icon: None,
193 data: None,
194 disabled: false,
195 }
196 }
197
198 pub fn with_detail(mut self, detail: String) -> Self {
199 self.detail = Some(detail);
200 self
201 }
202
203 pub fn with_icon(mut self, icon: String) -> Self {
204 self.icon = Some(icon);
205 self
206 }
207
208 pub fn with_data(mut self, data: String) -> Self {
209 self.data = Some(data);
210 self
211 }
212
213 pub fn disabled(mut self) -> Self {
214 self.disabled = true;
215 self
216 }
217}
218
219#[derive(Debug, Clone, PartialEq)]
228pub struct Popup {
229 pub kind: PopupKind,
231
232 pub title: Option<String>,
234
235 pub description: Option<String>,
237
238 pub transient: bool,
240
241 pub content: PopupContent,
243
244 pub position: PopupPosition,
246
247 pub width: u16,
249
250 pub max_height: u16,
252
253 pub bordered: bool,
255
256 pub border_style: Style,
258
259 pub background_style: Style,
261
262 pub scroll_offset: usize,
264
265 pub text_selection: Option<PopupTextSelection>,
267
268 pub accept_key_hint: Option<String>,
270
271 pub resolver: PopupResolver,
274
275 pub focused: bool,
284
285 pub focus_key_hint: Option<String>,
291}
292
293impl Popup {
294 pub fn text(content: Vec<String>, theme: &crate::view::theme::Theme) -> Self {
296 Self {
297 kind: PopupKind::Text,
298 title: None,
299 description: None,
300 transient: false,
301 content: PopupContent::Text(content),
302 position: PopupPosition::AtCursor,
303 width: 50,
304 max_height: 15,
305 bordered: true,
306 border_style: Style::default().fg(theme.popup_border_fg),
307 background_style: Style::default().bg(theme.popup_bg),
308 scroll_offset: 0,
309 text_selection: None,
310 accept_key_hint: None,
311 resolver: PopupResolver::None,
312 focused: false,
313 focus_key_hint: None,
314 }
315 }
316
317 pub fn markdown(
322 markdown_text: &str,
323 theme: &crate::view::theme::Theme,
324 registry: Option<&GrammarRegistry>,
325 ) -> Self {
326 let styled_lines = parse_markdown(markdown_text, theme, registry);
327 Self {
328 kind: PopupKind::Text,
329 title: None,
330 description: None,
331 transient: false,
332 content: PopupContent::Markdown(styled_lines),
333 position: PopupPosition::AtCursor,
334 width: 60, max_height: 20, bordered: true,
337 border_style: Style::default().fg(theme.popup_border_fg),
338 background_style: Style::default().bg(theme.popup_bg),
339 scroll_offset: 0,
340 text_selection: None,
341 accept_key_hint: None,
342 resolver: PopupResolver::None,
343 focused: false,
344 focus_key_hint: None,
345 }
346 }
347
348 pub fn list(items: Vec<PopupListItem>, theme: &crate::view::theme::Theme) -> Self {
350 Self {
351 kind: PopupKind::List,
352 title: None,
353 description: None,
354 transient: false,
355 content: PopupContent::List { items, selected: 0 },
356 position: PopupPosition::AtCursor,
357 width: 50,
358 max_height: 15,
359 bordered: true,
360 border_style: Style::default().fg(theme.popup_border_fg),
361 background_style: Style::default().bg(theme.popup_bg),
362 scroll_offset: 0,
363 text_selection: None,
364 accept_key_hint: None,
365 resolver: PopupResolver::None,
366 focused: false,
367 focus_key_hint: None,
368 }
369 }
370
371 pub fn with_title(mut self, title: String) -> Self {
373 self.title = Some(title);
374 self
375 }
376
377 pub fn with_kind(mut self, kind: PopupKind) -> Self {
379 self.kind = kind;
380 self
381 }
382
383 pub fn with_transient(mut self, transient: bool) -> Self {
385 self.transient = transient;
386 self
387 }
388
389 pub fn with_position(mut self, position: PopupPosition) -> Self {
391 self.position = position;
392 self
393 }
394
395 pub fn with_width(mut self, width: u16) -> Self {
397 self.width = width;
398 self
399 }
400
401 pub fn with_max_height(mut self, max_height: u16) -> Self {
403 self.max_height = max_height;
404 self
405 }
406
407 pub fn with_border_style(mut self, style: Style) -> Self {
409 self.border_style = style;
410 self
411 }
412
413 pub fn with_resolver(mut self, resolver: PopupResolver) -> Self {
416 self.resolver = resolver;
417 self
418 }
419
420 pub fn with_focused(mut self, focused: bool) -> Self {
424 self.focused = focused;
425 self
426 }
427
428 pub fn with_focus_key_hint(mut self, hint: String) -> Self {
431 self.focus_key_hint = Some(hint);
432 self
433 }
434
435 pub fn render_title(&self) -> Option<String> {
443 let hint_label = if !self.focused {
444 let hint = self
445 .focus_key_hint
446 .clone()
447 .unwrap_or_else(|| "Alt+T".to_string());
448 Some(format!("[{} to focus]", hint))
449 } else {
450 None
451 };
452 match (&self.title, hint_label) {
453 (Some(title), Some(hint)) => Some(format!("{} {}", title, hint)),
454 (Some(title), None) => Some(title.clone()),
455 (None, Some(hint)) => Some(hint),
456 (None, None) => None,
457 }
458 }
459
460 pub fn selected_item(&self) -> Option<&PopupListItem> {
462 match &self.content {
463 PopupContent::List { items, selected } => items.get(*selected),
464 _ => None,
465 }
466 }
467
468 fn visible_height(&self) -> usize {
470 let border_offset = if self.bordered { 2 } else { 0 };
471 (self.max_height as usize).saturating_sub(border_offset)
472 }
473
474 pub fn select_next(&mut self) {
476 let visible = self.visible_height();
477 if let PopupContent::List { items, selected } = &mut self.content {
478 if *selected < items.len().saturating_sub(1) {
479 *selected += 1;
480 if *selected >= self.scroll_offset + visible {
482 self.scroll_offset = (*selected + 1).saturating_sub(visible);
483 }
484 }
485 }
486 }
487
488 pub fn select_prev(&mut self) {
490 if let PopupContent::List { items: _, selected } = &mut self.content {
491 if *selected > 0 {
492 *selected -= 1;
493 if *selected < self.scroll_offset {
495 self.scroll_offset = *selected;
496 }
497 }
498 }
499 }
500
501 pub fn select_index(&mut self, index: usize) -> bool {
503 let visible = self.visible_height();
504 if let PopupContent::List { items, selected } = &mut self.content {
505 if index < items.len() {
506 *selected = index;
507 if *selected >= self.scroll_offset + visible {
509 self.scroll_offset = (*selected + 1).saturating_sub(visible);
510 } else if *selected < self.scroll_offset {
511 self.scroll_offset = *selected;
512 }
513 return true;
514 }
515 }
516 false
517 }
518
519 pub fn page_down(&mut self) {
521 let visible = self.visible_height();
522 if let PopupContent::List { items, selected } = &mut self.content {
523 *selected = (*selected + visible).min(items.len().saturating_sub(1));
524 self.scroll_offset = (*selected + 1).saturating_sub(visible);
525 } else {
526 self.scroll_offset += visible;
527 }
528 }
529
530 pub fn page_up(&mut self) {
532 let visible = self.visible_height();
533 if let PopupContent::List { items: _, selected } = &mut self.content {
534 *selected = selected.saturating_sub(visible);
535 self.scroll_offset = *selected;
536 } else {
537 self.scroll_offset = self.scroll_offset.saturating_sub(visible);
538 }
539 }
540
541 pub fn select_first(&mut self) {
543 if let PopupContent::List { items: _, selected } = &mut self.content {
544 *selected = 0;
545 self.scroll_offset = 0;
546 } else {
547 self.scroll_offset = 0;
548 }
549 }
550
551 pub fn select_last(&mut self) {
553 let visible = self.visible_height();
554 if let PopupContent::List { items, selected } = &mut self.content {
555 *selected = items.len().saturating_sub(1);
556 if *selected >= visible {
558 self.scroll_offset = (*selected + 1).saturating_sub(visible);
559 }
560 } else {
561 let content_height = self.item_count();
563 if content_height > visible {
564 self.scroll_offset = content_height.saturating_sub(visible);
565 }
566 }
567 }
568
569 pub fn scroll_by(&mut self, delta: i32) {
572 let content_len = self.wrapped_item_count();
573 let visible = self.visible_height();
574 let max_scroll = content_len.saturating_sub(visible);
575
576 if delta < 0 {
577 self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
579 } else {
580 self.scroll_offset = (self.scroll_offset + delta as usize).min(max_scroll);
582 }
583
584 if let PopupContent::List { items, selected } = &mut self.content {
586 let visible_start = self.scroll_offset;
587 let visible_end = (self.scroll_offset + visible).min(items.len());
588
589 if *selected < visible_start {
590 *selected = visible_start;
591 } else if *selected >= visible_end {
592 *selected = visible_end.saturating_sub(1);
593 }
594 }
595 }
596
597 pub fn item_count(&self) -> usize {
599 match &self.content {
600 PopupContent::Text(lines) => lines.len(),
601 PopupContent::Markdown(lines) => lines.len(),
602 PopupContent::List { items, .. } => items.len(),
603 PopupContent::Custom(lines) => lines.len(),
604 }
605 }
606
607 fn wrapped_item_count(&self) -> usize {
612 let border_width = if self.bordered { 2 } else { 0 };
614 let scrollbar_width = 2; let wrap_width = (self.width as usize)
616 .saturating_sub(border_width)
617 .saturating_sub(scrollbar_width);
618
619 if wrap_width == 0 {
620 return self.item_count();
621 }
622
623 match &self.content {
624 PopupContent::Text(lines) => wrap_text_lines(lines, wrap_width).len(),
625 PopupContent::Markdown(styled_lines) => {
626 wrap_styled_lines(styled_lines, wrap_width).len()
627 }
628 PopupContent::List { items, .. } => items.len(),
630 PopupContent::Custom(lines) => lines.len(),
631 }
632 }
633
634 pub fn start_selection(&mut self, line: usize, col: usize) {
636 self.text_selection = Some(PopupTextSelection {
637 start: (line, col),
638 end: (line, col),
639 });
640 }
641
642 pub fn extend_selection(&mut self, line: usize, col: usize) {
644 if let Some(ref mut sel) = self.text_selection {
645 sel.end = (line, col);
646 }
647 }
648
649 pub fn clear_selection(&mut self) {
651 self.text_selection = None;
652 }
653
654 pub fn has_selection(&self) -> bool {
656 if let Some(sel) = &self.text_selection {
657 sel.start != sel.end
658 } else {
659 false
660 }
661 }
662
663 fn content_wrap_width(&self) -> usize {
666 let border_width: u16 = if self.bordered { 2 } else { 0 };
667 let inner_width = self.width.saturating_sub(border_width);
668 let scrollbar_reserved: u16 = 2;
669 let conservative_width = inner_width.saturating_sub(scrollbar_reserved) as usize;
670
671 if conservative_width == 0 {
672 return 0;
673 }
674
675 let visible_height = self.max_height.saturating_sub(border_width) as usize;
676 let line_count = match &self.content {
677 PopupContent::Text(lines) => wrap_text_lines(lines, conservative_width).len(),
678 PopupContent::Markdown(styled_lines) => {
679 wrap_styled_lines(styled_lines, conservative_width).len()
680 }
681 _ => self.item_count(),
682 };
683
684 let needs_scrollbar = line_count > visible_height && inner_width > scrollbar_reserved;
685
686 if needs_scrollbar {
687 conservative_width
688 } else {
689 inner_width as usize
690 }
691 }
692
693 fn get_text_lines(&self) -> Vec<String> {
698 let wrap_width = self.content_wrap_width();
699
700 match &self.content {
701 PopupContent::Text(lines) => {
702 if wrap_width > 0 {
703 wrap_text_lines(lines, wrap_width)
704 } else {
705 lines.clone()
706 }
707 }
708 PopupContent::Markdown(styled_lines) => {
709 if wrap_width > 0 {
710 wrap_styled_lines(styled_lines, wrap_width)
711 .iter()
712 .map(|sl| sl.plain_text())
713 .collect()
714 } else {
715 styled_lines.iter().map(|sl| sl.plain_text()).collect()
716 }
717 }
718 PopupContent::List { items, .. } => items.iter().map(|i| i.text.clone()).collect(),
719 PopupContent::Custom(lines) => lines.clone(),
720 }
721 }
722
723 pub fn get_selected_text(&self) -> Option<String> {
725 let sel = self.text_selection.as_ref()?;
726 if sel.start == sel.end {
727 return None;
728 }
729
730 let ((start_line, start_col), (end_line, end_col)) = sel.normalized();
731 let lines = self.get_text_lines();
732
733 if start_line >= lines.len() {
734 return None;
735 }
736
737 if start_line == end_line {
738 let line = &lines[start_line];
739 let end_col = end_col.min(line.len());
740 let start_col = start_col.min(end_col);
741 Some(line[start_col..end_col].to_string())
742 } else {
743 let mut result = String::new();
744 let first_line = &lines[start_line];
746 result.push_str(&first_line[start_col.min(first_line.len())..]);
747 result.push('\n');
748 for line in lines.iter().take(end_line).skip(start_line + 1) {
750 result.push_str(line);
751 result.push('\n');
752 }
753 if end_line < lines.len() {
755 let last_line = &lines[end_line];
756 result.push_str(&last_line[..end_col.min(last_line.len())]);
757 }
758 Some(result)
759 }
760 }
761
762 pub fn needs_scrollbar(&self) -> bool {
764 self.item_count() > self.visible_height()
765 }
766
767 pub fn scroll_state(&self) -> (usize, usize, usize) {
769 let total = self.item_count();
770 let visible = self.visible_height();
771 (total, visible, self.scroll_offset)
772 }
773
774 pub fn link_at_position(&self, relative_col: usize, relative_row: usize) -> Option<String> {
780 let PopupContent::Markdown(styled_lines) = &self.content else {
781 return None;
782 };
783
784 let border_width = if self.bordered { 2 } else { 0 };
786 let scrollbar_reserved = 2;
787 let content_width = self
788 .width
789 .saturating_sub(border_width)
790 .saturating_sub(scrollbar_reserved) as usize;
791
792 let wrapped_lines = wrap_styled_lines(styled_lines, content_width);
794
795 let line_index = self.scroll_offset + relative_row;
797
798 let line = wrapped_lines.get(line_index)?;
800
801 line.link_at_column(relative_col).map(|s| s.to_string())
803 }
804
805 pub fn description_height(&self) -> u16 {
808 if let Some(desc) = &self.description {
809 let border_width = if self.bordered { 2 } else { 0 };
810 let scrollbar_reserved = 2;
811 let content_width = self
812 .width
813 .saturating_sub(border_width)
814 .saturating_sub(scrollbar_reserved) as usize;
815 let desc_vec = vec![desc.clone()];
816 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
817 wrapped.len() as u16 + 1 } else {
819 0
820 }
821 }
822
823 fn content_height(&self) -> u16 {
825 self.content_height_for_width(self.width)
827 }
828
829 fn content_height_for_width(&self, popup_width: u16) -> u16 {
831 let border_width = if self.bordered { 2 } else { 0 };
833 let scrollbar_reserved = 2; let content_width = popup_width
835 .saturating_sub(border_width)
836 .saturating_sub(scrollbar_reserved) as usize;
837
838 let description_lines = if let Some(desc) = &self.description {
840 let desc_vec = vec![desc.clone()];
841 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
842 wrapped.len() as u16 + 1 } else {
844 0
845 };
846
847 let content_lines = match &self.content {
848 PopupContent::Text(lines) => {
849 wrap_text_lines(lines, content_width).len() as u16
851 }
852 PopupContent::Markdown(styled_lines) => {
853 wrap_styled_lines(styled_lines, content_width).len() as u16
855 }
856 PopupContent::List { items, .. } => items.len() as u16,
857 PopupContent::Custom(lines) => lines.len() as u16,
858 };
859
860 let border_height = if self.bordered { 2 } else { 0 };
862
863 description_lines + content_lines + border_height
864 }
865
866 pub fn calculate_area(&self, terminal_area: Rect, cursor_pos: Option<(u16, u16)>) -> Rect {
868 match self.position {
869 PopupPosition::AtCursor | PopupPosition::BelowCursor | PopupPosition::AboveCursor => {
870 let (cursor_x, cursor_y) =
871 cursor_pos.unwrap_or((terminal_area.width / 2, terminal_area.height / 2));
872
873 let width = self.width.min(terminal_area.width);
874 let height = self
876 .content_height()
877 .min(self.max_height)
878 .min(terminal_area.height);
879
880 let x = if cursor_x + width > terminal_area.width {
881 terminal_area.width.saturating_sub(width)
882 } else {
883 cursor_x
884 };
885
886 let y = match self.position {
887 PopupPosition::AtCursor => cursor_y,
888 PopupPosition::BelowCursor => {
889 if cursor_y + 1 + height > terminal_area.height {
890 cursor_y.saturating_sub(height)
892 } else {
893 cursor_y + 1
895 }
896 }
897 PopupPosition::AboveCursor => {
898 (cursor_y + 1).saturating_sub(height)
900 }
901 _ => cursor_y,
902 };
903
904 Rect {
905 x,
906 y,
907 width,
908 height,
909 }
910 }
911 PopupPosition::Fixed { x, y } => {
912 let width = self.width.min(terminal_area.width);
913 let height = self
914 .content_height()
915 .min(self.max_height)
916 .min(terminal_area.height);
917 let x = if x + width > terminal_area.width {
919 terminal_area.width.saturating_sub(width)
920 } else {
921 x
922 };
923 let y = if y + height > terminal_area.height {
924 terminal_area.height.saturating_sub(height)
925 } else {
926 y
927 };
928 Rect {
929 x,
930 y,
931 width,
932 height,
933 }
934 }
935 PopupPosition::Centered => {
936 let width = self.width.min(terminal_area.width);
937 let height = self
938 .content_height()
939 .min(self.max_height)
940 .min(terminal_area.height);
941 let x = (terminal_area.width.saturating_sub(width)) / 2;
942 let y = (terminal_area.height.saturating_sub(height)) / 2;
943 Rect {
944 x,
945 y,
946 width,
947 height,
948 }
949 }
950 PopupPosition::CenteredOverlay {
951 width_pct,
952 height_pct,
953 } => {
954 let w_pct = width_pct.clamp(1, 100) as u32;
955 let h_pct = height_pct.clamp(1, 100) as u32;
956 let width = ((terminal_area.width as u32 * w_pct) / 100) as u16;
957 let height = ((terminal_area.height as u32 * h_pct) / 100) as u16;
958 let width = width.max(1).min(terminal_area.width);
959 let height = height.max(1).min(terminal_area.height);
960 let x = (terminal_area.width.saturating_sub(width)) / 2;
961 let y = (terminal_area.height.saturating_sub(height)) / 2;
962 Rect {
963 x,
964 y,
965 width,
966 height,
967 }
968 }
969 PopupPosition::BottomRight => {
970 let width = self.width.min(terminal_area.width);
971 let height = self
972 .content_height()
973 .min(self.max_height)
974 .min(terminal_area.height);
975 let x = terminal_area.width.saturating_sub(width);
977 let y = terminal_area
978 .height
979 .saturating_sub(height)
980 .saturating_sub(2);
981 Rect {
982 x,
983 y,
984 width,
985 height,
986 }
987 }
988 PopupPosition::AboveStatusBarAt { x, status_row } => {
989 let width = self.width.min(terminal_area.width);
990 let height = self
991 .content_height()
992 .min(self.max_height)
993 .min(terminal_area.height);
994 let max_x = terminal_area.width.saturating_sub(width).saturating_sub(1);
1000 let x = x.min(max_x);
1001 let y = status_row.saturating_sub(height);
1008 Rect {
1009 x,
1010 y,
1011 width,
1012 height,
1013 }
1014 }
1015 }
1016 }
1017
1018 pub fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::view::theme::Theme) {
1020 self.render_with_hover(frame, area, theme, None);
1021 }
1022
1023 pub fn render_with_hover(
1025 &self,
1026 frame: &mut Frame,
1027 area: Rect,
1028 theme: &crate::view::theme::Theme,
1029 hover_target: Option<&crate::app::HoverTarget>,
1030 ) {
1031 let frame_area = frame.area();
1033 let area = clamp_rect_to_bounds(area, frame_area);
1034
1035 if area.width == 0 || area.height == 0 {
1037 return;
1038 }
1039
1040 frame.render_widget(Clear, area);
1042
1043 let rendered_title = self.render_title();
1044 let block = if self.bordered {
1045 let mut block = Block::default()
1046 .borders(Borders::ALL)
1047 .border_style(self.border_style)
1048 .style(self.background_style);
1049
1050 if let Some(title) = rendered_title.as_deref() {
1051 block = block.title(title);
1052 }
1053
1054 block
1055 } else {
1056 Block::default().style(self.background_style)
1057 };
1058
1059 let inner_area = block.inner(area);
1060 frame.render_widget(block, area);
1061
1062 if self.bordered && area.width >= 5 {
1067 let close_x = area.x + area.width - 4;
1068 let close_area = Rect {
1069 x: close_x,
1070 y: area.y,
1071 width: 3,
1072 height: 1,
1073 };
1074 frame.render_widget(Paragraph::new("[×]").style(self.border_style), close_area);
1075 }
1076
1077 let content_start_y;
1079 if let Some(desc) = &self.description {
1080 let desc_wrap_width = inner_area.width.saturating_sub(2) as usize; let desc_vec = vec![desc.clone()];
1083 let wrapped_desc = wrap_text_lines(&desc_vec, desc_wrap_width);
1084 let desc_lines: usize = wrapped_desc.len();
1085
1086 for (i, line) in wrapped_desc.iter().enumerate() {
1088 if i >= inner_area.height as usize {
1089 break;
1090 }
1091 let line_area = Rect {
1092 x: inner_area.x,
1093 y: inner_area.y + i as u16,
1094 width: inner_area.width,
1095 height: 1,
1096 };
1097 let desc_style = Style::default().fg(theme.help_separator_fg);
1098 frame.render_widget(Paragraph::new(line.as_str()).style(desc_style), line_area);
1099 }
1100
1101 content_start_y = inner_area.y + (desc_lines as u16).min(inner_area.height) + 1;
1103 } else {
1104 content_start_y = inner_area.y;
1105 }
1106
1107 let inner_area = Rect {
1109 x: inner_area.x,
1110 y: content_start_y,
1111 width: inner_area.width,
1112 height: inner_area
1113 .height
1114 .saturating_sub(content_start_y - area.y - if self.bordered { 1 } else { 0 }),
1115 };
1116
1117 let scrollbar_reserved_width = 2; let wrap_width = inner_area.width.saturating_sub(scrollbar_reserved_width) as usize;
1121 let visible_lines_count = inner_area.height as usize;
1122
1123 let (wrapped_total_lines, needs_scrollbar) = match &self.content {
1125 PopupContent::Text(lines) => {
1126 let wrapped = wrap_text_lines(lines, wrap_width);
1127 let count = wrapped.len();
1128 (
1129 count,
1130 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1131 )
1132 }
1133 PopupContent::Markdown(styled_lines) => {
1134 let wrapped = wrap_styled_lines(styled_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::List { items, .. } => {
1142 let count = items.len();
1143 (
1144 count,
1145 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1146 )
1147 }
1148 PopupContent::Custom(lines) => {
1149 let count = lines.len();
1150 (
1151 count,
1152 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1153 )
1154 }
1155 };
1156
1157 let content_area = if needs_scrollbar {
1159 Rect {
1160 x: inner_area.x,
1161 y: inner_area.y,
1162 width: inner_area.width.saturating_sub(scrollbar_reserved_width),
1163 height: inner_area.height,
1164 }
1165 } else {
1166 inner_area
1167 };
1168
1169 match &self.content {
1170 PopupContent::Text(lines) => {
1171 let wrapped_lines = wrap_text_lines(lines, content_area.width as usize);
1173 let selection_style = Style::default().bg(theme.selection_bg);
1174
1175 let visible_lines: Vec<Line> = wrapped_lines
1176 .iter()
1177 .enumerate()
1178 .skip(self.scroll_offset)
1179 .take(content_area.height as usize)
1180 .map(|(line_idx, line)| {
1181 if let Some(ref sel) = self.text_selection {
1182 let chars: Vec<char> = line.chars().collect();
1184 let spans: Vec<Span> = chars
1185 .iter()
1186 .enumerate()
1187 .map(|(col, ch)| {
1188 if sel.contains(line_idx, col) {
1189 Span::styled(ch.to_string(), selection_style)
1190 } else {
1191 Span::raw(ch.to_string())
1192 }
1193 })
1194 .collect();
1195 Line::from(spans)
1196 } else {
1197 Line::from(line.as_str())
1198 }
1199 })
1200 .collect();
1201
1202 let paragraph = Paragraph::new(visible_lines);
1203 frame.render_widget(paragraph, content_area);
1204 }
1205 PopupContent::Markdown(styled_lines) => {
1206 let wrapped_lines = wrap_styled_lines(styled_lines, content_area.width as usize);
1208 let selection_style = Style::default().bg(theme.selection_bg);
1209
1210 let mut link_overlays: Vec<(usize, usize, String, String)> = Vec::new();
1213
1214 let visible_lines: Vec<Line> = wrapped_lines
1215 .iter()
1216 .enumerate()
1217 .skip(self.scroll_offset)
1218 .take(content_area.height as usize)
1219 .map(|(line_idx, styled_line)| {
1220 let mut col = 0usize;
1221 let spans: Vec<Span> = styled_line
1222 .spans
1223 .iter()
1224 .flat_map(|s| {
1225 let span_start_col = col;
1226 let span_width =
1227 unicode_width::UnicodeWidthStr::width(s.text.as_str());
1228 if let Some(url) = &s.link_url {
1229 link_overlays.push((
1230 line_idx - self.scroll_offset,
1231 col,
1232 s.text.clone(),
1233 url.clone(),
1234 ));
1235 }
1236 col += span_width;
1237
1238 if let Some(ref sel) = self.text_selection {
1240 let chars: Vec<char> = s.text.chars().collect();
1242 chars
1243 .iter()
1244 .enumerate()
1245 .map(|(i, ch)| {
1246 let char_col = span_start_col + i;
1247 if sel.contains(line_idx, char_col) {
1248 Span::styled(ch.to_string(), selection_style)
1249 } else {
1250 Span::styled(ch.to_string(), s.style)
1251 }
1252 })
1253 .collect::<Vec<_>>()
1254 } else {
1255 vec![Span::styled(s.text.clone(), s.style)]
1256 }
1257 })
1258 .collect();
1259 Line::from(spans)
1260 })
1261 .collect();
1262
1263 let paragraph = Paragraph::new(visible_lines);
1264 frame.render_widget(paragraph, content_area);
1265
1266 let buffer = frame.buffer_mut();
1268 let max_x = content_area.x + content_area.width;
1269 for (line_idx, col_start, text, url) in link_overlays {
1270 let y = content_area.y + line_idx as u16;
1271 if y >= content_area.y + content_area.height {
1272 continue;
1273 }
1274 let start_x = content_area.x + col_start as u16;
1275 apply_hyperlink_overlay(buffer, start_x, y, max_x, &text, &url);
1276 }
1277 }
1278 PopupContent::List { items, selected } => {
1279 let list_items: Vec<ListItem> = items
1280 .iter()
1281 .enumerate()
1282 .skip(self.scroll_offset)
1283 .take(content_area.height as usize)
1284 .map(|(idx, item)| {
1285 let is_hovered = matches!(
1287 hover_target,
1288 Some(crate::app::HoverTarget::PopupListItem(_, hovered_idx)) if *hovered_idx == idx
1289 );
1290 let is_selected = idx == *selected;
1291
1292 let mut spans = Vec::new();
1293
1294 if let Some(icon) = &item.icon {
1296 spans.push(Span::raw(format!("{} ", icon)));
1297 }
1298
1299 let text = &item.text;
1307 let trimmed = text.trim_start();
1308 let indent_len = text.len() - trimmed.len();
1309 if indent_len > 0 {
1310 spans.push(Span::raw(&text[..indent_len]));
1311 }
1312 let is_clickable = item.data.is_some() && !item.disabled;
1313 let mut text_style = Style::default();
1314 if is_selected {
1315 text_style = text_style.add_modifier(Modifier::BOLD);
1316 }
1317 if is_clickable {
1318 text_style = text_style.add_modifier(Modifier::UNDERLINED);
1319 }
1320 if item.disabled {
1321 text_style = text_style
1322 .fg(theme.help_separator_fg)
1323 .add_modifier(Modifier::DIM);
1324 }
1325 spans.push(Span::styled(trimmed, text_style));
1326
1327 if let Some(detail) = &item.detail {
1329 spans.push(Span::styled(
1330 format!(" {}", detail),
1331 Style::default().fg(theme.help_separator_fg),
1332 ));
1333 }
1334
1335 spans.push(Span::raw(""));
1338
1339 if is_selected {
1341 if let Some(ref hint) = self.accept_key_hint {
1342 let hint_text = format!("({})", hint);
1343 let used_width: usize = spans
1345 .iter()
1346 .map(|s| {
1347 unicode_width::UnicodeWidthStr::width(s.content.as_ref())
1348 })
1349 .sum();
1350 let available = content_area.width as usize;
1351 let hint_len = hint_text.len();
1352 if used_width + hint_len + 1 < available {
1353 let padding = available - used_width - hint_len;
1354 spans.push(Span::raw(" ".repeat(padding)));
1355 spans.push(Span::styled(
1356 hint_text,
1357 Style::default().fg(theme.help_separator_fg),
1358 ));
1359 }
1360 }
1361 }
1362
1363 let row_style = if is_selected {
1365 Style::default().bg(theme.popup_selection_bg)
1366 } else if is_hovered {
1367 Style::default()
1368 .bg(theme.menu_hover_bg)
1369 .fg(theme.menu_hover_fg)
1370 } else {
1371 Style::default()
1372 };
1373
1374 ListItem::new(Line::from(spans)).style(row_style)
1375 })
1376 .collect();
1377
1378 let list = List::new(list_items);
1379 frame.render_widget(list, content_area);
1380 }
1381 PopupContent::Custom(lines) => {
1382 let visible_lines: Vec<Line> = lines
1383 .iter()
1384 .skip(self.scroll_offset)
1385 .take(content_area.height as usize)
1386 .map(|line| Line::from(line.as_str()))
1387 .collect();
1388
1389 let paragraph = Paragraph::new(visible_lines);
1390 frame.render_widget(paragraph, content_area);
1391 }
1392 }
1393
1394 if needs_scrollbar {
1396 let scrollbar_area = Rect {
1397 x: inner_area.x + inner_area.width - 1,
1398 y: inner_area.y,
1399 width: 1,
1400 height: inner_area.height,
1401 };
1402
1403 let scrollbar_state =
1404 ScrollbarState::new(wrapped_total_lines, visible_lines_count, self.scroll_offset);
1405 let scrollbar_colors = ScrollbarColors::from_theme(theme);
1406 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
1407 }
1408 }
1409}
1410
1411#[derive(Debug, Clone)]
1413pub struct PopupManager {
1414 popups: Vec<Popup>,
1416}
1417
1418impl PopupManager {
1419 pub fn new() -> Self {
1420 Self { popups: Vec::new() }
1421 }
1422
1423 pub fn show(&mut self, popup: Popup) {
1425 self.popups.push(popup);
1426 }
1427
1428 pub fn show_or_replace(&mut self, popup: Popup) {
1432 if let Some(pos) = self.popups.iter().position(|p| p.kind == popup.kind) {
1433 self.popups[pos] = popup;
1434 } else {
1435 self.popups.push(popup);
1436 }
1437 }
1438
1439 pub fn hide(&mut self) -> Option<Popup> {
1441 self.popups.pop()
1442 }
1443
1444 pub fn clear(&mut self) {
1446 self.popups.clear();
1447 }
1448
1449 pub fn top(&self) -> Option<&Popup> {
1451 self.popups.last()
1452 }
1453
1454 pub fn top_mut(&mut self) -> Option<&mut Popup> {
1456 self.popups.last_mut()
1457 }
1458
1459 pub fn get(&self, index: usize) -> Option<&Popup> {
1461 self.popups.get(index)
1462 }
1463
1464 pub fn get_mut(&mut self, index: usize) -> Option<&mut Popup> {
1466 self.popups.get_mut(index)
1467 }
1468
1469 pub fn is_visible(&self) -> bool {
1471 !self.popups.is_empty()
1472 }
1473
1474 pub fn is_completion_popup(&self) -> bool {
1476 self.top()
1477 .map(|p| p.kind == PopupKind::Completion)
1478 .unwrap_or(false)
1479 }
1480
1481 pub fn is_hover_popup(&self) -> bool {
1483 self.top()
1484 .map(|p| p.kind == PopupKind::Hover)
1485 .unwrap_or(false)
1486 }
1487
1488 pub fn is_action_popup(&self) -> bool {
1490 self.top()
1491 .map(|p| p.kind == PopupKind::Action)
1492 .unwrap_or(false)
1493 }
1494
1495 pub fn all(&self) -> &[Popup] {
1497 &self.popups
1498 }
1499
1500 pub fn dismiss_transient(&mut self) -> bool {
1504 let is_transient = self.popups.last().is_some_and(|p| p.transient);
1505
1506 if is_transient {
1507 self.popups.pop();
1508 true
1509 } else {
1510 false
1511 }
1512 }
1513}
1514
1515impl Default for PopupManager {
1516 fn default() -> Self {
1517 Self::new()
1518 }
1519}
1520
1521fn apply_hyperlink_overlay(
1526 buffer: &mut ratatui::buffer::Buffer,
1527 start_x: u16,
1528 y: u16,
1529 max_x: u16,
1530 text: &str,
1531 url: &str,
1532) {
1533 let mut chunk_index = 0u16;
1534 let mut chars = text.chars();
1535
1536 loop {
1537 let mut chunk = String::new();
1538 for _ in 0..2 {
1539 if let Some(ch) = chars.next() {
1540 chunk.push(ch);
1541 } else {
1542 break;
1543 }
1544 }
1545
1546 if chunk.is_empty() {
1547 break;
1548 }
1549
1550 let x = start_x + chunk_index * 2;
1551 if x >= max_x {
1552 break;
1553 }
1554
1555 let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, chunk);
1556 buffer[(x, y)].set_symbol(&hyperlink);
1557
1558 chunk_index += 1;
1559 }
1560}
1561
1562#[cfg(test)]
1563mod tests {
1564 use super::*;
1565 use crate::view::theme;
1566
1567 #[test]
1568 fn test_popup_list_item() {
1569 let item = PopupListItem::new("test".to_string())
1570 .with_detail("detail".to_string())
1571 .with_icon("📄".to_string());
1572
1573 assert_eq!(item.text, "test");
1574 assert_eq!(item.detail, Some("detail".to_string()));
1575 assert_eq!(item.icon, Some("📄".to_string()));
1576 }
1577
1578 #[test]
1579 fn test_popup_selection() {
1580 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1581 let items = vec![
1582 PopupListItem::new("item1".to_string()),
1583 PopupListItem::new("item2".to_string()),
1584 PopupListItem::new("item3".to_string()),
1585 ];
1586
1587 let mut popup = Popup::list(items, &theme);
1588
1589 assert_eq!(popup.selected_item().unwrap().text, "item1");
1590
1591 popup.select_next();
1592 assert_eq!(popup.selected_item().unwrap().text, "item2");
1593
1594 popup.select_next();
1595 assert_eq!(popup.selected_item().unwrap().text, "item3");
1596
1597 popup.select_next(); assert_eq!(popup.selected_item().unwrap().text, "item3");
1599
1600 popup.select_prev();
1601 assert_eq!(popup.selected_item().unwrap().text, "item2");
1602
1603 popup.select_prev();
1604 assert_eq!(popup.selected_item().unwrap().text, "item1");
1605
1606 popup.select_prev(); assert_eq!(popup.selected_item().unwrap().text, "item1");
1608 }
1609
1610 #[test]
1611 fn test_popup_manager() {
1612 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1613 let mut manager = PopupManager::new();
1614
1615 assert!(!manager.is_visible());
1616 assert_eq!(manager.top(), None);
1617
1618 let popup1 = Popup::text(vec!["test1".to_string()], &theme);
1619 manager.show(popup1);
1620
1621 assert!(manager.is_visible());
1622 assert_eq!(manager.all().len(), 1);
1623
1624 let popup2 = Popup::text(vec!["test2".to_string()], &theme);
1625 manager.show(popup2);
1626
1627 assert_eq!(manager.all().len(), 2);
1628
1629 manager.hide();
1630 assert_eq!(manager.all().len(), 1);
1631
1632 manager.clear();
1633 assert!(!manager.is_visible());
1634 assert_eq!(manager.all().len(), 0);
1635 }
1636
1637 #[test]
1638 fn test_popup_area_calculation() {
1639 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1640 let terminal_area = Rect {
1641 x: 0,
1642 y: 0,
1643 width: 100,
1644 height: 50,
1645 };
1646
1647 let popup = Popup::text(vec!["test".to_string()], &theme)
1648 .with_width(30)
1649 .with_max_height(10);
1650
1651 let popup_centered = popup.clone().with_position(PopupPosition::Centered);
1653 let area = popup_centered.calculate_area(terminal_area, None);
1654 assert_eq!(area.width, 30);
1655 assert_eq!(area.height, 3);
1657 assert_eq!(area.x, (100 - 30) / 2);
1658 assert_eq!(area.y, (50 - 3) / 2);
1659
1660 let popup_below = popup.clone().with_position(PopupPosition::BelowCursor);
1662 let area = popup_below.calculate_area(terminal_area, Some((20, 10)));
1663 assert_eq!(area.x, 20);
1664 assert_eq!(area.y, 11); }
1666
1667 #[test]
1668 fn test_popup_fixed_position_clamping() {
1669 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1670 let terminal_area = Rect {
1671 x: 0,
1672 y: 0,
1673 width: 100,
1674 height: 50,
1675 };
1676
1677 let popup = Popup::text(vec!["test".to_string()], &theme)
1678 .with_width(30)
1679 .with_max_height(10);
1680
1681 let popup_fixed = popup
1683 .clone()
1684 .with_position(PopupPosition::Fixed { x: 10, y: 20 });
1685 let area = popup_fixed.calculate_area(terminal_area, None);
1686 assert_eq!(area.x, 10);
1687 assert_eq!(area.y, 20);
1688
1689 let popup_right_edge = popup
1691 .clone()
1692 .with_position(PopupPosition::Fixed { x: 99, y: 20 });
1693 let area = popup_right_edge.calculate_area(terminal_area, None);
1694 assert_eq!(area.x, 70);
1696 assert_eq!(area.y, 20);
1697
1698 let popup_beyond = popup
1700 .clone()
1701 .with_position(PopupPosition::Fixed { x: 199, y: 20 });
1702 let area = popup_beyond.calculate_area(terminal_area, None);
1703 assert_eq!(area.x, 70);
1705 assert_eq!(area.y, 20);
1706
1707 let popup_bottom = popup
1709 .clone()
1710 .with_position(PopupPosition::Fixed { x: 10, y: 49 });
1711 let area = popup_bottom.calculate_area(terminal_area, None);
1712 assert_eq!(area.x, 10);
1713 assert_eq!(area.y, 47);
1715 }
1716
1717 #[test]
1718 fn test_clamp_rect_to_bounds() {
1719 let bounds = Rect {
1720 x: 0,
1721 y: 0,
1722 width: 100,
1723 height: 50,
1724 };
1725
1726 let rect = Rect {
1728 x: 10,
1729 y: 20,
1730 width: 30,
1731 height: 10,
1732 };
1733 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1734 assert_eq!(clamped, rect);
1735
1736 let rect = Rect {
1738 x: 99,
1739 y: 20,
1740 width: 30,
1741 height: 10,
1742 };
1743 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1744 assert_eq!(clamped.x, 99); assert_eq!(clamped.width, 1); let rect = Rect {
1749 x: 199,
1750 y: 60,
1751 width: 30,
1752 height: 10,
1753 };
1754 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1755 assert_eq!(clamped.x, 99); assert_eq!(clamped.y, 49); assert_eq!(clamped.width, 1); assert_eq!(clamped.height, 1); }
1760
1761 #[test]
1762 fn hyperlink_overlay_chunks_pairs() {
1763 use ratatui::{buffer::Buffer, layout::Rect};
1764
1765 let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1766 buffer[(0, 0)].set_symbol("P");
1767 buffer[(1, 0)].set_symbol("l");
1768 buffer[(2, 0)].set_symbol("a");
1769 buffer[(3, 0)].set_symbol("y");
1770
1771 apply_hyperlink_overlay(&mut buffer, 0, 0, 10, "Play", "https://example.com");
1772
1773 let first = buffer[(0, 0)].symbol().to_string();
1774 let second = buffer[(2, 0)].symbol().to_string();
1775
1776 assert!(
1777 first.contains("Pl"),
1778 "first chunk should contain 'Pl', got {first:?}"
1779 );
1780 assert!(
1781 second.contains("ay"),
1782 "second chunk should contain 'ay', got {second:?}"
1783 );
1784 }
1785
1786 #[test]
1787 fn test_popup_text_selection() {
1788 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1789 let mut popup = Popup::text(
1790 vec![
1791 "Line 0: Hello".to_string(),
1792 "Line 1: World".to_string(),
1793 "Line 2: Test".to_string(),
1794 ],
1795 &theme,
1796 );
1797
1798 assert!(!popup.has_selection());
1800 assert_eq!(popup.get_selected_text(), None);
1801
1802 popup.start_selection(0, 8);
1804 assert!(!popup.has_selection()); popup.extend_selection(1, 8);
1808 assert!(popup.has_selection());
1809
1810 let selected = popup.get_selected_text().unwrap();
1812 assert_eq!(selected, "Hello\nLine 1: ");
1813
1814 popup.clear_selection();
1816 assert!(!popup.has_selection());
1817 assert_eq!(popup.get_selected_text(), None);
1818
1819 popup.start_selection(1, 8);
1821 popup.extend_selection(1, 13); let selected = popup.get_selected_text().unwrap();
1823 assert_eq!(selected, "World");
1824 }
1825
1826 #[test]
1827 fn test_popup_text_selection_contains() {
1828 let sel = PopupTextSelection {
1829 start: (1, 5),
1830 end: (2, 10),
1831 };
1832
1833 assert!(!sel.contains(0, 5));
1835
1836 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));
1849 }
1850
1851 #[test]
1852 fn test_popup_text_selection_normalized() {
1853 let sel = PopupTextSelection {
1855 start: (1, 5),
1856 end: (2, 10),
1857 };
1858 let ((s_line, s_col), (e_line, e_col)) = sel.normalized();
1859 assert_eq!((s_line, s_col), (1, 5));
1860 assert_eq!((e_line, e_col), (2, 10));
1861
1862 let sel_backward = PopupTextSelection {
1864 start: (2, 10),
1865 end: (1, 5),
1866 };
1867 let ((s_line, s_col), (e_line, e_col)) = sel_backward.normalized();
1868 assert_eq!((s_line, s_col), (1, 5));
1869 assert_eq!((e_line, e_col), (2, 10));
1870 }
1871}