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 BottomRight,
50 AboveStatusBarAt { x: u16 },
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum PopupKind {
59 Completion,
61 Hover,
63 Action,
65 List,
67 Text,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Default)]
81pub enum PopupResolver {
82 #[default]
85 None,
86 Completion,
88 LspConfirm { language: String },
92 LspStatus,
95 CodeAction,
99 PluginAction { popup_id: String },
103 RemoteIndicator,
108}
109
110#[derive(Debug, Clone, PartialEq)]
112pub enum PopupContent {
113 Text(Vec<String>),
115 Markdown(Vec<StyledLine>),
117 List {
119 items: Vec<PopupListItem>,
120 selected: usize,
121 },
122 Custom(Vec<String>),
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct PopupTextSelection {
129 pub start: (usize, usize),
131 pub end: (usize, usize),
133}
134
135impl PopupTextSelection {
136 pub fn normalized(&self) -> ((usize, usize), (usize, usize)) {
138 if self.start.0 < self.end.0 || (self.start.0 == self.end.0 && self.start.1 <= self.end.1) {
139 (self.start, self.end)
140 } else {
141 (self.end, self.start)
142 }
143 }
144
145 pub fn contains(&self, line: usize, col: usize) -> bool {
147 let ((start_line, start_col), (end_line, end_col)) = self.normalized();
148 if line < start_line || line > end_line {
149 return false;
150 }
151 if line == start_line && line == end_line {
152 col >= start_col && col < end_col
153 } else if line == start_line {
154 col >= start_col
155 } else if line == end_line {
156 col < end_col
157 } else {
158 true
159 }
160 }
161}
162
163#[derive(Debug, Clone, PartialEq)]
165pub struct PopupListItem {
166 pub text: String,
168 pub detail: Option<String>,
170 pub icon: Option<String>,
172 pub data: Option<String>,
174 pub disabled: bool,
176}
177
178impl PopupListItem {
179 pub fn new(text: String) -> Self {
180 Self {
181 text,
182 detail: None,
183 icon: None,
184 data: None,
185 disabled: false,
186 }
187 }
188
189 pub fn with_detail(mut self, detail: String) -> Self {
190 self.detail = Some(detail);
191 self
192 }
193
194 pub fn with_icon(mut self, icon: String) -> Self {
195 self.icon = Some(icon);
196 self
197 }
198
199 pub fn with_data(mut self, data: String) -> Self {
200 self.data = Some(data);
201 self
202 }
203
204 pub fn disabled(mut self) -> Self {
205 self.disabled = true;
206 self
207 }
208}
209
210#[derive(Debug, Clone, PartialEq)]
219pub struct Popup {
220 pub kind: PopupKind,
222
223 pub title: Option<String>,
225
226 pub description: Option<String>,
228
229 pub transient: bool,
231
232 pub content: PopupContent,
234
235 pub position: PopupPosition,
237
238 pub width: u16,
240
241 pub max_height: u16,
243
244 pub bordered: bool,
246
247 pub border_style: Style,
249
250 pub background_style: Style,
252
253 pub scroll_offset: usize,
255
256 pub text_selection: Option<PopupTextSelection>,
258
259 pub accept_key_hint: Option<String>,
261
262 pub resolver: PopupResolver,
265}
266
267impl Popup {
268 pub fn text(content: Vec<String>, theme: &crate::view::theme::Theme) -> Self {
270 Self {
271 kind: PopupKind::Text,
272 title: None,
273 description: None,
274 transient: false,
275 content: PopupContent::Text(content),
276 position: PopupPosition::AtCursor,
277 width: 50,
278 max_height: 15,
279 bordered: true,
280 border_style: Style::default().fg(theme.popup_border_fg),
281 background_style: Style::default().bg(theme.popup_bg),
282 scroll_offset: 0,
283 text_selection: None,
284 accept_key_hint: None,
285 resolver: PopupResolver::None,
286 }
287 }
288
289 pub fn markdown(
294 markdown_text: &str,
295 theme: &crate::view::theme::Theme,
296 registry: Option<&GrammarRegistry>,
297 ) -> Self {
298 let styled_lines = parse_markdown(markdown_text, theme, registry);
299 Self {
300 kind: PopupKind::Text,
301 title: None,
302 description: None,
303 transient: false,
304 content: PopupContent::Markdown(styled_lines),
305 position: PopupPosition::AtCursor,
306 width: 60, max_height: 20, bordered: true,
309 border_style: Style::default().fg(theme.popup_border_fg),
310 background_style: Style::default().bg(theme.popup_bg),
311 scroll_offset: 0,
312 text_selection: None,
313 accept_key_hint: None,
314 resolver: PopupResolver::None,
315 }
316 }
317
318 pub fn list(items: Vec<PopupListItem>, theme: &crate::view::theme::Theme) -> Self {
320 Self {
321 kind: PopupKind::List,
322 title: None,
323 description: None,
324 transient: false,
325 content: PopupContent::List { items, selected: 0 },
326 position: PopupPosition::AtCursor,
327 width: 50,
328 max_height: 15,
329 bordered: true,
330 border_style: Style::default().fg(theme.popup_border_fg),
331 background_style: Style::default().bg(theme.popup_bg),
332 scroll_offset: 0,
333 text_selection: None,
334 accept_key_hint: None,
335 resolver: PopupResolver::None,
336 }
337 }
338
339 pub fn with_title(mut self, title: String) -> Self {
341 self.title = Some(title);
342 self
343 }
344
345 pub fn with_kind(mut self, kind: PopupKind) -> Self {
347 self.kind = kind;
348 self
349 }
350
351 pub fn with_transient(mut self, transient: bool) -> Self {
353 self.transient = transient;
354 self
355 }
356
357 pub fn with_position(mut self, position: PopupPosition) -> Self {
359 self.position = position;
360 self
361 }
362
363 pub fn with_width(mut self, width: u16) -> Self {
365 self.width = width;
366 self
367 }
368
369 pub fn with_max_height(mut self, max_height: u16) -> Self {
371 self.max_height = max_height;
372 self
373 }
374
375 pub fn with_border_style(mut self, style: Style) -> Self {
377 self.border_style = style;
378 self
379 }
380
381 pub fn with_resolver(mut self, resolver: PopupResolver) -> Self {
384 self.resolver = resolver;
385 self
386 }
387
388 pub fn selected_item(&self) -> Option<&PopupListItem> {
390 match &self.content {
391 PopupContent::List { items, selected } => items.get(*selected),
392 _ => None,
393 }
394 }
395
396 fn visible_height(&self) -> usize {
398 let border_offset = if self.bordered { 2 } else { 0 };
399 (self.max_height as usize).saturating_sub(border_offset)
400 }
401
402 pub fn select_next(&mut self) {
404 let visible = self.visible_height();
405 if let PopupContent::List { items, selected } = &mut self.content {
406 if *selected < items.len().saturating_sub(1) {
407 *selected += 1;
408 if *selected >= self.scroll_offset + visible {
410 self.scroll_offset = (*selected + 1).saturating_sub(visible);
411 }
412 }
413 }
414 }
415
416 pub fn select_prev(&mut self) {
418 if let PopupContent::List { items: _, selected } = &mut self.content {
419 if *selected > 0 {
420 *selected -= 1;
421 if *selected < self.scroll_offset {
423 self.scroll_offset = *selected;
424 }
425 }
426 }
427 }
428
429 pub fn select_index(&mut self, index: usize) -> bool {
431 let visible = self.visible_height();
432 if let PopupContent::List { items, selected } = &mut self.content {
433 if index < items.len() {
434 *selected = index;
435 if *selected >= self.scroll_offset + visible {
437 self.scroll_offset = (*selected + 1).saturating_sub(visible);
438 } else if *selected < self.scroll_offset {
439 self.scroll_offset = *selected;
440 }
441 return true;
442 }
443 }
444 false
445 }
446
447 pub fn page_down(&mut self) {
449 let visible = self.visible_height();
450 if let PopupContent::List { items, selected } = &mut self.content {
451 *selected = (*selected + visible).min(items.len().saturating_sub(1));
452 self.scroll_offset = (*selected + 1).saturating_sub(visible);
453 } else {
454 self.scroll_offset += visible;
455 }
456 }
457
458 pub fn page_up(&mut self) {
460 let visible = self.visible_height();
461 if let PopupContent::List { items: _, selected } = &mut self.content {
462 *selected = selected.saturating_sub(visible);
463 self.scroll_offset = *selected;
464 } else {
465 self.scroll_offset = self.scroll_offset.saturating_sub(visible);
466 }
467 }
468
469 pub fn select_first(&mut self) {
471 if let PopupContent::List { items: _, selected } = &mut self.content {
472 *selected = 0;
473 self.scroll_offset = 0;
474 } else {
475 self.scroll_offset = 0;
476 }
477 }
478
479 pub fn select_last(&mut self) {
481 let visible = self.visible_height();
482 if let PopupContent::List { items, selected } = &mut self.content {
483 *selected = items.len().saturating_sub(1);
484 if *selected >= visible {
486 self.scroll_offset = (*selected + 1).saturating_sub(visible);
487 }
488 } else {
489 let content_height = self.item_count();
491 if content_height > visible {
492 self.scroll_offset = content_height.saturating_sub(visible);
493 }
494 }
495 }
496
497 pub fn scroll_by(&mut self, delta: i32) {
500 let content_len = self.wrapped_item_count();
501 let visible = self.visible_height();
502 let max_scroll = content_len.saturating_sub(visible);
503
504 if delta < 0 {
505 self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
507 } else {
508 self.scroll_offset = (self.scroll_offset + delta as usize).min(max_scroll);
510 }
511
512 if let PopupContent::List { items, selected } = &mut self.content {
514 let visible_start = self.scroll_offset;
515 let visible_end = (self.scroll_offset + visible).min(items.len());
516
517 if *selected < visible_start {
518 *selected = visible_start;
519 } else if *selected >= visible_end {
520 *selected = visible_end.saturating_sub(1);
521 }
522 }
523 }
524
525 pub fn item_count(&self) -> usize {
527 match &self.content {
528 PopupContent::Text(lines) => lines.len(),
529 PopupContent::Markdown(lines) => lines.len(),
530 PopupContent::List { items, .. } => items.len(),
531 PopupContent::Custom(lines) => lines.len(),
532 }
533 }
534
535 fn wrapped_item_count(&self) -> usize {
540 let border_width = if self.bordered { 2 } else { 0 };
542 let scrollbar_width = 2; let wrap_width = (self.width as usize)
544 .saturating_sub(border_width)
545 .saturating_sub(scrollbar_width);
546
547 if wrap_width == 0 {
548 return self.item_count();
549 }
550
551 match &self.content {
552 PopupContent::Text(lines) => wrap_text_lines(lines, wrap_width).len(),
553 PopupContent::Markdown(styled_lines) => {
554 wrap_styled_lines(styled_lines, wrap_width).len()
555 }
556 PopupContent::List { items, .. } => items.len(),
558 PopupContent::Custom(lines) => lines.len(),
559 }
560 }
561
562 pub fn start_selection(&mut self, line: usize, col: usize) {
564 self.text_selection = Some(PopupTextSelection {
565 start: (line, col),
566 end: (line, col),
567 });
568 }
569
570 pub fn extend_selection(&mut self, line: usize, col: usize) {
572 if let Some(ref mut sel) = self.text_selection {
573 sel.end = (line, col);
574 }
575 }
576
577 pub fn clear_selection(&mut self) {
579 self.text_selection = None;
580 }
581
582 pub fn has_selection(&self) -> bool {
584 if let Some(sel) = &self.text_selection {
585 sel.start != sel.end
586 } else {
587 false
588 }
589 }
590
591 fn content_wrap_width(&self) -> usize {
594 let border_width: u16 = if self.bordered { 2 } else { 0 };
595 let inner_width = self.width.saturating_sub(border_width);
596 let scrollbar_reserved: u16 = 2;
597 let conservative_width = inner_width.saturating_sub(scrollbar_reserved) as usize;
598
599 if conservative_width == 0 {
600 return 0;
601 }
602
603 let visible_height = self.max_height.saturating_sub(border_width) as usize;
604 let line_count = match &self.content {
605 PopupContent::Text(lines) => wrap_text_lines(lines, conservative_width).len(),
606 PopupContent::Markdown(styled_lines) => {
607 wrap_styled_lines(styled_lines, conservative_width).len()
608 }
609 _ => self.item_count(),
610 };
611
612 let needs_scrollbar = line_count > visible_height && inner_width > scrollbar_reserved;
613
614 if needs_scrollbar {
615 conservative_width
616 } else {
617 inner_width as usize
618 }
619 }
620
621 fn get_text_lines(&self) -> Vec<String> {
626 let wrap_width = self.content_wrap_width();
627
628 match &self.content {
629 PopupContent::Text(lines) => {
630 if wrap_width > 0 {
631 wrap_text_lines(lines, wrap_width)
632 } else {
633 lines.clone()
634 }
635 }
636 PopupContent::Markdown(styled_lines) => {
637 if wrap_width > 0 {
638 wrap_styled_lines(styled_lines, wrap_width)
639 .iter()
640 .map(|sl| sl.plain_text())
641 .collect()
642 } else {
643 styled_lines.iter().map(|sl| sl.plain_text()).collect()
644 }
645 }
646 PopupContent::List { items, .. } => items.iter().map(|i| i.text.clone()).collect(),
647 PopupContent::Custom(lines) => lines.clone(),
648 }
649 }
650
651 pub fn get_selected_text(&self) -> Option<String> {
653 let sel = self.text_selection.as_ref()?;
654 if sel.start == sel.end {
655 return None;
656 }
657
658 let ((start_line, start_col), (end_line, end_col)) = sel.normalized();
659 let lines = self.get_text_lines();
660
661 if start_line >= lines.len() {
662 return None;
663 }
664
665 if start_line == end_line {
666 let line = &lines[start_line];
667 let end_col = end_col.min(line.len());
668 let start_col = start_col.min(end_col);
669 Some(line[start_col..end_col].to_string())
670 } else {
671 let mut result = String::new();
672 let first_line = &lines[start_line];
674 result.push_str(&first_line[start_col.min(first_line.len())..]);
675 result.push('\n');
676 for line in lines.iter().take(end_line).skip(start_line + 1) {
678 result.push_str(line);
679 result.push('\n');
680 }
681 if end_line < lines.len() {
683 let last_line = &lines[end_line];
684 result.push_str(&last_line[..end_col.min(last_line.len())]);
685 }
686 Some(result)
687 }
688 }
689
690 pub fn needs_scrollbar(&self) -> bool {
692 self.item_count() > self.visible_height()
693 }
694
695 pub fn scroll_state(&self) -> (usize, usize, usize) {
697 let total = self.item_count();
698 let visible = self.visible_height();
699 (total, visible, self.scroll_offset)
700 }
701
702 pub fn link_at_position(&self, relative_col: usize, relative_row: usize) -> Option<String> {
708 let PopupContent::Markdown(styled_lines) = &self.content else {
709 return None;
710 };
711
712 let border_width = if self.bordered { 2 } else { 0 };
714 let scrollbar_reserved = 2;
715 let content_width = self
716 .width
717 .saturating_sub(border_width)
718 .saturating_sub(scrollbar_reserved) as usize;
719
720 let wrapped_lines = wrap_styled_lines(styled_lines, content_width);
722
723 let line_index = self.scroll_offset + relative_row;
725
726 let line = wrapped_lines.get(line_index)?;
728
729 line.link_at_column(relative_col).map(|s| s.to_string())
731 }
732
733 pub fn description_height(&self) -> u16 {
736 if let Some(desc) = &self.description {
737 let border_width = if self.bordered { 2 } else { 0 };
738 let scrollbar_reserved = 2;
739 let content_width = self
740 .width
741 .saturating_sub(border_width)
742 .saturating_sub(scrollbar_reserved) as usize;
743 let desc_vec = vec![desc.clone()];
744 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
745 wrapped.len() as u16 + 1 } else {
747 0
748 }
749 }
750
751 fn content_height(&self) -> u16 {
753 self.content_height_for_width(self.width)
755 }
756
757 fn content_height_for_width(&self, popup_width: u16) -> u16 {
759 let border_width = if self.bordered { 2 } else { 0 };
761 let scrollbar_reserved = 2; let content_width = popup_width
763 .saturating_sub(border_width)
764 .saturating_sub(scrollbar_reserved) as usize;
765
766 let description_lines = if let Some(desc) = &self.description {
768 let desc_vec = vec![desc.clone()];
769 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
770 wrapped.len() as u16 + 1 } else {
772 0
773 };
774
775 let content_lines = match &self.content {
776 PopupContent::Text(lines) => {
777 wrap_text_lines(lines, content_width).len() as u16
779 }
780 PopupContent::Markdown(styled_lines) => {
781 wrap_styled_lines(styled_lines, content_width).len() as u16
783 }
784 PopupContent::List { items, .. } => items.len() as u16,
785 PopupContent::Custom(lines) => lines.len() as u16,
786 };
787
788 let border_height = if self.bordered { 2 } else { 0 };
790
791 description_lines + content_lines + border_height
792 }
793
794 pub fn calculate_area(&self, terminal_area: Rect, cursor_pos: Option<(u16, u16)>) -> Rect {
796 match self.position {
797 PopupPosition::AtCursor | PopupPosition::BelowCursor | PopupPosition::AboveCursor => {
798 let (cursor_x, cursor_y) =
799 cursor_pos.unwrap_or((terminal_area.width / 2, terminal_area.height / 2));
800
801 let width = self.width.min(terminal_area.width);
802 let height = self
804 .content_height()
805 .min(self.max_height)
806 .min(terminal_area.height);
807
808 let x = if cursor_x + width > terminal_area.width {
809 terminal_area.width.saturating_sub(width)
810 } else {
811 cursor_x
812 };
813
814 let y = match self.position {
815 PopupPosition::AtCursor => cursor_y,
816 PopupPosition::BelowCursor => {
817 if cursor_y + 1 + height > terminal_area.height {
818 cursor_y.saturating_sub(height)
820 } else {
821 cursor_y + 1
823 }
824 }
825 PopupPosition::AboveCursor => {
826 (cursor_y + 1).saturating_sub(height)
828 }
829 _ => cursor_y,
830 };
831
832 Rect {
833 x,
834 y,
835 width,
836 height,
837 }
838 }
839 PopupPosition::Fixed { x, y } => {
840 let width = self.width.min(terminal_area.width);
841 let height = self
842 .content_height()
843 .min(self.max_height)
844 .min(terminal_area.height);
845 let x = if x + width > terminal_area.width {
847 terminal_area.width.saturating_sub(width)
848 } else {
849 x
850 };
851 let y = if y + height > terminal_area.height {
852 terminal_area.height.saturating_sub(height)
853 } else {
854 y
855 };
856 Rect {
857 x,
858 y,
859 width,
860 height,
861 }
862 }
863 PopupPosition::Centered => {
864 let width = self.width.min(terminal_area.width);
865 let height = self
866 .content_height()
867 .min(self.max_height)
868 .min(terminal_area.height);
869 let x = (terminal_area.width.saturating_sub(width)) / 2;
870 let y = (terminal_area.height.saturating_sub(height)) / 2;
871 Rect {
872 x,
873 y,
874 width,
875 height,
876 }
877 }
878 PopupPosition::BottomRight => {
879 let width = self.width.min(terminal_area.width);
880 let height = self
881 .content_height()
882 .min(self.max_height)
883 .min(terminal_area.height);
884 let x = terminal_area.width.saturating_sub(width);
886 let y = terminal_area
887 .height
888 .saturating_sub(height)
889 .saturating_sub(2);
890 Rect {
891 x,
892 y,
893 width,
894 height,
895 }
896 }
897 PopupPosition::AboveStatusBarAt { x } => {
898 let width = self.width.min(terminal_area.width);
899 let height = self
900 .content_height()
901 .min(self.max_height)
902 .min(terminal_area.height);
903 let x = if x + width > terminal_area.width {
906 terminal_area.width.saturating_sub(width)
907 } else {
908 x
909 };
910 let y = terminal_area
919 .height
920 .saturating_sub(height)
921 .saturating_sub(2);
922 Rect {
923 x,
924 y,
925 width,
926 height,
927 }
928 }
929 }
930 }
931
932 pub fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::view::theme::Theme) {
934 self.render_with_hover(frame, area, theme, None);
935 }
936
937 pub fn render_with_hover(
939 &self,
940 frame: &mut Frame,
941 area: Rect,
942 theme: &crate::view::theme::Theme,
943 hover_target: Option<&crate::app::HoverTarget>,
944 ) {
945 let frame_area = frame.area();
947 let area = clamp_rect_to_bounds(area, frame_area);
948
949 if area.width == 0 || area.height == 0 {
951 return;
952 }
953
954 frame.render_widget(Clear, area);
956
957 let block = if self.bordered {
958 let mut block = Block::default()
959 .borders(Borders::ALL)
960 .border_style(self.border_style)
961 .style(self.background_style);
962
963 if let Some(title) = &self.title {
964 block = block.title(title.as_str());
965 }
966
967 block
968 } else {
969 Block::default().style(self.background_style)
970 };
971
972 let inner_area = block.inner(area);
973 frame.render_widget(block, area);
974
975 if self.bordered && area.width >= 5 {
980 let close_x = area.x + area.width - 4;
981 let close_area = Rect {
982 x: close_x,
983 y: area.y,
984 width: 3,
985 height: 1,
986 };
987 frame.render_widget(Paragraph::new("[×]").style(self.border_style), close_area);
988 }
989
990 let content_start_y;
992 if let Some(desc) = &self.description {
993 let desc_wrap_width = inner_area.width.saturating_sub(2) as usize; let desc_vec = vec![desc.clone()];
996 let wrapped_desc = wrap_text_lines(&desc_vec, desc_wrap_width);
997 let desc_lines: usize = wrapped_desc.len();
998
999 for (i, line) in wrapped_desc.iter().enumerate() {
1001 if i >= inner_area.height as usize {
1002 break;
1003 }
1004 let line_area = Rect {
1005 x: inner_area.x,
1006 y: inner_area.y + i as u16,
1007 width: inner_area.width,
1008 height: 1,
1009 };
1010 let desc_style = Style::default().fg(theme.help_separator_fg);
1011 frame.render_widget(Paragraph::new(line.as_str()).style(desc_style), line_area);
1012 }
1013
1014 content_start_y = inner_area.y + (desc_lines as u16).min(inner_area.height) + 1;
1016 } else {
1017 content_start_y = inner_area.y;
1018 }
1019
1020 let inner_area = Rect {
1022 x: inner_area.x,
1023 y: content_start_y,
1024 width: inner_area.width,
1025 height: inner_area
1026 .height
1027 .saturating_sub(content_start_y - area.y - if self.bordered { 1 } else { 0 }),
1028 };
1029
1030 let scrollbar_reserved_width = 2; let wrap_width = inner_area.width.saturating_sub(scrollbar_reserved_width) as usize;
1034 let visible_lines_count = inner_area.height as usize;
1035
1036 let (wrapped_total_lines, needs_scrollbar) = match &self.content {
1038 PopupContent::Text(lines) => {
1039 let wrapped = wrap_text_lines(lines, wrap_width);
1040 let count = wrapped.len();
1041 (
1042 count,
1043 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1044 )
1045 }
1046 PopupContent::Markdown(styled_lines) => {
1047 let wrapped = wrap_styled_lines(styled_lines, wrap_width);
1048 let count = wrapped.len();
1049 (
1050 count,
1051 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1052 )
1053 }
1054 PopupContent::List { items, .. } => {
1055 let count = items.len();
1056 (
1057 count,
1058 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1059 )
1060 }
1061 PopupContent::Custom(lines) => {
1062 let count = lines.len();
1063 (
1064 count,
1065 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1066 )
1067 }
1068 };
1069
1070 let content_area = if needs_scrollbar {
1072 Rect {
1073 x: inner_area.x,
1074 y: inner_area.y,
1075 width: inner_area.width.saturating_sub(scrollbar_reserved_width),
1076 height: inner_area.height,
1077 }
1078 } else {
1079 inner_area
1080 };
1081
1082 match &self.content {
1083 PopupContent::Text(lines) => {
1084 let wrapped_lines = wrap_text_lines(lines, content_area.width as usize);
1086 let selection_style = Style::default().bg(theme.selection_bg);
1087
1088 let visible_lines: Vec<Line> = wrapped_lines
1089 .iter()
1090 .enumerate()
1091 .skip(self.scroll_offset)
1092 .take(content_area.height as usize)
1093 .map(|(line_idx, line)| {
1094 if let Some(ref sel) = self.text_selection {
1095 let chars: Vec<char> = line.chars().collect();
1097 let spans: Vec<Span> = chars
1098 .iter()
1099 .enumerate()
1100 .map(|(col, ch)| {
1101 if sel.contains(line_idx, col) {
1102 Span::styled(ch.to_string(), selection_style)
1103 } else {
1104 Span::raw(ch.to_string())
1105 }
1106 })
1107 .collect();
1108 Line::from(spans)
1109 } else {
1110 Line::from(line.as_str())
1111 }
1112 })
1113 .collect();
1114
1115 let paragraph = Paragraph::new(visible_lines);
1116 frame.render_widget(paragraph, content_area);
1117 }
1118 PopupContent::Markdown(styled_lines) => {
1119 let wrapped_lines = wrap_styled_lines(styled_lines, content_area.width as usize);
1121 let selection_style = Style::default().bg(theme.selection_bg);
1122
1123 let mut link_overlays: Vec<(usize, usize, String, String)> = Vec::new();
1126
1127 let visible_lines: Vec<Line> = wrapped_lines
1128 .iter()
1129 .enumerate()
1130 .skip(self.scroll_offset)
1131 .take(content_area.height as usize)
1132 .map(|(line_idx, styled_line)| {
1133 let mut col = 0usize;
1134 let spans: Vec<Span> = styled_line
1135 .spans
1136 .iter()
1137 .flat_map(|s| {
1138 let span_start_col = col;
1139 let span_width =
1140 unicode_width::UnicodeWidthStr::width(s.text.as_str());
1141 if let Some(url) = &s.link_url {
1142 link_overlays.push((
1143 line_idx - self.scroll_offset,
1144 col,
1145 s.text.clone(),
1146 url.clone(),
1147 ));
1148 }
1149 col += span_width;
1150
1151 if let Some(ref sel) = self.text_selection {
1153 let chars: Vec<char> = s.text.chars().collect();
1155 chars
1156 .iter()
1157 .enumerate()
1158 .map(|(i, ch)| {
1159 let char_col = span_start_col + i;
1160 if sel.contains(line_idx, char_col) {
1161 Span::styled(ch.to_string(), selection_style)
1162 } else {
1163 Span::styled(ch.to_string(), s.style)
1164 }
1165 })
1166 .collect::<Vec<_>>()
1167 } else {
1168 vec![Span::styled(s.text.clone(), s.style)]
1169 }
1170 })
1171 .collect();
1172 Line::from(spans)
1173 })
1174 .collect();
1175
1176 let paragraph = Paragraph::new(visible_lines);
1177 frame.render_widget(paragraph, content_area);
1178
1179 let buffer = frame.buffer_mut();
1181 let max_x = content_area.x + content_area.width;
1182 for (line_idx, col_start, text, url) in link_overlays {
1183 let y = content_area.y + line_idx as u16;
1184 if y >= content_area.y + content_area.height {
1185 continue;
1186 }
1187 let start_x = content_area.x + col_start as u16;
1188 apply_hyperlink_overlay(buffer, start_x, y, max_x, &text, &url);
1189 }
1190 }
1191 PopupContent::List { items, selected } => {
1192 let list_items: Vec<ListItem> = items
1193 .iter()
1194 .enumerate()
1195 .skip(self.scroll_offset)
1196 .take(content_area.height as usize)
1197 .map(|(idx, item)| {
1198 let is_hovered = matches!(
1200 hover_target,
1201 Some(crate::app::HoverTarget::PopupListItem(_, hovered_idx)) if *hovered_idx == idx
1202 );
1203 let is_selected = idx == *selected;
1204
1205 let mut spans = Vec::new();
1206
1207 if let Some(icon) = &item.icon {
1209 spans.push(Span::raw(format!("{} ", icon)));
1210 }
1211
1212 let text = &item.text;
1220 let trimmed = text.trim_start();
1221 let indent_len = text.len() - trimmed.len();
1222 if indent_len > 0 {
1223 spans.push(Span::raw(&text[..indent_len]));
1224 }
1225 let is_clickable = item.data.is_some() && !item.disabled;
1226 let mut text_style = Style::default();
1227 if is_selected {
1228 text_style = text_style.add_modifier(Modifier::BOLD);
1229 }
1230 if is_clickable {
1231 text_style = text_style.add_modifier(Modifier::UNDERLINED);
1232 }
1233 if item.disabled {
1234 text_style = text_style
1235 .fg(theme.help_separator_fg)
1236 .add_modifier(Modifier::DIM);
1237 }
1238 spans.push(Span::styled(trimmed, text_style));
1239
1240 if let Some(detail) = &item.detail {
1242 spans.push(Span::styled(
1243 format!(" {}", detail),
1244 Style::default().fg(theme.help_separator_fg),
1245 ));
1246 }
1247
1248 spans.push(Span::raw(""));
1251
1252 if is_selected {
1254 if let Some(ref hint) = self.accept_key_hint {
1255 let hint_text = format!("({})", hint);
1256 let used_width: usize = spans
1258 .iter()
1259 .map(|s| {
1260 unicode_width::UnicodeWidthStr::width(s.content.as_ref())
1261 })
1262 .sum();
1263 let available = content_area.width as usize;
1264 let hint_len = hint_text.len();
1265 if used_width + hint_len + 1 < available {
1266 let padding = available - used_width - hint_len;
1267 spans.push(Span::raw(" ".repeat(padding)));
1268 spans.push(Span::styled(
1269 hint_text,
1270 Style::default().fg(theme.help_separator_fg),
1271 ));
1272 }
1273 }
1274 }
1275
1276 let row_style = if is_selected {
1278 Style::default().bg(theme.popup_selection_bg)
1279 } else if is_hovered {
1280 Style::default()
1281 .bg(theme.menu_hover_bg)
1282 .fg(theme.menu_hover_fg)
1283 } else {
1284 Style::default()
1285 };
1286
1287 ListItem::new(Line::from(spans)).style(row_style)
1288 })
1289 .collect();
1290
1291 let list = List::new(list_items);
1292 frame.render_widget(list, content_area);
1293 }
1294 PopupContent::Custom(lines) => {
1295 let visible_lines: Vec<Line> = lines
1296 .iter()
1297 .skip(self.scroll_offset)
1298 .take(content_area.height as usize)
1299 .map(|line| Line::from(line.as_str()))
1300 .collect();
1301
1302 let paragraph = Paragraph::new(visible_lines);
1303 frame.render_widget(paragraph, content_area);
1304 }
1305 }
1306
1307 if needs_scrollbar {
1309 let scrollbar_area = Rect {
1310 x: inner_area.x + inner_area.width - 1,
1311 y: inner_area.y,
1312 width: 1,
1313 height: inner_area.height,
1314 };
1315
1316 let scrollbar_state =
1317 ScrollbarState::new(wrapped_total_lines, visible_lines_count, self.scroll_offset);
1318 let scrollbar_colors = ScrollbarColors::from_theme(theme);
1319 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
1320 }
1321 }
1322}
1323
1324#[derive(Debug, Clone)]
1326pub struct PopupManager {
1327 popups: Vec<Popup>,
1329}
1330
1331impl PopupManager {
1332 pub fn new() -> Self {
1333 Self { popups: Vec::new() }
1334 }
1335
1336 pub fn show(&mut self, popup: Popup) {
1338 self.popups.push(popup);
1339 }
1340
1341 pub fn show_or_replace(&mut self, popup: Popup) {
1345 if let Some(pos) = self.popups.iter().position(|p| p.kind == popup.kind) {
1346 self.popups[pos] = popup;
1347 } else {
1348 self.popups.push(popup);
1349 }
1350 }
1351
1352 pub fn hide(&mut self) -> Option<Popup> {
1354 self.popups.pop()
1355 }
1356
1357 pub fn clear(&mut self) {
1359 self.popups.clear();
1360 }
1361
1362 pub fn top(&self) -> Option<&Popup> {
1364 self.popups.last()
1365 }
1366
1367 pub fn top_mut(&mut self) -> Option<&mut Popup> {
1369 self.popups.last_mut()
1370 }
1371
1372 pub fn get(&self, index: usize) -> Option<&Popup> {
1374 self.popups.get(index)
1375 }
1376
1377 pub fn get_mut(&mut self, index: usize) -> Option<&mut Popup> {
1379 self.popups.get_mut(index)
1380 }
1381
1382 pub fn is_visible(&self) -> bool {
1384 !self.popups.is_empty()
1385 }
1386
1387 pub fn is_completion_popup(&self) -> bool {
1389 self.top()
1390 .map(|p| p.kind == PopupKind::Completion)
1391 .unwrap_or(false)
1392 }
1393
1394 pub fn is_hover_popup(&self) -> bool {
1396 self.top()
1397 .map(|p| p.kind == PopupKind::Hover)
1398 .unwrap_or(false)
1399 }
1400
1401 pub fn is_action_popup(&self) -> bool {
1403 self.top()
1404 .map(|p| p.kind == PopupKind::Action)
1405 .unwrap_or(false)
1406 }
1407
1408 pub fn all(&self) -> &[Popup] {
1410 &self.popups
1411 }
1412
1413 pub fn dismiss_transient(&mut self) -> bool {
1417 let is_transient = self.popups.last().is_some_and(|p| p.transient);
1418
1419 if is_transient {
1420 self.popups.pop();
1421 true
1422 } else {
1423 false
1424 }
1425 }
1426}
1427
1428impl Default for PopupManager {
1429 fn default() -> Self {
1430 Self::new()
1431 }
1432}
1433
1434fn apply_hyperlink_overlay(
1439 buffer: &mut ratatui::buffer::Buffer,
1440 start_x: u16,
1441 y: u16,
1442 max_x: u16,
1443 text: &str,
1444 url: &str,
1445) {
1446 let mut chunk_index = 0u16;
1447 let mut chars = text.chars();
1448
1449 loop {
1450 let mut chunk = String::new();
1451 for _ in 0..2 {
1452 if let Some(ch) = chars.next() {
1453 chunk.push(ch);
1454 } else {
1455 break;
1456 }
1457 }
1458
1459 if chunk.is_empty() {
1460 break;
1461 }
1462
1463 let x = start_x + chunk_index * 2;
1464 if x >= max_x {
1465 break;
1466 }
1467
1468 let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, chunk);
1469 buffer[(x, y)].set_symbol(&hyperlink);
1470
1471 chunk_index += 1;
1472 }
1473}
1474
1475#[cfg(test)]
1476mod tests {
1477 use super::*;
1478 use crate::view::theme;
1479
1480 #[test]
1481 fn test_popup_list_item() {
1482 let item = PopupListItem::new("test".to_string())
1483 .with_detail("detail".to_string())
1484 .with_icon("📄".to_string());
1485
1486 assert_eq!(item.text, "test");
1487 assert_eq!(item.detail, Some("detail".to_string()));
1488 assert_eq!(item.icon, Some("📄".to_string()));
1489 }
1490
1491 #[test]
1492 fn test_popup_selection() {
1493 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1494 let items = vec![
1495 PopupListItem::new("item1".to_string()),
1496 PopupListItem::new("item2".to_string()),
1497 PopupListItem::new("item3".to_string()),
1498 ];
1499
1500 let mut popup = Popup::list(items, &theme);
1501
1502 assert_eq!(popup.selected_item().unwrap().text, "item1");
1503
1504 popup.select_next();
1505 assert_eq!(popup.selected_item().unwrap().text, "item2");
1506
1507 popup.select_next();
1508 assert_eq!(popup.selected_item().unwrap().text, "item3");
1509
1510 popup.select_next(); assert_eq!(popup.selected_item().unwrap().text, "item3");
1512
1513 popup.select_prev();
1514 assert_eq!(popup.selected_item().unwrap().text, "item2");
1515
1516 popup.select_prev();
1517 assert_eq!(popup.selected_item().unwrap().text, "item1");
1518
1519 popup.select_prev(); assert_eq!(popup.selected_item().unwrap().text, "item1");
1521 }
1522
1523 #[test]
1524 fn test_popup_manager() {
1525 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1526 let mut manager = PopupManager::new();
1527
1528 assert!(!manager.is_visible());
1529 assert_eq!(manager.top(), None);
1530
1531 let popup1 = Popup::text(vec!["test1".to_string()], &theme);
1532 manager.show(popup1);
1533
1534 assert!(manager.is_visible());
1535 assert_eq!(manager.all().len(), 1);
1536
1537 let popup2 = Popup::text(vec!["test2".to_string()], &theme);
1538 manager.show(popup2);
1539
1540 assert_eq!(manager.all().len(), 2);
1541
1542 manager.hide();
1543 assert_eq!(manager.all().len(), 1);
1544
1545 manager.clear();
1546 assert!(!manager.is_visible());
1547 assert_eq!(manager.all().len(), 0);
1548 }
1549
1550 #[test]
1551 fn test_popup_area_calculation() {
1552 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1553 let terminal_area = Rect {
1554 x: 0,
1555 y: 0,
1556 width: 100,
1557 height: 50,
1558 };
1559
1560 let popup = Popup::text(vec!["test".to_string()], &theme)
1561 .with_width(30)
1562 .with_max_height(10);
1563
1564 let popup_centered = popup.clone().with_position(PopupPosition::Centered);
1566 let area = popup_centered.calculate_area(terminal_area, None);
1567 assert_eq!(area.width, 30);
1568 assert_eq!(area.height, 3);
1570 assert_eq!(area.x, (100 - 30) / 2);
1571 assert_eq!(area.y, (50 - 3) / 2);
1572
1573 let popup_below = popup.clone().with_position(PopupPosition::BelowCursor);
1575 let area = popup_below.calculate_area(terminal_area, Some((20, 10)));
1576 assert_eq!(area.x, 20);
1577 assert_eq!(area.y, 11); }
1579
1580 #[test]
1581 fn test_popup_fixed_position_clamping() {
1582 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1583 let terminal_area = Rect {
1584 x: 0,
1585 y: 0,
1586 width: 100,
1587 height: 50,
1588 };
1589
1590 let popup = Popup::text(vec!["test".to_string()], &theme)
1591 .with_width(30)
1592 .with_max_height(10);
1593
1594 let popup_fixed = popup
1596 .clone()
1597 .with_position(PopupPosition::Fixed { x: 10, y: 20 });
1598 let area = popup_fixed.calculate_area(terminal_area, None);
1599 assert_eq!(area.x, 10);
1600 assert_eq!(area.y, 20);
1601
1602 let popup_right_edge = popup
1604 .clone()
1605 .with_position(PopupPosition::Fixed { x: 99, y: 20 });
1606 let area = popup_right_edge.calculate_area(terminal_area, None);
1607 assert_eq!(area.x, 70);
1609 assert_eq!(area.y, 20);
1610
1611 let popup_beyond = popup
1613 .clone()
1614 .with_position(PopupPosition::Fixed { x: 199, y: 20 });
1615 let area = popup_beyond.calculate_area(terminal_area, None);
1616 assert_eq!(area.x, 70);
1618 assert_eq!(area.y, 20);
1619
1620 let popup_bottom = popup
1622 .clone()
1623 .with_position(PopupPosition::Fixed { x: 10, y: 49 });
1624 let area = popup_bottom.calculate_area(terminal_area, None);
1625 assert_eq!(area.x, 10);
1626 assert_eq!(area.y, 47);
1628 }
1629
1630 #[test]
1631 fn test_clamp_rect_to_bounds() {
1632 let bounds = Rect {
1633 x: 0,
1634 y: 0,
1635 width: 100,
1636 height: 50,
1637 };
1638
1639 let rect = Rect {
1641 x: 10,
1642 y: 20,
1643 width: 30,
1644 height: 10,
1645 };
1646 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1647 assert_eq!(clamped, rect);
1648
1649 let rect = Rect {
1651 x: 99,
1652 y: 20,
1653 width: 30,
1654 height: 10,
1655 };
1656 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1657 assert_eq!(clamped.x, 99); assert_eq!(clamped.width, 1); let rect = Rect {
1662 x: 199,
1663 y: 60,
1664 width: 30,
1665 height: 10,
1666 };
1667 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1668 assert_eq!(clamped.x, 99); assert_eq!(clamped.y, 49); assert_eq!(clamped.width, 1); assert_eq!(clamped.height, 1); }
1673
1674 #[test]
1675 fn hyperlink_overlay_chunks_pairs() {
1676 use ratatui::{buffer::Buffer, layout::Rect};
1677
1678 let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1679 buffer[(0, 0)].set_symbol("P");
1680 buffer[(1, 0)].set_symbol("l");
1681 buffer[(2, 0)].set_symbol("a");
1682 buffer[(3, 0)].set_symbol("y");
1683
1684 apply_hyperlink_overlay(&mut buffer, 0, 0, 10, "Play", "https://example.com");
1685
1686 let first = buffer[(0, 0)].symbol().to_string();
1687 let second = buffer[(2, 0)].symbol().to_string();
1688
1689 assert!(
1690 first.contains("Pl"),
1691 "first chunk should contain 'Pl', got {first:?}"
1692 );
1693 assert!(
1694 second.contains("ay"),
1695 "second chunk should contain 'ay', got {second:?}"
1696 );
1697 }
1698
1699 #[test]
1700 fn test_popup_text_selection() {
1701 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1702 let mut popup = Popup::text(
1703 vec![
1704 "Line 0: Hello".to_string(),
1705 "Line 1: World".to_string(),
1706 "Line 2: Test".to_string(),
1707 ],
1708 &theme,
1709 );
1710
1711 assert!(!popup.has_selection());
1713 assert_eq!(popup.get_selected_text(), None);
1714
1715 popup.start_selection(0, 8);
1717 assert!(!popup.has_selection()); popup.extend_selection(1, 8);
1721 assert!(popup.has_selection());
1722
1723 let selected = popup.get_selected_text().unwrap();
1725 assert_eq!(selected, "Hello\nLine 1: ");
1726
1727 popup.clear_selection();
1729 assert!(!popup.has_selection());
1730 assert_eq!(popup.get_selected_text(), None);
1731
1732 popup.start_selection(1, 8);
1734 popup.extend_selection(1, 13); let selected = popup.get_selected_text().unwrap();
1736 assert_eq!(selected, "World");
1737 }
1738
1739 #[test]
1740 fn test_popup_text_selection_contains() {
1741 let sel = PopupTextSelection {
1742 start: (1, 5),
1743 end: (2, 10),
1744 };
1745
1746 assert!(!sel.contains(0, 5));
1748
1749 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));
1762 }
1763
1764 #[test]
1765 fn test_popup_text_selection_normalized() {
1766 let sel = PopupTextSelection {
1768 start: (1, 5),
1769 end: (2, 10),
1770 };
1771 let ((s_line, s_col), (e_line, e_col)) = sel.normalized();
1772 assert_eq!((s_line, s_col), (1, 5));
1773 assert_eq!((e_line, e_col), (2, 10));
1774
1775 let sel_backward = PopupTextSelection {
1777 start: (2, 10),
1778 end: (1, 5),
1779 };
1780 let ((s_line, s_col), (e_line, e_col)) = sel_backward.normalized();
1781 assert_eq!((s_line, s_col), (1, 5));
1782 assert_eq!((e_line, e_col), (2, 10));
1783 }
1784}