ccf_gpui_widgets/widgets/
radio_group.rs1use gpui::prelude::*;
62use gpui::*;
63
64use crate::theme::{get_theme_or, Theme};
65use super::focus_navigation::{handle_tab_navigation, with_focus_actions, EnabledCursorExt};
66use super::selection::{SelectionItem, StringItem};
67
68#[derive(Clone, Debug)]
70pub enum RadioGroupEvent<T: SelectionItem> {
71 Change(T),
73}
74
75pub struct RadioGroup<T: SelectionItem = StringItem> {
80 items: Vec<T>,
81 selected: T,
82 focus_handle: FocusHandle,
83 highlight_index: usize,
84 custom_theme: Option<Theme>,
85 enabled: bool,
87}
88
89impl<T: SelectionItem> EventEmitter<RadioGroupEvent<T>> for RadioGroup<T> {}
90
91impl<T: SelectionItem> Focusable for RadioGroup<T> {
92 fn focus_handle(&self, _cx: &App) -> FocusHandle {
93 self.focus_handle.clone()
94 }
95}
96
97impl RadioGroup<StringItem> {
98 pub fn new(cx: &mut Context<Self>) -> Self {
102 Self {
103 items: Vec::new(),
104 selected: StringItem::new(""),
105 focus_handle: cx.focus_handle().tab_stop(true),
106 highlight_index: 0,
107 custom_theme: None,
108 enabled: true,
109 }
110 }
111
112 #[must_use]
116 pub fn choices(mut self, choices: Vec<String>) -> Self {
117 self.items = choices.into_iter().map(StringItem::new).collect();
118 if !self.items.is_empty() && self.selected.value().is_empty() {
119 self.selected = self.items[0].clone();
120 }
121 self
122 }
123
124 #[must_use]
128 pub fn with_selected_value(mut self, value: &str) -> Self {
129 if let Some(index) = self.items.iter().position(|c| c.value() == value) {
130 self.selected = self.items[index].clone();
131 self.highlight_index = index;
132 }
133 self
134 }
135
136 pub fn set_selected_value(&mut self, value: &str, cx: &mut Context<Self>) {
140 if let Some(index) = self.items.iter().position(|c| c.value() == value) {
141 if self.selected.value() != value {
142 self.selected = self.items[index].clone();
143 self.highlight_index = index;
144 cx.emit(RadioGroupEvent::Change(self.selected.clone()));
145 cx.notify();
146 }
147 }
148 }
149
150 pub fn selected_value(&self) -> &str {
154 self.selected.value()
155 }
156}
157
158impl<T: SelectionItem> RadioGroup<T> {
159 pub fn new_with_items(items: Vec<T>, selected: T, cx: &mut Context<Self>) -> Self {
161 let highlight_index = items.iter().position(|i| *i == selected).unwrap_or(0);
162 Self {
163 items,
164 selected,
165 focus_handle: cx.focus_handle().tab_stop(true),
166 highlight_index,
167 custom_theme: None,
168 enabled: true,
169 }
170 }
171
172 #[must_use]
174 pub fn with_items(mut self, items: Vec<T>) -> Self {
175 self.items = items;
176 if !self.items.is_empty() {
177 self.selected = self.items[0].clone();
178 self.highlight_index = 0;
179 }
180 self
181 }
182
183 #[must_use]
185 pub fn with_selected(mut self, item: T) -> Self {
186 if let Some(index) = self.items.iter().position(|i| *i == item) {
187 self.selected = item;
188 self.highlight_index = index;
189 }
190 self
191 }
192
193 #[must_use]
195 pub fn with_selected_index(mut self, index: usize) -> Self {
196 if let Some(item) = self.items.get(index) {
197 self.selected = item.clone();
198 self.highlight_index = index;
199 }
200 self
201 }
202
203 #[must_use]
205 pub fn theme(mut self, theme: Theme) -> Self {
206 self.custom_theme = Some(theme);
207 self
208 }
209
210 #[must_use]
212 pub fn with_enabled(mut self, enabled: bool) -> Self {
213 self.enabled = enabled;
214 self
215 }
216
217 pub fn selected(&self) -> &T {
219 &self.selected
220 }
221
222 pub fn selected_index(&self) -> usize {
224 self.items.iter().position(|i| *i == self.selected).unwrap_or(0)
225 }
226
227 pub fn set_selected(&mut self, item: T, cx: &mut Context<Self>) {
231 if let Some(index) = self.items.iter().position(|i| *i == item) {
232 if self.selected != item {
233 self.selected = item;
234 self.highlight_index = index;
235 cx.emit(RadioGroupEvent::Change(self.selected.clone()));
236 cx.notify();
237 }
238 }
239 }
240
241 pub fn set_selected_index(&mut self, index: usize, cx: &mut Context<Self>) {
245 if let Some(item) = self.items.get(index).cloned() {
246 if self.selected != item {
247 self.selected = item;
248 self.highlight_index = index;
249 cx.emit(RadioGroupEvent::Change(self.selected.clone()));
250 cx.notify();
251 }
252 }
253 }
254
255 pub fn focus_handle(&self) -> &FocusHandle {
257 &self.focus_handle
258 }
259
260 pub fn is_enabled(&self) -> bool {
262 self.enabled
263 }
264
265 pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
267 if self.enabled != enabled {
268 self.enabled = enabled;
269 cx.notify();
270 }
271 }
272
273 fn select_by_index(&mut self, cx: &mut Context<Self>) {
274 if let Some(item) = self.items.get(self.highlight_index) {
275 if self.selected != *item {
276 self.selected = item.clone();
277 cx.emit(RadioGroupEvent::Change(self.selected.clone()));
278 }
279 }
280 }
281}
282
283impl<T: SelectionItem> Render for RadioGroup<T> {
284 fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
285 let theme = get_theme_or(cx, self.custom_theme.as_ref());
286 let focus_handle = self.focus_handle.clone();
287 let is_focused = self.focus_handle.is_focused(window);
288 let highlight_index = self.highlight_index;
289 let num_items = self.items.len();
290 let enabled = self.enabled;
291
292 with_focus_actions(
293 div()
294 .id("ccf_radio_group")
295 .track_focus(&focus_handle)
296 .tab_stop(enabled),
297 cx,
298 )
299 .on_key_down(cx.listener(move |radio_group, event: &KeyDownEvent, window, cx| {
300 if !radio_group.enabled {
301 return;
302 }
303 if handle_tab_navigation(event, window) {
304 return;
305 }
306 match event.keystroke.key.as_str() {
307 "up" => {
308 if radio_group.highlight_index > 0 {
309 radio_group.highlight_index -= 1;
310 } else if num_items > 0 {
311 radio_group.highlight_index = num_items - 1;
312 }
313 radio_group.select_by_index(cx);
314 cx.notify();
315 }
316 "down" => {
317 if radio_group.highlight_index < num_items.saturating_sub(1) {
318 radio_group.highlight_index += 1;
319 } else {
320 radio_group.highlight_index = 0;
321 }
322 radio_group.select_by_index(cx);
323 cx.notify();
324 }
325 "space" => {
326 radio_group.select_by_index(cx);
327 cx.notify();
328 }
329 _ => {}
330 }
331 }))
332 .flex()
333 .flex_col()
334 .gap_1()
335 .p_2()
336 .when(enabled, |d| d.bg(rgb(theme.bg_input)))
337 .when(!enabled, |d| d.bg(rgb(theme.disabled_bg)))
338 .border_1()
339 .when(enabled, |d| {
340 d.border_color(if is_focused { rgb(theme.border_focus) } else { rgb(theme.border_input) })
341 })
342 .when(!enabled, |d| d.border_color(rgb(theme.disabled_bg)))
343 .rounded_md()
344 .children(self.items.iter().enumerate().map(|(idx, item)| {
345 let item_clone = item.clone();
346 let is_selected = self.selected == *item;
347 let is_highlighted = is_focused && idx == highlight_index && enabled;
348
349 div()
350 .id(item.id())
351 .flex()
352 .flex_row()
353 .gap_2()
354 .items_center()
355 .py_1()
356 .px_1()
357 .cursor_for_enabled(enabled)
358 .rounded_sm()
359 .when(is_highlighted, |d| d.bg(rgb(theme.bg_input_hover)))
360 .when(!is_highlighted && enabled, |d| d.hover(|d| d.bg(rgb(theme.bg_input_hover))))
361 .when(enabled, |d| {
362 d.on_click(cx.listener(move |radio_group, _event, window, cx| {
363 radio_group.focus_handle.focus(window);
364 radio_group.selected = item_clone.clone();
365 radio_group.highlight_index = idx;
366 cx.emit(RadioGroupEvent::Change(item_clone.clone()));
367 cx.notify();
368 }))
369 })
370 .child({
371 let border_color = if enabled { theme.border_checkbox } else { theme.disabled_text };
373 let inner_color = if enabled { theme.accent } else { theme.disabled_text };
374
375 div()
376 .w(px(16.))
377 .h(px(16.))
378 .border_1()
379 .border_color(rgb(border_color))
380 .rounded(px(8.))
381 .when(is_selected, |d| {
382 d.child(
383 div()
384 .flex()
385 .items_center()
386 .justify_center()
387 .size_full()
388 .child(
389 div()
390 .w(px(8.))
391 .h(px(8.))
392 .bg(rgb(inner_color))
393 .rounded(px(4.))
394 )
395 )
396 })
397 })
398 .child(
399 div()
400 .text_sm()
401 .when(enabled, |d| d.text_color(rgb(theme.text_value)))
402 .when(!enabled, |d| d.text_color(rgb(theme.disabled_text)))
403 .child(item.label())
404 )
405 }))
406 }
407}