返回 CodeWhale
composer_legacy.rs
根目录 / crates / tui / src / tui / widgets / composer_legacy.rs
1 // Frozen mounted composer source fragments; private test counterpart only.
2 use super::*;
3
4 const COMPOSER_PROMPT_GUTTER_WIDTH: u16 = 2;
5 const COMPOSER_PANEL_MIN_WIDTH: u16 = 12;
6
7 /// Whether the active composer should use its full rounded enclosure.
8 ///
9 /// `composer_border` is a legacy configuration name, but its compatibility
10 /// policy is deliberate: the default `true` means the Tideline enclosure;
11 /// `false` is an explicit compact/quiet opt-out. Keep every layout consumer
12 /// behind this helper so the reserved floor, measured height, and rendered
13 /// geometry cannot drift apart.
14 #[must_use]
15 pub(crate) fn composer_enclosure_enabled(app: &App) -> bool {
16 app.composer_border
17 }
18
19 /// Shared `[↵]` submit rect for the live composer, or `None` when the
20 /// enclosure cannot host the three-cell affordance.
21 ///
22 /// The gate is the same `enclosed_composer_panel_fits` predicate the painter
23 /// uses: a hitbox without the painted panel would be an invisible click
24 /// target (widths 6–11 rendered a borderless rule while still accepting
25 /// clicks).
26 #[must_use]
27 pub(crate) fn active_composer_submit_rect(app: &App, area: Rect) -> Option<Rect> {
28 if !enclosed_composer_panel_fits(composer_enclosure_enabled(app), area.width, area.height) {
29 return None;
30 }
31 Some(crate::tui::composer_chrome::tideline_composer_geometry(area).submit)
32 }
33
34 /// Restore rounded corners after the title-bearing top/bottom passes.
35 ///
36 /// Ratatui renders a `TOP`-only (or `BOTTOM`-only) block through the corner
37 /// cells as horizontal line glyphs. The live composer needs those passes for
38 /// its localized titles and shared focus outline, so put
39 /// the four rounded joins back afterward rather than replacing its mature
40 /// input widget with the unfinished translation scaffold.
41 fn render_composer_panel_corners(
42 area: Rect,
43 buf: &mut Buffer,
44 background: Style,
45 permission_color: Color,
46 mode_color: Color,
47 ) {
48 let top_style = background.fg(permission_color);
49 let bottom_style = background.fg(mode_color);
50 let left = area.left();
51 let right = area.right().saturating_sub(1);
52 let top = area.top();
53 let bottom = area.bottom().saturating_sub(1);
54
55 buf[(left, top)].set_symbol("╭").set_style(top_style);
56 buf[(right, top)].set_symbol("╮").set_style(top_style);
57 buf[(left, bottom)].set_symbol("╰").set_style(bottom_style);
58 buf[(right, bottom)].set_symbol("╯").set_style(bottom_style);
59 }
60
61 /// Whether the outer composer rect can carry both semantic border rows.
62 ///
63 /// Keep this policy in outer-area coordinates. Input wrapping subtracts the
64 /// prompt gutter later; using that narrower text width here made 12- and
65 /// 13-column composers render as panels after reserving only the quiet rule.
66 fn enclosed_composer_panel_fits(show_panel: bool, area_width: u16, area_height: u16) -> bool {
67 show_panel && area_height >= 3 && area_width >= COMPOSER_PANEL_MIN_WIDTH
68 }
69
70 /// Border-aware input plane for the active composer.
71 ///
72 /// The shared shell's `[↵]` control occupies three cells on the inner row.
73 /// Keep the text plane to its left, with one blank cell in between, so input
74 /// wrapping, cursor placement, and pointer mapping cannot claim painted send
75 /// cells. The outer block still owns the trailing breathing cell before its
76 /// right rail.
77 fn composer_inner_area(area: Rect, has_panel: bool) -> Rect {
78 let inner = if has_panel {
79 Block::default()
80 .borders(Borders::ALL)
81 .border_type(BorderType::Rounded)
82 .inner(area)
83 } else if area.height >= 2 {
84 Block::default().borders(Borders::TOP).inner(area)
85 } else {
86 area
87 };
88 if !has_panel {
89 return inner;
90 }
91
92 let shell = crate::tui::composer_chrome::tideline_composer_geometry(area);
93 Rect {
94 width: shell.content.right().saturating_sub(inner.x),
95 ..inner
96 }
97 }
98
99 /// Canonical horizontal geometry for composer input text.
100 ///
101 /// The prompt glyph occupies the first gutter column and the second column is
102 /// breathing room. Every consumer that wraps or maps input must use
103 /// `text_area`: rendering and cursor placement, viewport scroll bookkeeping,
104 /// and mouse hit-to-character conversion. Keeping the inset here prevents the
105 /// first typed character and exact wrap boundaries from using different
106 /// effective widths.
107 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
108 pub(crate) struct ComposerContentGeometry {
109 pub(crate) text_area: Rect,
110 pub(crate) prompt_inset: u16,
111 }
112
113 impl ComposerContentGeometry {
114 #[must_use]
115 pub(crate) fn text_width(self) -> usize {
116 usize::from(self.text_area.width.max(1))
117 }
118
119 #[must_use]
120 fn prompt_padding(self) -> &'static str {
121 if self.prompt_inset == COMPOSER_PROMPT_GUTTER_WIDTH {
122 " "
123 } else {
124 ""
125 }
126 }
127
128 #[must_use]
129 fn prompt_x(self) -> Option<u16> {
130 (self.prompt_inset > 0).then(|| self.text_area.x.saturating_sub(self.prompt_inset))
131 }
132 }
133
134 #[must_use]
135 pub(crate) fn composer_content_geometry(
136 inner_area: Rect,
137 history_search_active: bool,
138 ) -> ComposerContentGeometry {
139 let prompt_inset = if !history_search_active
140 && inner_area.width >= COMPOSER_PROMPT_GUTTER_WIDTH.saturating_add(1)
141 {
142 COMPOSER_PROMPT_GUTTER_WIDTH
143 } else {
144 0
145 };
146 ComposerContentGeometry {
147 text_area: Rect {
148 x: inner_area.x.saturating_add(prompt_inset),
149 y: inner_area.y,
150 width: inner_area.width.saturating_sub(prompt_inset),
151 height: inner_area.height,
152 },
153 prompt_inset,
154 }
155 }
156
157 pub struct ComposerWidget<'a> {
158 app: &'a App,
159 max_height: u16,
160 slash_menu_entries: &'a [SlashMenuEntry],
161 mention_menu_entries: &'a [String],
162 }
163
164 impl<'a> ComposerWidget<'a> {
165 pub fn new(
166 app: &'a App,
167 max_height: u16,
168 slash_menu_entries: &'a [SlashMenuEntry],
169 mention_menu_entries: &'a [String],
170 ) -> Self {
171 Self {
172 app,
173 max_height,
174 slash_menu_entries,
175 mention_menu_entries,
176 }
177 }
178
179 /// Number of popup rows below the input. Mention and slash menus are
180 /// mutually exclusive — the cursor can only sit inside an `@token` OR
181 /// a `/cmd` token, not both at once. Mention takes precedence because
182 /// the partial-mention check is positional and stricter than slash's
183 /// "starts-with-/" check.
184 fn active_menu_row_count(&self) -> usize {
185 if self.app.is_history_search_active() {
186 self.app.history_search_matches().len().max(1)
187 } else if !self.mention_menu_entries.is_empty() {
188 self.mention_menu_entries.len()
189 } else {
190 self.slash_menu_entries.len()
191 }
192 }
193
194 /// Row reservation passed to `composer_height`. When the slash- or
195 /// mention-menu is active we lock the composer to its worst-case
196 /// envelope so the chat area above doesn't repaint every keystroke
197 /// as the matched-entry count shrinks. Pure cosmetic: the menu
198 /// itself still renders its actual entries — the extra rows are
199 /// just panel padding inside the same Rect.
200 ///
201 /// Reported on Windows 10 PowerShell + WSL where the console
202 /// backend's per-cell write cost makes the layout jitter visible
203 /// even though the work is tiny on Unix terminals. See user
204 /// feedback in v0.8.8 polish thread.
205 pub fn active_menu_reserved_rows(&self) -> usize {
206 let actual = self.active_menu_row_count();
207 if actual == 0 {
208 return 0;
209 }
210 if self.app.is_history_search_active() {
211 return actual;
212 }
213 // Slash- and mention-menu are the cases that grow/shrink mid-typing.
214 // Reserve the composer's panel-max so the layout stays stable
215 // for the lifetime of the menu session.
216 actual.max(usize::from(self.max_height_cap()))
217 }
218
219 fn wants_enclosed_panel(&self) -> bool {
220 composer_enclosure_enabled(self.app)
221 }
222
223 pub(crate) fn has_panel(&self, area: Rect) -> bool {
224 enclosed_composer_panel_fits(self.wants_enclosed_panel(), area.width, area.height)
225 }
226
227 /// The border- and submit-aware input rectangle shared by rendering,
228 /// cursor mapping, and the frame's persistent mouse geometry.
229 pub(crate) fn inner_area(&self, area: Rect) -> Rect {
230 composer_inner_area(area, self.has_panel(area))
231 }
232
233 fn focus_color(&self) -> Color {
234 use crate::tui::shell_key_routing::Focus;
235 let editing = match self.app.focus() {
236 Focus::Composer => true,
237 Focus::Launch => self.app.launch.menu_selected.is_none(),
238 _ => false,
239 };
240 if editing {
241 self.app.ui_theme.accent_primary
242 } else {
243 self.app.ui_theme.border
244 }
245 }
246
247 fn max_height_cap(&self) -> u16 {
248 composer_max_height(self.app.composer_density)
249 }
250 }
251
252 impl Renderable for ComposerWidget<'_> {
253 fn render(&self, area: Rect, buf: &mut Buffer) {
254 // Slash rows are re-recorded below; clear first so a closed or
255 // resized menu cannot keep stale hitboxes from the prior frame.
256 self.app
257 .viewport
258 .last_slash_menu_hitboxes
259 .borrow_mut()
260 .clear();
261 let background = Style::default().bg(self.app.ui_theme.composer_bg);
262 let has_panel = self.has_panel(area);
263 let inner_area = self.inner_area(area);
264 let input_text = self.app.composer_display_input();
265 let input_cursor = self.app.composer_display_cursor();
266 let history_search_matches = if self.app.is_history_search_active() {
267 self.app.history_search_matches()
268 } else {
269 Vec::new()
270 };
271 let menu_lines = self.active_menu_row_count();
272 // For the layout-budget calculation, treat the menu as if it were
273 // already at its locked, worst-case height (see
274 // `active_menu_reserved_rows`). Without this, when the matched-entry
275 // count drops mid-typing, `top_padding` grows and the input visually
276 // jumps down inside the panel even though the panel rect stayed put.
277 let menu_lines_for_budget = self.active_menu_reserved_rows().max(menu_lines);
278 let input_rows_budget =
279 composer_input_rows_budget(inner_area.height, menu_lines_for_budget);
280 // Menu rows span the full inner panel. Input text alone uses the
281 // prompt-adjusted geometry below.
282 let content_width = usize::from(inner_area.width.max(1));
283 let content_geometry =
284 composer_content_geometry(inner_area, self.app.is_history_search_active());
285 let input_content_width = content_geometry.text_width();
286
287 // Use the extended version that also returns character indices to avoid
288 // redundant wrapping when rendering text selections (issue #3909).
289 let (visible_lines, _cursor_row, _cursor_col, _scroll_offset, visible_char_indices) =
290 layout_input_with_scroll_and_char_indices(
291 input_text,
292 input_cursor,
293 input_content_width,
294 input_rows_budget,
295 );
296 if has_panel {
297 let hint_line = if self.app.is_history_search_active() {
298 Some(Line::from(vec![
299 Span::styled(
300 format!(
301 " {} ",
302 self.app
303 .tr(codewhale_localization::MessageId::HistoryHintMove)
304 ),
305 Style::default().fg(palette::TEXT_MUTED),
306 ),
307 Span::styled(
308 format!(
309 "{} ",
310 self.app
311 .tr(codewhale_localization::MessageId::HistoryHintAccept)
312 ),
313 Style::default().fg(palette::TEXT_MUTED),
314 ),
315 Span::styled(
316 self.app
317 .tr(codewhale_localization::MessageId::HistoryHintRestore),
318 Style::default().fg(palette::TEXT_MUTED),
319 ),
320 ]))
321 } else if !self.slash_menu_entries.is_empty() {
322 Some(Line::from(Span::styled(
323 self.app
324 .tr(codewhale_localization::MessageId::ComposerSlashMenuHint),
325 Style::default().fg(self.app.ui_theme.text_hint),
326 )))
327 } else if !input_text.trim().is_empty() {
328 composer_submit_hint(self.app).map(|hint| {
329 Line::from(vec![Span::styled(
330 format!(" {} ", hint.text),
331 Style::default().fg(hint.color),
332 )])
333 })
334 } else {
335 None
336 };
337
338 // Focus has one outline. Permission and mode remain explicit in
339 // their footer; repeating both around the input competes with it.
340 let focus_color = self.focus_color();
341 Block::default()
342 .borders(Borders::ALL)
343 .border_type(BorderType::Rounded)
344 .border_style(Style::default().fg(focus_color))
345 .style(background)
346 .render(area, buf);
347 let mut top_border = Block::default()
348 .borders(Borders::TOP)
349 .border_type(BorderType::Rounded)
350 .border_style(Style::default().fg(focus_color))
351 .style(background);
352 if self.app.is_history_search_active() {
353 top_border = top_border.title(Line::from(Span::styled(
354 format!(
355 " {} ",
356 self.app
357 .tr(codewhale_localization::MessageId::HistorySearchTitle)
358 ),
359 Style::default().fg(palette::TEXT_MUTED),
360 )));
361 }
362 // Agent focus chip: the composer names the fork it addresses so
363 // a message never goes to a worker by surprise.
364 if let Some(chip) = crate::tui::agent_focus::composer_chip_text(self.app) {
365 top_border = top_border.title_top(
366 Line::from(Span::styled(
367 format!(" {chip} "),
368 Style::default()
369 .fg(self.app.ui_theme.accent_action)
370 .add_modifier(Modifier::BOLD),
371 ))
372 .right_aligned(),
373 );
374 }
375 top_border.render(area, buf);
376
377 let mut bottom_border = Block::default()
378 .borders(Borders::BOTTOM)
379 .border_type(BorderType::Rounded)
380 .border_style(Style::default().fg(focus_color))
381 .style(background);
382 if let Some(hint_line) = hint_line {
383 bottom_border = bottom_border.title_bottom(hint_line);
384 }
385 bottom_border.render(area, buf);
386 render_composer_panel_corners(area, buf, background, focus_color, focus_color);
387 } else if area.height >= 2 {
388 let mut block = Block::default()
389 .borders(Borders::TOP)
390 .border_style(Style::default().fg(self.app.ui_theme.border))
391 .style(background);
392 if !input_text.trim().is_empty()
393 && let Some(hint) = composer_submit_hint(self.app)
394 {
395 block = block.title(Line::from(Span::styled(
396 format!(" {} ", hint.text),
397 Style::default().fg(hint.color),
398 )));
399 }
400 if let Some(chip) = crate::tui::agent_focus::composer_chip_text(self.app) {
401 block = block.title_top(
402 Line::from(Span::styled(
403 format!(" {chip} "),
404 Style::default()
405 .fg(self.app.ui_theme.accent_action)
406 .add_modifier(Modifier::BOLD),
407 ))
408 .right_aligned(),
409 );
410 }
411 block.render(area, buf);
412 } else {
413 Block::default().style(background).render(area, buf);
414 }
415
416 let mut input_lines = Vec::new();
417 if input_text.is_empty() {
418 let (placeholder, style): (Cow<'_, str>, Style) = if let Some(ref suggestion) =
419 self.app.prompt_suggestion
420 && !self.app.is_history_search_active()
421 {
422 (
423 Cow::Borrowed(suggestion.as_str()),
424 Style::default().fg(palette::TEXT_HINT),
425 )
426 } else {
427 (
428 composer_empty_hint_text(self.app),
429 Style::default().fg(self.app.ui_theme.text_soft),
430 )
431 };
432 input_lines.push(Line::from(vec![
433 Span::raw(content_geometry.prompt_padding()),
434 Span::styled(placeholder, style),
435 ]));
436 } else if let Some((sel_start, sel_end)) = self.app.selection_range() {
437 // Use the character indices we already computed during layout
438 // to avoid redundant wrapping (issue #3909).
439 let line_ranges: Vec<(usize, usize)> = visible_char_indices
440 .iter()
441 .map(|(start, text)| (*start, *start + text.chars().count()))
442 .collect();
443 for (line_text, (line_start, line_end)) in visible_lines.iter().zip(line_ranges.iter())
444 {
445 let mut spans = line_spans_with_selection(
446 line_text,
447 *line_start,
448 *line_end,
449 sel_start,
450 sel_end,
451 self.app.ui_theme.selection_bg,
452 );
453 if content_geometry.prompt_inset > 0 {
454 spans.insert(0, Span::raw(content_geometry.prompt_padding()));
455 }
456 input_lines.push(Line::from(spans));
457 }
458 } else {
459 for line in &visible_lines {
460 let mut spans = Vec::new();
461 if content_geometry.prompt_inset > 0 {
462 spans.push(Span::raw(content_geometry.prompt_padding()));
463 }
464 spans.push(Span::styled(
465 line.clone(),
466 Style::default().fg(palette::TEXT_PRIMARY),
467 ));
468 input_lines.push(Line::from(spans));
469 }
470 }
471
472 // For non-empty input, input_lines.len() already reflects wrapping via
473 // layout_input. For empty input, keep the first row reserved for the
474 // real terminal cursor so IME preedit text has a clean surface.
475 let visual_rows = if input_text.is_empty() {
476 let hint: Option<Cow<'_, str>> = if let Some(ref suggestion) =
477 self.app.prompt_suggestion
478 && !self.app.is_history_search_active()
479 {
480 Some(Cow::Borrowed(suggestion.as_str()))
481 } else {
482 Some(composer_empty_hint_text(self.app))
483 };
484 empty_composer_visual_rows(hint.as_deref(), input_content_width, input_rows_budget)
485 } else {
486 input_lines.len()
487 };
488 let top_padding = composer_top_padding(visual_rows, input_rows_budget);
489 let mut lines = Vec::new();
490 for _ in 0..top_padding {
491 lines.push(Line::from(""));
492 }
493 lines.extend(input_lines);
494
495 if self.app.is_history_search_active() {
496 if history_search_matches.is_empty() {
497 lines.push(Line::from(Span::styled(
498 self.app
499 .tr(codewhale_localization::MessageId::HistoryNoMatches),
500 Style::default().fg(palette::TEXT_MUTED),
501 )));
502 } else {
503 let selected = self
504 .app
505 .history_search_selected_index()
506 .min(history_search_matches.len().saturating_sub(1));
507 let menu_visible_rows = inner_area
508 .height
509 .saturating_sub(visual_rows as u16)
510 .saturating_sub(top_padding as u16)
511 .saturating_sub(1)
512 .max(1) as usize;
513 let menu_total = history_search_matches.len();
514 let menu_top = if menu_total <= menu_visible_rows {
515 0
516 } else {
517 let half = menu_visible_rows / 2;
518 if selected <= half {
519 0
520 } else if selected + half >= menu_total {
521 menu_total.saturating_sub(menu_visible_rows)
522 } else {
523 selected.saturating_sub(half)
524 }
525 };
526 let menu_bottom = (menu_top + menu_visible_rows).min(menu_total);
527
528 for (idx, entry) in history_search_matches
529 .iter()
530 .enumerate()
531 .take(menu_bottom)
532 .skip(menu_top)
533 {
534 let is_selected = idx == selected;
535 let style = if is_selected {
536 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
537 } else {
538 Style::default().fg(palette::TEXT_MUTED)
539 };
540 let marker = crate::tui::glyphs::selection_marker(is_selected);
541 lines.push(Line::from(vec![
542 Span::styled(" ", Style::default()),
543 Span::styled(marker, style),
544 Span::styled(" ", style),
545 Span::styled(entry.clone(), style),
546 ]));
547 }
548 }
549 } else if !self.mention_menu_entries.is_empty() {
550 let selected = self
551 .app
552 .mention_menu_selected
553 .min(self.mention_menu_entries.len().saturating_sub(1));
554 let menu_visible_rows = inner_area
555 .height
556 .saturating_sub(visual_rows as u16)
557 .saturating_sub(top_padding as u16)
558 .saturating_sub(1)
559 .max(1) as usize;
560 let menu_total = self.mention_menu_entries.len();
561 let menu_top = if menu_total <= menu_visible_rows {
562 0
563 } else {
564 let half = menu_visible_rows / 2;
565 if selected <= half {
566 0
567 } else if selected + half >= menu_total {
568 menu_total.saturating_sub(menu_visible_rows)
569 } else {
570 selected.saturating_sub(half)
571 }
572 };
573 let menu_bottom = (menu_top + menu_visible_rows).min(menu_total);
574
575 for (idx, entry) in self
576 .mention_menu_entries
577 .iter()
578 .enumerate()
579 .take(menu_bottom)
580 .skip(menu_top)
581 {
582 let is_selected = idx == selected;
583 let style = if is_selected {
584 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
585 } else {
586 Style::default().fg(palette::TEXT_MUTED)
587 };
588 let marker = crate::tui::glyphs::selection_marker(is_selected);
589 lines.push(Line::from(vec![
590 Span::styled(" ", Style::default()),
591 Span::styled(marker, style),
592 Span::styled(" ", style),
593 Span::styled(format!("@{entry}"), style),
594 ]));
595 }
596 } else if !self.slash_menu_entries.is_empty() {
597 let selected = self
598 .app
599 .slash_menu_selected
600 .min(self.slash_menu_entries.len().saturating_sub(1));
601 let menu_visible_rows = inner_area
602 .height
603 .saturating_sub(visual_rows as u16)
604 .saturating_sub(top_padding as u16)
605 .saturating_sub(1)
606 .max(1) as usize;
607 let menu_total = self.slash_menu_entries.len();
608 let menu_top = if menu_total <= menu_visible_rows {
609 0
610 } else {
611 let half = menu_visible_rows / 2;
612 if selected <= half {
613 0
614 } else if selected + half >= menu_total {
615 menu_total.saturating_sub(menu_visible_rows)
616 } else {
617 selected.saturating_sub(half)
618 }
619 };
620 let menu_bottom = (menu_top + menu_visible_rows).min(menu_total);
621
622 // Label column width — grows to fit the widest visible name
623 // (including alias hint like " or /bangzhu") but stays bounded.
624 let label_width = self
625 .slash_menu_entries
626 .iter()
627 .take(menu_bottom)
628 .skip(menu_top)
629 .map(|e| {
630 if let Some(ref hint) = e.alias_hint {
631 format!("{} or /{}", e.name, hint).width()
632 } else {
633 e.name.width()
634 }
635 })
636 .max()
637 .unwrap_or(22)
638 .min(content_width.saturating_sub(4))
639 .max(8);
640 for (idx, entry) in self
641 .slash_menu_entries
642 .iter()
643 .enumerate()
644 .take(menu_bottom)
645 .skip(menu_top)
646 {
647 let is_selected = idx == selected;
648 let sel_style = if is_selected {
649 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
650 } else {
651 Style::default().fg(palette::TEXT_MUTED)
652 };
653 let marker = crate::tui::glyphs::selection_marker(is_selected);
654
655 // Name column
656 let name_style = if entry.is_skill && !is_selected {
657 Style::default().fg(palette::WHALE_ACTION)
658 } else {
659 sel_style
660 };
661
662 // Description column (muted when not selected, secondary when selected)
663 let desc_style = if is_selected {
664 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
665 } else {
666 Style::default().fg(palette::TEXT_DIM)
667 };
668
669 // Build display name: canonical name, with "or /alias" hint
670 // when the user typed via a pinyin alias.
671 let display_name = if let Some(ref hint) = entry.alias_hint {
672 format!("{} or /{}", entry.name, hint)
673 } else {
674 entry.name.clone()
675 };
676
677 let name_was_truncated = display_name.width() > label_width;
678 let mut name_display =
679 crate::tui::ui_text::truncate_line_to_width(&display_name, label_width);
680 while name_display.width() < label_width {
681 name_display.push(' ');
682 }
683
684 // Skill marker prefix
685 let skill_prefix = if entry.is_skill { "✦" } else { " " };
686
687 // Compute exact prefix display width to avoid Paragraph wrap:
688 // 1(" ") + 1(marker) + skill_prefix.width() + label_width + 2(" ")
689 let prefix_display_width = 1 + 1 + skill_prefix.width() + label_width + 2;
690 let desc_capacity = content_width.saturating_sub(prefix_display_width);
691 let description_was_truncated = entry.description.width() > desc_capacity;
692 let desc_display =
693 crate::tui::ui_text::truncate_line_to_width(&entry.description, desc_capacity);
694
695 let row_line_index = lines.len();
696 lines.push(Line::from(vec![
697 Span::styled(" ", Style::default()),
698 Span::styled(marker, sel_style),
699 Span::styled(skill_prefix, name_style),
700 Span::styled(name_display, name_style),
701 Span::styled(" ", desc_style),
702 Span::styled(desc_display, desc_style),
703 ]));
704
705 let row_y = inner_area
706 .y
707 .saturating_add(u16::try_from(row_line_index).unwrap_or(u16::MAX));
708 if row_y < inner_area.bottom() && inner_area.width > 0 {
709 self.app
710 .viewport
711 .last_slash_menu_hitboxes
712 .borrow_mut()
713 .push((idx, Rect::new(inner_area.x, row_y, inner_area.width, 1)));
714 }
715
716 if name_was_truncated || description_was_truncated {
717 let full_text = if entry.description.trim().is_empty() {
718 display_name
719 } else {
720 format!("{display_name} {}", entry.description)
721 };
722 if row_y < inner_area.bottom() {
723 crate::tui::hover_layer::register_rect(
724 crate::tui::hover_hit::HoverTargetKind::TruncatedText,
725 Rect::new(inner_area.x, row_y, inner_area.width, 1),
726 full_text,
727 false,
728 );
729 }
730 }
731 }
732 }
733
734 let paragraph = Paragraph::new(lines)
735 .style(background)
736 .wrap(Wrap { trim: false });
737 paragraph.render(inner_area, buf);
738
739 // The prompt is a persistent focus anchor, not empty-state chrome.
740 // Rendering it on every input row keeps the first character from
741 // causing a visible leftward jump.
742 if let Some(prompt_x) = content_geometry.prompt_x()
743 && let Some((cursor_x, cursor_y)) = self.cursor_pos(area)
744 {
745 debug_assert!(cursor_x >= content_geometry.text_area.x);
746 buf[(prompt_x, cursor_y)]
747 .set_symbol("❯")
748 .set_style(Style::default().fg(self.app.ui_theme.accent_primary));
749 }
750
751 // Restore the shared `[↵]` after caller-owned input so a long draft
752 // cannot erase the one cell target the mouse handler also uses.
753 if has_panel {
754 crate::tui::composer_chrome::render_tideline_composer_submit(
755 area,
756 buf,
757 &self.app.ui_theme,
758 // Display state, not key-routing state: the paste-burst
759 // window reopens on every fast keystroke, so drawing from
760 // `composer_enter_would_submit` strobed the chip while
761 // typing (#6397).
762 self.app.composer_draft_is_submittable(),
763 crate::tui::color_compat::ascii_safe_enabled(),
764 );
765 }
766 }
767
768 fn desired_height(&self, width: u16) -> u16 {
769 composer_height(
770 self.app.composer_display_input(),
771 width,
772 self.max_height.min(self.max_height_cap()),
773 self.active_menu_reserved_rows(),
774 self.app.composer_density,
775 self.wants_enclosed_panel(),
776 )
777 }
778
779 fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
780 let inner_area = self.inner_area(area);
781 let input_text = self.app.composer_display_input();
782 let input_cursor = self.app.composer_display_cursor();
783 let content_geometry =
784 composer_content_geometry(inner_area, self.app.is_history_search_active());
785 let input_content_width = content_geometry.text_width();
786 // Match the render path's locked-budget calculation so the cursor
787 // lands on the same row the input is drawn on.
788 let input_rows_budget =
789 composer_input_rows_budget(inner_area.height, self.active_menu_reserved_rows());
790
791 let (visible_lines, cursor_row, cursor_col) = layout_input(
792 input_text,
793 input_cursor,
794 input_content_width,
795 input_rows_budget,
796 );
797 let visual_rows = if input_text.is_empty() {
798 let hint: Option<Cow<'_, str>> = if let Some(ref suggestion) =
799 self.app.prompt_suggestion
800 && !self.app.is_history_search_active()
801 {
802 Some(Cow::Borrowed(suggestion.as_str()))
803 } else {
804 Some(composer_empty_hint_text(self.app))
805 };
806 empty_composer_visual_rows(hint.as_deref(), input_content_width, input_rows_budget)
807 } else {
808 visible_lines.len()
809 };
810 let top_padding = composer_top_padding(visual_rows, input_rows_budget);
811
812 let cursor_x = content_geometry
813 .text_area
814 .x
815 .saturating_add(u16::try_from(cursor_col).unwrap_or(u16::MAX));
816 let cursor_y = inner_area
817 .y
818 .saturating_add(u16::try_from(top_padding + cursor_row).unwrap_or(u16::MAX));
819 if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
820 Some((cursor_x, cursor_y))
821 } else {
822 None
823 }
824 }
825 }
826
827 pub fn composer_input_rows_budget(inner_height: u16, extra_lines: usize) -> usize {
828 usize::from(inner_height).saturating_sub(extra_lines).max(1)
829 }
830
831 fn composer_top_padding(content_lines: usize, rows_budget: usize) -> usize {
832 crate::tui::composer_chrome::top_padding(content_lines, rows_budget)
833 }
834
835 pub(crate) fn empty_composer_visual_rows(
836 _hint: Option<&str>,
837 _content_width: usize,
838 _rows_budget: usize,
839 ) -> usize {
840 1
841 }
842
843 fn composer_max_height(density: ComposerDensity) -> u16 {
844 crate::tui::composer_chrome::ComposerChrome::for_density(density, false).max_total_rows
845 }
846
847 fn composer_height(
848 input: &str,
849 area_width: u16,
850 available_height: u16,
851 extra_lines: usize,
852 density: ComposerDensity,
853 show_panel: bool,
854 ) -> u16 {
855 let has_panel = enclosed_composer_panel_fits(show_panel, area_width, available_height);
856 // Measure through the same border- and submit-aware plane that rendering,
857 // cursor placement, the frame viewport, and mouse mapping use. A draft
858 // that wraps here therefore cannot consume the painted `[↵]` cells later.
859 let measurement_area = Rect::new(0, 0, area_width, if has_panel { 3 } else { 1 });
860 let content_width =
861 composer_content_geometry(composer_inner_area(measurement_area, has_panel), false)
862 .text_width();
863 let mut line_count = wrap_input_lines(input, content_width).len();
864 if line_count == 0 {
865 line_count = 1;
866 }
867 crate::tui::composer_chrome::desired_height(
868 line_count,
869 extra_lines,
870 available_height,
871 density,
872 has_panel,
873 )
874 }
875
876 fn layout_input(
877 input: &str,
878 cursor: usize,
879 width: usize,
880 max_height: usize,
881 ) -> (Vec<String>, usize, usize) {
882 let (visible, visible_cursor_row, visible_cursor_col, _) =
883 layout_input_with_scroll(input, cursor, width, max_height);
884 (visible, visible_cursor_row, visible_cursor_col)
885 }
886
887 pub fn layout_input_with_scroll(
888 input: &str,
889 cursor: usize,
890 width: usize,
891 max_height: usize,
892 ) -> (Vec<String>, usize, usize, usize) {
893 let mut lines = wrap_input_lines(input, width);
894 if lines.is_empty() {
895 lines.push(String::new());
896 }
897 let (cursor_row, cursor_col) = cursor_row_col(input, cursor, width.max(1));
898
899 let max_height = max_height.max(1);
900 let mut start = 0usize;
901 if cursor_row >= max_height {
902 start = cursor_row + 1 - max_height;
903 }
904 if start + max_height > lines.len() {
905 start = lines.len().saturating_sub(max_height);
906 }
907 let visible = lines
908 .into_iter()
909 .skip(start)
910 .take(max_height)
911 .collect::<Vec<_>>();
912 let visible_cursor_row = cursor_row.saturating_sub(start);
913
914 (
915 visible,
916 visible_cursor_row,
917 cursor_col.min(width.saturating_sub(1)),
918 start,
919 )
920 }
921
922 /// Extended version of `layout_input_with_scroll` that also returns character
923 /// indices for each wrapped line. Used by ComposerWidget to avoid redundant
924 /// wrapping when rendering text selections.
925 fn layout_input_with_scroll_and_char_indices(
926 input: &str,
927 cursor: usize,
928 width: usize,
929 max_height: usize,
930 ) -> (Vec<String>, usize, usize, usize, Vec<(usize, String)>) {
931 let (all_lines, all_with_indices) = wrap_input_lines_internal(input, width);
932
933 let lines = if all_lines.is_empty() {
934 vec![String::new()]
935 } else {
936 all_lines
937 };
938
939 let (cursor_row, cursor_col) = cursor_row_col(input, cursor, width.max(1));
940
941 let max_height = max_height.max(1);
942 let mut start = 0usize;
943 if cursor_row >= max_height {
944 start = cursor_row + 1 - max_height;
945 }
946 if start + max_height > lines.len() {
947 start = lines.len().saturating_sub(max_height);
948 }
949 let visible = lines
950 .into_iter()
951 .skip(start)
952 .take(max_height)
953 .collect::<Vec<_>>();
954 let visible_cursor_row = cursor_row.saturating_sub(start);
955
956 // Also slice the char indices to match visible lines
957 let visible_with_indices = all_with_indices
958 .into_iter()
959 .skip(start)
960 .take(max_height)
961 .collect();
962
963 (
964 visible,
965 visible_cursor_row,
966 cursor_col.min(width.saturating_sub(1)),
967 start,
968 visible_with_indices,
969 )
970 }
971
972 fn cursor_row_col(input: &str, cursor: usize, width: usize) -> (usize, usize) {
973 // Derive the cursor's row/col from the SAME wrapped lines the renderer
974 // draws. An earlier version recomputed wrapping here with hard margin
975 // breaks while wrap_text broke on word boundaries, so the two disagreed on
976 // row count: a long paste landed one row short of its marker, and the
977 // caret drifted behind fast typing. Walking the actual wrapped lines makes
978 // a desync impossible by construction (regression introduced in ff97641b7).
979 let (_, lines_with_indices) = wrap_input_lines_internal(input, width.max(1));
980 cursor_row_col_in_lines(&lines_with_indices, cursor)
981 }
982
983 /// Map a char-index cursor onto wrapped lines tagged with their starting char
984 /// index, as produced by wrap_input_lines_internal. The row is the line whose
985 /// char range contains the cursor; the column is the display width of that
986 /// line up to the cursor. Because wrap_text emits a trailing empty line when a
987 /// line fills exactly to the width, a cursor at the end of a full line lands
988 /// on that empty line (row+1, col 0), the display convention callers rely on,
989 /// without any special case here.
990 fn cursor_row_col_in_lines(
991 lines_with_indices: &[(usize, String)],
992 cursor: usize,
993 ) -> (usize, usize) {
994 let mut row = 0usize;
995 let mut line_start = 0usize;
996 let mut line: &str = "";
997 let mut found = false;
998 for (i, (start, l)) in lines_with_indices.iter().enumerate() {
999 if *start <= cursor {
1000 row = i;
1001 line_start = *start;
1002 line = l.as_str();
1003 found = true;
1004 } else {
1005 break;
1006 }
1007 }
1008 if !found {
1009 return (0, 0);
1010 }
1011 let offset = cursor.saturating_sub(line_start);
1012 let byte_end = line
1013 .char_indices()
1014 .nth(offset)
1015 .map(|(b, _)| b)
1016 .unwrap_or(line.len());
1017 let col = visible_str_width(&line[..byte_end]);
1018 (row, col)
1019 }
1020
1021 /// Internal helper that returns both wrapped lines and character indices.
1022 /// Used by `wrap_input_lines`, `wrap_input_lines_for_mouse`, and
1023 /// `layout_input_with_scroll` to avoid redundant wrapping computations.
1024 fn wrap_input_lines_internal(input: &str, width: usize) -> (Vec<String>, Vec<(usize, String)>) {
1025 let mut lines = Vec::new();
1026 let mut lines_with_indices = Vec::new();
1027 let mut char_idx = 0usize;
1028
1029 if input.is_empty() {
1030 lines_with_indices.push((0, String::new()));
1031 return (lines, lines_with_indices);
1032 }
1033
1034 for raw_line in input.split('\n') {
1035 if raw_line.is_empty() {
1036 lines.push(String::new());
1037 if width != 0 {
1038 lines_with_indices.push((char_idx, String::new()));
1039 }
1040 char_idx += 1; // the '\n'
1041 continue;
1042 }
1043
1044 let wrapped = wrap_text(raw_line, width);
1045 if wrapped.is_empty() {
1046 lines.push(String::new());
1047 if width != 0 {
1048 lines_with_indices.push((char_idx, String::new()));
1049 }
1050 } else {
1051 for wrapped_line in &wrapped {
1052 let line_char_len: usize = wrapped_line.chars().count();
1053 lines.push(wrapped_line.clone());
1054 if width != 0 {
1055 lines_with_indices.push((char_idx, wrapped_line.clone()));
1056 }
1057 char_idx += line_char_len;
1058 }
1059 }
1060 char_idx += 1; // the '\n'
1061 }
1062
1063 (lines, lines_with_indices)
1064 }
1065
1066 fn wrap_input_lines(input: &str, width: usize) -> Vec<String> {
1067 let (lines, _) = wrap_input_lines_internal(input, width);
1068 lines
1069 }
1070
1071 /// For mouse coordinate mapping: returns (char_start_of_line, line_text) pairs
1072 /// matching the wrapping produced by `wrap_input_lines`.
1073 pub fn wrap_input_lines_for_mouse(input: &str, width: usize) -> Vec<(usize, String)> {
1074 if input.is_empty() || width == 0 {
1075 return vec![(0, String::new())];
1076 }
1077
1078 let (_, lines_with_indices) = wrap_input_lines_internal(input, width);
1079 lines_with_indices
1080 }
1081
1082 /// Wrap composer text to `width` display columns, breaking at word boundaries
1083 /// where one is available.
1084 ///
1085 /// This used to break strictly on the grapheme that crossed the margin, so a
1086 /// wrapped sentence split mid-word — `…Write the file onl` / `y after the…`.
1087 /// The text was never lost, but a line ending in a severed word reads exactly
1088 /// like content that was cut off, which is what it was reported as.
1089 ///
1090 /// Two invariants the callers depend on and this must not break:
1091 ///
1092 /// * **Nothing is added or removed.** Concatenating the returned lines
1093 /// reproduces `text` exactly. `wrap_input_lines_internal` walks the wrapped
1094 /// lines accumulating `chars().count()` to map cursor and mouse positions
1095 /// back into the raw buffer, so a dropped break character would silently
1096 /// desynchronise the caret. The space a line breaks on therefore stays at
1097 /// the end of the preceding line rather than being swallowed.
1098 /// * **Every line fits.** A word longer than `width` — a URL, a path, a
1099 /// base64 blob — has no usable break point and still breaks hard.
1100 ///
1101 /// Display width as painted: ratatui strips control characters, so they
1102 /// occupy no cells. Non-control graphemes keep plain unicode width, matching
1103 /// the long-standing wrap/click/caret contract.
1104 pub(crate) fn visible_grapheme_width(grapheme: &str) -> usize {
1105 if grapheme.chars().any(|c| c.is_control()) {
1106 0
1107 } else {
1108 grapheme.width()
1109 }
1110 }
1111
1112 /// Plain unicode width with painted control handling: strip control
1113 /// graphemes first so emoji and wide-glyph measurement keeps the exact
1114 /// [`UnicodeWidthStr`] semantics on the remainder.
1115 fn visible_str_width(text: &str) -> usize {
1116 text.graphemes(true)
1117 .filter(|grapheme| !grapheme.chars().any(|c| c.is_control()))
1118 .collect::<String>()
1119 .width()
1120 }
1121
1122 fn wrap_text(text: &str, width: usize) -> Vec<String> {
1123 if width == 0 {
1124 return vec![text.to_string()];
1125 }
1126 if text.is_empty() {
1127 return vec![String::new()];
1128 }
1129
1130 let mut lines = Vec::new();
1131 let mut current = String::new();
1132 let mut current_width = 0;
1133 // Byte offset in `current` just past the most recent space, and the
1134 // display width up to that point. `None` while the line holds no usable
1135 // break point — a leading space is not one, since breaking there would
1136 // emit an empty line and make no progress.
1137 let mut break_at: Option<(usize, usize)> = None;
1138
1139 // Flush `current` up to its break point (if any), carrying the remainder
1140 // onto the next line.
1141 macro_rules! flush {
1142 () => {{
1143 match break_at.take() {
1144 Some((byte, _)) if byte < current.len() => {
1145 let remainder = current.split_off(byte);
1146 lines.push(std::mem::replace(&mut current, remainder));
1147 current_width = visible_str_width(&current);
1148 }
1149 _ => {
1150 lines.push(std::mem::take(&mut current));
1151 current_width = 0;
1152 }
1153 }
1154 }};
1155 }
1156
1157 for grapheme in text.graphemes(true) {
1158 if grapheme == "\n" {
1159 break_at = None;
1160 lines.push(std::mem::take(&mut current));
1161 current_width = 0;
1162 continue;
1163 }
1164
1165 let grapheme_width = visible_grapheme_width(grapheme);
1166 if current_width + grapheme_width > width && current_width != 0 {
1167 flush!();
1168 }
1169
1170 current.push_str(grapheme);
1171 current_width += grapheme_width;
1172 if grapheme == " " && !current.trim_start().is_empty() {
1173 break_at = Some((current.len(), current_width));
1174 }
1175
1176 if current_width >= width {
1177 flush!();
1178 }
1179 }
1180
1181 lines.push(current);
1182 lines
1183 }
1184
1185 fn line_spans_with_selection<'a>(
1186 line: &'a str,
1187 line_start: usize,
1188 line_end: usize,
1189 sel_start: usize,
1190 sel_end: usize,
1191 highlight_bg: Color,
1192 ) -> Vec<Span<'a>> {
1193 let normal_style = Style::default().fg(palette::TEXT_PRIMARY);
1194 let sel_style = Style::default().fg(palette::TEXT_PRIMARY).bg(highlight_bg);
1195
1196 // No overlap between this line and the selection
1197 if line_end <= sel_start || line_start >= sel_end {
1198 return vec![Span::styled(line, normal_style)];
1199 }
1200
1201 let local_sel_start = sel_start.saturating_sub(line_start);
1202 let local_sel_end = sel_end.min(line_end).saturating_sub(line_start);
1203
1204 // Build a Vec of byte offsets for each char boundary, plus one past the end.
1205 let mut byte_offsets: Vec<usize> = line.char_indices().map(|(i, _)| i).collect();
1206 byte_offsets.push(line.len());
1207
1208 let b0 = byte_offsets
1209 .get(local_sel_start)
1210 .copied()
1211 .unwrap_or(line.len());
1212 let b1 = byte_offsets
1213 .get(local_sel_end)
1214 .copied()
1215 .unwrap_or(line.len());
1216
1217 let mut spans = Vec::with_capacity(3);
1218
1219 // Text before selection
1220 if b0 > 0 {
1221 spans.push(Span::styled(&line[..b0], normal_style));
1222 }
1223 // Selected text
1224 if b1 > b0 {
1225 spans.push(Span::styled(&line[b0..b1], sel_style));
1226 }
1227 // Text after selection
1228 if b1 < line.len() {
1229 spans.push(Span::styled(&line[b1..], normal_style));
1230 }
1231
1232 spans
1233 }
1234
1235 // End exact frozen composer source fragments; test-only, no production fallback.
1236
1236 lines RUST