Skip to main content

boxmux_lib/components/
choice_menu.rs

1use crate::components::renderable_content::{
2    ContentEvent, ContentType, EventResult, EventType, RenderableContent, SensitiveMetadata,
3    SensitiveZone,
4};
5use crate::model::choice::Choice;
6use crate::Bounds;
7
8/// ChoiceMenu component - generates choice content and sensitive zones for BoxRenderer
9///
10/// This component handles choice logic (selection, waiting states, content generation)
11/// and outputs content strings and sensitive zones that BoxRenderer treats like any other content.
12pub struct ChoiceMenu<'a> {
13    /// Reference to the choices data
14    choices: &'a [Choice],
15    /// Selected choice index (if any)
16    selected_index: Option<usize>,
17    /// Focused choice index for keyboard navigation
18    focused_index: Option<usize>,
19    /// Component identifier
20    _id: String,
21}
22
23impl<'a> ChoiceMenu<'a> {
24    /// Create a new choice menu component
25    pub fn new(id: String, choices: &'a [Choice]) -> Self {
26        Self {
27            choices,
28            selected_index: None,
29            focused_index: None,
30            _id: id,
31        }
32    }
33
34    /// Set the selected choice index
35    pub fn with_selection(mut self, selected_index: Option<usize>) -> Self {
36        self.selected_index = selected_index;
37        self
38    }
39
40    /// Set the focused choice index for keyboard navigation
41    pub fn with_focus(mut self, focused_index: Option<usize>) -> Self {
42        self.focused_index = focused_index;
43        self
44    }
45
46    /// Generate formatted choice content as a single string
47    fn generate_choice_content(&self) -> String {
48        self.generate_choice_lines().join("\n")
49    }
50
51    /// Get content as raw string for BoxRenderer to handle like text content
52    pub fn get_raw_content(&self) -> String {
53        self.generate_choice_content()
54    }
55
56    /// Generate sensitive zones accounting for text wrapping at specified width
57    pub fn get_box_relative_sensitive_zones_with_width(
58        &self,
59        available_width: usize,
60    ) -> Vec<SensitiveZone> {
61        let mut zones = Vec::new();
62        let mut current_line = 0;
63
64        // Generate the actual content that will be rendered (with indicators and waiting states)
65        let content_lines = self.generate_choice_lines();
66
67        for (index, choice) in self.choices.iter().enumerate() {
68            if let Some(choice_content) = &choice.content {
69                // Get the actual formatted line for this choice (with indicators)
70                let formatted_line = &content_lines[index];
71                let line_chars = formatted_line.chars().count();
72
73                if available_width == usize::MAX || line_chars <= available_width {
74                    // Single line case - no wrapping needed. The y coordinate is the
75                    // choice index, exactly matching the row the renderer draws this
76                    // choice on, and the width is measured in display characters so it
77                    // lines up with the rendered glyphs (the indicator prefix and any
78                    // multi-byte characters count as one cell each).
79                    let zone_bounds = Bounds::new(
80                        0,                          // left edge of content area
81                        index,                      // row == choice index
82                        line_chars.saturating_sub(1), // last drawn character
83                        index,                      // single line height
84                    );
85
86                    let metadata = SensitiveMetadata {
87                        display_text: Some(formatted_line.clone()),
88                        tooltip: Some(format!("Choice {}: {}", index, choice_content)),
89                        selected: choice.selected,
90                        enabled: !choice.waiting,
91                        original_line: Some(index),
92                        char_range: Some((0, line_chars)),
93                    };
94
95                    zones.push(SensitiveZone::with_metadata(
96                        zone_bounds,
97                        format!("choice_{}", index),
98                        ContentType::Choice,
99                        metadata,
100                    ));
101
102                    current_line = index + 1;
103                } else {
104                    // Multi-line case - choice wraps across multiple lines
105                    let mut remaining_text = formatted_line.clone();
106                    let mut char_offset = 0;
107
108                    while !remaining_text.is_empty() {
109                        let line_width = remaining_text.len().min(available_width);
110                        let line_text = remaining_text[..line_width].to_string();
111
112                        let zone_bounds = Bounds::new(
113                            0,                            // Start at x=0 (left edge of content area)
114                            current_line,                 // y position for this wrapped segment
115                            line_width.saturating_sub(1), // End x coordinate for this segment
116                            current_line,                 // Single line height, so y2 == y1
117                        );
118
119                        let metadata = SensitiveMetadata {
120                            display_text: Some(line_text),
121                            tooltip: Some(format!("Choice {}: {} (part)", index, choice_content)),
122                            selected: choice.selected,
123                            enabled: !choice.waiting,
124                            original_line: Some(index),
125                            char_range: Some((char_offset, char_offset + line_width)),
126                        };
127
128                        zones.push(SensitiveZone::with_metadata(
129                            zone_bounds,
130                            format!("choice_{}", index),
131                            ContentType::Choice,
132                            metadata,
133                        ));
134
135                        remaining_text = remaining_text[line_width..].to_string();
136                        char_offset += line_width;
137                        current_line += 1;
138                    }
139                }
140            }
141        }
142
143        zones
144    }
145
146    /// Generate the formatted choice lines as they will actually be rendered
147    pub fn generate_choice_lines(&self) -> Vec<String> {
148        let mut content_lines = Vec::new();
149
150        for (index, choice) in self.choices.iter().enumerate() {
151            let mut line = String::new();
152
153            // Add selection indicator
154            if self.selected_index == Some(index) {
155                line.push_str("► ");
156            } else if self.focused_index == Some(index) {
157                line.push_str("• ");
158            } else {
159                line.push_str("  ");
160            }
161
162            // Add choice content
163            if let Some(content) = &choice.content {
164                line.push_str(content);
165
166                // Add waiting indicator
167                if choice.waiting {
168                    line.push_str("...");
169                }
170            }
171
172            content_lines.push(line);
173        }
174
175        content_lines
176    }
177}
178
179impl<'a> RenderableContent for ChoiceMenu<'a> {
180    /// Get the raw content as a string
181    fn get_raw_content(&self) -> String {
182        self.generate_choice_content()
183    }
184
185    /// Get sensitive zones in box-relative coordinates (0,0 = top-left of content area)
186    fn get_box_relative_sensitive_zones(&self) -> Vec<SensitiveZone> {
187        self.get_box_relative_sensitive_zones_with_width(usize::MAX)
188    }
189
190    /// Handle content events on choice menu
191    /// Note: This doesn't mutate the choices directly - that's handled at the MuxBox level
192    fn handle_event(&mut self, event: &ContentEvent) -> EventResult {
193        match event.event_type {
194            EventType::Click => {
195                if let Some(zone_id) = &event.zone_id {
196                    log::info!(
197                        "CHOICE CLICK: ChoiceMenu handling click on zone '{}'",
198                        zone_id
199                    );
200
201                    if let Some(index_str) = zone_id.strip_prefix("choice_") {
202                        if let Ok(choice_index) = index_str.parse::<usize>() {
203                            if choice_index < self.choices.len() {
204                                // Store the clicked choice index for external handling
205                                self.selected_index = Some(choice_index);
206
207                                log::info!(
208                                    "CHOICE CLICK: Registered click on choice index {}",
209                                    choice_index
210                                );
211
212                                // Actual choice mutation happens at the MuxBox level
213                                return EventResult::Handled;
214                            }
215                        }
216                    }
217                }
218                EventResult::NotHandled
219            }
220            EventType::Hover => {
221                // Handle hover for choice highlighting
222                if event.zone_id.is_some() {
223                    EventResult::HandledContinue
224                } else {
225                    EventResult::NotHandled
226                }
227            }
228            EventType::KeyPress => {
229                // Handle keyboard navigation
230                if let Some(key_info) = event.key_info() {
231                    match key_info.key.as_str() {
232                        "ArrowUp" | "ArrowDown" | "Enter" | "Space" => EventResult::Handled,
233                        _ => EventResult::NotHandled,
234                    }
235                } else {
236                    EventResult::NotHandled
237                }
238            }
239            _ => EventResult::NotHandled,
240        }
241    }
242
243    /// Get the raw content dimensions before any transformations
244    fn get_dimensions(&self) -> (usize, usize) {
245        let content = self.generate_choice_content();
246        let lines: Vec<&str> = content.lines().collect();
247        let height = lines.len();
248        let width = lines
249            .iter()
250            .map(|line| line.chars().count())
251            .max()
252            .unwrap_or(0);
253        (width, height)
254    }
255}