| 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 |