Skip to main content

omp_tui/components/
button.rs

1use omp_core::Str;
2
3use super::radio::pill;
4use crate::{
5	component::{Component, EventCtx, Flow, Hit, HitTag, PaintCtx, Slot, next_slot},
6	context::UiContext,
7	frame::Rect,
8	input::{Key, Mouse, UiEvent},
9	props::{Prop, PropValue, Props},
10	rich::cell_width,
11};
12
13#[derive(Default)]
14struct ButtonState {
15	armed: bool,
16}
17
18/// A pressable action button.
19pub struct Button {
20	props: Props,
21	slot:  Slot,
22	state: ButtonState,
23	label: Str,
24}
25
26impl Button {
27	/// Creates an unlabeled button.
28	pub fn new() -> Self {
29		Self {
30			props: Props::new(),
31			slot:  next_slot(),
32			state: ButtonState::default(),
33			label: Str::default(),
34		}
35	}
36
37	/// Sets one button property.
38	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
39		self.props.set(prop, value);
40		self
41	}
42
43	/// Sets one button property from a string.
44	pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
45		self.props.set(prop, value);
46		self
47	}
48
49	/// Sets the button's text child.
50	pub fn child(mut self, label: impl Into<Str>) -> Self {
51		let label = label.into();
52		if self.label.is_empty() {
53			self.label = label;
54		} else {
55			self.label = Str::from(format!("{}{}", self.label, label));
56		}
57		self
58	}
59
60	fn label(&self) -> &str {
61		if !self.label.is_empty() {
62			&self.label
63		} else if let Some(label) = self.props.str_of(Prop::Label) {
64			label
65		} else if let Some(id) = self.props.id() {
66			id
67		} else {
68			"ok"
69		}
70	}
71
72	fn press(&mut self) -> Flow {
73		if self.props.flag(Prop::Confirm) && !self.state.armed {
74			self.state.armed = true;
75			return Flow::Consumed;
76		}
77		self.state.armed = false;
78		if self.props.flag(Prop::Cancel) {
79			Flow::Event(UiEvent::Cancel)
80		} else if self.props.flag(Prop::Submit) {
81			Flow::Event(UiEvent::Submit)
82		} else if let Some(id) = self.props.id() {
83			Flow::Event(UiEvent::Pressed(id.clone()))
84		} else {
85			Flow::Event(UiEvent::Submit)
86		}
87	}
88}
89
90impl Default for Button {
91	fn default() -> Self {
92		Self::new()
93	}
94}
95
96impl Component for Button {
97	fn props(&self) -> &Props {
98		&self.props
99	}
100
101	fn props_mut(&mut self) -> &mut Props {
102		&mut self.props
103	}
104
105	fn slot(&self) -> Slot {
106		self.slot
107	}
108
109	fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
110		let width = cell_width(self.label()).saturating_add(4);
111		(width, width)
112	}
113
114	fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
115		1
116	}
117
118	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
119		if rect.y >= pc.clip {
120			return;
121		}
122		pc.hits
123			.push(Hit { rect, slot: self.slot, tag: HitTag::Press });
124		let focused = pc.focus == Some(self.slot);
125		let hovered = matches!(pc.hover, Some((slot, _)) if slot == self.slot);
126		let accent = self.props.flag(Prop::Accent) || self.props.flag(Prop::Submit);
127		let (text, background, foreground) = if self.state.armed {
128			("sure?", pc.ctx.theme.warn, pc.ctx.theme.contrast)
129		} else if accent {
130			(self.label(), pc.ctx.theme.accent, pc.ctx.theme.contrast)
131		} else {
132			(self.label(), pc.ctx.theme.surface, pc.ctx.theme.fg)
133		};
134		pill(
135			pc.frame,
136			rect.x,
137			rect.y,
138			text,
139			background,
140			foreground,
141			pc.ctx.charset.pill_caps(),
142			focused || hovered,
143		);
144	}
145
146	fn focusable(&self) -> bool {
147		true
148	}
149
150	fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
151		match key {
152			Key::Enter | Key::Space => self.press(),
153			_ => {
154				self.state.armed = false;
155				Flow::Skip
156			},
157		}
158	}
159
160	fn mouse(
161		&mut self,
162		_ec: &mut EventCtx<'_>,
163		tag: HitTag,
164		_at: (u16, u16),
165		_rect: Rect,
166		mouse: Mouse,
167	) -> Flow {
168		match mouse {
169			Mouse::Click if tag == HitTag::Press => self.press(),
170			Mouse::Click
171			| Mouse::RightClick
172			| Mouse::MiddleClick
173			| Mouse::Move
174			| Mouse::Drag
175			| Mouse::Release
176			| Mouse::WheelUp
177			| Mouse::WheelDown
178			| Mouse::WheelLeft
179			| Mouse::WheelRight => Flow::Skip,
180		}
181	}
182}
183
184#[cfg(test)]
185mod tests {
186	use super::*;
187	use crate::{Frame, Size, test_support::frame_row_text};
188
189	fn event_ctx(ctx: &UiContext) -> EventCtx<'_> {
190		EventCtx::new(ctx, 16, 1)
191	}
192
193	#[test]
194	fn enter_emits_matching_application_event() {
195		let ctx = UiContext::default();
196		let mut submit = Button::new().with(Prop::Submit, true).child("Go");
197		assert_eq!(submit.key(&mut event_ctx(&ctx), Key::Enter), Flow::Event(UiEvent::Submit));
198		let mut plain = Button::new().with(Prop::Id, "again").child("Again");
199		assert_eq!(
200			plain.key(&mut event_ctx(&ctx), Key::Enter),
201			Flow::Event(UiEvent::Pressed(Str::from("again")))
202		);
203	}
204
205	#[test]
206	fn confirm_arms_before_emitting() {
207		let ctx = UiContext::default();
208		let mut button = Button::new().with(Prop::Confirm, true);
209		assert_eq!(button.key(&mut event_ctx(&ctx), Key::Enter), Flow::Consumed);
210		assert_eq!(button.key(&mut event_ctx(&ctx), Key::Enter), Flow::Event(UiEvent::Submit));
211	}
212
213	#[test]
214	fn paint_draws_label_and_press_hit() {
215		let mut button = Button::new().child("Continue");
216		let ctx = UiContext::default();
217		let mut frame = Frame::new(Size::new(24, 1));
218		let mut hits = Vec::new();
219		let slot = button.slot();
220		let mut wakes = Vec::new();
221		let mut pc = PaintCtx::new(&mut frame, &ctx, &mut hits, &mut wakes);
222		pc.focus = Some(slot);
223		button.paint(&mut pc, Rect::new(0, 0, 24, 1));
224		assert!(frame_row_text(&frame, 0).contains("Continue"));
225		assert_eq!(hits[0].tag, HitTag::Press);
226	}
227}