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