Skip to main content

omp_tui/components/
radio.rs

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
20/// A compact single-choice row of chips backing the `<radio>` markup tag.
21pub struct Radio {
22	props: Props,
23	slot:  Slot,
24	state: RadioState,
25}
26
27impl Radio {
28	/// Creates an empty radio group.
29	pub fn new() -> Self {
30		Self { props: Props::new(), slot: next_slot(), state: RadioState::default() }
31	}
32
33	/// Sets one radio property.
34	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	/// Sets one radio property from a string.
43	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}