返回 CodeWhale
decision_card.rs
根目录 / crates / tui / src / tui / widgets / decision_card.rs
1 //! Decision-card widget for structured user input.
2 //!
3 //! When Brother Whale needs input, it surfaces a decision card: a labelled
4 //! question followed by numbered options, with the default option highlighted.
5 //! The user navigates with 1-9 keys (or j/k / Up/Down) and confirms with
6 //! Enter. Every decision is logged so the user can inspect the choice later.
7 //!
8 //! This replaces vague "what should I do?" prompts with a structured choice
9 //! surface — acceptance criterion from the v0.8.43 truth-surface tracker.
10
11 use crate::localization::truncate_to_width;
12 use ratatui::{
13 buffer::Buffer,
14 layout::Rect,
15 style::{Modifier, Style},
16 widgets::{Block, Borders, Widget},
17 };
18
19 use crate::palette;
20
21 use super::renderable::Renderable;
22
23 /// A single option in a decision card.
24 #[derive(Debug, Clone)]
25 pub struct DecisionOption {
26 /// Short label for the option (e.g. "Apply the patch").
27 pub label: String,
28 /// Optional longer description shown below the label.
29 pub description: Option<String>,
30 }
31
32 /// A decision card surfacing a structured choice to the user.
33 #[derive(Debug, Clone)]
34 pub struct DecisionCard {
35 /// The question or prompt the user is answering.
36 pub question: String,
37 /// The available options. Each is numbered 1..N.
38 pub options: Vec<DecisionOption>,
39 /// Index into `options` of the default (highlighted) choice.
40 pub default_index: usize,
41 /// Index of the currently selected option.
42 pub selected_index: usize,
43 /// Whether the card has been submitted (Enter pressed).
44 pub confirmed: bool,
45 /// The index that was confirmed, if any.
46 pub confirmed_index: Option<usize>,
47 }
48
49 impl DecisionCard {
50 pub fn new(question: String, options: Vec<DecisionOption>, default_index: usize) -> Self {
51 let default = default_index.min(options.len().saturating_sub(1));
52 Self {
53 question,
54 options,
55 default_index: default,
56 selected_index: default,
57 confirmed: false,
58 confirmed_index: None,
59 }
60 }
61
62 /// Number of options.
63 pub fn option_count(&self) -> usize {
64 self.options.len()
65 }
66
67 /// Move selection up (wrap around).
68 pub fn select_prev(&mut self) {
69 if self.option_count() == 0 {
70 return;
71 }
72 self.selected_index = self
73 .selected_index
74 .checked_sub(1)
75 .unwrap_or(self.option_count() - 1);
76 }
77
78 /// Move selection down (wrap around).
79 pub fn select_next(&mut self) {
80 if self.option_count() == 0 {
81 return;
82 }
83 self.selected_index = (self.selected_index + 1) % self.option_count();
84 }
85
86 /// Select by number key (1-based).
87 pub fn select_number(&mut self, n: usize) {
88 if n > 0 && n <= self.option_count() {
89 self.selected_index = n - 1;
90 }
91 }
92
93 /// Confirm the current selection.
94 pub fn confirm(&mut self) {
95 self.confirmed = true;
96 self.confirmed_index = Some(self.selected_index);
97 }
98
99 /// Get the label of the confirmed option, if any.
100 pub fn confirmed_label(&self) -> Option<&str> {
101 self.confirmed_index
102 .and_then(|i| self.options.get(i))
103 .map(|opt| opt.label.as_str())
104 }
105 }
106
107 impl Default for DecisionCard {
108 fn default() -> Self {
109 Self::new(String::new(), Vec::new(), 0)
110 }
111 }
112
113 impl Renderable for DecisionCard {
114 fn render(&self, area: Rect, buf: &mut Buffer) {
115 if area.width < 4 || area.height < 3 {
116 return;
117 }
118
119 let border_style = Style::default().fg(palette::WHALE_ACTION);
120 let question_style = Style::default()
121 .fg(palette::TEXT_BODY)
122 .add_modifier(Modifier::BOLD);
123 let dim_style = Style::default().fg(palette::TEXT_MUTED);
124 let selected_style = Style::default()
125 .fg(palette::WHALE_ACTION)
126 .add_modifier(Modifier::BOLD);
127
128 let block = Block::default()
129 .borders(Borders::ALL)
130 .border_style(border_style)
131 .title(" Decision Required ")
132 .title_style(question_style);
133 let inner = block.inner(area);
134 block.render(area, buf);
135
136 if inner.width < 2 || inner.height < 2 {
137 return;
138 }
139
140 let mut y = inner.y;
141
142 // Question line
143 let question = truncate_to_width(&self.question, inner.width as usize);
144 buf.set_string(inner.x, y, &question, question_style);
145 y += 1;
146
147 if y >= inner.y + inner.height {
148 return;
149 }
150
151 // Separator
152 let sep = "─".repeat(inner.width as usize);
153 buf.set_string(inner.x, y, &sep, dim_style);
154 y += 1;
155
156 // Options
157 let max_options = (inner.y + inner.height).saturating_sub(y) as usize;
158 for (i, option) in self.options.iter().enumerate().take(max_options) {
159 if y >= inner.y + inner.height {
160 break;
161 }
162
163 let num = format!("{}.", i + 1);
164 let is_selected = i == self.selected_index;
165 let style = if is_selected {
166 selected_style
167 } else {
168 dim_style
169 };
170
171 // "1. Label (default)" or "1. Label"
172 let mut label = format!("{} {}", num, option.label);
173 if i == self.default_index {
174 label.push_str(" (default)");
175 }
176 label = truncate_to_width(&label, inner.width.saturating_sub(1) as usize);
177
178 let prefix = if is_selected { "▸ " } else { " " };
179 let full_label = format!("{prefix}{label}");
180 buf.set_string(inner.x, y, &full_label, style);
181 y += 1;
182
183 // Description line if present
184 if let Some(ref desc) = option.description
185 && y < inner.y + inner.height
186 {
187 let desc = format!(
188 " {}",
189 truncate_to_width(desc, inner.width.saturating_sub(5) as usize)
190 );
191 buf.set_string(inner.x, y, &desc, dim_style);
192 y += 1;
193 }
194 }
195
196 // Footer hint
197 if y < inner.y + inner.height {
198 let hint = "1-9 select · j/k navigate · Enter confirm";
199 let hint = truncate_to_width(hint, inner.width as usize);
200 buf.set_string(inner.x, y, &hint, dim_style);
201 }
202 }
203
204 fn desired_height(&self, _width: u16) -> u16 {
205 // question + separator + options + footer
206 let option_lines: u16 = self
207 .options
208 .iter()
209 .map(|o| if o.description.is_some() { 2 } else { 1 })
210 .sum();
211 // 2 for borders, 1 question, 1 separator, options, 1 footer
212 2 + 1 + 1 + option_lines + 1
213 }
214 }
215
216 #[cfg(test)]
217 mod tests {
218 use super::truncate_to_width;
219 use unicode_width::UnicodeWidthStr;
220
221 /// 2026-08-04: this file carried its own `truncate_to_width` that counted
222 /// CHARS, not display columns, so wide text overflowed the card border —
223 /// `"数据库迁移任务结果"` at width 7 kept 6 chars, which render as 12
224 /// columns. It now uses the width-aware localization truncator.
225 #[test]
226 fn wide_text_never_exceeds_the_card_width() {
227 for width in [1usize, 4, 7, 12, 20] {
228 for sample in [
229 "数据库迁移任务结果",
230 "ASCII question that is quite long indeed",
231 "mixed 混合 text 内容",
232 ] {
233 let out = truncate_to_width(sample, width);
234 assert!(
235 out.width() <= width,
236 "width {width}: {out:?} renders {} columns",
237 out.width()
238 );
239 }
240 }
241 }
242 }
243
243 lines RUST