boxmux_lib/components/
choice_menu.rs1use crate::components::renderable_content::{
2 ContentEvent, ContentType, EventResult, EventType, RenderableContent, SensitiveMetadata,
3 SensitiveZone,
4};
5use crate::model::choice::Choice;
6use crate::Bounds;
7
8pub struct ChoiceMenu<'a> {
13 choices: &'a [Choice],
15 selected_index: Option<usize>,
17 focused_index: Option<usize>,
19 _id: String,
21}
22
23impl<'a> ChoiceMenu<'a> {
24 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 pub fn with_selection(mut self, selected_index: Option<usize>) -> Self {
36 self.selected_index = selected_index;
37 self
38 }
39
40 pub fn with_focus(mut self, focused_index: Option<usize>) -> Self {
42 self.focused_index = focused_index;
43 self
44 }
45
46 fn generate_choice_content(&self) -> String {
48 self.generate_choice_lines().join("\n")
49 }
50
51 pub fn get_raw_content(&self) -> String {
53 self.generate_choice_content()
54 }
55
56 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 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 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 let zone_bounds = Bounds::new(
80 0, index, line_chars.saturating_sub(1), index, );
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 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, current_line, line_width.saturating_sub(1), current_line, );
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 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 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 if let Some(content) = &choice.content {
164 line.push_str(content);
165
166 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 fn get_raw_content(&self) -> String {
182 self.generate_choice_content()
183 }
184
185 fn get_box_relative_sensitive_zones(&self) -> Vec<SensitiveZone> {
187 self.get_box_relative_sensitive_zones_with_width(usize::MAX)
188 }
189
190 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 self.selected_index = Some(choice_index);
206
207 log::info!(
208 "CHOICE CLICK: Registered click on choice index {}",
209 choice_index
210 );
211
212 return EventResult::Handled;
214 }
215 }
216 }
217 }
218 EventResult::NotHandled
219 }
220 EventType::Hover => {
221 if event.zone_id.is_some() {
223 EventResult::HandledContinue
224 } else {
225 EventResult::NotHandled
226 }
227 }
228 EventType::KeyPress => {
229 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 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}