| 1 | //! Acceptance of the adopted mounted composer against its frozen painter. |
| 2 | use super::*; |
| 3 | use crate::config::Config; |
| 4 | use std::path::PathBuf; |
| 5 | |
| 6 | fn app() -> App { |
| 7 | let mut app = App::new( |
| 8 | crate::test_support::test_tui_options(PathBuf::from(".")), |
| 9 | &Config::default(), |
| 10 | ); |
| 11 | app.launch.visible = false; |
| 12 | app.composer.vim_enabled = false; |
| 13 | app.ui_locale = Locale::En; |
| 14 | app.theme_id = palette::ThemeId::Underwater; |
| 15 | app.ui_theme = palette::UNDERWATER_UI_THEME; |
| 16 | app |
| 17 | } |
| 18 | #[test] |
| 19 | fn mounted_kit_exact_buffer_caret_height_and_menu_facts_match_legacy() { |
| 20 | for &locale in Locale::shipped() { |
| 21 | for case in 0..8 { |
| 22 | let mut app = app(); |
| 23 | app.ui_locale = locale; |
| 24 | app.input = match case { |
| 25 | 0 => "", |
| 26 | 1 => "hello", |
| 27 | 2 => "a CJK 鲸鱼 draft with cafe\u{0301} and a long https://example.com/path", |
| 28 | 3 => "1\u{fe0f}\u{20e3} 👩\u{200d}💻\n\nlast row", |
| 29 | 4 => "select these words across rows", |
| 30 | 5 => "/test", |
| 31 | 6 => "@builder", |
| 32 | _ => "", |
| 33 | } |
| 34 | .into(); |
| 35 | app.cursor_position = app.input.chars().count(); |
| 36 | if case == 4 { |
| 37 | app.selection_anchor = Some(2); |
| 38 | } |
| 39 | if case == 7 { |
| 40 | app.start_history_search(); |
| 41 | } |
| 42 | let slash = if case == 5 { |
| 43 | vec![ |
| 44 | SlashMenuEntry { |
| 45 | name: "/test".into(), |
| 46 | description: "Run checks for this project".into(), |
| 47 | is_skill: false, |
| 48 | alias_hint: Some("jiancha".into()), |
| 49 | }, |
| 50 | SlashMenuEntry { |
| 51 | name: "/review".into(), |
| 52 | description: "Review every changed source file".into(), |
| 53 | is_skill: true, |
| 54 | alias_hint: None, |
| 55 | }, |
| 56 | ] |
| 57 | } else { |
| 58 | Vec::new() |
| 59 | }; |
| 60 | let mentions = if case == 6 { |
| 61 | vec!["builder".into(), "reviewer 鲸鱼".into()] |
| 62 | } else { |
| 63 | Vec::new() |
| 64 | }; |
| 65 | for density in [ |
| 66 | ComposerDensity::Compact, |
| 67 | ComposerDensity::Comfortable, |
| 68 | ComposerDensity::Spacious, |
| 69 | ] { |
| 70 | app.composer_density = density; |
| 71 | for enclosed in [false, true] { |
| 72 | app.composer_border = enclosed; |
| 73 | for width in [20, 40, 80, 120] { |
| 74 | for height in [3, 5, 8, 12] { |
| 75 | let area = Rect::new(7, 5, width, height); |
| 76 | let canvas = Rect::new(2, 3, width + 12, height + 8); |
| 77 | let mut actual = Buffer::empty(canvas); |
| 78 | for cell in &mut actual.content { |
| 79 | cell.set_symbol("~") |
| 80 | .set_style(Style::default().bg(Color::Rgb(11, 23, 37))); |
| 81 | } |
| 82 | let mut expected = actual.clone(); |
| 83 | let legacy = |
| 84 | legacy_composer::ComposerWidget::new(&app, 12, &slash, &mentions); |
| 85 | let widget = ComposerWidget::new(&app, 12, &slash, &mentions); |
| 86 | assert_eq!(widget.desired_height(width), legacy.desired_height(width)); |
| 87 | assert_eq!(widget.cursor_pos(area), legacy.cursor_pos(area)); |
| 88 | assert_eq!( |
| 89 | active_composer_submit_rect(&app, area), |
| 90 | legacy_composer::active_composer_submit_rect(&app, area) |
| 91 | ); |
| 92 | assert_eq!( |
| 93 | wrap_input_lines_for_mouse(&app.input, usize::from(width)), |
| 94 | legacy_composer::wrap_input_lines_for_mouse( |
| 95 | &app.input, |
| 96 | usize::from(width) |
| 97 | ) |
| 98 | ); |
| 99 | legacy.render(area, &mut expected); |
| 100 | let old_boxes = app.viewport.last_slash_menu_hitboxes.borrow().clone(); |
| 101 | widget.render(area, &mut actual); |
| 102 | assert_eq!( |
| 103 | actual, expected, |
| 104 | "locale={locale:?} case={case} density={density:?} enclosed={enclosed} area={area:?}" |
| 105 | ); |
| 106 | let plan = widget.plan(area); |
| 107 | let boxes = app.viewport.last_slash_menu_hitboxes.borrow().clone(); |
| 108 | assert_eq!(boxes, plan.menu_rects); |
| 109 | // Full wrapped option rows intentionally replace old one-row targets. |
| 110 | if boxes.iter().all(|(_, rect)| rect.height == 1) { |
| 111 | assert_eq!(boxes, old_boxes); |
| 112 | } |
| 113 | for (_, rect) in boxes { |
| 114 | assert_eq!(rect.intersection(plan.geometry.inner), rect); |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 | } |
| 123 | #[test] |
| 124 | fn mounted_kit_live_custom_theme_slots_remain_exact() { |
| 125 | let mut app = app(); |
| 126 | app.input = "select words".into(); |
| 127 | app.cursor_position = 6; |
| 128 | app.selection_anchor = Some(0); |
| 129 | app.composer_border = true; |
| 130 | app.ui_theme.composer_bg = Color::Rgb(9, 21, 33); |
| 131 | app.ui_theme.accent_primary = Color::Rgb(41, 52, 63); |
| 132 | app.ui_theme.selection_bg = Color::Rgb(71, 82, 93); |
| 133 | app.ui_theme.info = Color::Rgb(101, 112, 123); |
| 134 | let widget = ComposerWidget::new(&app, 9, &[], &[]); |
| 135 | let area = Rect::new(7, 5, 80, 7); |
| 136 | let mut buf = Buffer::empty(area); |
| 137 | widget.render(area, &mut buf); |
| 138 | let plan = widget.plan(area); |
| 139 | assert_eq!(buf[(area.x, area.y)].fg, app.ui_theme.accent_primary); |
| 140 | assert_eq!( |
| 141 | buf[(plan.geometry.text.x, plan.cursor.unwrap().y)].bg, |
| 142 | app.ui_theme.selection_bg |
| 143 | ); |
| 144 | let submit = plan.geometry.submit.unwrap(); |
| 145 | assert_eq!( |
| 146 | buf[(submit.x, submit.y)].fg, |
| 147 | palette::chrome_style(&app.ui_theme, palette::ChromeInk::Info) |
| 148 | .fg |
| 149 | .unwrap() |
| 150 | ); |
| 151 | for y in area.y..area.bottom() { |
| 152 | for x in area.x..area.right() { |
| 153 | assert_eq!( |
| 154 | buf[(x, y)].bg, |
| 155 | if x >= plan.geometry.text.x |
| 156 | && x < plan.cursor.unwrap().x |
| 157 | && y == plan.cursor.unwrap().y |
| 158 | { |
| 159 | app.ui_theme.selection_bg |
| 160 | } else { |
| 161 | app.ui_theme.composer_bg |
| 162 | } |
| 163 | ); |
| 164 | } |
| 165 | } |
| 166 | } |
| 167 | #[test] |
| 168 | fn mounted_kit_shared_centered_padding_drives_source_pointer_projection() { |
| 169 | let mut app = app(); |
| 170 | app.composer_border = true; |
| 171 | app.input = "hello".into(); |
| 172 | app.cursor_position = 2; |
| 173 | let widget = ComposerWidget::new(&app, 9, &[], &[]); |
| 174 | let plan = widget.plan(Rect::new(7, 5, 40, 7)); |
| 175 | assert_eq!(plan.top_padding, 2); |
| 176 | let cursor = plan.cursor.unwrap(); |
| 177 | let index = codewhale_ratatui::native_composer_source_at( |
| 178 | &app.input, |
| 179 | usize::from(plan.geometry.text.width), |
| 180 | usize::from(cursor.x - plan.geometry.text.x), |
| 181 | usize::from(cursor.y - plan.geometry.text.y), |
| 182 | plan.scroll_offset, |
| 183 | plan.top_padding, |
| 184 | ); |
| 185 | assert_eq!( |
| 186 | index, app.cursor_position, |
| 187 | "viewport must retain the painter's centered padding, not bottom padding" |
| 188 | ); |
| 189 | app.input = "a\t\u{202e}中b".into(); |
| 190 | app.cursor_position = 4; |
| 191 | let plan = ComposerWidget::new(&app, 9, &[], &[]).plan(Rect::new(7, 5, 40, 7)); |
| 192 | let cursor = plan.cursor.unwrap(); |
| 193 | assert_eq!( |
| 194 | codewhale_ratatui::native_composer_source_at( |
| 195 | &app.input, |
| 196 | usize::from(plan.geometry.text.width), |
| 197 | usize::from(cursor.x - plan.geometry.text.x), |
| 198 | usize::from(cursor.y - plan.geometry.text.y), |
| 199 | plan.scroll_offset, |
| 200 | plan.top_padding |
| 201 | ), |
| 202 | 4 |
| 203 | ); |
| 204 | assert_eq!(app.input, "a\t\u{202e}中b"); |
| 205 | } |
| 206 | #[test] |
| 207 | fn mounted_kit_resize_to_empty_withdraws_slash_targets() { |
| 208 | let mut app = app(); |
| 209 | app.input = "/test".into(); |
| 210 | app.cursor_position = 5; |
| 211 | let slash = vec![SlashMenuEntry { |
| 212 | name: "/test".into(), |
| 213 | description: "test project".into(), |
| 214 | is_skill: false, |
| 215 | alias_hint: None, |
| 216 | }]; |
| 217 | let widget = ComposerWidget::new(&app, 9, &slash, &[]); |
| 218 | let area = Rect::new(7, 5, 80, 9); |
| 219 | let mut buf = Buffer::empty(area); |
| 220 | widget.render(area, &mut buf); |
| 221 | assert!(!app.viewport.last_slash_menu_hitboxes.borrow().is_empty()); |
| 222 | for zero in [Rect::new(7, 5, 0, 9), Rect::new(7, 5, 80, 0)] { |
| 223 | widget.render(zero, &mut buf); |
| 224 | assert!(app.viewport.last_slash_menu_hitboxes.borrow().is_empty()); |
| 225 | assert!(widget.cursor_pos(zero).is_none()); |
| 226 | assert!(active_composer_submit_rect(&app, zero).is_none()); |
| 227 | } |
| 228 | } |
| 229 |