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 pub focused: bool,
275
276 pub focus_key_hint: Option<String>,
282}
283
284impl Popup {
285 pub fn text(content: Vec<String>, theme: &crate::view::theme::Theme) -> Self {
287 Self {
288 kind: PopupKind::Text,
289 title: None,
290 description: None,
291 transient: false,
292 content: PopupContent::Text(content),
293 position: PopupPosition::AtCursor,
294 width: 50,
295 max_height: 15,
296 bordered: true,
297 border_style: Style::default().fg(theme.popup_border_fg),
298 background_style: Style::default().bg(theme.popup_bg),
299 scroll_offset: 0,
300 text_selection: None,
301 accept_key_hint: None,
302 resolver: PopupResolver::None,
303 focused: false,
304 focus_key_hint: None,
305 }
306 }
307
308 pub fn markdown(
313 markdown_text: &str,
314 theme: &crate::view::theme::Theme,
315 registry: Option<&GrammarRegistry>,
316 ) -> Self {
317 let styled_lines = parse_markdown(markdown_text, theme, registry);
318 Self {
319 kind: PopupKind::Text,
320 title: None,
321 description: None,
322 transient: false,
323 content: PopupContent::Markdown(styled_lines),
324 position: PopupPosition::AtCursor,
325 width: 60, max_height: 20, bordered: true,
328 border_style: Style::default().fg(theme.popup_border_fg),
329 background_style: Style::default().bg(theme.popup_bg),
330 scroll_offset: 0,
331 text_selection: None,
332 accept_key_hint: None,
333 resolver: PopupResolver::None,
334 focused: false,
335 focus_key_hint: None,
336 }
337 }
338
339 pub fn list(items: Vec<PopupListItem>, theme: &crate::view::theme::Theme) -> Self {
341 Self {
342 kind: PopupKind::List,
343 title: None,
344 description: None,
345 transient: false,
346 content: PopupContent::List { items, selected: 0 },
347 position: PopupPosition::AtCursor,
348 width: 50,
349 max_height: 15,
350 bordered: true,
351 border_style: Style::default().fg(theme.popup_border_fg),
352 background_style: Style::default().bg(theme.popup_bg),
353 scroll_offset: 0,
354 text_selection: None,
355 accept_key_hint: None,
356 resolver: PopupResolver::None,
357 focused: false,
358 focus_key_hint: None,
359 }
360 }
361
362 pub fn with_title(mut self, title: String) -> Self {
364 self.title = Some(title);
365 self
366 }
367
368 pub fn with_kind(mut self, kind: PopupKind) -> Self {
370 self.kind = kind;
371 self
372 }
373
374 pub fn with_transient(mut self, transient: bool) -> Self {
376 self.transient = transient;
377 self
378 }
379
380 pub fn with_position(mut self, position: PopupPosition) -> Self {
382 self.position = position;
383 self
384 }
385
386 pub fn with_width(mut self, width: u16) -> Self {
388 self.width = width;
389 self
390 }
391
392 pub fn with_max_height(mut self, max_height: u16) -> Self {
394 self.max_height = max_height;
395 self
396 }
397
398 pub fn with_border_style(mut self, style: Style) -> Self {
400 self.border_style = style;
401 self
402 }
403
404 pub fn with_resolver(mut self, resolver: PopupResolver) -> Self {
407 self.resolver = resolver;
408 self
409 }
410
411 pub fn with_focused(mut self, focused: bool) -> Self {
415 self.focused = focused;
416 self
417 }
418
419 pub fn with_focus_key_hint(mut self, hint: String) -> Self {
422 self.focus_key_hint = Some(hint);
423 self
424 }
425
426 pub fn render_title(&self) -> Option<String> {
434 let hint_label = if !self.focused {
435 let hint = self
436 .focus_key_hint
437 .clone()
438 .unwrap_or_else(|| "Alt+T".to_string());
439 Some(format!("[{} to focus]", hint))
440 } else {
441 None
442 };
443 match (&self.title, hint_label) {
444 (Some(title), Some(hint)) => Some(format!("{} {}", title, hint)),
445 (Some(title), None) => Some(title.clone()),
446 (None, Some(hint)) => Some(hint),
447 (None, None) => None,
448 }
449 }
450
451 pub fn selected_item(&self) -> Option<&PopupListItem> {
453 match &self.content {
454 PopupContent::List { items, selected } => items.get(*selected),
455 _ => None,
456 }
457 }
458
459 fn visible_height(&self) -> usize {
461 let border_offset = if self.bordered { 2 } else { 0 };
462 (self.max_height as usize).saturating_sub(border_offset)
463 }
464
465 pub fn select_next(&mut self) {
467 let visible = self.visible_height();
468 if let PopupContent::List { items, selected } = &mut self.content {
469 if *selected < items.len().saturating_sub(1) {
470 *selected += 1;
471 if *selected >= self.scroll_offset + visible {
473 self.scroll_offset = (*selected + 1).saturating_sub(visible);
474 }
475 }
476 }
477 }
478
479 pub fn select_prev(&mut self) {
481 if let PopupContent::List { items: _, selected } = &mut self.content {
482 if *selected > 0 {
483 *selected -= 1;
484 if *selected < self.scroll_offset {
486 self.scroll_offset = *selected;
487 }
488 }
489 }
490 }
491
492 pub fn select_index(&mut self, index: usize) -> bool {
494 let visible = self.visible_height();
495 if let PopupContent::List { items, selected } = &mut self.content {
496 if index < items.len() {
497 *selected = index;
498 if *selected >= self.scroll_offset + visible {
500 self.scroll_offset = (*selected + 1).saturating_sub(visible);
501 } else if *selected < self.scroll_offset {
502 self.scroll_offset = *selected;
503 }
504 return true;
505 }
506 }
507 false
508 }
509
510 pub fn page_down(&mut self) {
512 let visible = self.visible_height();
513 if let PopupContent::List { items, selected } = &mut self.content {
514 *selected = (*selected + visible).min(items.len().saturating_sub(1));
515 self.scroll_offset = (*selected + 1).saturating_sub(visible);
516 } else {
517 self.scroll_offset += visible;
518 }
519 }
520
521 pub fn page_up(&mut self) {
523 let visible = self.visible_height();
524 if let PopupContent::List { items: _, selected } = &mut self.content {
525 *selected = selected.saturating_sub(visible);
526 self.scroll_offset = *selected;
527 } else {
528 self.scroll_offset = self.scroll_offset.saturating_sub(visible);
529 }
530 }
531
532 pub fn select_first(&mut self) {
534 if let PopupContent::List { items: _, selected } = &mut self.content {
535 *selected = 0;
536 self.scroll_offset = 0;
537 } else {
538 self.scroll_offset = 0;
539 }
540 }
541
542 pub fn select_last(&mut self) {
544 let visible = self.visible_height();
545 if let PopupContent::List { items, selected } = &mut self.content {
546 *selected = items.len().saturating_sub(1);
547 if *selected >= visible {
549 self.scroll_offset = (*selected + 1).saturating_sub(visible);
550 }
551 } else {
552 let content_height = self.item_count();
554 if content_height > visible {
555 self.scroll_offset = content_height.saturating_sub(visible);
556 }
557 }
558 }
559
560 pub fn scroll_by(&mut self, delta: i32) {
563 let content_len = self.wrapped_item_count();
564 let visible = self.visible_height();
565 let max_scroll = content_len.saturating_sub(visible);
566
567 if delta < 0 {
568 self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
570 } else {
571 self.scroll_offset = (self.scroll_offset + delta as usize).min(max_scroll);
573 }
574
575 if let PopupContent::List { items, selected } = &mut self.content {
577 let visible_start = self.scroll_offset;
578 let visible_end = (self.scroll_offset + visible).min(items.len());
579
580 if *selected < visible_start {
581 *selected = visible_start;
582 } else if *selected >= visible_end {
583 *selected = visible_end.saturating_sub(1);
584 }
585 }
586 }
587
588 pub fn item_count(&self) -> usize {
590 match &self.content {
591 PopupContent::Text(lines) => lines.len(),
592 PopupContent::Markdown(lines) => lines.len(),
593 PopupContent::List { items, .. } => items.len(),
594 PopupContent::Custom(lines) => lines.len(),
595 }
596 }
597
598 fn wrapped_item_count(&self) -> usize {
603 let border_width = if self.bordered { 2 } else { 0 };
605 let scrollbar_width = 2; let wrap_width = (self.width as usize)
607 .saturating_sub(border_width)
608 .saturating_sub(scrollbar_width);
609
610 if wrap_width == 0 {
611 return self.item_count();
612 }
613
614 match &self.content {
615 PopupContent::Text(lines) => wrap_text_lines(lines, wrap_width).len(),
616 PopupContent::Markdown(styled_lines) => {
617 wrap_styled_lines(styled_lines, wrap_width).len()
618 }
619 PopupContent::List { items, .. } => items.len(),
621 PopupContent::Custom(lines) => lines.len(),
622 }
623 }
624
625 pub fn start_selection(&mut self, line: usize, col: usize) {
627 self.text_selection = Some(PopupTextSelection {
628 start: (line, col),
629 end: (line, col),
630 });
631 }
632
633 pub fn extend_selection(&mut self, line: usize, col: usize) {
635 if let Some(ref mut sel) = self.text_selection {
636 sel.end = (line, col);
637 }
638 }
639
640 pub fn clear_selection(&mut self) {
642 self.text_selection = None;
643 }
644
645 pub fn has_selection(&self) -> bool {
647 if let Some(sel) = &self.text_selection {
648 sel.start != sel.end
649 } else {
650 false
651 }
652 }
653
654 fn content_wrap_width(&self) -> usize {
657 let border_width: u16 = if self.bordered { 2 } else { 0 };
658 let inner_width = self.width.saturating_sub(border_width);
659 let scrollbar_reserved: u16 = 2;
660 let conservative_width = inner_width.saturating_sub(scrollbar_reserved) as usize;
661
662 if conservative_width == 0 {
663 return 0;
664 }
665
666 let visible_height = self.max_height.saturating_sub(border_width) as usize;
667 let line_count = match &self.content {
668 PopupContent::Text(lines) => wrap_text_lines(lines, conservative_width).len(),
669 PopupContent::Markdown(styled_lines) => {
670 wrap_styled_lines(styled_lines, conservative_width).len()
671 }
672 _ => self.item_count(),
673 };
674
675 let needs_scrollbar = line_count > visible_height && inner_width > scrollbar_reserved;
676
677 if needs_scrollbar {
678 conservative_width
679 } else {
680 inner_width as usize
681 }
682 }
683
684 fn get_text_lines(&self) -> Vec<String> {
689 let wrap_width = self.content_wrap_width();
690
691 match &self.content {
692 PopupContent::Text(lines) => {
693 if wrap_width > 0 {
694 wrap_text_lines(lines, wrap_width)
695 } else {
696 lines.clone()
697 }
698 }
699 PopupContent::Markdown(styled_lines) => {
700 if wrap_width > 0 {
701 wrap_styled_lines(styled_lines, wrap_width)
702 .iter()
703 .map(|sl| sl.plain_text())
704 .collect()
705 } else {
706 styled_lines.iter().map(|sl| sl.plain_text()).collect()
707 }
708 }
709 PopupContent::List { items, .. } => items.iter().map(|i| i.text.clone()).collect(),
710 PopupContent::Custom(lines) => lines.clone(),
711 }
712 }
713
714 pub fn get_selected_text(&self) -> Option<String> {
716 let sel = self.text_selection.as_ref()?;
717 if sel.start == sel.end {
718 return None;
719 }
720
721 let ((start_line, start_col), (end_line, end_col)) = sel.normalized();
722 let lines = self.get_text_lines();
723
724 if start_line >= lines.len() {
725 return None;
726 }
727
728 if start_line == end_line {
729 let line = &lines[start_line];
730 let end_col = end_col.min(line.len());
731 let start_col = start_col.min(end_col);
732 Some(line[start_col..end_col].to_string())
733 } else {
734 let mut result = String::new();
735 let first_line = &lines[start_line];
737 result.push_str(&first_line[start_col.min(first_line.len())..]);
738 result.push('\n');
739 for line in lines.iter().take(end_line).skip(start_line + 1) {
741 result.push_str(line);
742 result.push('\n');
743 }
744 if end_line < lines.len() {
746 let last_line = &lines[end_line];
747 result.push_str(&last_line[..end_col.min(last_line.len())]);
748 }
749 Some(result)
750 }
751 }
752
753 pub fn needs_scrollbar(&self) -> bool {
755 self.item_count() > self.visible_height()
756 }
757
758 pub fn scroll_state(&self) -> (usize, usize, usize) {
760 let total = self.item_count();
761 let visible = self.visible_height();
762 (total, visible, self.scroll_offset)
763 }
764
765 pub fn link_at_position(&self, relative_col: usize, relative_row: usize) -> Option<String> {
771 let PopupContent::Markdown(styled_lines) = &self.content else {
772 return None;
773 };
774
775 let border_width = if self.bordered { 2 } else { 0 };
777 let scrollbar_reserved = 2;
778 let content_width = self
779 .width
780 .saturating_sub(border_width)
781 .saturating_sub(scrollbar_reserved) as usize;
782
783 let wrapped_lines = wrap_styled_lines(styled_lines, content_width);
785
786 let line_index = self.scroll_offset + relative_row;
788
789 let line = wrapped_lines.get(line_index)?;
791
792 line.link_at_column(relative_col).map(|s| s.to_string())
794 }
795
796 pub fn description_height(&self) -> u16 {
799 if let Some(desc) = &self.description {
800 let border_width = if self.bordered { 2 } else { 0 };
801 let scrollbar_reserved = 2;
802 let content_width = self
803 .width
804 .saturating_sub(border_width)
805 .saturating_sub(scrollbar_reserved) as usize;
806 let desc_vec = vec![desc.clone()];
807 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
808 wrapped.len() as u16 + 1 } else {
810 0
811 }
812 }
813
814 fn content_height(&self) -> u16 {
816 self.content_height_for_width(self.width)
818 }
819
820 fn content_height_for_width(&self, popup_width: u16) -> u16 {
822 let border_width = if self.bordered { 2 } else { 0 };
824 let scrollbar_reserved = 2; let content_width = popup_width
826 .saturating_sub(border_width)
827 .saturating_sub(scrollbar_reserved) as usize;
828
829 let description_lines = if let Some(desc) = &self.description {
831 let desc_vec = vec![desc.clone()];
832 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
833 wrapped.len() as u16 + 1 } else {
835 0
836 };
837
838 let content_lines = match &self.content {
839 PopupContent::Text(lines) => {
840 wrap_text_lines(lines, content_width).len() as u16
842 }
843 PopupContent::Markdown(styled_lines) => {
844 wrap_styled_lines(styled_lines, content_width).len() as u16
846 }
847 PopupContent::List { items, .. } => items.len() as u16,
848 PopupContent::Custom(lines) => lines.len() as u16,
849 };
850
851 let border_height = if self.bordered { 2 } else { 0 };
853
854 description_lines + content_lines + border_height
855 }
856
857 pub fn calculate_area(&self, terminal_area: Rect, cursor_pos: Option<(u16, u16)>) -> Rect {
859 match self.position {
860 PopupPosition::AtCursor | PopupPosition::BelowCursor | PopupPosition::AboveCursor => {
861 let (cursor_x, cursor_y) =
862 cursor_pos.unwrap_or((terminal_area.width / 2, terminal_area.height / 2));
863
864 let width = self.width.min(terminal_area.width);
865 let height = self
867 .content_height()
868 .min(self.max_height)
869 .min(terminal_area.height);
870
871 let x = if cursor_x + width > terminal_area.width {
872 terminal_area.width.saturating_sub(width)
873 } else {
874 cursor_x
875 };
876
877 let y = match self.position {
878 PopupPosition::AtCursor => cursor_y,
879 PopupPosition::BelowCursor => {
880 if cursor_y + 1 + height > terminal_area.height {
881 cursor_y.saturating_sub(height)
883 } else {
884 cursor_y + 1
886 }
887 }
888 PopupPosition::AboveCursor => {
889 (cursor_y + 1).saturating_sub(height)
891 }
892 _ => cursor_y,
893 };
894
895 Rect {
896 x,
897 y,
898 width,
899 height,
900 }
901 }
902 PopupPosition::Fixed { x, y } => {
903 let width = self.width.min(terminal_area.width);
904 let height = self
905 .content_height()
906 .min(self.max_height)
907 .min(terminal_area.height);
908 let x = if x + width > terminal_area.width {
910 terminal_area.width.saturating_sub(width)
911 } else {
912 x
913 };
914 let y = if y + height > terminal_area.height {
915 terminal_area.height.saturating_sub(height)
916 } else {
917 y
918 };
919 Rect {
920 x,
921 y,
922 width,
923 height,
924 }
925 }
926 PopupPosition::Centered => {
927 let width = self.width.min(terminal_area.width);
928 let height = self
929 .content_height()
930 .min(self.max_height)
931 .min(terminal_area.height);
932 let x = (terminal_area.width.saturating_sub(width)) / 2;
933 let y = (terminal_area.height.saturating_sub(height)) / 2;
934 Rect {
935 x,
936 y,
937 width,
938 height,
939 }
940 }
941 PopupPosition::BottomRight => {
942 let width = self.width.min(terminal_area.width);
943 let height = self
944 .content_height()
945 .min(self.max_height)
946 .min(terminal_area.height);
947 let x = terminal_area.width.saturating_sub(width);
949 let y = terminal_area
950 .height
951 .saturating_sub(height)
952 .saturating_sub(2);
953 Rect {
954 x,
955 y,
956 width,
957 height,
958 }
959 }
960 PopupPosition::AboveStatusBarAt { x } => {
961 let width = self.width.min(terminal_area.width);
962 let height = self
963 .content_height()
964 .min(self.max_height)
965 .min(terminal_area.height);
966 let x = if x + width > terminal_area.width {
969 terminal_area.width.saturating_sub(width)
970 } else {
971 x
972 };
973 let y = terminal_area
982 .height
983 .saturating_sub(height)
984 .saturating_sub(2);
985 Rect {
986 x,
987 y,
988 width,
989 height,
990 }
991 }
992 }
993 }
994
995 pub fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::view::theme::Theme) {
997 self.render_with_hover(frame, area, theme, None);
998 }
999
1000 pub fn render_with_hover(
1002 &self,
1003 frame: &mut Frame,
1004 area: Rect,
1005 theme: &crate::view::theme::Theme,
1006 hover_target: Option<&crate::app::HoverTarget>,
1007 ) {
1008 let frame_area = frame.area();
1010 let area = clamp_rect_to_bounds(area, frame_area);
1011
1012 if area.width == 0 || area.height == 0 {
1014 return;
1015 }
1016
1017 frame.render_widget(Clear, area);
1019
1020 let rendered_title = self.render_title();
1021 let block = if self.bordered {
1022 let mut block = Block::default()
1023 .borders(Borders::ALL)
1024 .border_style(self.border_style)
1025 .style(self.background_style);
1026
1027 if let Some(title) = rendered_title.as_deref() {
1028 block = block.title(title);
1029 }
1030
1031 block
1032 } else {
1033 Block::default().style(self.background_style)
1034 };
1035
1036 let inner_area = block.inner(area);
1037 frame.render_widget(block, area);
1038
1039 if self.bordered && area.width >= 5 {
1044 let close_x = area.x + area.width - 4;
1045 let close_area = Rect {
1046 x: close_x,
1047 y: area.y,
1048 width: 3,
1049 height: 1,
1050 };
1051 frame.render_widget(Paragraph::new("[×]").style(self.border_style), close_area);
1052 }
1053
1054 let content_start_y;
1056 if let Some(desc) = &self.description {
1057 let desc_wrap_width = inner_area.width.saturating_sub(2) as usize; let desc_vec = vec![desc.clone()];
1060 let wrapped_desc = wrap_text_lines(&desc_vec, desc_wrap_width);
1061 let desc_lines: usize = wrapped_desc.len();
1062
1063 for (i, line) in wrapped_desc.iter().enumerate() {
1065 if i >= inner_area.height as usize {
1066 break;
1067 }
1068 let line_area = Rect {
1069 x: inner_area.x,
1070 y: inner_area.y + i as u16,
1071 width: inner_area.width,
1072 height: 1,
1073 };
1074 let desc_style = Style::default().fg(theme.help_separator_fg);
1075 frame.render_widget(Paragraph::new(line.as_str()).style(desc_style), line_area);
1076 }
1077
1078 content_start_y = inner_area.y + (desc_lines as u16).min(inner_area.height) + 1;
1080 } else {
1081 content_start_y = inner_area.y;
1082 }
1083
1084 let inner_area = Rect {
1086 x: inner_area.x,
1087 y: content_start_y,
1088 width: inner_area.width,
1089 height: inner_area
1090 .height
1091 .saturating_sub(content_start_y - area.y - if self.bordered { 1 } else { 0 }),
1092 };
1093
1094 let scrollbar_reserved_width = 2; let wrap_width = inner_area.width.saturating_sub(scrollbar_reserved_width) as usize;
1098 let visible_lines_count = inner_area.height as usize;
1099
1100 let (wrapped_total_lines, needs_scrollbar) = match &self.content {
1102 PopupContent::Text(lines) => {
1103 let wrapped = wrap_text_lines(lines, wrap_width);
1104 let count = wrapped.len();
1105 (
1106 count,
1107 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1108 )
1109 }
1110 PopupContent::Markdown(styled_lines) => {
1111 let wrapped = wrap_styled_lines(styled_lines, wrap_width);
1112 let count = wrapped.len();
1113 (
1114 count,
1115 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1116 )
1117 }
1118 PopupContent::List { items, .. } => {
1119 let count = items.len();
1120 (
1121 count,
1122 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1123 )
1124 }
1125 PopupContent::Custom(lines) => {
1126 let count = lines.len();
1127 (
1128 count,
1129 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1130 )
1131 }
1132 };
1133
1134 let content_area = if needs_scrollbar {
1136 Rect {
1137 x: inner_area.x,
1138 y: inner_area.y,
1139 width: inner_area.width.saturating_sub(scrollbar_reserved_width),
1140 height: inner_area.height,
1141 }
1142 } else {
1143 inner_area
1144 };
1145
1146 match &self.content {
1147 PopupContent::Text(lines) => {
1148 let wrapped_lines = wrap_text_lines(lines, content_area.width as usize);
1150 let selection_style = Style::default().bg(theme.selection_bg);
1151
1152 let visible_lines: Vec<Line> = wrapped_lines
1153 .iter()
1154 .enumerate()
1155 .skip(self.scroll_offset)
1156 .take(content_area.height as usize)
1157 .map(|(line_idx, line)| {
1158 if let Some(ref sel) = self.text_selection {
1159 let chars: Vec<char> = line.chars().collect();
1161 let spans: Vec<Span> = chars
1162 .iter()
1163 .enumerate()
1164 .map(|(col, ch)| {
1165 if sel.contains(line_idx, col) {
1166 Span::styled(ch.to_string(), selection_style)
1167 } else {
1168 Span::raw(ch.to_string())
1169 }
1170 })
1171 .collect();
1172 Line::from(spans)
1173 } else {
1174 Line::from(line.as_str())
1175 }
1176 })
1177 .collect();
1178
1179 let paragraph = Paragraph::new(visible_lines);
1180 frame.render_widget(paragraph, content_area);
1181 }
1182 PopupContent::Markdown(styled_lines) => {
1183 let wrapped_lines = wrap_styled_lines(styled_lines, content_area.width as usize);
1185 let selection_style = Style::default().bg(theme.selection_bg);
1186
1187 let mut link_overlays: Vec<(usize, usize, String, String)> = Vec::new();
1190
1191 let visible_lines: Vec<Line> = wrapped_lines
1192 .iter()
1193 .enumerate()
1194 .skip(self.scroll_offset)
1195 .take(content_area.height as usize)
1196 .map(|(line_idx, styled_line)| {
1197 let mut col = 0usize;
1198 let spans: Vec<Span> = styled_line
1199 .spans
1200 .iter()
1201 .flat_map(|s| {
1202 let span_start_col = col;
1203 let span_width =
1204 unicode_width::UnicodeWidthStr::width(s.text.as_str());
1205 if let Some(url) = &s.link_url {
1206 link_overlays.push((
1207 line_idx - self.scroll_offset,
1208 col,
1209 s.text.clone(),
1210 url.clone(),
1211 ));
1212 }
1213 col += span_width;
1214
1215 if let Some(ref sel) = self.text_selection {
1217 let chars: Vec<char> = s.text.chars().collect();
1219 chars
1220 .iter()
1221 .enumerate()
1222 .map(|(i, ch)| {
1223 let char_col = span_start_col + i;
1224 if sel.contains(line_idx, char_col) {
1225 Span::styled(ch.to_string(), selection_style)
1226 } else {
1227 Span::styled(ch.to_string(), s.style)
1228 }
1229 })
1230 .collect::<Vec<_>>()
1231 } else {
1232 vec![Span::styled(s.text.clone(), s.style)]
1233 }
1234 })
1235 .collect();
1236 Line::from(spans)
1237 })
1238 .collect();
1239
1240 let paragraph = Paragraph::new(visible_lines);
1241 frame.render_widget(paragraph, content_area);
1242
1243 let buffer = frame.buffer_mut();
1245 let max_x = content_area.x + content_area.width;
1246 for (line_idx, col_start, text, url) in link_overlays {
1247 let y = content_area.y + line_idx as u16;
1248 if y >= content_area.y + content_area.height {
1249 continue;
1250 }
1251 let start_x = content_area.x + col_start as u16;
1252 apply_hyperlink_overlay(buffer, start_x, y, max_x, &text, &url);
1253 }
1254 }
1255 PopupContent::List { items, selected } => {
1256 let list_items: Vec<ListItem> = items
1257 .iter()
1258 .enumerate()
1259 .skip(self.scroll_offset)
1260 .take(content_area.height as usize)
1261 .map(|(idx, item)| {
1262 let is_hovered = matches!(
1264 hover_target,
1265 Some(crate::app::HoverTarget::PopupListItem(_, hovered_idx)) if *hovered_idx == idx
1266 );
1267 let is_selected = idx == *selected;
1268
1269 let mut spans = Vec::new();
1270
1271 if let Some(icon) = &item.icon {
1273 spans.push(Span::raw(format!("{} ", icon)));
1274 }
1275
1276 let text = &item.text;
1284 let trimmed = text.trim_start();
1285 let indent_len = text.len() - trimmed.len();
1286 if indent_len > 0 {
1287 spans.push(Span::raw(&text[..indent_len]));
1288 }
1289 let is_clickable = item.data.is_some() && !item.disabled;
1290 let mut text_style = Style::default();
1291 if is_selected {
1292 text_style = text_style.add_modifier(Modifier::BOLD);
1293 }
1294 if is_clickable {
1295 text_style = text_style.add_modifier(Modifier::UNDERLINED);
1296 }
1297 if item.disabled {
1298 text_style = text_style
1299 .fg(theme.help_separator_fg)
1300 .add_modifier(Modifier::DIM);
1301 }
1302 spans.push(Span::styled(trimmed, text_style));
1303
1304 if let Some(detail) = &item.detail {
1306 spans.push(Span::styled(
1307 format!(" {}", detail),
1308 Style::default().fg(theme.help_separator_fg),
1309 ));
1310 }
1311
1312 spans.push(Span::raw(""));
1315
1316 if is_selected {
1318 if let Some(ref hint) = self.accept_key_hint {
1319 let hint_text = format!("({})", hint);
1320 let used_width: usize = spans
1322 .iter()
1323 .map(|s| {
1324 unicode_width::UnicodeWidthStr::width(s.content.as_ref())
1325 })
1326 .sum();
1327 let available = content_area.width as usize;
1328 let hint_len = hint_text.len();
1329 if used_width + hint_len + 1 < available {
1330 let padding = available - used_width - hint_len;
1331 spans.push(Span::raw(" ".repeat(padding)));
1332 spans.push(Span::styled(
1333 hint_text,
1334 Style::default().fg(theme.help_separator_fg),
1335 ));
1336 }
1337 }
1338 }
1339
1340 let row_style = if is_selected {
1342 Style::default().bg(theme.popup_selection_bg)
1343 } else if is_hovered {
1344 Style::default()
1345 .bg(theme.menu_hover_bg)
1346 .fg(theme.menu_hover_fg)
1347 } else {
1348 Style::default()
1349 };
1350
1351 ListItem::new(Line::from(spans)).style(row_style)
1352 })
1353 .collect();
1354
1355 let list = List::new(list_items);
1356 frame.render_widget(list, content_area);
1357 }
1358 PopupContent::Custom(lines) => {
1359 let visible_lines: Vec<Line> = lines
1360 .iter()
1361 .skip(self.scroll_offset)
1362 .take(content_area.height as usize)
1363 .map(|line| Line::from(line.as_str()))
1364 .collect();
1365
1366 let paragraph = Paragraph::new(visible_lines);
1367 frame.render_widget(paragraph, content_area);
1368 }
1369 }
1370
1371 if needs_scrollbar {
1373 let scrollbar_area = Rect {
1374 x: inner_area.x + inner_area.width - 1,
1375 y: inner_area.y,
1376 width: 1,
1377 height: inner_area.height,
1378 };
1379
1380 let scrollbar_state =
1381 ScrollbarState::new(wrapped_total_lines, visible_lines_count, self.scroll_offset);
1382 let scrollbar_colors = ScrollbarColors::from_theme(theme);
1383 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
1384 }
1385 }
1386}
1387
1388#[derive(Debug, Clone)]
1390pub struct PopupManager {
1391 popups: Vec<Popup>,
1393}
1394
1395impl PopupManager {
1396 pub fn new() -> Self {
1397 Self { popups: Vec::new() }
1398 }
1399
1400 pub fn show(&mut self, popup: Popup) {
1402 self.popups.push(popup);
1403 }
1404
1405 pub fn show_or_replace(&mut self, popup: Popup) {
1409 if let Some(pos) = self.popups.iter().position(|p| p.kind == popup.kind) {
1410 self.popups[pos] = popup;
1411 } else {
1412 self.popups.push(popup);
1413 }
1414 }
1415
1416 pub fn hide(&mut self) -> Option<Popup> {
1418 self.popups.pop()
1419 }
1420
1421 pub fn clear(&mut self) {
1423 self.popups.clear();
1424 }
1425
1426 pub fn top(&self) -> Option<&Popup> {
1428 self.popups.last()
1429 }
1430
1431 pub fn top_mut(&mut self) -> Option<&mut Popup> {
1433 self.popups.last_mut()
1434 }
1435
1436 pub fn get(&self, index: usize) -> Option<&Popup> {
1438 self.popups.get(index)
1439 }
1440
1441 pub fn get_mut(&mut self, index: usize) -> Option<&mut Popup> {
1443 self.popups.get_mut(index)
1444 }
1445
1446 pub fn is_visible(&self) -> bool {
1448 !self.popups.is_empty()
1449 }
1450
1451 pub fn is_completion_popup(&self) -> bool {
1453 self.top()
1454 .map(|p| p.kind == PopupKind::Completion)
1455 .unwrap_or(false)
1456 }
1457
1458 pub fn is_hover_popup(&self) -> bool {
1460 self.top()
1461 .map(|p| p.kind == PopupKind::Hover)
1462 .unwrap_or(false)
1463 }
1464
1465 pub fn is_action_popup(&self) -> bool {
1467 self.top()
1468 .map(|p| p.kind == PopupKind::Action)
1469 .unwrap_or(false)
1470 }
1471
1472 pub fn all(&self) -> &[Popup] {
1474 &self.popups
1475 }
1476
1477 pub fn dismiss_transient(&mut self) -> bool {
1481 let is_transient = self.popups.last().is_some_and(|p| p.transient);
1482
1483 if is_transient {
1484 self.popups.pop();
1485 true
1486 } else {
1487 false
1488 }
1489 }
1490}
1491
1492impl Default for PopupManager {
1493 fn default() -> Self {
1494 Self::new()
1495 }
1496}
1497
1498fn apply_hyperlink_overlay(
1503 buffer: &mut ratatui::buffer::Buffer,
1504 start_x: u16,
1505 y: u16,
1506 max_x: u16,
1507 text: &str,
1508 url: &str,
1509) {
1510 let mut chunk_index = 0u16;
1511 let mut chars = text.chars();
1512
1513 loop {
1514 let mut chunk = String::new();
1515 for _ in 0..2 {
1516 if let Some(ch) = chars.next() {
1517 chunk.push(ch);
1518 } else {
1519 break;
1520 }
1521 }
1522
1523 if chunk.is_empty() {
1524 break;
1525 }
1526
1527 let x = start_x + chunk_index * 2;
1528 if x >= max_x {
1529 break;
1530 }
1531
1532 let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, chunk);
1533 buffer[(x, y)].set_symbol(&hyperlink);
1534
1535 chunk_index += 1;
1536 }
1537}
1538
1539#[cfg(test)]
1540mod tests {
1541 use super::*;
1542 use crate::view::theme;
1543
1544 #[test]
1545 fn test_popup_list_item() {
1546 let item = PopupListItem::new("test".to_string())
1547 .with_detail("detail".to_string())
1548 .with_icon("📄".to_string());
1549
1550 assert_eq!(item.text, "test");
1551 assert_eq!(item.detail, Some("detail".to_string()));
1552 assert_eq!(item.icon, Some("📄".to_string()));
1553 }
1554
1555 #[test]
1556 fn test_popup_selection() {
1557 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1558 let items = vec![
1559 PopupListItem::new("item1".to_string()),
1560 PopupListItem::new("item2".to_string()),
1561 PopupListItem::new("item3".to_string()),
1562 ];
1563
1564 let mut popup = Popup::list(items, &theme);
1565
1566 assert_eq!(popup.selected_item().unwrap().text, "item1");
1567
1568 popup.select_next();
1569 assert_eq!(popup.selected_item().unwrap().text, "item2");
1570
1571 popup.select_next();
1572 assert_eq!(popup.selected_item().unwrap().text, "item3");
1573
1574 popup.select_next(); assert_eq!(popup.selected_item().unwrap().text, "item3");
1576
1577 popup.select_prev();
1578 assert_eq!(popup.selected_item().unwrap().text, "item2");
1579
1580 popup.select_prev();
1581 assert_eq!(popup.selected_item().unwrap().text, "item1");
1582
1583 popup.select_prev(); assert_eq!(popup.selected_item().unwrap().text, "item1");
1585 }
1586
1587 #[test]
1588 fn test_popup_manager() {
1589 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1590 let mut manager = PopupManager::new();
1591
1592 assert!(!manager.is_visible());
1593 assert_eq!(manager.top(), None);
1594
1595 let popup1 = Popup::text(vec!["test1".to_string()], &theme);
1596 manager.show(popup1);
1597
1598 assert!(manager.is_visible());
1599 assert_eq!(manager.all().len(), 1);
1600
1601 let popup2 = Popup::text(vec!["test2".to_string()], &theme);
1602 manager.show(popup2);
1603
1604 assert_eq!(manager.all().len(), 2);
1605
1606 manager.hide();
1607 assert_eq!(manager.all().len(), 1);
1608
1609 manager.clear();
1610 assert!(!manager.is_visible());
1611 assert_eq!(manager.all().len(), 0);
1612 }
1613
1614 #[test]
1615 fn test_popup_area_calculation() {
1616 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1617 let terminal_area = Rect {
1618 x: 0,
1619 y: 0,
1620 width: 100,
1621 height: 50,
1622 };
1623
1624 let popup = Popup::text(vec!["test".to_string()], &theme)
1625 .with_width(30)
1626 .with_max_height(10);
1627
1628 let popup_centered = popup.clone().with_position(PopupPosition::Centered);
1630 let area = popup_centered.calculate_area(terminal_area, None);
1631 assert_eq!(area.width, 30);
1632 assert_eq!(area.height, 3);
1634 assert_eq!(area.x, (100 - 30) / 2);
1635 assert_eq!(area.y, (50 - 3) / 2);
1636
1637 let popup_below = popup.clone().with_position(PopupPosition::BelowCursor);
1639 let area = popup_below.calculate_area(terminal_area, Some((20, 10)));
1640 assert_eq!(area.x, 20);
1641 assert_eq!(area.y, 11); }
1643
1644 #[test]
1645 fn test_popup_fixed_position_clamping() {
1646 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1647 let terminal_area = Rect {
1648 x: 0,
1649 y: 0,
1650 width: 100,
1651 height: 50,
1652 };
1653
1654 let popup = Popup::text(vec!["test".to_string()], &theme)
1655 .with_width(30)
1656 .with_max_height(10);
1657
1658 let popup_fixed = popup
1660 .clone()
1661 .with_position(PopupPosition::Fixed { x: 10, y: 20 });
1662 let area = popup_fixed.calculate_area(terminal_area, None);
1663 assert_eq!(area.x, 10);
1664 assert_eq!(area.y, 20);
1665
1666 let popup_right_edge = popup
1668 .clone()
1669 .with_position(PopupPosition::Fixed { x: 99, y: 20 });
1670 let area = popup_right_edge.calculate_area(terminal_area, None);
1671 assert_eq!(area.x, 70);
1673 assert_eq!(area.y, 20);
1674
1675 let popup_beyond = popup
1677 .clone()
1678 .with_position(PopupPosition::Fixed { x: 199, y: 20 });
1679 let area = popup_beyond.calculate_area(terminal_area, None);
1680 assert_eq!(area.x, 70);
1682 assert_eq!(area.y, 20);
1683
1684 let popup_bottom = popup
1686 .clone()
1687 .with_position(PopupPosition::Fixed { x: 10, y: 49 });
1688 let area = popup_bottom.calculate_area(terminal_area, None);
1689 assert_eq!(area.x, 10);
1690 assert_eq!(area.y, 47);
1692 }
1693
1694 #[test]
1695 fn test_clamp_rect_to_bounds() {
1696 let bounds = Rect {
1697 x: 0,
1698 y: 0,
1699 width: 100,
1700 height: 50,
1701 };
1702
1703 let rect = Rect {
1705 x: 10,
1706 y: 20,
1707 width: 30,
1708 height: 10,
1709 };
1710 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1711 assert_eq!(clamped, rect);
1712
1713 let rect = Rect {
1715 x: 99,
1716 y: 20,
1717 width: 30,
1718 height: 10,
1719 };
1720 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1721 assert_eq!(clamped.x, 99); assert_eq!(clamped.width, 1); let rect = Rect {
1726 x: 199,
1727 y: 60,
1728 width: 30,
1729 height: 10,
1730 };
1731 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1732 assert_eq!(clamped.x, 99); assert_eq!(clamped.y, 49); assert_eq!(clamped.width, 1); assert_eq!(clamped.height, 1); }
1737
1738 #[test]
1739 fn hyperlink_overlay_chunks_pairs() {
1740 use ratatui::{buffer::Buffer, layout::Rect};
1741
1742 let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1743 buffer[(0, 0)].set_symbol("P");
1744 buffer[(1, 0)].set_symbol("l");
1745 buffer[(2, 0)].set_symbol("a");
1746 buffer[(3, 0)].set_symbol("y");
1747
1748 apply_hyperlink_overlay(&mut buffer, 0, 0, 10, "Play", "https://example.com");
1749
1750 let first = buffer[(0, 0)].symbol().to_string();
1751 let second = buffer[(2, 0)].symbol().to_string();
1752
1753 assert!(
1754 first.contains("Pl"),
1755 "first chunk should contain 'Pl', got {first:?}"
1756 );
1757 assert!(
1758 second.contains("ay"),
1759 "second chunk should contain 'ay', got {second:?}"
1760 );
1761 }
1762
1763 #[test]
1764 fn test_popup_text_selection() {
1765 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1766 let mut popup = Popup::text(
1767 vec![
1768 "Line 0: Hello".to_string(),
1769 "Line 1: World".to_string(),
1770 "Line 2: Test".to_string(),
1771 ],
1772 &theme,
1773 );
1774
1775 assert!(!popup.has_selection());
1777 assert_eq!(popup.get_selected_text(), None);
1778
1779 popup.start_selection(0, 8);
1781 assert!(!popup.has_selection()); popup.extend_selection(1, 8);
1785 assert!(popup.has_selection());
1786
1787 let selected = popup.get_selected_text().unwrap();
1789 assert_eq!(selected, "Hello\nLine 1: ");
1790
1791 popup.clear_selection();
1793 assert!(!popup.has_selection());
1794 assert_eq!(popup.get_selected_text(), None);
1795
1796 popup.start_selection(1, 8);
1798 popup.extend_selection(1, 13); let selected = popup.get_selected_text().unwrap();
1800 assert_eq!(selected, "World");
1801 }
1802
1803 #[test]
1804 fn test_popup_text_selection_contains() {
1805 let sel = PopupTextSelection {
1806 start: (1, 5),
1807 end: (2, 10),
1808 };
1809
1810 assert!(!sel.contains(0, 5));
1812
1813 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));
1826 }
1827
1828 #[test]
1829 fn test_popup_text_selection_normalized() {
1830 let sel = PopupTextSelection {
1832 start: (1, 5),
1833 end: (2, 10),
1834 };
1835 let ((s_line, s_col), (e_line, e_col)) = sel.normalized();
1836 assert_eq!((s_line, s_col), (1, 5));
1837 assert_eq!((e_line, e_col), (2, 10));
1838
1839 let sel_backward = PopupTextSelection {
1841 start: (2, 10),
1842 end: (1, 5),
1843 };
1844 let ((s_line, s_col), (e_line, e_col)) = sel_backward.normalized();
1845 assert_eq!((s_line, s_col), (1, 5));
1846 assert_eq!((e_line, e_col), (2, 10));
1847 }
1848}