1use ratatui::{
2 layout::Rect,
3 style::{Modifier, Style},
4 text::{Line, Span},
5 widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
6 Frame,
7};
8
9use super::markdown::{parse_markdown, wrap_styled_lines, wrap_text_lines, StyledLine};
10
11pub mod input;
12use super::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
13use crate::primitives::grammar::GrammarRegistry;
14
15fn clamp_rect_to_bounds(rect: Rect, bounds: Rect) -> Rect {
18 let x = rect.x.min(bounds.x + bounds.width.saturating_sub(1));
20 let y = rect.y.min(bounds.y + bounds.height.saturating_sub(1));
22
23 let max_width = (bounds.x + bounds.width).saturating_sub(x);
25 let max_height = (bounds.y + bounds.height).saturating_sub(y);
26
27 Rect {
28 x,
29 y,
30 width: rect.width.min(max_width),
31 height: rect.height.min(max_height),
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum PopupPosition {
38 AtCursor,
40 BelowCursor,
42 AboveCursor,
44 Fixed { x: u16, y: u16 },
46 Centered,
48 CenteredOverlay { width_pct: u8, height_pct: u8 },
54 BottomRight,
56 AboveStatusBarAt { x: u16, status_row: u16 },
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum PopupKind {
68 Completion,
70 Hover,
72 Action,
74 List,
76 Text,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Default)]
90pub enum PopupResolver {
91 #[default]
94 None,
95 Completion,
97 LspConfirm { language: String },
101 LspStatus,
104 CodeAction,
108 PluginAction { popup_id: String },
112 RemoteIndicator,
117 WorkspaceTrust,
122 ReadOnly,
126 SettingsSaveError {
130 layer: crate::config_io::ConfigLayer,
131 },
132}
133
134#[derive(Debug, Clone, PartialEq)]
136pub enum PopupContent {
137 Text(Vec<String>),
139 Markdown(Vec<StyledLine>),
141 List {
143 items: Vec<PopupListItem>,
144 selected: usize,
145 },
146 Custom(Vec<String>),
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub struct PopupTextSelection {
153 pub start: (usize, usize),
155 pub end: (usize, usize),
157}
158
159impl PopupTextSelection {
160 pub fn normalized(&self) -> ((usize, usize), (usize, usize)) {
162 if self.start.0 < self.end.0 || (self.start.0 == self.end.0 && self.start.1 <= self.end.1) {
163 (self.start, self.end)
164 } else {
165 (self.end, self.start)
166 }
167 }
168
169 pub fn contains(&self, line: usize, col: usize) -> bool {
171 let ((start_line, start_col), (end_line, end_col)) = self.normalized();
172 if line < start_line || line > end_line {
173 return false;
174 }
175 if line == start_line && line == end_line {
176 col >= start_col && col < end_col
177 } else if line == start_line {
178 col >= start_col
179 } else if line == end_line {
180 col < end_col
181 } else {
182 true
183 }
184 }
185}
186
187#[derive(Debug, Clone, PartialEq)]
189pub struct PopupListItem {
190 pub text: String,
192 pub detail: Option<String>,
194 pub icon: Option<String>,
196 pub data: Option<String>,
198 pub disabled: bool,
200}
201
202impl PopupListItem {
203 pub fn new(text: String) -> Self {
204 Self {
205 text,
206 detail: None,
207 icon: None,
208 data: None,
209 disabled: false,
210 }
211 }
212
213 pub fn with_detail(mut self, detail: String) -> Self {
214 self.detail = Some(detail);
215 self
216 }
217
218 pub fn with_icon(mut self, icon: String) -> Self {
219 self.icon = Some(icon);
220 self
221 }
222
223 pub fn with_data(mut self, data: String) -> Self {
224 self.data = Some(data);
225 self
226 }
227
228 pub fn disabled(mut self) -> Self {
229 self.disabled = true;
230 self
231 }
232}
233
234#[derive(Debug, Clone, PartialEq)]
243pub struct Popup {
244 pub kind: PopupKind,
246
247 pub title: Option<String>,
249
250 pub description: Option<String>,
252
253 pub transient: bool,
255
256 pub content: PopupContent,
258
259 pub position: PopupPosition,
261
262 pub width: u16,
264
265 pub max_height: u16,
267
268 pub bordered: bool,
270
271 pub border_style: Style,
273
274 pub background_style: Style,
276
277 pub scroll_offset: usize,
279
280 pub text_selection: Option<PopupTextSelection>,
282
283 pub accept_key_hint: Option<String>,
285
286 pub resolver: PopupResolver,
289
290 pub focused: bool,
299
300 pub focus_key_hint: Option<String>,
306}
307
308impl Popup {
309 pub fn text(content: Vec<String>, theme: &crate::view::theme::Theme) -> Self {
311 Self {
312 kind: PopupKind::Text,
313 title: None,
314 description: None,
315 transient: false,
316 content: PopupContent::Text(content),
317 position: PopupPosition::AtCursor,
318 width: 50,
319 max_height: 15,
320 bordered: true,
321 border_style: Style::default().fg(theme.popup_border_fg),
322 background_style: Style::default().bg(theme.popup_bg),
323 scroll_offset: 0,
324 text_selection: None,
325 accept_key_hint: None,
326 resolver: PopupResolver::None,
327 focused: false,
328 focus_key_hint: None,
329 }
330 }
331
332 pub fn markdown(
337 markdown_text: &str,
338 theme: &crate::view::theme::Theme,
339 registry: Option<&GrammarRegistry>,
340 ) -> Self {
341 let styled_lines = parse_markdown(markdown_text, theme, registry);
342 Self {
343 kind: PopupKind::Text,
344 title: None,
345 description: None,
346 transient: false,
347 content: PopupContent::Markdown(styled_lines),
348 position: PopupPosition::AtCursor,
349 width: 60, max_height: 20, bordered: true,
352 border_style: Style::default().fg(theme.popup_border_fg),
353 background_style: Style::default().bg(theme.popup_bg),
354 scroll_offset: 0,
355 text_selection: None,
356 accept_key_hint: None,
357 resolver: PopupResolver::None,
358 focused: false,
359 focus_key_hint: None,
360 }
361 }
362
363 pub fn list(items: Vec<PopupListItem>, theme: &crate::view::theme::Theme) -> Self {
365 Self {
366 kind: PopupKind::List,
367 title: None,
368 description: None,
369 transient: false,
370 content: PopupContent::List { items, selected: 0 },
371 position: PopupPosition::AtCursor,
372 width: 50,
373 max_height: 15,
374 bordered: true,
375 border_style: Style::default().fg(theme.popup_border_fg),
376 background_style: Style::default().bg(theme.popup_bg),
377 scroll_offset: 0,
378 text_selection: None,
379 accept_key_hint: None,
380 resolver: PopupResolver::None,
381 focused: false,
382 focus_key_hint: None,
383 }
384 }
385
386 pub fn with_title(mut self, title: String) -> Self {
388 self.title = Some(title);
389 self
390 }
391
392 pub fn with_kind(mut self, kind: PopupKind) -> Self {
394 self.kind = kind;
395 self
396 }
397
398 pub fn with_transient(mut self, transient: bool) -> Self {
400 self.transient = transient;
401 self
402 }
403
404 pub fn with_position(mut self, position: PopupPosition) -> Self {
406 self.position = position;
407 self
408 }
409
410 pub fn with_width(mut self, width: u16) -> Self {
412 self.width = width;
413 self
414 }
415
416 pub fn with_max_height(mut self, max_height: u16) -> Self {
418 self.max_height = max_height;
419 self
420 }
421
422 pub fn with_border_style(mut self, style: Style) -> Self {
424 self.border_style = style;
425 self
426 }
427
428 pub fn with_resolver(mut self, resolver: PopupResolver) -> Self {
431 self.resolver = resolver;
432 self
433 }
434
435 pub fn with_focused(mut self, focused: bool) -> Self {
439 self.focused = focused;
440 self
441 }
442
443 pub fn with_focus_key_hint(mut self, hint: String) -> Self {
446 self.focus_key_hint = Some(hint);
447 self
448 }
449
450 pub fn render_title(&self) -> Option<String> {
458 let hint_label = if !self.focused {
459 let hint = self
460 .focus_key_hint
461 .clone()
462 .unwrap_or_else(|| "Alt+T".to_string());
463 Some(format!("[{} to focus]", hint))
464 } else {
465 None
466 };
467 match (&self.title, hint_label) {
468 (Some(title), Some(hint)) => Some(format!("{} {}", title, hint)),
469 (Some(title), None) => Some(title.clone()),
470 (None, Some(hint)) => Some(hint),
471 (None, None) => None,
472 }
473 }
474
475 pub fn selected_item(&self) -> Option<&PopupListItem> {
477 match &self.content {
478 PopupContent::List { items, selected } => items.get(*selected),
479 _ => None,
480 }
481 }
482
483 fn visible_height(&self) -> usize {
485 let border_offset = if self.bordered { 2 } else { 0 };
486 (self.max_height as usize).saturating_sub(border_offset)
487 }
488
489 pub fn select_next(&mut self) {
491 let visible = self.visible_height();
492 if let PopupContent::List { items, selected } = &mut self.content {
493 if *selected < items.len().saturating_sub(1) {
494 *selected += 1;
495 if *selected >= self.scroll_offset + visible {
497 self.scroll_offset = (*selected + 1).saturating_sub(visible);
498 }
499 }
500 }
501 }
502
503 pub fn select_prev(&mut self) {
505 if let PopupContent::List { items: _, selected } = &mut self.content {
506 if *selected > 0 {
507 *selected -= 1;
508 if *selected < self.scroll_offset {
510 self.scroll_offset = *selected;
511 }
512 }
513 }
514 }
515
516 pub fn select_index(&mut self, index: usize) -> bool {
518 let visible = self.visible_height();
519 if let PopupContent::List { items, selected } = &mut self.content {
520 if index < items.len() {
521 *selected = index;
522 if *selected >= self.scroll_offset + visible {
524 self.scroll_offset = (*selected + 1).saturating_sub(visible);
525 } else if *selected < self.scroll_offset {
526 self.scroll_offset = *selected;
527 }
528 return true;
529 }
530 }
531 false
532 }
533
534 pub fn page_down(&mut self) {
536 let visible = self.visible_height();
537 if let PopupContent::List { items, selected } = &mut self.content {
538 *selected = (*selected + visible).min(items.len().saturating_sub(1));
539 self.scroll_offset = (*selected + 1).saturating_sub(visible);
540 } else {
541 self.scroll_offset += visible;
542 }
543 }
544
545 pub fn page_up(&mut self) {
547 let visible = self.visible_height();
548 if let PopupContent::List { items: _, selected } = &mut self.content {
549 *selected = selected.saturating_sub(visible);
550 self.scroll_offset = *selected;
551 } else {
552 self.scroll_offset = self.scroll_offset.saturating_sub(visible);
553 }
554 }
555
556 pub fn select_first(&mut self) {
558 if let PopupContent::List { items: _, selected } = &mut self.content {
559 *selected = 0;
560 self.scroll_offset = 0;
561 } else {
562 self.scroll_offset = 0;
563 }
564 }
565
566 pub fn select_last(&mut self) {
568 let visible = self.visible_height();
569 if let PopupContent::List { items, selected } = &mut self.content {
570 *selected = items.len().saturating_sub(1);
571 if *selected >= visible {
573 self.scroll_offset = (*selected + 1).saturating_sub(visible);
574 }
575 } else {
576 let content_height = self.item_count();
578 if content_height > visible {
579 self.scroll_offset = content_height.saturating_sub(visible);
580 }
581 }
582 }
583
584 pub fn scroll_by(&mut self, delta: i32) {
587 let content_len = self.wrapped_item_count();
588 let visible = self.visible_height();
589 let max_scroll = content_len.saturating_sub(visible);
590
591 if delta < 0 {
592 self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
594 } else {
595 self.scroll_offset = (self.scroll_offset + delta as usize).min(max_scroll);
597 }
598
599 if let PopupContent::List { items, selected } = &mut self.content {
601 let visible_start = self.scroll_offset;
602 let visible_end = (self.scroll_offset + visible).min(items.len());
603
604 if *selected < visible_start {
605 *selected = visible_start;
606 } else if *selected >= visible_end {
607 *selected = visible_end.saturating_sub(1);
608 }
609 }
610 }
611
612 pub fn item_count(&self) -> usize {
614 match &self.content {
615 PopupContent::Text(lines) => lines.len(),
616 PopupContent::Markdown(lines) => lines.len(),
617 PopupContent::List { items, .. } => items.len(),
618 PopupContent::Custom(lines) => lines.len(),
619 }
620 }
621
622 fn wrapped_item_count(&self) -> usize {
627 let border_width = if self.bordered { 2 } else { 0 };
629 let scrollbar_width = 2; let wrap_width = (self.width as usize)
631 .saturating_sub(border_width)
632 .saturating_sub(scrollbar_width);
633
634 if wrap_width == 0 {
635 return self.item_count();
636 }
637
638 match &self.content {
639 PopupContent::Text(lines) => wrap_text_lines(lines, wrap_width).len(),
640 PopupContent::Markdown(styled_lines) => {
641 wrap_styled_lines(styled_lines, wrap_width).len()
642 }
643 PopupContent::List { items, .. } => items.len(),
645 PopupContent::Custom(lines) => lines.len(),
646 }
647 }
648
649 pub fn start_selection(&mut self, line: usize, col: usize) {
651 self.text_selection = Some(PopupTextSelection {
652 start: (line, col),
653 end: (line, col),
654 });
655 }
656
657 pub fn extend_selection(&mut self, line: usize, col: usize) {
659 if let Some(ref mut sel) = self.text_selection {
660 sel.end = (line, col);
661 }
662 }
663
664 pub fn clear_selection(&mut self) {
666 self.text_selection = None;
667 }
668
669 pub fn has_selection(&self) -> bool {
671 if let Some(sel) = &self.text_selection {
672 sel.start != sel.end
673 } else {
674 false
675 }
676 }
677
678 fn content_wrap_width(&self) -> usize {
681 let border_width: u16 = if self.bordered { 2 } else { 0 };
682 let inner_width = self.width.saturating_sub(border_width);
683 let scrollbar_reserved: u16 = 2;
684 let conservative_width = inner_width.saturating_sub(scrollbar_reserved) as usize;
685
686 if conservative_width == 0 {
687 return 0;
688 }
689
690 let visible_height = self.max_height.saturating_sub(border_width) as usize;
691 let line_count = match &self.content {
692 PopupContent::Text(lines) => wrap_text_lines(lines, conservative_width).len(),
693 PopupContent::Markdown(styled_lines) => {
694 wrap_styled_lines(styled_lines, conservative_width).len()
695 }
696 _ => self.item_count(),
697 };
698
699 let needs_scrollbar = line_count > visible_height && inner_width > scrollbar_reserved;
700
701 if needs_scrollbar {
702 conservative_width
703 } else {
704 inner_width as usize
705 }
706 }
707
708 fn get_text_lines(&self) -> Vec<String> {
713 let wrap_width = self.content_wrap_width();
714
715 match &self.content {
716 PopupContent::Text(lines) => {
717 if wrap_width > 0 {
718 wrap_text_lines(lines, wrap_width)
719 } else {
720 lines.clone()
721 }
722 }
723 PopupContent::Markdown(styled_lines) => {
724 if wrap_width > 0 {
725 wrap_styled_lines(styled_lines, wrap_width)
726 .iter()
727 .map(|sl| sl.plain_text())
728 .collect()
729 } else {
730 styled_lines.iter().map(|sl| sl.plain_text()).collect()
731 }
732 }
733 PopupContent::List { items, .. } => items.iter().map(|i| i.text.clone()).collect(),
734 PopupContent::Custom(lines) => lines.clone(),
735 }
736 }
737
738 pub fn get_selected_text(&self) -> Option<String> {
740 let sel = self.text_selection.as_ref()?;
741 if sel.start == sel.end {
742 return None;
743 }
744
745 let ((start_line, start_col), (end_line, end_col)) = sel.normalized();
746 let lines = self.get_text_lines();
747
748 if start_line >= lines.len() {
749 return None;
750 }
751
752 if start_line == end_line {
753 let line = &lines[start_line];
754 let end_col = end_col.min(line.len());
755 let start_col = start_col.min(end_col);
756 Some(line[start_col..end_col].to_string())
757 } else {
758 let mut result = String::new();
759 let first_line = &lines[start_line];
761 result.push_str(&first_line[start_col.min(first_line.len())..]);
762 result.push('\n');
763 for line in lines.iter().take(end_line).skip(start_line + 1) {
765 result.push_str(line);
766 result.push('\n');
767 }
768 if end_line < lines.len() {
770 let last_line = &lines[end_line];
771 result.push_str(&last_line[..end_col.min(last_line.len())]);
772 }
773 Some(result)
774 }
775 }
776
777 pub fn needs_scrollbar(&self) -> bool {
779 self.item_count() > self.visible_height()
780 }
781
782 pub fn scroll_state(&self) -> (usize, usize, usize) {
784 let total = self.item_count();
785 let visible = self.visible_height();
786 (total, visible, self.scroll_offset)
787 }
788
789 pub fn link_at_position(&self, relative_col: usize, relative_row: usize) -> Option<String> {
795 let PopupContent::Markdown(styled_lines) = &self.content else {
796 return None;
797 };
798
799 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
807 let wrapped_lines = wrap_styled_lines(styled_lines, content_width);
809
810 let line_index = self.scroll_offset + relative_row;
812
813 let line = wrapped_lines.get(line_index)?;
815
816 line.link_at_column(relative_col).map(|s| s.to_string())
818 }
819
820 pub fn description_height(&self) -> u16 {
823 if let Some(desc) = &self.description {
824 let border_width = if self.bordered { 2 } else { 0 };
825 let scrollbar_reserved = 2;
826 let content_width = self
827 .width
828 .saturating_sub(border_width)
829 .saturating_sub(scrollbar_reserved) as usize;
830 let desc_vec = vec![desc.clone()];
831 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
832 wrapped.len() as u16 + 1 } else {
834 0
835 }
836 }
837
838 fn content_height(&self) -> u16 {
840 self.content_height_for_width(self.width)
842 }
843
844 fn content_height_for_width(&self, popup_width: u16) -> u16 {
846 let border_width = if self.bordered { 2 } else { 0 };
848 let scrollbar_reserved = 2; let content_width = popup_width
850 .saturating_sub(border_width)
851 .saturating_sub(scrollbar_reserved) as usize;
852
853 let description_lines = if let Some(desc) = &self.description {
855 let desc_vec = vec![desc.clone()];
856 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
857 wrapped.len() as u16 + 1 } else {
859 0
860 };
861
862 let content_lines = match &self.content {
863 PopupContent::Text(lines) => {
864 wrap_text_lines(lines, content_width).len() as u16
866 }
867 PopupContent::Markdown(styled_lines) => {
868 wrap_styled_lines(styled_lines, content_width).len() as u16
870 }
871 PopupContent::List { items, .. } => items.len() as u16,
872 PopupContent::Custom(lines) => lines.len() as u16,
873 };
874
875 let border_height = if self.bordered { 2 } else { 0 };
877
878 description_lines + content_lines + border_height
879 }
880
881 pub fn calculate_area(&self, terminal_area: Rect, cursor_pos: Option<(u16, u16)>) -> Rect {
883 match self.position {
884 PopupPosition::AtCursor | PopupPosition::BelowCursor | PopupPosition::AboveCursor => {
885 let (cursor_x, cursor_y) = cursor_pos.unwrap_or((
886 terminal_area.x + terminal_area.width / 2,
887 terminal_area.y + terminal_area.height / 2,
888 ));
889
890 let width = self.width.min(terminal_area.width);
891 let height = self
893 .content_height()
894 .min(self.max_height)
895 .min(terminal_area.height);
896
897 let right = terminal_area.x + terminal_area.width;
903 let bottom = terminal_area.y + terminal_area.height;
904
905 let x = if cursor_x + width > right {
906 right.saturating_sub(width)
907 } else {
908 cursor_x
909 }
910 .max(terminal_area.x);
911
912 let y = match self.position {
913 PopupPosition::AtCursor => cursor_y,
914 PopupPosition::BelowCursor => {
915 if cursor_y + 1 + height > bottom {
916 cursor_y.saturating_sub(height)
918 } else {
919 cursor_y + 1
921 }
922 }
923 PopupPosition::AboveCursor => {
924 (cursor_y + 1).saturating_sub(height)
926 }
927 _ => cursor_y,
928 };
929
930 Rect {
931 x,
932 y,
933 width,
934 height,
935 }
936 }
937 PopupPosition::Fixed { x, y } => {
938 let width = self.width.min(terminal_area.width);
939 let height = self
940 .content_height()
941 .min(self.max_height)
942 .min(terminal_area.height);
943 let right = terminal_area.x + terminal_area.width;
946 let bottom = terminal_area.y + terminal_area.height;
947 let x = if x + width > right {
948 right.saturating_sub(width)
949 } else {
950 x
951 };
952 let y = if y + height > bottom {
953 bottom.saturating_sub(height)
954 } else {
955 y
956 };
957 Rect {
958 x,
959 y,
960 width,
961 height,
962 }
963 }
964 PopupPosition::Centered => {
965 let width = self.width.min(terminal_area.width);
966 let height = self
967 .content_height()
968 .min(self.max_height)
969 .min(terminal_area.height);
970 let x = terminal_area.x + (terminal_area.width.saturating_sub(width)) / 2;
971 let y = terminal_area.y + (terminal_area.height.saturating_sub(height)) / 2;
972 Rect {
973 x,
974 y,
975 width,
976 height,
977 }
978 }
979 PopupPosition::CenteredOverlay {
980 width_pct,
981 height_pct,
982 } => {
983 let w_pct = width_pct.clamp(1, 100) as u32;
984 let h_pct = height_pct.clamp(1, 100) as u32;
985 let width = ((terminal_area.width as u32 * w_pct) / 100) as u16;
986 let height = ((terminal_area.height as u32 * h_pct) / 100) as u16;
987 let width = width.max(1).min(terminal_area.width);
988 let height = height.max(1).min(terminal_area.height);
989 let x = terminal_area.x + (terminal_area.width.saturating_sub(width)) / 2;
990 let y = terminal_area.y + (terminal_area.height.saturating_sub(height)) / 2;
991 Rect {
992 x,
993 y,
994 width,
995 height,
996 }
997 }
998 PopupPosition::BottomRight => {
999 let width = self.width.min(terminal_area.width);
1000 let height = self
1001 .content_height()
1002 .min(self.max_height)
1003 .min(terminal_area.height);
1004 let x = terminal_area.x + terminal_area.width.saturating_sub(width);
1009 let y = terminal_area.y
1010 + terminal_area
1011 .height
1012 .saturating_sub(height)
1013 .saturating_sub(2);
1014 Rect {
1015 x,
1016 y,
1017 width,
1018 height,
1019 }
1020 }
1021 PopupPosition::AboveStatusBarAt { x, status_row } => {
1022 let width = self.width.min(terminal_area.width);
1023 let height = self
1024 .content_height()
1025 .min(self.max_height)
1026 .min(terminal_area.height);
1027 let right = terminal_area.x + terminal_area.width;
1034 let max_x = right.saturating_sub(width).saturating_sub(1);
1035 let x = x.min(max_x).max(terminal_area.x);
1036 let y = status_row.saturating_sub(height);
1043 Rect {
1044 x,
1045 y,
1046 width,
1047 height,
1048 }
1049 }
1050 }
1051 }
1052
1053 pub fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::view::theme::Theme) {
1055 self.render_with_hover(frame, area, theme, None);
1056 }
1057
1058 pub fn render_with_hover(
1060 &self,
1061 frame: &mut Frame,
1062 area: Rect,
1063 theme: &crate::view::theme::Theme,
1064 hover_target: Option<&crate::app::HoverTarget>,
1065 ) {
1066 let frame_area = frame.area();
1068 let area = clamp_rect_to_bounds(area, frame_area);
1069
1070 if area.width == 0 || area.height == 0 {
1072 return;
1073 }
1074
1075 frame.render_widget(Clear, area);
1077
1078 let rendered_title = self.render_title();
1079 let block = if self.bordered {
1080 let mut block = Block::default()
1081 .borders(Borders::ALL)
1082 .border_style(self.border_style)
1083 .style(self.background_style);
1084
1085 if let Some(title) = rendered_title.as_deref() {
1086 block = block.title(title);
1087 }
1088
1089 block
1090 } else {
1091 Block::default().style(self.background_style)
1092 };
1093
1094 let inner_area = block.inner(area);
1095 frame.render_widget(block, area);
1096
1097 let dismissible = !matches!(self.resolver, PopupResolver::WorkspaceTrust);
1104 if self.bordered && area.width >= 5 && dismissible {
1105 let close_x = area.x + area.width - 4;
1106 let close_area = Rect {
1107 x: close_x,
1108 y: area.y,
1109 width: 3,
1110 height: 1,
1111 };
1112 frame.render_widget(Paragraph::new("[×]").style(self.border_style), close_area);
1113 }
1114
1115 let content_start_y;
1117 if let Some(desc) = &self.description {
1118 let desc_wrap_width = inner_area.width.saturating_sub(2) as usize; let desc_vec = vec![desc.clone()];
1121 let wrapped_desc = wrap_text_lines(&desc_vec, desc_wrap_width);
1122 let desc_lines: usize = wrapped_desc.len();
1123
1124 for (i, line) in wrapped_desc.iter().enumerate() {
1126 if i >= inner_area.height as usize {
1127 break;
1128 }
1129 let line_area = Rect {
1130 x: inner_area.x,
1131 y: inner_area.y + i as u16,
1132 width: inner_area.width,
1133 height: 1,
1134 };
1135 let desc_style = Style::default().fg(theme.help_separator_fg);
1136 frame.render_widget(Paragraph::new(line.as_str()).style(desc_style), line_area);
1137 }
1138
1139 content_start_y = inner_area.y + (desc_lines as u16).min(inner_area.height) + 1;
1141 } else {
1142 content_start_y = inner_area.y;
1143 }
1144
1145 let inner_area = Rect {
1147 x: inner_area.x,
1148 y: content_start_y,
1149 width: inner_area.width,
1150 height: inner_area
1151 .height
1152 .saturating_sub(content_start_y - area.y - if self.bordered { 1 } else { 0 }),
1153 };
1154
1155 let scrollbar_reserved_width = 2; let wrap_width = inner_area.width.saturating_sub(scrollbar_reserved_width) as usize;
1159 let visible_lines_count = inner_area.height as usize;
1160
1161 let (wrapped_total_lines, needs_scrollbar) = match &self.content {
1163 PopupContent::Text(lines) => {
1164 let wrapped = wrap_text_lines(lines, wrap_width);
1165 let count = wrapped.len();
1166 (
1167 count,
1168 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1169 )
1170 }
1171 PopupContent::Markdown(styled_lines) => {
1172 let wrapped = wrap_styled_lines(styled_lines, wrap_width);
1173 let count = wrapped.len();
1174 (
1175 count,
1176 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1177 )
1178 }
1179 PopupContent::List { items, .. } => {
1180 let count = items.len();
1181 (
1182 count,
1183 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1184 )
1185 }
1186 PopupContent::Custom(lines) => {
1187 let count = lines.len();
1188 (
1189 count,
1190 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1191 )
1192 }
1193 };
1194
1195 let content_area = if needs_scrollbar {
1197 Rect {
1198 x: inner_area.x,
1199 y: inner_area.y,
1200 width: inner_area.width.saturating_sub(scrollbar_reserved_width),
1201 height: inner_area.height,
1202 }
1203 } else {
1204 inner_area
1205 };
1206
1207 match &self.content {
1208 PopupContent::Text(lines) => {
1209 let wrapped_lines = wrap_text_lines(lines, content_area.width as usize);
1211 let selection_style = Style::default().bg(theme.selection_bg);
1212
1213 let visible_lines: Vec<Line> = wrapped_lines
1214 .iter()
1215 .enumerate()
1216 .skip(self.scroll_offset)
1217 .take(content_area.height as usize)
1218 .map(|(line_idx, line)| {
1219 if let Some(ref sel) = self.text_selection {
1220 let chars: Vec<char> = line.chars().collect();
1222 let spans: Vec<Span> = chars
1223 .iter()
1224 .enumerate()
1225 .map(|(col, ch)| {
1226 if sel.contains(line_idx, col) {
1227 Span::styled(ch.to_string(), selection_style)
1228 } else {
1229 Span::raw(ch.to_string())
1230 }
1231 })
1232 .collect();
1233 Line::from(spans)
1234 } else {
1235 Line::from(line.as_str())
1236 }
1237 })
1238 .collect();
1239
1240 let paragraph = Paragraph::new(visible_lines);
1241 frame.render_widget(paragraph, content_area);
1242 }
1243 PopupContent::Markdown(styled_lines) => {
1244 let wrapped_lines = wrap_styled_lines(styled_lines, content_area.width as usize);
1246 let selection_style = Style::default().bg(theme.selection_bg);
1247
1248 let mut link_overlays: Vec<(usize, usize, String, String)> = Vec::new();
1251
1252 let visible_lines: Vec<Line> = wrapped_lines
1253 .iter()
1254 .enumerate()
1255 .skip(self.scroll_offset)
1256 .take(content_area.height as usize)
1257 .map(|(line_idx, styled_line)| {
1258 let mut col = 0usize;
1259 let spans: Vec<Span> = styled_line
1260 .spans
1261 .iter()
1262 .flat_map(|s| {
1263 let span_start_col = col;
1264 let span_width =
1265 unicode_width::UnicodeWidthStr::width(s.text.as_str());
1266 if let Some(url) = &s.link_url {
1267 link_overlays.push((
1268 line_idx - self.scroll_offset,
1269 col,
1270 s.text.clone(),
1271 url.clone(),
1272 ));
1273 }
1274 col += span_width;
1275
1276 if let Some(ref sel) = self.text_selection {
1278 let chars: Vec<char> = s.text.chars().collect();
1280 chars
1281 .iter()
1282 .enumerate()
1283 .map(|(i, ch)| {
1284 let char_col = span_start_col + i;
1285 if sel.contains(line_idx, char_col) {
1286 Span::styled(ch.to_string(), selection_style)
1287 } else {
1288 Span::styled(ch.to_string(), s.style)
1289 }
1290 })
1291 .collect::<Vec<_>>()
1292 } else {
1293 vec![Span::styled(s.text.clone(), s.style)]
1294 }
1295 })
1296 .collect();
1297 Line::from(spans)
1298 })
1299 .collect();
1300
1301 let paragraph = Paragraph::new(visible_lines);
1302 frame.render_widget(paragraph, content_area);
1303
1304 let buffer = frame.buffer_mut();
1306 let max_x = content_area.x + content_area.width;
1307 for (line_idx, col_start, text, url) in link_overlays {
1308 let y = content_area.y + line_idx as u16;
1309 if y >= content_area.y + content_area.height {
1310 continue;
1311 }
1312 let start_x = content_area.x + col_start as u16;
1313 apply_hyperlink_overlay(buffer, start_x, y, max_x, &text, &url);
1314 }
1315 }
1316 PopupContent::List { items, selected } => {
1317 let list_items: Vec<ListItem> = items
1318 .iter()
1319 .enumerate()
1320 .skip(self.scroll_offset)
1321 .take(content_area.height as usize)
1322 .map(|(idx, item)| {
1323 let is_hovered = matches!(
1325 hover_target,
1326 Some(crate::app::HoverTarget::PopupListItem(_, hovered_idx)) if *hovered_idx == idx
1327 );
1328 let is_selected = idx == *selected;
1329
1330 let mut spans = Vec::new();
1331
1332 if let Some(icon) = &item.icon {
1334 spans.push(Span::raw(format!("{} ", icon)));
1335 }
1336
1337 let text = &item.text;
1345 let trimmed = text.trim_start();
1346 let indent_len = text.len() - trimmed.len();
1347 if indent_len > 0 {
1348 spans.push(Span::raw(&text[..indent_len]));
1349 }
1350 let is_clickable = item.data.is_some() && !item.disabled;
1351 let mut text_style = Style::default();
1352 if is_selected {
1353 text_style = text_style.add_modifier(Modifier::BOLD);
1354 }
1355 if is_clickable {
1356 text_style = text_style.add_modifier(Modifier::UNDERLINED);
1357 }
1358 if item.disabled {
1359 text_style = text_style
1360 .fg(theme.help_separator_fg)
1361 .add_modifier(Modifier::DIM);
1362 }
1363 spans.push(Span::styled(trimmed, text_style));
1364
1365 if let Some(detail) = &item.detail {
1367 spans.push(Span::styled(
1368 format!(" {}", detail),
1369 Style::default().fg(theme.help_separator_fg),
1370 ));
1371 }
1372
1373 spans.push(Span::raw(""));
1376
1377 if is_selected {
1379 if let Some(ref hint) = self.accept_key_hint {
1380 let hint_text = format!("({})", hint);
1381 let used_width: usize = spans
1383 .iter()
1384 .map(|s| {
1385 unicode_width::UnicodeWidthStr::width(s.content.as_ref())
1386 })
1387 .sum();
1388 let available = content_area.width as usize;
1389 let hint_len = hint_text.len();
1390 if used_width + hint_len + 1 < available {
1391 let padding = available - used_width - hint_len;
1392 spans.push(Span::raw(" ".repeat(padding)));
1393 spans.push(Span::styled(
1394 hint_text,
1395 Style::default().fg(theme.help_separator_fg),
1396 ));
1397 }
1398 }
1399 }
1400
1401 let row_style = if is_selected {
1410 Style::default()
1411 .fg(theme.popup_selection_fg)
1412 .bg(theme.popup_selection_bg)
1413 } else if is_hovered {
1414 Style::default()
1415 .bg(theme.menu_hover_bg)
1416 .fg(theme.menu_hover_fg)
1417 } else {
1418 Style::default()
1419 .fg(theme.popup_text_fg)
1420 .bg(theme.popup_bg)
1421 };
1422
1423 ListItem::new(Line::from(spans)).style(row_style)
1424 })
1425 .collect();
1426
1427 let list = List::new(list_items);
1428 frame.render_widget(list, content_area);
1429 }
1430 PopupContent::Custom(lines) => {
1431 let visible_lines: Vec<Line> = lines
1432 .iter()
1433 .skip(self.scroll_offset)
1434 .take(content_area.height as usize)
1435 .map(|line| Line::from(line.as_str()))
1436 .collect();
1437
1438 let paragraph = Paragraph::new(visible_lines);
1439 frame.render_widget(paragraph, content_area);
1440 }
1441 }
1442
1443 if needs_scrollbar {
1445 let scrollbar_area = Rect {
1446 x: inner_area.x + inner_area.width - 1,
1447 y: inner_area.y,
1448 width: 1,
1449 height: inner_area.height,
1450 };
1451
1452 let scrollbar_state =
1453 ScrollbarState::new(wrapped_total_lines, visible_lines_count, self.scroll_offset);
1454 let scrollbar_colors = ScrollbarColors::from_theme(theme);
1455 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
1456 }
1457 }
1458}
1459
1460#[derive(Debug, Clone)]
1462pub struct PopupManager {
1463 popups: Vec<Popup>,
1465}
1466
1467impl PopupManager {
1468 pub fn new() -> Self {
1469 Self { popups: Vec::new() }
1470 }
1471
1472 pub fn show(&mut self, popup: Popup) {
1474 self.popups.push(popup);
1475 }
1476
1477 pub fn show_or_replace(&mut self, popup: Popup) {
1481 if let Some(pos) = self.popups.iter().position(|p| p.kind == popup.kind) {
1482 self.popups[pos] = popup;
1483 } else {
1484 self.popups.push(popup);
1485 }
1486 }
1487
1488 pub fn hide(&mut self) -> Option<Popup> {
1490 self.popups.pop()
1491 }
1492
1493 pub fn clear(&mut self) {
1495 self.popups.clear();
1496 }
1497
1498 pub fn top(&self) -> Option<&Popup> {
1500 self.popups.last()
1501 }
1502
1503 pub fn top_mut(&mut self) -> Option<&mut Popup> {
1505 self.popups.last_mut()
1506 }
1507
1508 pub fn get(&self, index: usize) -> Option<&Popup> {
1510 self.popups.get(index)
1511 }
1512
1513 pub fn get_mut(&mut self, index: usize) -> Option<&mut Popup> {
1515 self.popups.get_mut(index)
1516 }
1517
1518 pub fn is_visible(&self) -> bool {
1520 !self.popups.is_empty()
1521 }
1522
1523 pub fn is_completion_popup(&self) -> bool {
1525 self.top()
1526 .map(|p| p.kind == PopupKind::Completion)
1527 .unwrap_or(false)
1528 }
1529
1530 pub fn is_hover_popup(&self) -> bool {
1532 self.top()
1533 .map(|p| p.kind == PopupKind::Hover)
1534 .unwrap_or(false)
1535 }
1536
1537 pub fn is_action_popup(&self) -> bool {
1539 self.top()
1540 .map(|p| p.kind == PopupKind::Action)
1541 .unwrap_or(false)
1542 }
1543
1544 pub fn all(&self) -> &[Popup] {
1546 &self.popups
1547 }
1548
1549 pub fn dismiss_transient(&mut self) -> bool {
1553 let is_transient = self.popups.last().is_some_and(|p| p.transient);
1554
1555 if is_transient {
1556 self.popups.pop();
1557 true
1558 } else {
1559 false
1560 }
1561 }
1562}
1563
1564impl Default for PopupManager {
1565 fn default() -> Self {
1566 Self::new()
1567 }
1568}
1569
1570fn apply_hyperlink_overlay(
1575 buffer: &mut ratatui::buffer::Buffer,
1576 start_x: u16,
1577 y: u16,
1578 max_x: u16,
1579 text: &str,
1580 url: &str,
1581) {
1582 let mut chunk_index = 0u16;
1583 let mut chars = text.chars();
1584
1585 loop {
1586 let mut chunk = String::new();
1587 for _ in 0..2 {
1588 if let Some(ch) = chars.next() {
1589 chunk.push(ch);
1590 } else {
1591 break;
1592 }
1593 }
1594
1595 if chunk.is_empty() {
1596 break;
1597 }
1598
1599 let x = start_x + chunk_index * 2;
1600 if x >= max_x {
1601 break;
1602 }
1603
1604 let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, chunk);
1605 buffer[(x, y)].set_symbol(&hyperlink);
1606
1607 chunk_index += 1;
1608 }
1609}
1610
1611#[cfg(test)]
1612mod tests {
1613 use super::*;
1614 use crate::view::theme;
1615
1616 #[test]
1617 fn test_popup_list_item() {
1618 let item = PopupListItem::new("test".to_string())
1619 .with_detail("detail".to_string())
1620 .with_icon("📄".to_string());
1621
1622 assert_eq!(item.text, "test");
1623 assert_eq!(item.detail, Some("detail".to_string()));
1624 assert_eq!(item.icon, Some("📄".to_string()));
1625 }
1626
1627 #[test]
1628 fn test_popup_selection() {
1629 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1630 let items = vec![
1631 PopupListItem::new("item1".to_string()),
1632 PopupListItem::new("item2".to_string()),
1633 PopupListItem::new("item3".to_string()),
1634 ];
1635
1636 let mut popup = Popup::list(items, &theme);
1637
1638 assert_eq!(popup.selected_item().unwrap().text, "item1");
1639
1640 popup.select_next();
1641 assert_eq!(popup.selected_item().unwrap().text, "item2");
1642
1643 popup.select_next();
1644 assert_eq!(popup.selected_item().unwrap().text, "item3");
1645
1646 popup.select_next(); assert_eq!(popup.selected_item().unwrap().text, "item3");
1648
1649 popup.select_prev();
1650 assert_eq!(popup.selected_item().unwrap().text, "item2");
1651
1652 popup.select_prev();
1653 assert_eq!(popup.selected_item().unwrap().text, "item1");
1654
1655 popup.select_prev(); assert_eq!(popup.selected_item().unwrap().text, "item1");
1657 }
1658
1659 #[test]
1660 fn test_popup_manager() {
1661 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1662 let mut manager = PopupManager::new();
1663
1664 assert!(!manager.is_visible());
1665 assert_eq!(manager.top(), None);
1666
1667 let popup1 = Popup::text(vec!["test1".to_string()], &theme);
1668 manager.show(popup1);
1669
1670 assert!(manager.is_visible());
1671 assert_eq!(manager.all().len(), 1);
1672
1673 let popup2 = Popup::text(vec!["test2".to_string()], &theme);
1674 manager.show(popup2);
1675
1676 assert_eq!(manager.all().len(), 2);
1677
1678 manager.hide();
1679 assert_eq!(manager.all().len(), 1);
1680
1681 manager.clear();
1682 assert!(!manager.is_visible());
1683 assert_eq!(manager.all().len(), 0);
1684 }
1685
1686 #[test]
1687 fn test_popup_area_calculation() {
1688 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1689 let terminal_area = Rect {
1690 x: 0,
1691 y: 0,
1692 width: 100,
1693 height: 50,
1694 };
1695
1696 let popup = Popup::text(vec!["test".to_string()], &theme)
1697 .with_width(30)
1698 .with_max_height(10);
1699
1700 let popup_centered = popup.clone().with_position(PopupPosition::Centered);
1702 let area = popup_centered.calculate_area(terminal_area, None);
1703 assert_eq!(area.width, 30);
1704 assert_eq!(area.height, 3);
1706 assert_eq!(area.x, (100 - 30) / 2);
1707 assert_eq!(area.y, (50 - 3) / 2);
1708
1709 let popup_below = popup.clone().with_position(PopupPosition::BelowCursor);
1711 let area = popup_below.calculate_area(terminal_area, Some((20, 10)));
1712 assert_eq!(area.x, 20);
1713 assert_eq!(area.y, 11); }
1715
1716 #[test]
1722 fn test_popup_area_respects_left_dock_offset() {
1723 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1724 let chrome = Rect {
1726 x: 34,
1727 y: 0,
1728 width: 120 - 34,
1729 height: 40,
1730 };
1731
1732 let popup = Popup::text(vec!["test".to_string()], &theme)
1733 .with_width(30)
1734 .with_max_height(10);
1735
1736 let centered = popup
1738 .clone()
1739 .with_position(PopupPosition::Centered)
1740 .calculate_area(chrome, None);
1741 assert!(centered.x >= chrome.x, "centered popup bleeds under dock");
1742 assert_eq!(centered.x, chrome.x + (chrome.width - 30) / 2);
1743 assert!(centered.x + centered.width <= chrome.x + chrome.width);
1744
1745 let overlay = popup
1747 .clone()
1748 .with_position(PopupPosition::CenteredOverlay {
1749 width_pct: 50,
1750 height_pct: 50,
1751 })
1752 .calculate_area(chrome, None);
1753 assert!(overlay.x >= chrome.x);
1754 assert!(overlay.x + overlay.width <= chrome.x + chrome.width);
1755
1756 let br = popup
1758 .clone()
1759 .with_position(PopupPosition::BottomRight)
1760 .calculate_area(chrome, None);
1761 assert_eq!(br.x + br.width, chrome.x + chrome.width);
1762 assert!(br.x >= chrome.x);
1763
1764 let below = popup
1767 .clone()
1768 .with_position(PopupPosition::BelowCursor)
1769 .calculate_area(chrome, Some((115, 10)));
1770 assert!(below.x >= chrome.x, "cursor popup bleeds under dock");
1771 assert!(below.x + below.width <= chrome.x + chrome.width);
1772 }
1773
1774 #[test]
1775 fn test_popup_fixed_position_clamping() {
1776 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1777 let terminal_area = Rect {
1778 x: 0,
1779 y: 0,
1780 width: 100,
1781 height: 50,
1782 };
1783
1784 let popup = Popup::text(vec!["test".to_string()], &theme)
1785 .with_width(30)
1786 .with_max_height(10);
1787
1788 let popup_fixed = popup
1790 .clone()
1791 .with_position(PopupPosition::Fixed { x: 10, y: 20 });
1792 let area = popup_fixed.calculate_area(terminal_area, None);
1793 assert_eq!(area.x, 10);
1794 assert_eq!(area.y, 20);
1795
1796 let popup_right_edge = popup
1798 .clone()
1799 .with_position(PopupPosition::Fixed { x: 99, y: 20 });
1800 let area = popup_right_edge.calculate_area(terminal_area, None);
1801 assert_eq!(area.x, 70);
1803 assert_eq!(area.y, 20);
1804
1805 let popup_beyond = popup
1807 .clone()
1808 .with_position(PopupPosition::Fixed { x: 199, y: 20 });
1809 let area = popup_beyond.calculate_area(terminal_area, None);
1810 assert_eq!(area.x, 70);
1812 assert_eq!(area.y, 20);
1813
1814 let popup_bottom = popup
1816 .clone()
1817 .with_position(PopupPosition::Fixed { x: 10, y: 49 });
1818 let area = popup_bottom.calculate_area(terminal_area, None);
1819 assert_eq!(area.x, 10);
1820 assert_eq!(area.y, 47);
1822 }
1823
1824 #[test]
1825 fn test_clamp_rect_to_bounds() {
1826 let bounds = Rect {
1827 x: 0,
1828 y: 0,
1829 width: 100,
1830 height: 50,
1831 };
1832
1833 let rect = Rect {
1835 x: 10,
1836 y: 20,
1837 width: 30,
1838 height: 10,
1839 };
1840 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1841 assert_eq!(clamped, rect);
1842
1843 let rect = Rect {
1845 x: 99,
1846 y: 20,
1847 width: 30,
1848 height: 10,
1849 };
1850 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1851 assert_eq!(clamped.x, 99); assert_eq!(clamped.width, 1); let rect = Rect {
1856 x: 199,
1857 y: 60,
1858 width: 30,
1859 height: 10,
1860 };
1861 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1862 assert_eq!(clamped.x, 99); assert_eq!(clamped.y, 49); assert_eq!(clamped.width, 1); assert_eq!(clamped.height, 1); }
1867
1868 #[test]
1869 fn hyperlink_overlay_chunks_pairs() {
1870 use ratatui::{buffer::Buffer, layout::Rect};
1871
1872 let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1873 buffer[(0, 0)].set_symbol("P");
1874 buffer[(1, 0)].set_symbol("l");
1875 buffer[(2, 0)].set_symbol("a");
1876 buffer[(3, 0)].set_symbol("y");
1877
1878 apply_hyperlink_overlay(&mut buffer, 0, 0, 10, "Play", "https://example.com");
1879
1880 let first = buffer[(0, 0)].symbol().to_string();
1881 let second = buffer[(2, 0)].symbol().to_string();
1882
1883 assert!(
1884 first.contains("Pl"),
1885 "first chunk should contain 'Pl', got {first:?}"
1886 );
1887 assert!(
1888 second.contains("ay"),
1889 "second chunk should contain 'ay', got {second:?}"
1890 );
1891 }
1892
1893 #[test]
1894 fn test_popup_text_selection() {
1895 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1896 let mut popup = Popup::text(
1897 vec![
1898 "Line 0: Hello".to_string(),
1899 "Line 1: World".to_string(),
1900 "Line 2: Test".to_string(),
1901 ],
1902 &theme,
1903 );
1904
1905 assert!(!popup.has_selection());
1907 assert_eq!(popup.get_selected_text(), None);
1908
1909 popup.start_selection(0, 8);
1911 assert!(!popup.has_selection()); popup.extend_selection(1, 8);
1915 assert!(popup.has_selection());
1916
1917 let selected = popup.get_selected_text().unwrap();
1919 assert_eq!(selected, "Hello\nLine 1: ");
1920
1921 popup.clear_selection();
1923 assert!(!popup.has_selection());
1924 assert_eq!(popup.get_selected_text(), None);
1925
1926 popup.start_selection(1, 8);
1928 popup.extend_selection(1, 13); let selected = popup.get_selected_text().unwrap();
1930 assert_eq!(selected, "World");
1931 }
1932
1933 #[test]
1934 fn test_popup_text_selection_contains() {
1935 let sel = PopupTextSelection {
1936 start: (1, 5),
1937 end: (2, 10),
1938 };
1939
1940 assert!(!sel.contains(0, 5));
1942
1943 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));
1956 }
1957
1958 #[test]
1959 fn test_popup_text_selection_normalized() {
1960 let sel = PopupTextSelection {
1962 start: (1, 5),
1963 end: (2, 10),
1964 };
1965 let ((s_line, s_col), (e_line, e_col)) = sel.normalized();
1966 assert_eq!((s_line, s_col), (1, 5));
1967 assert_eq!((e_line, e_col), (2, 10));
1968
1969 let sel_backward = PopupTextSelection {
1971 start: (2, 10),
1972 end: (1, 5),
1973 };
1974 let ((s_line, s_col), (e_line, e_col)) = sel_backward.normalized();
1975 assert_eq!((s_line, s_col), (1, 5));
1976 assert_eq!((e_line, e_col), (2, 10));
1977 }
1978}