Skip to main content

omp_tui/components/
shader.rs

1//! Half-block shader viewport: the retained face of [`crate::shader`].
2
3use crate::{
4	anim::FRAME,
5	component::{Component, PaintCtx, Slot, next_slot},
6	context::UiContext,
7	frame::Rect,
8	markup::Dim,
9	props::{Prop, PropValue, Props},
10	shader::{Program, Surface},
11};
12
13/// A live effect viewport that renders a [`Program`] into half-block cells.
14///
15/// The program advances on the shared presentation clock and repaints at
16/// [`FRAME`] cadence while presented; a [`still`](Self::still) program
17/// paints once and requests nothing. Unlit cells stay transparent, so set
18/// `bg` for a backdrop. Mount it in `dom!` as an expression child, or
19/// register a factory for a `<shader>` markup tag through
20/// [`crate::Elements`].
21///
22/// ```
23/// use omp_tui::{components::Shader, dom, scene::vec3};
24///
25/// // A still radial glow; animated effects implement `shader::Program`.
26/// let glow = |x: f32, y: f32| {
27/// 	let (dx, dy) = (x - 12.0, y - 8.0);
28/// 	let fall = 1.0 - ((dx * dx + dy * dy).sqrt() / 10.0).min(1.0);
29/// 	(vec3(0.4, 0.8, 1.0) * fall, fall)
30/// };
31/// let tree = dom! {
32/// 	<col>
33/// 		{Shader::new(glow).size(24, 8).still()}
34/// 	</col>
35/// };
36/// # let _ = tree;
37/// ```
38pub struct Shader {
39	props:   Props,
40	slot:    Slot,
41	program: Box<dyn Program>,
42	surface: Surface,
43	cols:    u16,
44	rows:    u16,
45	live:    bool,
46}
47
48impl Shader {
49	/// Creates a 24×8-cell viewport over `program`.
50	pub fn new(program: impl Program + 'static) -> Self {
51		Self {
52			props:   Props::new(),
53			slot:    next_slot(),
54			program: Box::new(program),
55			surface: Surface::new(),
56			cols:    24,
57			rows:    8,
58			live:    true,
59		}
60	}
61
62	/// Sets the viewport size in terminal cells.
63	///
64	/// A live `w` (in cells) or `h` prop overrides this, so a host resizes
65	/// the viewport with [`crate::Ui::set_prop`] / [`crate::Ui::set_height`]
66	/// instead of rebuilding the tree.
67	pub const fn size(mut self, cols: u16, rows: u16) -> Self {
68		self.cols = cols;
69		self.rows = rows;
70		self
71	}
72
73	/// Viewport columns: a cell-valued `w` prop wins over the built size.
74	/// A percentage `w` has no parent width at measure time and is ignored.
75	fn viewport_cols(&self) -> u16 {
76		match self.props.w() {
77			Some(Dim::Cells(cols)) => cols,
78			_ => self.cols,
79		}
80	}
81
82	/// Viewport rows: an `h` prop wins over the built size.
83	fn viewport_rows(&self) -> u16 {
84		self.props.h().unwrap_or(self.rows)
85	}
86
87	/// Paints once instead of animating — for programs that ignore the clock.
88	pub const fn still(mut self) -> Self {
89		self.live = false;
90		self
91	}
92
93	/// Sets one shader property.
94	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
95		self.props.set(prop, value);
96		self
97	}
98}
99
100impl Component for Shader {
101	fn props(&self) -> &Props {
102		&self.props
103	}
104
105	fn props_mut(&mut self) -> &mut Props {
106		&mut self.props
107	}
108
109	fn slot(&self) -> Slot {
110		self.slot
111	}
112
113	fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
114		let cols = self.viewport_cols();
115		(cols, cols)
116	}
117
118	fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
119		self.viewport_rows()
120	}
121
122	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
123		if rect.y >= pc.clip || rect.width == 0 || rect.height == 0 {
124			return;
125		}
126		let base = self.props.style(&pc.ctx.theme);
127		let cols = self.viewport_cols().min(rect.width);
128		let rows = self.viewport_rows().min(rect.height).min(pc.clip - rect.y);
129		let frame = &mut *pc.frame;
130		let mut buffer = [0_u8; 4];
131		self
132			.surface
133			.render(&mut *self.program, pc.now, cols, rows, |x, y, glyph, fg, bg| {
134				let style = match bg {
135					Some(bg) => base.fg(fg).bg(bg),
136					None => base.fg(fg),
137				};
138				frame.put(rect.x + x, rect.y + y, glyph.encode_utf8(&mut buffer), style);
139			});
140		if self.live {
141			pc.wake(self.slot, pc.now + FRAME);
142		}
143	}
144}
145
146#[cfg(test)]
147mod tests {
148	use std::time::Duration;
149
150	use super::*;
151	use crate::{
152		anim,
153		scene::{Vec3, vec3},
154		test_support::frame_row_text,
155		ui::Ui,
156	};
157
158	fn wall(_: f32, _: f32) -> (Vec3, f32) {
159		(vec3(1.0, 0.0, 0.0), 1.0)
160	}
161
162	fn lit_blocks(ui: &Ui) -> bool {
163		(0..ui.frame().size().height).any(|row| {
164			frame_row_text(ui.frame(), row)
165				.chars()
166				.any(|glyph| glyph == '▀')
167		})
168	}
169
170	#[test]
171	fn live_shader_paints_half_blocks_and_reschedules() {
172		let mut ui = Ui::from_root(Shader::new(wall).size(12, 4), 12, UiContext::default());
173		assert!(lit_blocks(&ui), "the wall lights half-block cells");
174		assert_eq!(ui.next_wake(), Some(anim::FRAME));
175		assert!(ui.tick(anim::FRAME), "the frame deadline repaints");
176		assert_eq!(ui.next_wake(), Some(anim::FRAME + anim::FRAME));
177	}
178
179	#[test]
180	fn still_shader_requests_no_wake() {
181		let ui = Ui::from_root(Shader::new(wall).size(12, 4).still(), 12, UiContext::default());
182		assert!(lit_blocks(&ui));
183		assert_eq!(ui.next_wake(), None);
184	}
185
186	#[test]
187	fn animated_shader_reads_the_paint_clock() {
188		/// A vertical bar sweeping one column per second.
189		struct Sweep {
190			column: f32,
191		}
192		impl Program for Sweep {
193			fn advance(&mut self, now: Duration, width: f32, _: f32) {
194				self.column = now.as_secs_f32() % width;
195			}
196
197			fn fragment(&self, x: f32, _: f32) -> (Vec3, f32) {
198				let lit = x >= self.column && x < self.column + 1.0;
199				(vec3(1.0, 1.0, 1.0), if lit { 1.0 } else { 0.0 })
200			}
201		}
202
203		let rows = |ui: &Ui| -> Vec<String> {
204			(0..ui.frame().size().height)
205				.map(|row| frame_row_text(ui.frame(), row))
206				.collect()
207		};
208		let mut ui =
209			Ui::from_root(Shader::new(Sweep { column: 0.0 }).size(16, 4), 16, UiContext::default());
210		let start = rows(&ui);
211		ui.tick(Duration::from_millis(2000));
212		assert_ne!(start, rows(&ui), "the clock moves the bar");
213	}
214}