返回 CodeWhale
pending_input.rs
根目录 / vendor / codewhale-ratatui / src / components / pending_input.rs
1 //! Host-reported pending input above the composer.
2 //!
3 //! The host owns the queue, the turn and every mutation. This component owns
4 //! only what a person can see: caller items with an explicit status word and
5 //! mark, attached context with an explicit state, a compact queue summary and
6 //! the action metadata a host may dispatch. The kit never advances a status,
7 //! so nothing here can claim an item was sent, delivered or verified.
8
9 use std::borrow::Cow;
10
11 use ratatui::{
12 buffer::Buffer,
13 layout::Rect,
14 style::{Modifier, Style},
15 text::{Line, Span},
16 widgets::{Paragraph, Widget},
17 };
18
19 use unicode_segmentation::UnicodeSegmentation;
20
21 use crate::{Paint, Role, Theme, glyphs, text};
22
23 /// What the host reports about one pending item. The word and the mark are
24 /// the only claims; the kit never moves an item between states.
25 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
26 pub enum PendingInputStatus {
27 /// Typed input captured for a later turn.
28 Queued,
29 /// A note the host will deliver into the running turn.
30 Steering,
31 /// The person is editing this item now.
32 Editing,
33 /// The host held this item.
34 Paused,
35 /// The host reports delivery is already under way.
36 InFlight,
37 }
38
39 const ACTIONS_SEND_EDIT_DROP: [PendingInputAction; 3] = [
40 PendingInputAction::SendNow,
41 PendingInputAction::Edit,
42 PendingInputAction::Drop,
43 ];
44 const ACTIONS_EDIT_DROP: [PendingInputAction; 2] =
45 [PendingInputAction::Edit, PendingInputAction::Drop];
46 const ACTIONS_SEND_DROP: [PendingInputAction; 2] =
47 [PendingInputAction::SendNow, PendingInputAction::Drop];
48 const ACTIONS_NONE: [PendingInputAction; 0] = [];
49
50 impl PendingInputStatus {
51 pub const ALL: [Self; 5] = [
52 Self::Queued,
53 Self::Steering,
54 Self::Editing,
55 Self::Paused,
56 Self::InFlight,
57 ];
58
59 /// The word shown for this status.
60 #[must_use]
61 pub const fn word(self) -> &'static str {
62 match self {
63 Self::Queued => "queued",
64 Self::Steering => "steering",
65 Self::Editing => "editing",
66 Self::Paused => "paused",
67 Self::InFlight => "in flight",
68 }
69 }
70
71 #[must_use]
72 pub const fn role(self) -> Role {
73 match self {
74 Self::Queued => Role::Primary,
75 Self::Steering => Role::Live,
76 Self::Editing => Role::Attention,
77 Self::Paused => Role::Attention,
78 Self::InFlight => Role::Muted,
79 }
80 }
81
82 /// The mark for this terminal; `theme.ascii()` never widens the line.
83 #[must_use]
84 pub fn glyph(self, theme: &Theme) -> &'static str {
85 let ascii = theme.ascii();
86 match self {
87 Self::Queued => {
88 if ascii {
89 "o"
90 } else {
91 glyphs::AVAILABLE
92 }
93 }
94 Self::Steering => {
95 if ascii {
96 "+"
97 } else {
98 "↳"
99 }
100 }
101 Self::Editing => {
102 if ascii {
103 "e"
104 } else {
105 "✎"
106 }
107 }
108 Self::Paused => {
109 if ascii {
110 "="
111 } else {
112 glyphs::PAUSED
113 }
114 }
115 Self::InFlight => {
116 if ascii {
117 ">"
118 } else {
119 glyphs::CURRENT
120 }
121 }
122 }
123 }
124
125 /// The actions a host may dispatch for an item in this status. The kit
126 /// emits them as metadata only; it never performs one.
127 #[must_use]
128 pub const fn actions(self) -> &'static [PendingInputAction] {
129 match self {
130 Self::Queued | Self::Paused => &ACTIONS_SEND_EDIT_DROP,
131 Self::Steering => &ACTIONS_EDIT_DROP,
132 Self::Editing => &ACTIONS_SEND_DROP,
133 Self::InFlight => &ACTIONS_NONE,
134 }
135 }
136 }
137
138 /// One action a host may offer beside a pending item. The kit paints the
139 /// word and hands the choice back; mutation stays with the host.
140 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
141 pub enum PendingInputAction {
142 SendNow,
143 Edit,
144 Drop,
145 }
146
147 impl PendingInputAction {
148 pub const ALL: [Self; 3] = [Self::SendNow, Self::Edit, Self::Drop];
149
150 #[must_use]
151 pub const fn word(self) -> &'static str {
152 match self {
153 Self::SendNow => "Send now",
154 Self::Edit => "Edit",
155 Self::Drop => "Drop",
156 }
157 }
158
159 #[must_use]
160 pub const fn role(self) -> Role {
161 match self {
162 Self::SendNow => Role::Primary,
163 Self::Edit => Role::Foreground,
164 Self::Drop => Role::Danger,
165 }
166 }
167 }
168
169 /// One queued, steering, editing, paused or in-flight item.
170 ///
171 /// `content` is display text; it may hold newlines and controls, and the
172 /// component sanitizes and flattens it. `id` is the stable caller key used
173 /// for selection and for `actions_for`.
174 #[derive(Clone, Debug)]
175 pub struct PendingInputItem<'a> {
176 pub id: Cow<'a, str>,
177 pub label: Cow<'a, str>,
178 pub content: Cow<'a, str>,
179 pub status: PendingInputStatus,
180 pub status_word: Option<Cow<'a, str>>,
181 }
182
183 impl<'a> PendingInputItem<'a> {
184 #[must_use]
185 pub fn new(
186 id: impl Into<Cow<'a, str>>,
187 content: impl Into<Cow<'a, str>>,
188 status: PendingInputStatus,
189 ) -> Self {
190 Self {
191 id: id.into(),
192 label: Cow::Borrowed(""),
193 content: content.into(),
194 status,
195 status_word: None,
196 }
197 }
198
199 #[must_use]
200 pub fn label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
201 self.label = label.into();
202 self
203 }
204
205 /// A caller-localized status word, replacing the default one.
206 #[must_use]
207 pub fn status_word(mut self, word: impl Into<Cow<'a, str>>) -> Self {
208 self.status_word = Some(word.into());
209 self
210 }
211
212 #[must_use]
213 pub fn word(&self) -> &str {
214 self.status_word
215 .as_deref()
216 .unwrap_or_else(|| self.status.word())
217 }
218
219 #[must_use]
220 pub fn actions(&self) -> &'static [PendingInputAction] {
221 self.status.actions()
222 }
223 }
224
225 /// What the host reports about one attached context item. `Unconfirmed` is
226 /// deliberately not `Included`: unconfirmed context is never shown as sent.
227 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
228 pub enum ContextPreviewState {
229 /// Known to be included with the message.
230 Included,
231 /// Attached, but not confirmed by the host.
232 Unconfirmed,
233 /// Attached and offered for removal.
234 Removable,
235 }
236
237 impl ContextPreviewState {
238 pub const ALL: [Self; 3] = [Self::Included, Self::Unconfirmed, Self::Removable];
239
240 #[must_use]
241 pub const fn word(self) -> &'static str {
242 match self {
243 Self::Included => "included",
244 Self::Unconfirmed => "unconfirmed",
245 Self::Removable => "removable",
246 }
247 }
248
249 #[must_use]
250 pub const fn role(self) -> Role {
251 match self {
252 Self::Included => Role::Live,
253 Self::Unconfirmed => Role::Attention,
254 Self::Removable => Role::Muted,
255 }
256 }
257
258 #[must_use]
259 pub fn glyph(self, theme: &Theme) -> &'static str {
260 let ascii = theme.ascii();
261 match self {
262 Self::Included => {
263 if ascii {
264 "+"
265 } else {
266 glyphs::DONE
267 }
268 }
269 Self::Unconfirmed => {
270 if ascii {
271 "?"
272 } else {
273 glyphs::ATTENTION
274 }
275 }
276 Self::Removable => {
277 if ascii {
278 "-"
279 } else {
280 glyphs::NEUTRAL
281 }
282 }
283 }
284 }
285 }
286
287 /// One attached context item, with its own reported state.
288 #[derive(Clone, Debug)]
289 pub struct ContextPreviewItem<'a> {
290 pub id: Cow<'a, str>,
291 pub label: Cow<'a, str>,
292 pub detail: Option<Cow<'a, str>>,
293 pub state: ContextPreviewState,
294 }
295
296 impl<'a> ContextPreviewItem<'a> {
297 #[must_use]
298 pub fn new(
299 id: impl Into<Cow<'a, str>>,
300 label: impl Into<Cow<'a, str>>,
301 state: ContextPreviewState,
302 ) -> Self {
303 Self {
304 id: id.into(),
305 label: label.into(),
306 detail: None,
307 state,
308 }
309 }
310
311 #[must_use]
312 pub fn detail(mut self, detail: impl Into<Cow<'a, str>>) -> Self {
313 self.detail = Some(detail.into());
314 self
315 }
316
317 #[must_use]
318 pub fn word(&self) -> &str {
319 self.state.word()
320 }
321 }
322
323 #[derive(Clone)]
324 struct PaintedRow {
325 line: Line<'static>,
326 selected: bool,
327 }
328
329 // One geometry and paint authority for the generic list and native card.
330 struct PendingPlan {
331 width: u16,
332 max_rows: u16,
333 rows: Vec<PaintedRow>,
334 one_row: Option<PaintedRow>,
335 tail: Option<PendingTail>,
336 }
337
338 struct PendingTail {
339 hint: String,
340 omitted: String,
341 hint_style: Style,
342 omitted_style: Style,
343 }
344
345 impl PendingPlan {
346 fn new(width: u16, max_rows: u16) -> Self {
347 Self {
348 width,
349 max_rows,
350 rows: Vec::new(),
351 one_row: None,
352 tail: None,
353 }
354 }
355 fn push(&mut self, line: Line<'static>) {
356 self.rows.push(PaintedRow {
357 line,
358 selected: false,
359 });
360 }
361 fn height(&self) -> u16 {
362 if self.width < 4 {
363 return 0;
364 }
365 u16::try_from(
366 self.rows
367 .len()
368 .saturating_add(usize::from(self.tail.is_some())),
369 )
370 .unwrap_or(u16::MAX)
371 .min(self.max_rows)
372 }
373 fn shown(&self, height: u16) -> Vec<PaintedRow> {
374 let height = height.min(self.max_rows);
375 if self.width < 4 || height == 0 || self.rows.is_empty() {
376 return Vec::new();
377 }
378 if height == 1
379 && let Some(row) = &self.one_row
380 {
381 return vec![row.clone()];
382 }
383 let keep = usize::from(height).saturating_sub(usize::from(self.tail.is_some()));
384 let mut out: Vec<_> = self.rows.iter().take(keep).cloned().collect();
385 if let Some(tail) = &self.tail {
386 let hidden = self.rows.len().saturating_sub(keep);
387 let mut spans = Vec::new();
388 if hidden > 0 {
389 spans.push(Span::styled(
390 format!("+{hidden} {} ", tail.omitted),
391 tail.omitted_style,
392 ));
393 }
394 spans.push(Span::styled(tail.hint.clone(), tail.hint_style));
395 out.push(PaintedRow {
396 line: Line::from(spans),
397 selected: false,
398 });
399 }
400 out
401 }
402 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
403 if area.is_empty() {
404 return;
405 }
406 let shown = self.shown(area.height);
407 for (index, painted) in shown.iter().enumerate() {
408 let rect = Rect::new(
409 area.x,
410 area.y
411 .saturating_add(u16::try_from(index).unwrap_or(u16::MAX)),
412 area.width,
413 1,
414 );
415 if painted.selected {
416 buf.set_style(rect, theme.bg(Role::Selected));
417 }
418 }
419 // Keep native Ratatui glyph painting, including caller-owned styles
420 // on wide-character continuation cells, in one shared paint path.
421 Paragraph::new(shown.into_iter().map(|row| row.line).collect::<Vec<_>>()).render(area, buf);
422 }
423 }
424
425 /// Localized copy for the native composer preview. Templates use `{count}`
426 /// and `{number}`; key labels are resolved by the host before being supplied.
427 #[derive(Clone, Debug)]
428 pub struct PendingCardWords<'a> {
429 pub context_header: Cow<'a, str>,
430 pub inputs_header: Cow<'a, str>,
431 pub sending_prefix: Cow<'a, str>,
432 pub editing_prefix: Cow<'a, str>,
433 pub editing_restore: Cow<'a, str>,
434 pub queued_prefix: Cow<'a, str>,
435 pub queued_one_prefix: Cow<'a, str>,
436 pub queued_many_prefix: Cow<'a, str>,
437 pub queued_controls: Cow<'a, str>,
438 pub compact_controls: Cow<'a, str>,
439 pub removable: Cow<'a, str>,
440 pub selected_remove: Cow<'a, str>,
441 }
442
443 impl Default for PendingCardWords<'_> {
444 fn default() -> Self {
445 Self {
446 context_header: "Context for next send".into(),
447 inputs_header: "Pending inputs".into(),
448 sending_prefix: "Sending into this turn: ".into(),
449 editing_prefix: "Editing follow-up: ".into(),
450 editing_restore: "Esc restores the queued follow-up".into(),
451 queued_prefix: "Queued follow-up #{number}: ".into(),
452 queued_one_prefix: "Queued #1: ".into(),
453 queued_many_prefix: "Queued {count}; next: ".into(),
454 queued_controls: "Enter send now · ↑ edit".into(),
455 compact_controls: "Enter send now · ↑ edit · /queue drop 1".into(),
456 removable: "removable".into(),
457 selected_remove: "Backspace/Delete removes".into(),
458 }
459 }
460 }
461
462 /// Inclusion, removability and keyboard selection are independent host facts.
463 /// None of them advances a queue or claims delivery.
464 #[derive(Clone, Debug)]
465 pub struct PendingCardContext<'a> {
466 pub kind: Cow<'a, str>,
467 pub label: Cow<'a, str>,
468 pub detail: Option<Cow<'a, str>>,
469 pub included: bool,
470 pub removable: bool,
471 pub selected: bool,
472 }
473
474 /// The native card's five concrete style slots. Defaults come from `Theme`;
475 /// a host retaining its palette grammar may provide these exact styles.
476 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
477 pub struct PendingCardStyles {
478 pub input: Style,
479 pub warning: Style,
480 pub context_muted: Style,
481 pub context_label: Style,
482 pub selected: Style,
483 }
484
485 impl PendingCardStyles {
486 #[must_use]
487 pub fn for_theme(theme: &Theme) -> Self {
488 Self {
489 input: theme.fg(Role::Dim).add_modifier(Modifier::DIM),
490 warning: theme.fg(Role::Attention),
491 context_muted: theme.fg(Role::Muted),
492 context_label: theme.fg(Role::Foreground),
493 selected: theme.fg(Role::Foreground).patch(theme.bg(Role::Selected)),
494 }
495 }
496 }
497
498 /// Native pending input above a composer, over caller-owned facts and copy.
499 /// The same measured row plan as `PendingInputPreview` owns height, clipping,
500 /// the one-row action fallback and painting. There is no queue or request state.
501 #[derive(Clone, Debug)]
502 pub struct PendingCard<'a> {
503 pub context: Vec<PendingCardContext<'a>>,
504 pub sending: Vec<Cow<'a, str>>,
505 pub queued: Vec<Cow<'a, str>>,
506 pub editing: Option<Cow<'a, str>>,
507 /// Already-localized notices that precede context and input, including
508 /// hidden child requests. A notice always outranks a queue action fallback.
509 pub priority_rows: Vec<Cow<'a, str>>,
510 pub words: PendingCardWords<'a>,
511 pub styles: Option<PendingCardStyles>,
512 }
513
514 impl<'a> PendingCard<'a> {
515 #[must_use]
516 pub fn new(words: PendingCardWords<'a>) -> Self {
517 Self {
518 context: Vec::new(),
519 sending: Vec::new(),
520 queued: Vec::new(),
521 editing: None,
522 priority_rows: Vec::new(),
523 words,
524 styles: None,
525 }
526 }
527
528 fn plan(&self, width: u16, theme: &Theme) -> PendingPlan {
529 let mut plan = PendingPlan::new(width, u16::MAX);
530 if width < 4 {
531 return plan;
532 }
533 let styles = self
534 .styles
535 .unwrap_or_else(|| PendingCardStyles::for_theme(theme));
536 for notice in &self.priority_rows {
537 plan.push(Line::from(Span::styled(
538 card_clip(&card_safe(notice), usize::from(width), theme.ascii()),
539 styles.warning,
540 )));
541 }
542 let queued_only = self.context.is_empty()
543 && self.sending.is_empty()
544 && self.editing.is_none()
545 && !self.queued.is_empty();
546 if queued_only {
547 let prefix = if self.queued.len() == 1 {
548 card_copy(&self.words.queued_one_prefix, theme)
549 } else {
550 card_copy(&self.words.queued_many_prefix, theme)
551 .replace("{count}", &self.queued.len().to_string())
552 };
553 let next = card_safe(&self.queued[0].replace('\n', " "));
554 plan.push(Line::from(Span::styled(
555 card_clip(
556 &format!("{prefix}{next}"),
557 usize::from(width),
558 theme.ascii(),
559 ),
560 styles.input.add_modifier(Modifier::ITALIC),
561 )));
562 let controls = Line::from(Span::styled(
563 card_clip(
564 &card_copy(&self.words.compact_controls, theme),
565 usize::from(width),
566 theme.ascii(),
567 ),
568 styles.input,
569 ));
570 if self.priority_rows.is_empty() {
571 plan.one_row = Some(PaintedRow {
572 line: controls.clone(),
573 selected: false,
574 });
575 }
576 plan.push(controls);
577 return plan;
578 }
579 if !self.context.is_empty() {
580 plan.push(card_header(&self.words.context_header, theme));
581 for item in &self.context {
582 let prefix_style = if item.selected {
583 styles.selected.add_modifier(Modifier::BOLD)
584 } else if item.included {
585 styles.context_muted
586 } else {
587 styles.warning
588 };
589 let body_style = if item.selected {
590 styles.selected
591 } else if item.included {
592 styles.context_label
593 } else {
594 styles.context_muted
595 };
596 let mut body = format!("[{}] {}", card_safe(&item.kind), card_safe(&item.label));
597 let separator = if theme.ascii() { " . " } else { " · " };
598 if let Some(detail) = item
599 .detail
600 .as_deref()
601 .filter(|value| !value.trim().is_empty())
602 {
603 body.push_str(separator);
604 body.push_str(&card_safe(detail));
605 }
606 let action = if item.selected {
607 Some(&self.words.selected_remove)
608 } else if item.removable {
609 Some(&self.words.removable)
610 } else {
611 None
612 };
613 if let Some(action) = action {
614 body.push_str(separator);
615 body.push_str(&card_copy(action, theme));
616 }
617 for (index, segment) in
618 card_wrap(&body, usize::from(width.saturating_sub(4).max(1)))
619 .into_iter()
620 .enumerate()
621 {
622 let prefix = if index > 0 {
623 " "
624 } else if item.selected {
625 if theme.ascii() { " > " } else { " ▸ " }
626 } else if theme.ascii() {
627 " + "
628 } else {
629 " ↳ "
630 };
631 plan.push(Line::from(vec![
632 Span::styled(prefix, prefix_style),
633 Span::styled(segment, body_style),
634 ]));
635 }
636 }
637 }
638 let has_inputs =
639 !self.sending.is_empty() || !self.queued.is_empty() || self.editing.is_some();
640 if has_inputs {
641 if !self.context.is_empty() {
642 plan.push(Line::from(""));
643 }
644 plan.push(card_header(&self.words.inputs_header, theme));
645 for value in &self.sending {
646 card_item(
647 &mut plan,
648 value,
649 &card_copy(&self.words.sending_prefix, theme),
650 styles.input,
651 theme,
652 );
653 }
654 if let Some(value) = &self.editing {
655 card_item(
656 &mut plan,
657 value,
658 &card_copy(&self.words.editing_prefix, theme),
659 styles.input.add_modifier(Modifier::ITALIC),
660 theme,
661 );
662 plan.push(Line::from(Span::styled(
663 card_copy(&self.words.editing_restore, theme),
664 styles.input,
665 )));
666 }
667 for (index, value) in self.queued.iter().enumerate() {
668 let prefix = card_copy(&self.words.queued_prefix, theme)
669 .replace("{number}", &(index + 1).to_string());
670 card_item(
671 &mut plan,
672 value,
673 &prefix,
674 styles.input.add_modifier(Modifier::ITALIC),
675 theme,
676 );
677 }
678 if !self.queued.is_empty() {
679 plan.push(Line::from(Span::styled(
680 card_copy(&self.words.queued_controls, theme),
681 styles.input,
682 )));
683 }
684 }
685 plan
686 }
687
688 #[must_use]
689 pub fn height(&self, width: u16, theme: &Theme) -> u16 {
690 self.plan(width, theme).height()
691 }
692 }
693
694 impl Paint for PendingCard<'_> {
695 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
696 let area = area.intersection(buf.area);
697 self.plan(area.width, theme).paint(area, buf, theme);
698 }
699 fn height(&self, width: u16, theme: &Theme) -> u16 {
700 self.plan(width, theme).height()
701 }
702 }
703
704 fn card_safe(value: &str) -> String {
705 // Newlines are handled by card_item. Sanitize once before both measuring
706 // and painting; invisible control/bidi bytes do not create phantom rows.
707 text::display_safe(value).into_owned()
708 }
709
710 fn card_copy(value: &str, theme: &Theme) -> String {
711 let value = card_safe(value);
712 if !theme.ascii() {
713 return value;
714 }
715 value
716 .chars()
717 .map(|c| {
718 glyphs::ascii_fallback(c.encode_utf8(&mut [0; 4]))
719 .map_or_else(|| c.to_string(), str::to_owned)
720 })
721 .collect()
722 }
723
724 fn card_header(value: &str, theme: &Theme) -> Line<'static> {
725 Line::from(format!(
726 "{} {}",
727 glyphs::pick("•", theme.ascii()),
728 card_copy(value, theme)
729 ))
730 }
731
732 fn card_clip(value: &str, width: usize, ascii: bool) -> String {
733 if text::width(value) <= width {
734 return value.to_owned();
735 }
736 let ellipsis = if ascii && width >= 4 {
737 "..."
738 } else if ascii {
739 "."
740 } else {
741 "…"
742 };
743 let budget = width.saturating_sub(text::width(ellipsis));
744 let mut out = String::new();
745 let mut cells = 0;
746 for g in value.graphemes(true) {
747 let next = text::width(g);
748 if cells + next > budget {
749 break;
750 }
751 out.push_str(g);
752 cells += next;
753 }
754 if width > 0 {
755 out.push_str(ellipsis);
756 }
757 out
758 }
759
760 fn card_item(plan: &mut PendingPlan, value: &str, prefix: &str, style: Style, theme: &Theme) {
761 let indent = " ".repeat(card_body_width(prefix));
762 let body_width = usize::from(plan.width)
763 .saturating_sub(card_body_width(prefix))
764 .max(1);
765 let mut produced = 0;
766 for (paragraph_index, paragraph) in value.split('\n').enumerate() {
767 for (index, segment) in card_wrap(&card_safe(paragraph), body_width)
768 .into_iter()
769 .enumerate()
770 {
771 if produced == 3 {
772 plan.push(Line::from(Span::styled(
773 format!("{indent}{}", if theme.ascii() { "..." } else { "…" }),
774 style,
775 )));
776 return;
777 }
778 let leader = if paragraph_index == 0 && index == 0 {
779 prefix
780 } else {
781 &indent
782 };
783 plan.push(Line::from(Span::styled(
784 format!("{leader}{segment}"),
785 style,
786 )));
787 produced += 1;
788 }
789 }
790 }
791
792 fn card_wrap(value: &str, width: usize) -> Vec<String> {
793 if value.is_empty() {
794 return vec![String::new()];
795 }
796 let mut out = Vec::new();
797 let mut current = String::new();
798 let mut cells = 0;
799 for word in value.split_inclusive(' ') {
800 let next = card_body_width(word);
801 if cells + next > width && !current.is_empty() {
802 out.push(std::mem::take(&mut current));
803 cells = 0;
804 }
805 if next > width {
806 // Preserve a long URL/token as one clipped row. Never split it
807 // into several misleading overflow fragments.
808 out.push(word.trim_end().to_owned());
809 } else {
810 current.push_str(word);
811 cells += next;
812 }
813 }
814 if !current.is_empty() {
815 out.push(current);
816 }
817 out
818 }
819
820 fn card_body_width(value: &str) -> usize {
821 // Native composer wrapping keeps its existing terminal contract for
822 // circled numbers and keycaps without a variation selector. Compact
823 // summaries still use ordinary Ratatui Unicode width when truncated.
824 value
825 .graphemes(true)
826 .map(|grapheme| {
827 if grapheme.contains('\u{20e3}') {
828 return 2;
829 }
830 if let Some(c) = grapheme.chars().next()
831 && c.len_utf8() == grapheme.len()
832 && matches!(c, '\u{2460}'..='\u{24ff}' | '\u{2776}'..='\u{2793}' | '\u{3248}'..='\u{324f}')
833 {
834 return 2;
835 }
836 text::width(grapheme)
837 })
838 .sum()
839 }
840
841 /// Pending items and attached context over caller-owned facts.
842 ///
843 /// The component holds borrowed items and an optional selected ID. It paints
844 /// a compact queue summary, the context row, one row per item, and an action
845 /// hint built from the union of the item actions. At one row of height the
846 /// summary and the action words share the row so an action stays
847 /// discoverable. An empty preview asks for zero rows.
848 #[derive(Clone, Debug)]
849 pub struct PendingInputPreview<'a> {
850 pub items: Vec<PendingInputItem<'a>>,
851 pub context: Vec<ContextPreviewItem<'a>>,
852 pub selected: Option<Cow<'a, str>>,
853 pub context_label: Cow<'a, str>,
854 pub hint: Cow<'a, str>,
855 pub omitted_label: Cow<'a, str>,
856 pub max_rows: u16,
857 }
858
859 impl<'a> PendingInputPreview<'a> {
860 #[must_use]
861 pub fn new(items: Vec<PendingInputItem<'a>>, context: Vec<ContextPreviewItem<'a>>) -> Self {
862 Self {
863 items,
864 context,
865 selected: None,
866 context_label: Cow::Borrowed("Context"),
867 hint: Cow::Borrowed(""),
868 omitted_label: Cow::Borrowed("more"),
869 max_rows: 8,
870 }
871 }
872
873 /// Select an item by its stable caller ID.
874 #[must_use]
875 pub fn selected(mut self, id: impl Into<Cow<'a, str>>) -> Self {
876 self.selected = Some(id.into());
877 self
878 }
879
880 #[must_use]
881 pub fn context_label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
882 self.context_label = label.into();
883 self
884 }
885
886 /// Replace the action hint. Empty builds one from the item actions.
887 #[must_use]
888 pub fn hint(mut self, hint: impl Into<Cow<'a, str>>) -> Self {
889 self.hint = hint.into();
890 self
891 }
892
893 #[must_use]
894 pub fn omitted_label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
895 self.omitted_label = label.into();
896 self
897 }
898
899 #[must_use]
900 pub fn max_rows(mut self, rows: u16) -> Self {
901 self.max_rows = rows;
902 self
903 }
904
905 #[must_use]
906 pub fn is_empty(&self) -> bool {
907 self.items.is_empty() && self.context.is_empty()
908 }
909
910 #[must_use]
911 pub fn pending_count(&self) -> usize {
912 self.items.len()
913 }
914
915 #[must_use]
916 pub fn selected_index(&self) -> Option<usize> {
917 let selected = self.selected.as_deref()?;
918 self.items.iter().position(|item| &*item.id == selected)
919 }
920
921 /// The union of the actions the items report, in `Send now`, `Edit`,
922 /// `Drop` order. Metadata only; the host dispatches.
923 #[must_use]
924 pub fn actions(&self) -> Vec<PendingInputAction> {
925 PendingInputAction::ALL
926 .into_iter()
927 .filter(|action| {
928 self.items
929 .iter()
930 .any(|item| item.actions().contains(action))
931 })
932 .collect()
933 }
934
935 /// The actions for one caller ID, or `None` when no item has that ID.
936 #[must_use]
937 pub fn actions_for(&self, id: &str) -> Option<&'static [PendingInputAction]> {
938 self.items
939 .iter()
940 .find(|item| &*item.id == id)
941 .map(PendingInputItem::actions)
942 }
943
944 /// Rows this preview wants at `width`. Empty input asks for zero.
945 #[must_use]
946 pub fn height(&self, width: u16, theme: &Theme) -> u16 {
947 self.plan(width, theme).height()
948 }
949
950 fn separator(theme: &Theme) -> &'static str {
951 if theme.ascii() { " / " } else { " · " }
952 }
953
954 fn hint_text(&self, _theme: &Theme) -> String {
955 if !self.hint.is_empty() {
956 return text::display_safe(&self.hint).into_owned();
957 }
958 self.actions()
959 .iter()
960 .map(|action| action.word())
961 .collect::<Vec<_>>()
962 .join(" / ")
963 }
964
965 fn summary_text(&self, theme: &Theme) -> String {
966 let mut parts = Vec::new();
967 for status in PendingInputStatus::ALL {
968 let count = self
969 .items
970 .iter()
971 .filter(|item| item.status == status)
972 .count();
973 if count > 0 {
974 parts.push(format!("{count} {}", status.word()));
975 }
976 }
977 if parts.is_empty() && !self.context.is_empty() {
978 parts.push(format!(
979 "{} {}",
980 self.context.len(),
981 text::display_safe(&self.context_label)
982 ));
983 }
984 parts.join(Self::separator(theme))
985 }
986
987 fn summary_line(&self, theme: &Theme) -> Line<'static> {
988 let mut spans = Vec::new();
989 let separator = Self::separator(theme);
990 let mut first = true;
991 for status in PendingInputStatus::ALL {
992 let count = self
993 .items
994 .iter()
995 .filter(|item| item.status == status)
996 .count();
997 if count == 0 {
998 continue;
999 }
1000 if !first {
1001 spans.push(Span::styled(separator, theme.fg(Role::BorderStrong)));
1002 }
1003 spans.push(Span::styled(
1004 format!("{count} {}", status.word()),
1005 theme.fg(status.role()),
1006 ));
1007 first = false;
1008 }
1009 if first {
1010 spans.push(Span::styled(
1011 format!(
1012 "{} {}",
1013 self.context.len(),
1014 text::display_safe(&self.context_label)
1015 ),
1016 theme.fg(Role::Muted),
1017 ));
1018 }
1019 Line::from(spans)
1020 }
1021
1022 fn context_line(&self, item: &ContextPreviewItem<'_>, theme: &Theme) -> Line<'static> {
1023 let mut spans = vec![
1024 Span::styled(item.state.glyph(theme), theme.fg(item.state.role())),
1025 Span::raw(" "),
1026 Span::styled(item.state.word(), theme.fg(item.state.role())),
1027 Span::raw(" / "),
1028 Span::styled(
1029 text::display_safe(&item.label).into_owned(),
1030 theme.fg(Role::Foreground),
1031 ),
1032 ];
1033 if let Some(detail) = &item.detail {
1034 spans.push(Span::styled(
1035 format!(" ({})", text::display_safe(detail)),
1036 theme.fg(Role::Muted),
1037 ));
1038 }
1039 Line::from(spans)
1040 }
1041
1042 fn item_line(&self, index: usize, theme: &Theme) -> Line<'static> {
1043 let item = &self.items[index];
1044 let selected = self.selected.as_deref() == Some(&*item.id);
1045 let marker = if selected {
1046 if theme.ascii() { "> " } else { "▸ " }
1047 } else {
1048 " "
1049 };
1050 let mut spans = vec![
1051 Span::styled(marker, theme.fg(Role::Primary)),
1052 Span::styled(item.status.glyph(theme), theme.fg(item.status.role())),
1053 Span::raw(" "),
1054 Span::styled(
1055 text::display_safe(item.word()).into_owned(),
1056 theme.fg(item.status.role()).add_modifier(Modifier::BOLD),
1057 ),
1058 Span::styled(": ", theme.fg(Role::BorderStrong)),
1059 ];
1060 if !item.label.is_empty() {
1061 spans.push(Span::styled(
1062 format!("{} ", text::display_safe(&item.label)),
1063 theme.fg(Role::Muted),
1064 ));
1065 }
1066 spans.push(Span::styled(
1067 one_line(&item.content),
1068 theme.fg(Role::Foreground),
1069 ));
1070 if selected {
1071 let actions = item.actions();
1072 if !actions.is_empty() {
1073 spans.push(Span::raw(" "));
1074 for (position, action) in actions.iter().enumerate() {
1075 if position > 0 {
1076 spans.push(Span::styled(" / ", theme.fg(Role::BorderStrong)));
1077 }
1078 spans.push(Span::styled(action.word(), theme.fg(action.role())));
1079 }
1080 }
1081 }
1082 Line::from(spans)
1083 }
1084
1085 fn single_line(&self, width: usize, theme: &Theme) -> Line<'static> {
1086 let ascii = theme.ascii();
1087 let hint = self.hint_text(theme);
1088 let summary = self.summary_text(theme);
1089 let separator = Self::separator(theme);
1090 let shown = if hint.is_empty() {
1091 text::truncate_words(&summary, width, ascii).into_owned()
1092 } else {
1093 let full = format!("{summary}{separator}{hint}");
1094 let short = format!(
1095 "{} pending{separator}{hint}",
1096 self.items.len() + self.context.len()
1097 );
1098 if text::width(&full) <= width {
1099 full
1100 } else if text::width(&short) <= width {
1101 short
1102 } else {
1103 text::truncate_words(&hint, width, ascii).into_owned()
1104 }
1105 };
1106 Line::from(Span::styled(shown, theme.fg(Role::Foreground)))
1107 }
1108
1109 fn plan(&self, width: u16, theme: &Theme) -> PendingPlan {
1110 let mut plan = PendingPlan::new(width, self.max_rows);
1111 if width < 4 || self.is_empty() {
1112 return plan;
1113 }
1114 plan.one_row = Some(PaintedRow {
1115 line: self.single_line(usize::from(width), theme),
1116 selected: false,
1117 });
1118 plan.push(self.summary_line(theme));
1119 let selected = self.selected_index();
1120 if let Some(index) = selected {
1121 plan.rows.push(PaintedRow {
1122 line: self.item_line(index, theme),
1123 selected: true,
1124 });
1125 }
1126 for item in &self.context {
1127 plan.push(self.context_line(item, theme));
1128 }
1129 for (index, _) in self.items.iter().enumerate() {
1130 if selected != Some(index) {
1131 plan.push(self.item_line(index, theme));
1132 }
1133 }
1134 let hint = self.hint_text(theme);
1135 if !hint.is_empty() {
1136 plan.tail = Some(PendingTail {
1137 hint,
1138 omitted: text::display_safe(&self.omitted_label).into_owned(),
1139 hint_style: theme.fg(Role::Primary),
1140 omitted_style: theme.fg(Role::Muted),
1141 });
1142 }
1143 plan
1144 }
1145 }
1146
1147 fn one_line(value: &str) -> String {
1148 text::display_safe(&value.replace(['\n', '\r', '\t'], " ")).into_owned()
1149 }
1150
1151 impl Paint for PendingInputPreview<'_> {
1152 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
1153 let area = area.intersection(buf.area);
1154 self.plan(area.width, theme).paint(area, buf, theme);
1155 }
1156 fn height(&self, width: u16, theme: &Theme) -> u16 {
1157 self.plan(width, theme).height()
1158 }
1159 }
1160
1160 lines RUST