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)]
73pub enum PopupContent {
74 Text(Vec<String>),
76 Markdown(Vec<StyledLine>),
78 List {
80 items: Vec<PopupListItem>,
81 selected: usize,
82 },
83 Custom(Vec<String>),
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub struct PopupTextSelection {
90 pub start: (usize, usize),
92 pub end: (usize, usize),
94}
95
96impl PopupTextSelection {
97 pub fn normalized(&self) -> ((usize, usize), (usize, usize)) {
99 if self.start.0 < self.end.0 || (self.start.0 == self.end.0 && self.start.1 <= self.end.1) {
100 (self.start, self.end)
101 } else {
102 (self.end, self.start)
103 }
104 }
105
106 pub fn contains(&self, line: usize, col: usize) -> bool {
108 let ((start_line, start_col), (end_line, end_col)) = self.normalized();
109 if line < start_line || line > end_line {
110 return false;
111 }
112 if line == start_line && line == end_line {
113 col >= start_col && col < end_col
114 } else if line == start_line {
115 col >= start_col
116 } else if line == end_line {
117 col < end_col
118 } else {
119 true
120 }
121 }
122}
123
124#[derive(Debug, Clone, PartialEq)]
126pub struct PopupListItem {
127 pub text: String,
129 pub detail: Option<String>,
131 pub icon: Option<String>,
133 pub data: Option<String>,
135 pub disabled: bool,
137}
138
139impl PopupListItem {
140 pub fn new(text: String) -> Self {
141 Self {
142 text,
143 detail: None,
144 icon: None,
145 data: None,
146 disabled: false,
147 }
148 }
149
150 pub fn with_detail(mut self, detail: String) -> Self {
151 self.detail = Some(detail);
152 self
153 }
154
155 pub fn with_icon(mut self, icon: String) -> Self {
156 self.icon = Some(icon);
157 self
158 }
159
160 pub fn with_data(mut self, data: String) -> Self {
161 self.data = Some(data);
162 self
163 }
164
165 pub fn disabled(mut self) -> Self {
166 self.disabled = true;
167 self
168 }
169}
170
171#[derive(Debug, Clone, PartialEq)]
180pub struct Popup {
181 pub kind: PopupKind,
183
184 pub title: Option<String>,
186
187 pub description: Option<String>,
189
190 pub transient: bool,
192
193 pub content: PopupContent,
195
196 pub position: PopupPosition,
198
199 pub width: u16,
201
202 pub max_height: u16,
204
205 pub bordered: bool,
207
208 pub border_style: Style,
210
211 pub background_style: Style,
213
214 pub scroll_offset: usize,
216
217 pub text_selection: Option<PopupTextSelection>,
219
220 pub accept_key_hint: Option<String>,
222}
223
224impl Popup {
225 pub fn text(content: Vec<String>, theme: &crate::view::theme::Theme) -> Self {
227 Self {
228 kind: PopupKind::Text,
229 title: None,
230 description: None,
231 transient: false,
232 content: PopupContent::Text(content),
233 position: PopupPosition::AtCursor,
234 width: 50,
235 max_height: 15,
236 bordered: true,
237 border_style: Style::default().fg(theme.popup_border_fg),
238 background_style: Style::default().bg(theme.popup_bg),
239 scroll_offset: 0,
240 text_selection: None,
241 accept_key_hint: None,
242 }
243 }
244
245 pub fn markdown(
250 markdown_text: &str,
251 theme: &crate::view::theme::Theme,
252 registry: Option<&GrammarRegistry>,
253 ) -> Self {
254 let styled_lines = parse_markdown(markdown_text, theme, registry);
255 Self {
256 kind: PopupKind::Text,
257 title: None,
258 description: None,
259 transient: false,
260 content: PopupContent::Markdown(styled_lines),
261 position: PopupPosition::AtCursor,
262 width: 60, max_height: 20, bordered: true,
265 border_style: Style::default().fg(theme.popup_border_fg),
266 background_style: Style::default().bg(theme.popup_bg),
267 scroll_offset: 0,
268 text_selection: None,
269 accept_key_hint: None,
270 }
271 }
272
273 pub fn list(items: Vec<PopupListItem>, theme: &crate::view::theme::Theme) -> Self {
275 Self {
276 kind: PopupKind::List,
277 title: None,
278 description: None,
279 transient: false,
280 content: PopupContent::List { items, selected: 0 },
281 position: PopupPosition::AtCursor,
282 width: 50,
283 max_height: 15,
284 bordered: true,
285 border_style: Style::default().fg(theme.popup_border_fg),
286 background_style: Style::default().bg(theme.popup_bg),
287 scroll_offset: 0,
288 text_selection: None,
289 accept_key_hint: None,
290 }
291 }
292
293 pub fn with_title(mut self, title: String) -> Self {
295 self.title = Some(title);
296 self
297 }
298
299 pub fn with_kind(mut self, kind: PopupKind) -> Self {
301 self.kind = kind;
302 self
303 }
304
305 pub fn with_transient(mut self, transient: bool) -> Self {
307 self.transient = transient;
308 self
309 }
310
311 pub fn with_position(mut self, position: PopupPosition) -> Self {
313 self.position = position;
314 self
315 }
316
317 pub fn with_width(mut self, width: u16) -> Self {
319 self.width = width;
320 self
321 }
322
323 pub fn with_max_height(mut self, max_height: u16) -> Self {
325 self.max_height = max_height;
326 self
327 }
328
329 pub fn with_border_style(mut self, style: Style) -> Self {
331 self.border_style = style;
332 self
333 }
334
335 pub fn selected_item(&self) -> Option<&PopupListItem> {
337 match &self.content {
338 PopupContent::List { items, selected } => items.get(*selected),
339 _ => None,
340 }
341 }
342
343 fn visible_height(&self) -> usize {
345 let border_offset = if self.bordered { 2 } else { 0 };
346 (self.max_height as usize).saturating_sub(border_offset)
347 }
348
349 pub fn select_next(&mut self) {
351 let visible = self.visible_height();
352 if let PopupContent::List { items, selected } = &mut self.content {
353 if *selected < items.len().saturating_sub(1) {
354 *selected += 1;
355 if *selected >= self.scroll_offset + visible {
357 self.scroll_offset = (*selected + 1).saturating_sub(visible);
358 }
359 }
360 }
361 }
362
363 pub fn select_prev(&mut self) {
365 if let PopupContent::List { items: _, selected } = &mut self.content {
366 if *selected > 0 {
367 *selected -= 1;
368 if *selected < self.scroll_offset {
370 self.scroll_offset = *selected;
371 }
372 }
373 }
374 }
375
376 pub fn select_index(&mut self, index: usize) -> bool {
378 let visible = self.visible_height();
379 if let PopupContent::List { items, selected } = &mut self.content {
380 if index < items.len() {
381 *selected = index;
382 if *selected >= self.scroll_offset + visible {
384 self.scroll_offset = (*selected + 1).saturating_sub(visible);
385 } else if *selected < self.scroll_offset {
386 self.scroll_offset = *selected;
387 }
388 return true;
389 }
390 }
391 false
392 }
393
394 pub fn page_down(&mut self) {
396 let visible = self.visible_height();
397 if let PopupContent::List { items, selected } = &mut self.content {
398 *selected = (*selected + visible).min(items.len().saturating_sub(1));
399 self.scroll_offset = (*selected + 1).saturating_sub(visible);
400 } else {
401 self.scroll_offset += visible;
402 }
403 }
404
405 pub fn page_up(&mut self) {
407 let visible = self.visible_height();
408 if let PopupContent::List { items: _, selected } = &mut self.content {
409 *selected = selected.saturating_sub(visible);
410 self.scroll_offset = *selected;
411 } else {
412 self.scroll_offset = self.scroll_offset.saturating_sub(visible);
413 }
414 }
415
416 pub fn select_first(&mut self) {
418 if let PopupContent::List { items: _, selected } = &mut self.content {
419 *selected = 0;
420 self.scroll_offset = 0;
421 } else {
422 self.scroll_offset = 0;
423 }
424 }
425
426 pub fn select_last(&mut self) {
428 let visible = self.visible_height();
429 if let PopupContent::List { items, selected } = &mut self.content {
430 *selected = items.len().saturating_sub(1);
431 if *selected >= visible {
433 self.scroll_offset = (*selected + 1).saturating_sub(visible);
434 }
435 } else {
436 let content_height = self.item_count();
438 if content_height > visible {
439 self.scroll_offset = content_height.saturating_sub(visible);
440 }
441 }
442 }
443
444 pub fn scroll_by(&mut self, delta: i32) {
447 let content_len = self.wrapped_item_count();
448 let visible = self.visible_height();
449 let max_scroll = content_len.saturating_sub(visible);
450
451 if delta < 0 {
452 self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
454 } else {
455 self.scroll_offset = (self.scroll_offset + delta as usize).min(max_scroll);
457 }
458
459 if let PopupContent::List { items, selected } = &mut self.content {
461 let visible_start = self.scroll_offset;
462 let visible_end = (self.scroll_offset + visible).min(items.len());
463
464 if *selected < visible_start {
465 *selected = visible_start;
466 } else if *selected >= visible_end {
467 *selected = visible_end.saturating_sub(1);
468 }
469 }
470 }
471
472 pub fn item_count(&self) -> usize {
474 match &self.content {
475 PopupContent::Text(lines) => lines.len(),
476 PopupContent::Markdown(lines) => lines.len(),
477 PopupContent::List { items, .. } => items.len(),
478 PopupContent::Custom(lines) => lines.len(),
479 }
480 }
481
482 fn wrapped_item_count(&self) -> usize {
487 let border_width = if self.bordered { 2 } else { 0 };
489 let scrollbar_width = 2; let wrap_width = (self.width as usize)
491 .saturating_sub(border_width)
492 .saturating_sub(scrollbar_width);
493
494 if wrap_width == 0 {
495 return self.item_count();
496 }
497
498 match &self.content {
499 PopupContent::Text(lines) => wrap_text_lines(lines, wrap_width).len(),
500 PopupContent::Markdown(styled_lines) => {
501 wrap_styled_lines(styled_lines, wrap_width).len()
502 }
503 PopupContent::List { items, .. } => items.len(),
505 PopupContent::Custom(lines) => lines.len(),
506 }
507 }
508
509 pub fn start_selection(&mut self, line: usize, col: usize) {
511 self.text_selection = Some(PopupTextSelection {
512 start: (line, col),
513 end: (line, col),
514 });
515 }
516
517 pub fn extend_selection(&mut self, line: usize, col: usize) {
519 if let Some(ref mut sel) = self.text_selection {
520 sel.end = (line, col);
521 }
522 }
523
524 pub fn clear_selection(&mut self) {
526 self.text_selection = None;
527 }
528
529 pub fn has_selection(&self) -> bool {
531 if let Some(sel) = &self.text_selection {
532 sel.start != sel.end
533 } else {
534 false
535 }
536 }
537
538 fn content_wrap_width(&self) -> usize {
541 let border_width: u16 = if self.bordered { 2 } else { 0 };
542 let inner_width = self.width.saturating_sub(border_width);
543 let scrollbar_reserved: u16 = 2;
544 let conservative_width = inner_width.saturating_sub(scrollbar_reserved) as usize;
545
546 if conservative_width == 0 {
547 return 0;
548 }
549
550 let visible_height = self.max_height.saturating_sub(border_width) as usize;
551 let line_count = match &self.content {
552 PopupContent::Text(lines) => wrap_text_lines(lines, conservative_width).len(),
553 PopupContent::Markdown(styled_lines) => {
554 wrap_styled_lines(styled_lines, conservative_width).len()
555 }
556 _ => self.item_count(),
557 };
558
559 let needs_scrollbar = line_count > visible_height && inner_width > scrollbar_reserved;
560
561 if needs_scrollbar {
562 conservative_width
563 } else {
564 inner_width as usize
565 }
566 }
567
568 fn get_text_lines(&self) -> Vec<String> {
573 let wrap_width = self.content_wrap_width();
574
575 match &self.content {
576 PopupContent::Text(lines) => {
577 if wrap_width > 0 {
578 wrap_text_lines(lines, wrap_width)
579 } else {
580 lines.clone()
581 }
582 }
583 PopupContent::Markdown(styled_lines) => {
584 if wrap_width > 0 {
585 wrap_styled_lines(styled_lines, wrap_width)
586 .iter()
587 .map(|sl| sl.plain_text())
588 .collect()
589 } else {
590 styled_lines.iter().map(|sl| sl.plain_text()).collect()
591 }
592 }
593 PopupContent::List { items, .. } => items.iter().map(|i| i.text.clone()).collect(),
594 PopupContent::Custom(lines) => lines.clone(),
595 }
596 }
597
598 pub fn get_selected_text(&self) -> Option<String> {
600 let sel = self.text_selection.as_ref()?;
601 if sel.start == sel.end {
602 return None;
603 }
604
605 let ((start_line, start_col), (end_line, end_col)) = sel.normalized();
606 let lines = self.get_text_lines();
607
608 if start_line >= lines.len() {
609 return None;
610 }
611
612 if start_line == end_line {
613 let line = &lines[start_line];
614 let end_col = end_col.min(line.len());
615 let start_col = start_col.min(end_col);
616 Some(line[start_col..end_col].to_string())
617 } else {
618 let mut result = String::new();
619 let first_line = &lines[start_line];
621 result.push_str(&first_line[start_col.min(first_line.len())..]);
622 result.push('\n');
623 for line in lines.iter().take(end_line).skip(start_line + 1) {
625 result.push_str(line);
626 result.push('\n');
627 }
628 if end_line < lines.len() {
630 let last_line = &lines[end_line];
631 result.push_str(&last_line[..end_col.min(last_line.len())]);
632 }
633 Some(result)
634 }
635 }
636
637 pub fn needs_scrollbar(&self) -> bool {
639 self.item_count() > self.visible_height()
640 }
641
642 pub fn scroll_state(&self) -> (usize, usize, usize) {
644 let total = self.item_count();
645 let visible = self.visible_height();
646 (total, visible, self.scroll_offset)
647 }
648
649 pub fn link_at_position(&self, relative_col: usize, relative_row: usize) -> Option<String> {
655 let PopupContent::Markdown(styled_lines) = &self.content else {
656 return None;
657 };
658
659 let border_width = if self.bordered { 2 } else { 0 };
661 let scrollbar_reserved = 2;
662 let content_width = self
663 .width
664 .saturating_sub(border_width)
665 .saturating_sub(scrollbar_reserved) as usize;
666
667 let wrapped_lines = wrap_styled_lines(styled_lines, content_width);
669
670 let line_index = self.scroll_offset + relative_row;
672
673 let line = wrapped_lines.get(line_index)?;
675
676 line.link_at_column(relative_col).map(|s| s.to_string())
678 }
679
680 pub fn description_height(&self) -> u16 {
683 if let Some(desc) = &self.description {
684 let border_width = if self.bordered { 2 } else { 0 };
685 let scrollbar_reserved = 2;
686 let content_width = self
687 .width
688 .saturating_sub(border_width)
689 .saturating_sub(scrollbar_reserved) as usize;
690 let desc_vec = vec![desc.clone()];
691 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
692 wrapped.len() as u16 + 1 } else {
694 0
695 }
696 }
697
698 fn content_height(&self) -> u16 {
700 self.content_height_for_width(self.width)
702 }
703
704 fn content_height_for_width(&self, popup_width: u16) -> u16 {
706 let border_width = if self.bordered { 2 } else { 0 };
708 let scrollbar_reserved = 2; let content_width = popup_width
710 .saturating_sub(border_width)
711 .saturating_sub(scrollbar_reserved) as usize;
712
713 let description_lines = if let Some(desc) = &self.description {
715 let desc_vec = vec![desc.clone()];
716 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
717 wrapped.len() as u16 + 1 } else {
719 0
720 };
721
722 let content_lines = match &self.content {
723 PopupContent::Text(lines) => {
724 wrap_text_lines(lines, content_width).len() as u16
726 }
727 PopupContent::Markdown(styled_lines) => {
728 wrap_styled_lines(styled_lines, content_width).len() as u16
730 }
731 PopupContent::List { items, .. } => items.len() as u16,
732 PopupContent::Custom(lines) => lines.len() as u16,
733 };
734
735 let border_height = if self.bordered { 2 } else { 0 };
737
738 description_lines + content_lines + border_height
739 }
740
741 pub fn calculate_area(&self, terminal_area: Rect, cursor_pos: Option<(u16, u16)>) -> Rect {
743 match self.position {
744 PopupPosition::AtCursor | PopupPosition::BelowCursor | PopupPosition::AboveCursor => {
745 let (cursor_x, cursor_y) =
746 cursor_pos.unwrap_or((terminal_area.width / 2, terminal_area.height / 2));
747
748 let width = self.width.min(terminal_area.width);
749 let height = self
751 .content_height()
752 .min(self.max_height)
753 .min(terminal_area.height);
754
755 let x = if cursor_x + width > terminal_area.width {
756 terminal_area.width.saturating_sub(width)
757 } else {
758 cursor_x
759 };
760
761 let y = match self.position {
762 PopupPosition::AtCursor => cursor_y,
763 PopupPosition::BelowCursor => {
764 if cursor_y + 1 + height > terminal_area.height {
765 cursor_y.saturating_sub(height)
767 } else {
768 cursor_y + 1
770 }
771 }
772 PopupPosition::AboveCursor => {
773 (cursor_y + 1).saturating_sub(height)
775 }
776 _ => cursor_y,
777 };
778
779 Rect {
780 x,
781 y,
782 width,
783 height,
784 }
785 }
786 PopupPosition::Fixed { x, y } => {
787 let width = self.width.min(terminal_area.width);
788 let height = self
789 .content_height()
790 .min(self.max_height)
791 .min(terminal_area.height);
792 let x = if x + width > terminal_area.width {
794 terminal_area.width.saturating_sub(width)
795 } else {
796 x
797 };
798 let y = if y + height > terminal_area.height {
799 terminal_area.height.saturating_sub(height)
800 } else {
801 y
802 };
803 Rect {
804 x,
805 y,
806 width,
807 height,
808 }
809 }
810 PopupPosition::Centered => {
811 let width = self.width.min(terminal_area.width);
812 let height = self
813 .content_height()
814 .min(self.max_height)
815 .min(terminal_area.height);
816 let x = (terminal_area.width.saturating_sub(width)) / 2;
817 let y = (terminal_area.height.saturating_sub(height)) / 2;
818 Rect {
819 x,
820 y,
821 width,
822 height,
823 }
824 }
825 PopupPosition::BottomRight => {
826 let width = self.width.min(terminal_area.width);
827 let height = self
828 .content_height()
829 .min(self.max_height)
830 .min(terminal_area.height);
831 let x = terminal_area.width.saturating_sub(width);
833 let y = terminal_area
834 .height
835 .saturating_sub(height)
836 .saturating_sub(2);
837 Rect {
838 x,
839 y,
840 width,
841 height,
842 }
843 }
844 PopupPosition::AboveStatusBarAt { x } => {
845 let width = self.width.min(terminal_area.width);
846 let height = self
847 .content_height()
848 .min(self.max_height)
849 .min(terminal_area.height);
850 let x = if x + width > terminal_area.width {
853 terminal_area.width.saturating_sub(width)
854 } else {
855 x
856 };
857 let y = terminal_area
866 .height
867 .saturating_sub(height)
868 .saturating_sub(2);
869 Rect {
870 x,
871 y,
872 width,
873 height,
874 }
875 }
876 }
877 }
878
879 pub fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::view::theme::Theme) {
881 self.render_with_hover(frame, area, theme, None);
882 }
883
884 pub fn render_with_hover(
886 &self,
887 frame: &mut Frame,
888 area: Rect,
889 theme: &crate::view::theme::Theme,
890 hover_target: Option<&crate::app::HoverTarget>,
891 ) {
892 let frame_area = frame.area();
894 let area = clamp_rect_to_bounds(area, frame_area);
895
896 if area.width == 0 || area.height == 0 {
898 return;
899 }
900
901 frame.render_widget(Clear, area);
903
904 let block = if self.bordered {
905 let mut block = Block::default()
906 .borders(Borders::ALL)
907 .border_style(self.border_style)
908 .style(self.background_style);
909
910 if let Some(title) = &self.title {
911 block = block.title(title.as_str());
912 }
913
914 block
915 } else {
916 Block::default().style(self.background_style)
917 };
918
919 let inner_area = block.inner(area);
920 frame.render_widget(block, area);
921
922 if self.bordered && area.width >= 5 {
927 let close_x = area.x + area.width - 4;
928 let close_area = Rect {
929 x: close_x,
930 y: area.y,
931 width: 3,
932 height: 1,
933 };
934 frame.render_widget(Paragraph::new("[×]").style(self.border_style), close_area);
935 }
936
937 let content_start_y;
939 if let Some(desc) = &self.description {
940 let desc_wrap_width = inner_area.width.saturating_sub(2) as usize; let desc_vec = vec![desc.clone()];
943 let wrapped_desc = wrap_text_lines(&desc_vec, desc_wrap_width);
944 let desc_lines: usize = wrapped_desc.len();
945
946 for (i, line) in wrapped_desc.iter().enumerate() {
948 if i >= inner_area.height as usize {
949 break;
950 }
951 let line_area = Rect {
952 x: inner_area.x,
953 y: inner_area.y + i as u16,
954 width: inner_area.width,
955 height: 1,
956 };
957 let desc_style = Style::default().fg(theme.help_separator_fg);
958 frame.render_widget(Paragraph::new(line.as_str()).style(desc_style), line_area);
959 }
960
961 content_start_y = inner_area.y + (desc_lines as u16).min(inner_area.height) + 1;
963 } else {
964 content_start_y = inner_area.y;
965 }
966
967 let inner_area = Rect {
969 x: inner_area.x,
970 y: content_start_y,
971 width: inner_area.width,
972 height: inner_area
973 .height
974 .saturating_sub(content_start_y - area.y - if self.bordered { 1 } else { 0 }),
975 };
976
977 let scrollbar_reserved_width = 2; let wrap_width = inner_area.width.saturating_sub(scrollbar_reserved_width) as usize;
981 let visible_lines_count = inner_area.height as usize;
982
983 let (wrapped_total_lines, needs_scrollbar) = match &self.content {
985 PopupContent::Text(lines) => {
986 let wrapped = wrap_text_lines(lines, wrap_width);
987 let count = wrapped.len();
988 (
989 count,
990 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
991 )
992 }
993 PopupContent::Markdown(styled_lines) => {
994 let wrapped = wrap_styled_lines(styled_lines, wrap_width);
995 let count = wrapped.len();
996 (
997 count,
998 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
999 )
1000 }
1001 PopupContent::List { items, .. } => {
1002 let count = items.len();
1003 (
1004 count,
1005 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1006 )
1007 }
1008 PopupContent::Custom(lines) => {
1009 let count = lines.len();
1010 (
1011 count,
1012 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1013 )
1014 }
1015 };
1016
1017 let content_area = if needs_scrollbar {
1019 Rect {
1020 x: inner_area.x,
1021 y: inner_area.y,
1022 width: inner_area.width.saturating_sub(scrollbar_reserved_width),
1023 height: inner_area.height,
1024 }
1025 } else {
1026 inner_area
1027 };
1028
1029 match &self.content {
1030 PopupContent::Text(lines) => {
1031 let wrapped_lines = wrap_text_lines(lines, content_area.width as usize);
1033 let selection_style = Style::default().bg(theme.selection_bg);
1034
1035 let visible_lines: Vec<Line> = wrapped_lines
1036 .iter()
1037 .enumerate()
1038 .skip(self.scroll_offset)
1039 .take(content_area.height as usize)
1040 .map(|(line_idx, line)| {
1041 if let Some(ref sel) = self.text_selection {
1042 let chars: Vec<char> = line.chars().collect();
1044 let spans: Vec<Span> = chars
1045 .iter()
1046 .enumerate()
1047 .map(|(col, ch)| {
1048 if sel.contains(line_idx, col) {
1049 Span::styled(ch.to_string(), selection_style)
1050 } else {
1051 Span::raw(ch.to_string())
1052 }
1053 })
1054 .collect();
1055 Line::from(spans)
1056 } else {
1057 Line::from(line.as_str())
1058 }
1059 })
1060 .collect();
1061
1062 let paragraph = Paragraph::new(visible_lines);
1063 frame.render_widget(paragraph, content_area);
1064 }
1065 PopupContent::Markdown(styled_lines) => {
1066 let wrapped_lines = wrap_styled_lines(styled_lines, content_area.width as usize);
1068 let selection_style = Style::default().bg(theme.selection_bg);
1069
1070 let mut link_overlays: Vec<(usize, usize, String, String)> = Vec::new();
1073
1074 let visible_lines: Vec<Line> = wrapped_lines
1075 .iter()
1076 .enumerate()
1077 .skip(self.scroll_offset)
1078 .take(content_area.height as usize)
1079 .map(|(line_idx, styled_line)| {
1080 let mut col = 0usize;
1081 let spans: Vec<Span> = styled_line
1082 .spans
1083 .iter()
1084 .flat_map(|s| {
1085 let span_start_col = col;
1086 let span_width =
1087 unicode_width::UnicodeWidthStr::width(s.text.as_str());
1088 if let Some(url) = &s.link_url {
1089 link_overlays.push((
1090 line_idx - self.scroll_offset,
1091 col,
1092 s.text.clone(),
1093 url.clone(),
1094 ));
1095 }
1096 col += span_width;
1097
1098 if let Some(ref sel) = self.text_selection {
1100 let chars: Vec<char> = s.text.chars().collect();
1102 chars
1103 .iter()
1104 .enumerate()
1105 .map(|(i, ch)| {
1106 let char_col = span_start_col + i;
1107 if sel.contains(line_idx, char_col) {
1108 Span::styled(ch.to_string(), selection_style)
1109 } else {
1110 Span::styled(ch.to_string(), s.style)
1111 }
1112 })
1113 .collect::<Vec<_>>()
1114 } else {
1115 vec![Span::styled(s.text.clone(), s.style)]
1116 }
1117 })
1118 .collect();
1119 Line::from(spans)
1120 })
1121 .collect();
1122
1123 let paragraph = Paragraph::new(visible_lines);
1124 frame.render_widget(paragraph, content_area);
1125
1126 let buffer = frame.buffer_mut();
1128 let max_x = content_area.x + content_area.width;
1129 for (line_idx, col_start, text, url) in link_overlays {
1130 let y = content_area.y + line_idx as u16;
1131 if y >= content_area.y + content_area.height {
1132 continue;
1133 }
1134 let start_x = content_area.x + col_start as u16;
1135 apply_hyperlink_overlay(buffer, start_x, y, max_x, &text, &url);
1136 }
1137 }
1138 PopupContent::List { items, selected } => {
1139 let list_items: Vec<ListItem> = items
1140 .iter()
1141 .enumerate()
1142 .skip(self.scroll_offset)
1143 .take(content_area.height as usize)
1144 .map(|(idx, item)| {
1145 let is_hovered = matches!(
1147 hover_target,
1148 Some(crate::app::HoverTarget::PopupListItem(_, hovered_idx)) if *hovered_idx == idx
1149 );
1150 let is_selected = idx == *selected;
1151
1152 let mut spans = Vec::new();
1153
1154 if let Some(icon) = &item.icon {
1156 spans.push(Span::raw(format!("{} ", icon)));
1157 }
1158
1159 let text = &item.text;
1167 let trimmed = text.trim_start();
1168 let indent_len = text.len() - trimmed.len();
1169 if indent_len > 0 {
1170 spans.push(Span::raw(&text[..indent_len]));
1171 }
1172 let is_clickable = item.data.is_some() && !item.disabled;
1173 let mut text_style = Style::default();
1174 if is_selected {
1175 text_style = text_style.add_modifier(Modifier::BOLD);
1176 }
1177 if is_clickable {
1178 text_style = text_style.add_modifier(Modifier::UNDERLINED);
1179 }
1180 if item.disabled {
1181 text_style = text_style
1182 .fg(theme.help_separator_fg)
1183 .add_modifier(Modifier::DIM);
1184 }
1185 spans.push(Span::styled(trimmed, text_style));
1186
1187 if let Some(detail) = &item.detail {
1189 spans.push(Span::styled(
1190 format!(" {}", detail),
1191 Style::default().fg(theme.help_separator_fg),
1192 ));
1193 }
1194
1195 spans.push(Span::raw(""));
1198
1199 if is_selected {
1201 if let Some(ref hint) = self.accept_key_hint {
1202 let hint_text = format!("({})", hint);
1203 let used_width: usize = spans
1205 .iter()
1206 .map(|s| {
1207 unicode_width::UnicodeWidthStr::width(s.content.as_ref())
1208 })
1209 .sum();
1210 let available = content_area.width as usize;
1211 let hint_len = hint_text.len();
1212 if used_width + hint_len + 1 < available {
1213 let padding = available - used_width - hint_len;
1214 spans.push(Span::raw(" ".repeat(padding)));
1215 spans.push(Span::styled(
1216 hint_text,
1217 Style::default().fg(theme.help_separator_fg),
1218 ));
1219 }
1220 }
1221 }
1222
1223 let row_style = if is_selected {
1225 Style::default().bg(theme.popup_selection_bg)
1226 } else if is_hovered {
1227 Style::default()
1228 .bg(theme.menu_hover_bg)
1229 .fg(theme.menu_hover_fg)
1230 } else {
1231 Style::default()
1232 };
1233
1234 ListItem::new(Line::from(spans)).style(row_style)
1235 })
1236 .collect();
1237
1238 let list = List::new(list_items);
1239 frame.render_widget(list, content_area);
1240 }
1241 PopupContent::Custom(lines) => {
1242 let visible_lines: Vec<Line> = lines
1243 .iter()
1244 .skip(self.scroll_offset)
1245 .take(content_area.height as usize)
1246 .map(|line| Line::from(line.as_str()))
1247 .collect();
1248
1249 let paragraph = Paragraph::new(visible_lines);
1250 frame.render_widget(paragraph, content_area);
1251 }
1252 }
1253
1254 if needs_scrollbar {
1256 let scrollbar_area = Rect {
1257 x: inner_area.x + inner_area.width - 1,
1258 y: inner_area.y,
1259 width: 1,
1260 height: inner_area.height,
1261 };
1262
1263 let scrollbar_state =
1264 ScrollbarState::new(wrapped_total_lines, visible_lines_count, self.scroll_offset);
1265 let scrollbar_colors = ScrollbarColors::from_theme(theme);
1266 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
1267 }
1268 }
1269}
1270
1271#[derive(Debug, Clone)]
1273pub struct PopupManager {
1274 popups: Vec<Popup>,
1276}
1277
1278impl PopupManager {
1279 pub fn new() -> Self {
1280 Self { popups: Vec::new() }
1281 }
1282
1283 pub fn show(&mut self, popup: Popup) {
1285 self.popups.push(popup);
1286 }
1287
1288 pub fn show_or_replace(&mut self, popup: Popup) {
1292 if let Some(pos) = self.popups.iter().position(|p| p.kind == popup.kind) {
1293 self.popups[pos] = popup;
1294 } else {
1295 self.popups.push(popup);
1296 }
1297 }
1298
1299 pub fn hide(&mut self) -> Option<Popup> {
1301 self.popups.pop()
1302 }
1303
1304 pub fn clear(&mut self) {
1306 self.popups.clear();
1307 }
1308
1309 pub fn top(&self) -> Option<&Popup> {
1311 self.popups.last()
1312 }
1313
1314 pub fn top_mut(&mut self) -> Option<&mut Popup> {
1316 self.popups.last_mut()
1317 }
1318
1319 pub fn get(&self, index: usize) -> Option<&Popup> {
1321 self.popups.get(index)
1322 }
1323
1324 pub fn get_mut(&mut self, index: usize) -> Option<&mut Popup> {
1326 self.popups.get_mut(index)
1327 }
1328
1329 pub fn is_visible(&self) -> bool {
1331 !self.popups.is_empty()
1332 }
1333
1334 pub fn is_completion_popup(&self) -> bool {
1336 self.top()
1337 .map(|p| p.kind == PopupKind::Completion)
1338 .unwrap_or(false)
1339 }
1340
1341 pub fn is_hover_popup(&self) -> bool {
1343 self.top()
1344 .map(|p| p.kind == PopupKind::Hover)
1345 .unwrap_or(false)
1346 }
1347
1348 pub fn is_action_popup(&self) -> bool {
1350 self.top()
1351 .map(|p| p.kind == PopupKind::Action)
1352 .unwrap_or(false)
1353 }
1354
1355 pub fn all(&self) -> &[Popup] {
1357 &self.popups
1358 }
1359
1360 pub fn dismiss_transient(&mut self) -> bool {
1364 let is_transient = self.popups.last().is_some_and(|p| p.transient);
1365
1366 if is_transient {
1367 self.popups.pop();
1368 true
1369 } else {
1370 false
1371 }
1372 }
1373}
1374
1375impl Default for PopupManager {
1376 fn default() -> Self {
1377 Self::new()
1378 }
1379}
1380
1381fn apply_hyperlink_overlay(
1386 buffer: &mut ratatui::buffer::Buffer,
1387 start_x: u16,
1388 y: u16,
1389 max_x: u16,
1390 text: &str,
1391 url: &str,
1392) {
1393 let mut chunk_index = 0u16;
1394 let mut chars = text.chars();
1395
1396 loop {
1397 let mut chunk = String::new();
1398 for _ in 0..2 {
1399 if let Some(ch) = chars.next() {
1400 chunk.push(ch);
1401 } else {
1402 break;
1403 }
1404 }
1405
1406 if chunk.is_empty() {
1407 break;
1408 }
1409
1410 let x = start_x + chunk_index * 2;
1411 if x >= max_x {
1412 break;
1413 }
1414
1415 let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, chunk);
1416 buffer[(x, y)].set_symbol(&hyperlink);
1417
1418 chunk_index += 1;
1419 }
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424 use super::*;
1425 use crate::view::theme;
1426
1427 #[test]
1428 fn test_popup_list_item() {
1429 let item = PopupListItem::new("test".to_string())
1430 .with_detail("detail".to_string())
1431 .with_icon("📄".to_string());
1432
1433 assert_eq!(item.text, "test");
1434 assert_eq!(item.detail, Some("detail".to_string()));
1435 assert_eq!(item.icon, Some("📄".to_string()));
1436 }
1437
1438 #[test]
1439 fn test_popup_selection() {
1440 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1441 let items = vec![
1442 PopupListItem::new("item1".to_string()),
1443 PopupListItem::new("item2".to_string()),
1444 PopupListItem::new("item3".to_string()),
1445 ];
1446
1447 let mut popup = Popup::list(items, &theme);
1448
1449 assert_eq!(popup.selected_item().unwrap().text, "item1");
1450
1451 popup.select_next();
1452 assert_eq!(popup.selected_item().unwrap().text, "item2");
1453
1454 popup.select_next();
1455 assert_eq!(popup.selected_item().unwrap().text, "item3");
1456
1457 popup.select_next(); assert_eq!(popup.selected_item().unwrap().text, "item3");
1459
1460 popup.select_prev();
1461 assert_eq!(popup.selected_item().unwrap().text, "item2");
1462
1463 popup.select_prev();
1464 assert_eq!(popup.selected_item().unwrap().text, "item1");
1465
1466 popup.select_prev(); assert_eq!(popup.selected_item().unwrap().text, "item1");
1468 }
1469
1470 #[test]
1471 fn test_popup_manager() {
1472 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1473 let mut manager = PopupManager::new();
1474
1475 assert!(!manager.is_visible());
1476 assert_eq!(manager.top(), None);
1477
1478 let popup1 = Popup::text(vec!["test1".to_string()], &theme);
1479 manager.show(popup1);
1480
1481 assert!(manager.is_visible());
1482 assert_eq!(manager.all().len(), 1);
1483
1484 let popup2 = Popup::text(vec!["test2".to_string()], &theme);
1485 manager.show(popup2);
1486
1487 assert_eq!(manager.all().len(), 2);
1488
1489 manager.hide();
1490 assert_eq!(manager.all().len(), 1);
1491
1492 manager.clear();
1493 assert!(!manager.is_visible());
1494 assert_eq!(manager.all().len(), 0);
1495 }
1496
1497 #[test]
1498 fn test_popup_area_calculation() {
1499 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1500 let terminal_area = Rect {
1501 x: 0,
1502 y: 0,
1503 width: 100,
1504 height: 50,
1505 };
1506
1507 let popup = Popup::text(vec!["test".to_string()], &theme)
1508 .with_width(30)
1509 .with_max_height(10);
1510
1511 let popup_centered = popup.clone().with_position(PopupPosition::Centered);
1513 let area = popup_centered.calculate_area(terminal_area, None);
1514 assert_eq!(area.width, 30);
1515 assert_eq!(area.height, 3);
1517 assert_eq!(area.x, (100 - 30) / 2);
1518 assert_eq!(area.y, (50 - 3) / 2);
1519
1520 let popup_below = popup.clone().with_position(PopupPosition::BelowCursor);
1522 let area = popup_below.calculate_area(terminal_area, Some((20, 10)));
1523 assert_eq!(area.x, 20);
1524 assert_eq!(area.y, 11); }
1526
1527 #[test]
1528 fn test_popup_fixed_position_clamping() {
1529 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1530 let terminal_area = Rect {
1531 x: 0,
1532 y: 0,
1533 width: 100,
1534 height: 50,
1535 };
1536
1537 let popup = Popup::text(vec!["test".to_string()], &theme)
1538 .with_width(30)
1539 .with_max_height(10);
1540
1541 let popup_fixed = popup
1543 .clone()
1544 .with_position(PopupPosition::Fixed { x: 10, y: 20 });
1545 let area = popup_fixed.calculate_area(terminal_area, None);
1546 assert_eq!(area.x, 10);
1547 assert_eq!(area.y, 20);
1548
1549 let popup_right_edge = popup
1551 .clone()
1552 .with_position(PopupPosition::Fixed { x: 99, y: 20 });
1553 let area = popup_right_edge.calculate_area(terminal_area, None);
1554 assert_eq!(area.x, 70);
1556 assert_eq!(area.y, 20);
1557
1558 let popup_beyond = popup
1560 .clone()
1561 .with_position(PopupPosition::Fixed { x: 199, y: 20 });
1562 let area = popup_beyond.calculate_area(terminal_area, None);
1563 assert_eq!(area.x, 70);
1565 assert_eq!(area.y, 20);
1566
1567 let popup_bottom = popup
1569 .clone()
1570 .with_position(PopupPosition::Fixed { x: 10, y: 49 });
1571 let area = popup_bottom.calculate_area(terminal_area, None);
1572 assert_eq!(area.x, 10);
1573 assert_eq!(area.y, 47);
1575 }
1576
1577 #[test]
1578 fn test_clamp_rect_to_bounds() {
1579 let bounds = Rect {
1580 x: 0,
1581 y: 0,
1582 width: 100,
1583 height: 50,
1584 };
1585
1586 let rect = Rect {
1588 x: 10,
1589 y: 20,
1590 width: 30,
1591 height: 10,
1592 };
1593 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1594 assert_eq!(clamped, rect);
1595
1596 let rect = Rect {
1598 x: 99,
1599 y: 20,
1600 width: 30,
1601 height: 10,
1602 };
1603 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1604 assert_eq!(clamped.x, 99); assert_eq!(clamped.width, 1); let rect = Rect {
1609 x: 199,
1610 y: 60,
1611 width: 30,
1612 height: 10,
1613 };
1614 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1615 assert_eq!(clamped.x, 99); assert_eq!(clamped.y, 49); assert_eq!(clamped.width, 1); assert_eq!(clamped.height, 1); }
1620
1621 #[test]
1622 fn hyperlink_overlay_chunks_pairs() {
1623 use ratatui::{buffer::Buffer, layout::Rect};
1624
1625 let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1626 buffer[(0, 0)].set_symbol("P");
1627 buffer[(1, 0)].set_symbol("l");
1628 buffer[(2, 0)].set_symbol("a");
1629 buffer[(3, 0)].set_symbol("y");
1630
1631 apply_hyperlink_overlay(&mut buffer, 0, 0, 10, "Play", "https://example.com");
1632
1633 let first = buffer[(0, 0)].symbol().to_string();
1634 let second = buffer[(2, 0)].symbol().to_string();
1635
1636 assert!(
1637 first.contains("Pl"),
1638 "first chunk should contain 'Pl', got {first:?}"
1639 );
1640 assert!(
1641 second.contains("ay"),
1642 "second chunk should contain 'ay', got {second:?}"
1643 );
1644 }
1645
1646 #[test]
1647 fn test_popup_text_selection() {
1648 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1649 let mut popup = Popup::text(
1650 vec![
1651 "Line 0: Hello".to_string(),
1652 "Line 1: World".to_string(),
1653 "Line 2: Test".to_string(),
1654 ],
1655 &theme,
1656 );
1657
1658 assert!(!popup.has_selection());
1660 assert_eq!(popup.get_selected_text(), None);
1661
1662 popup.start_selection(0, 8);
1664 assert!(!popup.has_selection()); popup.extend_selection(1, 8);
1668 assert!(popup.has_selection());
1669
1670 let selected = popup.get_selected_text().unwrap();
1672 assert_eq!(selected, "Hello\nLine 1: ");
1673
1674 popup.clear_selection();
1676 assert!(!popup.has_selection());
1677 assert_eq!(popup.get_selected_text(), None);
1678
1679 popup.start_selection(1, 8);
1681 popup.extend_selection(1, 13); let selected = popup.get_selected_text().unwrap();
1683 assert_eq!(selected, "World");
1684 }
1685
1686 #[test]
1687 fn test_popup_text_selection_contains() {
1688 let sel = PopupTextSelection {
1689 start: (1, 5),
1690 end: (2, 10),
1691 };
1692
1693 assert!(!sel.contains(0, 5));
1695
1696 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));
1709 }
1710
1711 #[test]
1712 fn test_popup_text_selection_normalized() {
1713 let sel = PopupTextSelection {
1715 start: (1, 5),
1716 end: (2, 10),
1717 };
1718 let ((s_line, s_col), (e_line, e_col)) = sel.normalized();
1719 assert_eq!((s_line, s_col), (1, 5));
1720 assert_eq!((e_line, e_col), (2, 10));
1721
1722 let sel_backward = PopupTextSelection {
1724 start: (2, 10),
1725 end: (1, 5),
1726 };
1727 let ((s_line, s_col), (e_line, e_col)) = sel_backward.normalized();
1728 assert_eq!((s_line, s_col), (1, 5));
1729 assert_eq!((e_line, e_col), (2, 10));
1730 }
1731}