1use omp_core::Str;
2use smallvec::SmallVec;
3
4use crate::{
5 component::{Component, EventCtx, Flow, Hit, HitTag, PaintCtx, Slot, next_slot},
6 context::{Theme, UiContext},
7 frame::{Color, Frame, Rect, Style},
8 input::{Key, Mouse},
9 props::{Prop, PropValue, Props},
10 rich::cell_width,
11};
12
13#[derive(Default)]
14struct RadioState {
15 options: SmallVec<Str, 8>,
16 idx: u16,
17 spans: SmallVec<(u16, u16), 8>,
18}
19
20pub struct Radio {
22 props: Props,
23 slot: Slot,
24 state: RadioState,
25}
26
27impl Radio {
28 pub fn new() -> Self {
30 Self { props: Props::new(), slot: next_slot(), state: RadioState::default() }
31 }
32
33 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
35 self.props.set(prop, value);
36 if matches!(prop, Prop::Options | Prop::Value) {
37 self.sync_options();
38 }
39 self
40 }
41
42 pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
44 self.props.set(prop, value);
45 if matches!(prop, Prop::Options | Prop::Value) {
46 self.sync_options();
47 }
48 self
49 }
50
51 fn sync_options(&mut self) {
52 self.state.options = self
53 .props
54 .str_of(Prop::Options)
55 .map(|options| {
56 options
57 .split_whitespace()
58 .map(|word| options.slice_ref(word))
59 .collect()
60 })
61 .unwrap_or_default();
62 self.state.idx = self
63 .props
64 .str_of(Prop::Value)
65 .and_then(|value| self.state.options.iter().position(|option| option == value))
66 .unwrap_or(0) as u16;
67 }
68}
69
70impl Default for Radio {
71 fn default() -> Self {
72 Self::new()
73 }
74}
75
76impl Component for Radio {
77 fn props(&self) -> &Props {
78 &self.props
79 }
80
81 fn props_mut(&mut self) -> &mut Props {
82 &mut self.props
83 }
84
85 fn slot(&self) -> Slot {
86 self.slot
87 }
88
89 fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
90 let total = self
91 .state
92 .options
93 .iter()
94 .map(|option| cell_width(option).saturating_add(3))
95 .fold(2u16, u16::saturating_add);
96 (total, total)
97 }
98
99 fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
100 1
101 }
102
103 fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
104 if rect.y >= pc.clip {
105 return;
106 }
107 let focused = pc.focus == Some(self.slot);
108 let hover_chip = match pc.hover {
109 Some((slot, HitTag::Chip(index))) if slot == self.slot => Some(index),
110 _ => None,
111 };
112 self.state.spans.clear();
113 let mut x = pc.frame.put(
114 rect.x,
115 rect.y,
116 if focused {
117 pc.ctx.charset.cursor()
118 } else {
119 " "
120 },
121 Style::new().fg(pc.ctx.theme.accent),
122 );
123 for (index, option) in self.state.options.iter().enumerate() {
124 let start = x.saturating_sub(rect.x);
125 let active = index as u16 == self.state.idx;
126 let hovered = hover_chip == Some(index as u16);
127 if active {
128 x = pill(
129 pc.frame,
130 x,
131 rect.y,
132 option,
133 pc.ctx.theme.accent,
134 pc.ctx.theme.contrast,
135 pc.ctx.charset.pill_caps(),
136 focused || hovered,
137 );
138 } else {
139 let mut style = Style::new().fg(if hovered {
140 pc.ctx.theme.fg
141 } else {
142 pc.ctx.theme.muted
143 });
144 if hovered {
145 style = style.underline();
146 }
147 x = pc.frame.put(x, rect.y, " ", base(&pc.ctx.theme));
148 x = pc.frame.put(x, rect.y, option, style);
149 x = pc.frame.put(x, rect.y, " ", base(&pc.ctx.theme));
150 }
151 let end = x.saturating_sub(rect.x);
152 self.state.spans.push((start, end));
153 pc.hits.push(Hit {
154 rect: Rect::new(rect.x.saturating_add(start), rect.y, end.saturating_sub(start), 1),
155 slot: self.slot,
156 tag: HitTag::Chip(index as u16),
157 });
158 x = pc.frame.put(x, rect.y, " ", base(&pc.ctx.theme));
159 }
160 }
161
162 fn focusable(&self) -> bool {
163 true
164 }
165
166 fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
167 let len = self.state.options.len() as u16;
168 match key {
169 Key::Left if len > 0 => {
170 self.state.idx = (self.state.idx + len - 1) % len;
171 Flow::Consumed
172 },
173 Key::Right if len > 0 => {
174 self.state.idx = (self.state.idx + 1) % len;
175 Flow::Consumed
176 },
177 _ => Flow::Skip,
178 }
179 }
180
181 fn mouse(
182 &mut self,
183 _ec: &mut EventCtx<'_>,
184 tag: HitTag,
185 _at: (u16, u16),
186 _rect: Rect,
187 mouse: Mouse,
188 ) -> Flow {
189 match mouse {
190 Mouse::Click
191 if let HitTag::Chip(index) = tag
192 && usize::from(index) < self.state.options.len() =>
193 {
194 self.state.idx = index;
195 Flow::Consumed
196 },
197 Mouse::Click
198 | Mouse::RightClick
199 | Mouse::MiddleClick
200 | Mouse::Move
201 | Mouse::Drag
202 | Mouse::Release
203 | Mouse::WheelUp
204 | Mouse::WheelDown
205 | Mouse::WheelLeft
206 | Mouse::WheelRight => Flow::Skip,
207 }
208 }
209
210 fn value(&self, out: &mut serde_json::Map<String, serde_json::Value>) {
211 let Some(id) = self.props.id() else {
212 return;
213 };
214 let value = self
215 .state
216 .options
217 .get(usize::from(self.state.idx))
218 .map_or(serde_json::Value::Null, |option| serde_json::Value::String(option.to_string()));
219 out.insert(id.to_string(), value);
220 }
221}
222
223pub(super) fn pill(
224 frame: &mut Frame,
225 x: u16,
226 y: u16,
227 label: &str,
228 background: Color,
229 foreground: Color,
230 caps: (&str, &str),
231 highlight: bool,
232) -> u16 {
233 let background = if highlight {
234 brighten(background)
235 } else {
236 background
237 };
238 let cap = Style::new().fg(background);
239 let body = Style::new().fg(foreground).bg(background).bold();
240 let mut x = frame.put(x, y, caps.0, cap);
241 x = frame.put(x, y, label, body);
242 frame.put(x, y, caps.1, cap)
243}
244
245fn brighten(color: Color) -> Color {
246 match color {
247 Color::Rgb(red, green, blue) => Color::Rgb(
248 red.saturating_add((255 - u16::from(red)) as u8 / 5),
249 green.saturating_add((255 - u16::from(green)) as u8 / 5),
250 blue.saturating_add((255 - u16::from(blue)) as u8 / 5),
251 ),
252 other => other,
253 }
254}
255
256const fn base(theme: &Theme) -> Style {
257 Style::new().fg(theme.fg)
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use crate::{Frame, Size, test_support::frame_row_text};
264
265 fn event_ctx(ctx: &UiContext) -> EventCtx<'_> {
266 EventCtx::new(ctx, 40, 1)
267 }
268
269 #[test]
270 fn left_and_right_cycle_and_export_value() {
271 let mut radio = Radio::new()
272 .with(Prop::Id, "mode")
273 .with(Prop::Options, "one two three");
274 let ctx = UiContext::default();
275 assert_eq!(radio.key(&mut event_ctx(&ctx), Key::Left), Flow::Consumed);
276 let mut values = serde_json::Map::new();
277 radio.value(&mut values);
278 assert_eq!(values["mode"], serde_json::json!("three"));
279 assert_eq!(radio.key(&mut event_ctx(&ctx), Key::Right), Flow::Consumed);
280 }
281
282 #[test]
283 fn paint_draws_chips_and_hits() {
284 let mut radio = Radio::new().with(Prop::Options, "one two");
285 let ctx = UiContext::default();
286 let mut frame = Frame::new(Size::new(32, 1));
287 let mut hits = Vec::new();
288 let slot = radio.slot();
289 let mut wakes = Vec::new();
290 let mut pc = PaintCtx::new(&mut frame, &ctx, &mut hits, &mut wakes);
291 pc.focus = Some(slot);
292 radio.paint(&mut pc, Rect::new(0, 0, 32, 1));
293 assert!(frame_row_text(&frame, 0).contains("one"));
294 assert_eq!(hits.len(), 2);
295 }
296}