Skip to main content

tern_sdk/
helpers.rs

1//! Layer 6: one-call helpers. [`print`] shows a static view that stays in
2//! the scrollback like command output; [`ask`] shows a form and returns the
3//! submission; both fall back when TSP isn't available.
4
5use std::io::Write as _;
6
7use serde_json::{Map, Value};
8
9use crate::{
10	Error, Input, Options, Session, SurfaceOptions,
11	plain::plain,
12	term,
13	ui::View,
14	wire::{Event, Font},
15};
16
17/// Options of [`print`].
18#[derive(Clone, Debug, Default)]
19pub struct PrintOptions {
20	/// A stylesheet for the view.
21	pub css:      Option<String>,
22	/// Text to write instead of the plain rendering when TSP isn't
23	/// available.
24	pub fallback: Option<String>,
25}
26
27impl PrintOptions {
28	/// No stylesheet, the plain rendering as fallback.
29	pub fn new() -> Self {
30		Self::default()
31	}
32
33	/// Sets the stylesheet.
34	pub fn css(mut self, css: impl Into<String>) -> Self {
35		self.css = Some(css.into());
36		self
37	}
38
39	/// Sets the fallback text.
40	pub fn fallback(mut self, text: impl Into<String>) -> Self {
41		self.fallback = Some(text.into());
42		self
43	}
44}
45
46/// Shows `view` as static output that stays in the scrollback.
47///
48/// In Tern it is a `flow` surface opened with `listen:false`, its sheet, one
49/// frame, then `x` with `keep`. Without TSP (or when stdout isn't a tty) it
50/// writes the plain rendering, or `fallback`, to stdout.
51///
52/// # Errors
53/// When I/O fails or two siblings share an id.
54///
55/// # Example
56/// ```no_run
57/// use tern_sdk::{PrintOptions, ui, ui::View};
58///
59/// let view: View = View::new().main([ui::badge("deployed").tone("success")]);
60/// tern_sdk::print(view, PrintOptions::new())?;
61/// tern_sdk::print(
62/// 	ui::card()
63/// 		.head("cargo test")
64/// 		.status(ui::Status::Done)
65/// 		.child(ui::md("All **42** tests pass.")),
66/// 	PrintOptions::new(),
67/// )?;
68/// # Ok::<(), tern_sdk::Error>(())
69/// ```
70pub fn print(view: impl Into<View>, options: PrintOptions) -> Result<(), Error> {
71	let view = view.into();
72	let session = if term::stdout_is_tty() {
73		Session::<()>::connect(Options::new().bracketed_paste(false).kitty_keyboard(false))?
74	} else {
75		None
76	};
77	let Some(mut session) = session else {
78		let text = options
79			.fallback
80			.unwrap_or_else(|| plain(view, term::columns().map_or(80, usize::from)));
81		let mut out = std::io::stdout().lock();
82		out.write_all(text.as_bytes())?;
83		if !text.ends_with('\n') {
84			out.write_all(b"\n")?;
85		}
86		out.flush()?;
87		return Ok(());
88	};
89	let sf = session.open(SurfaceOptions::flow().listen(false))?;
90	if let Some(css) = &options.css {
91		session.stylesheet(sf, "main", Some(css))?;
92	}
93	session.render(sf, view)?;
94	session.close()
95}
96
97/// Options of [`ask`].
98#[derive(Clone, Debug)]
99pub struct AskOptions {
100	/// A stylesheet for the form.
101	pub css:    Option<String>,
102	/// The action that submits (default `submit`).
103	pub submit: String,
104	/// The surface's base face (default mono; `Font::Sans` for forms).
105	pub font:   Option<Font>,
106}
107
108impl Default for AskOptions {
109	fn default() -> Self {
110		Self { css: None, submit: "submit".into(), font: None }
111	}
112}
113
114impl AskOptions {
115	/// No stylesheet, submitted by `submit`.
116	pub fn new() -> Self {
117		Self::default()
118	}
119
120	/// Sets the stylesheet.
121	pub fn css(mut self, css: impl Into<String>) -> Self {
122		self.css = Some(css.into());
123		self
124	}
125
126	/// Sets the surface's base face.
127	pub const fn font(mut self, font: Font) -> Self {
128		self.font = Some(font);
129		self
130	}
131
132	/// Sets the action that submits.
133	pub fn submit(mut self, act: impl Into<String>) -> Self {
134		self.submit = act.into();
135		self
136	}
137}
138
139/// A submitted form: the `action` event that submitted it.
140#[derive(Clone, Debug, PartialEq, Eq)]
141pub struct Submission {
142	/// The node that submitted (the button).
143	pub id:     String,
144	/// The action (`submit`).
145	pub act:    String,
146	/// The form's values.
147	pub values: Map<String, Value>,
148}
149
150/// What [`ask`] got.
151#[derive(Clone, Debug, PartialEq, Eq)]
152pub enum Answer {
153	/// The user submitted the form.
154	Submitted(Submission),
155	/// The user pressed Escape, Ctrl+C or Ctrl+D.
156	Cancelled,
157	/// TSP isn't available here: ask another way.
158	Unsupported,
159}
160
161/// Asks one question: shows `view` (a form) and returns its submission.
162///
163/// The form is a `flow` surface that stays in the scrollback. The answer is
164/// the first `action` whose `act` is the submit action, or
165/// [`Answer::Cancelled`] on Escape, Ctrl+C or Ctrl+D. Without TSP it is
166/// [`Answer::Unsupported`].
167///
168/// # Errors
169/// When I/O fails or two siblings share an id.
170///
171/// # Example
172/// ```no_run
173/// use tern_sdk::{
174/// 	Answer, AskOptions,
175/// 	ui::{Actions, View, html},
176/// };
177///
178/// let view: View = View::new().main([html::form()
179/// 	.child(
180/// 		html::label()
181/// 			.child(html::checkbox("notify", "on"))
182/// 			.child(html::span("Notify me")),
183/// 	)
184/// 	.child(html::button("OK").actions(Actions::new().click("submit")))]);
185/// match tern_sdk::ask(view, AskOptions::new())? {
186/// 	Answer::Submitted(s) => println!("{:?}", s.values),
187/// 	Answer::Cancelled => println!("cancelled"),
188/// 	Answer::Unsupported => println!("no TSP"),
189/// }
190/// # Ok::<(), tern_sdk::Error>(())
191/// ```
192pub fn ask<M>(view: impl Into<View<M>>, options: AskOptions) -> Result<Answer, Error> {
193	ask_with(view, options, |_| None)
194}
195
196/// [`ask`], with the form's handlers running while it waits: each message
197/// goes to `update`, and a view it returns is rendered in place.
198///
199/// # Errors
200/// When I/O fails or two siblings share an id.
201pub fn ask_with<M>(
202	view: impl Into<View<M>>,
203	options: AskOptions,
204	update: impl FnMut(M) -> Option<View<M>>,
205) -> Result<Answer, Error> {
206	let Some(mut session) = Session::<M>::connect(Options::new())? else {
207		return Ok(Answer::Unsupported);
208	};
209	let answer = ask_session(&mut session, view.into(), options, update)?;
210	session.close()?;
211	Ok(answer)
212}
213
214/// Runs a question on an established connection.
215fn ask_session<M>(
216	session: &mut Session<M>,
217	view: View<M>,
218	options: AskOptions,
219	mut update: impl FnMut(M) -> Option<View<M>>,
220) -> Result<Answer, Error> {
221	let mut surface = SurfaceOptions::flow();
222	if let Some(font) = options.font {
223		surface = surface.font(font);
224	}
225	let sf = session.open(surface)?;
226	if let Some(css) = &options.css {
227		session.stylesheet(sf, "main", Some(css))?;
228	}
229	session.render(sf, view)?;
230	let answer = loop {
231		let Some(input) = session.next(None)? else {
232			continue;
233		};
234		let (msg, event) = match input {
235			Input::Key(key) => {
236				if key.is("escape") || key.is("ctrl+c") || key.is("ctrl+d") {
237					break Answer::Cancelled;
238				}
239				continue;
240			},
241			Input::Clipboard(_) => continue,
242			Input::Event(event) => (None, event),
243			Input::Msg(msg, event) => (Some(msg), event),
244		};
245		if let Some(next) = msg.and_then(&mut update) {
246			session.render(sf, next)?;
247		}
248		if let Event::Action(a) = &event
249			&& a.act == options.submit
250		{
251			break Answer::Submitted(Submission {
252				id:     a.id.clone(),
253				act:    a.act.clone(),
254				values: a.values.clone().unwrap_or_default(),
255			});
256		}
257		if !session.is_open(sf) {
258			break Answer::Cancelled;
259		}
260	};
261	Ok(answer)
262}
263
264#[cfg(test)]
265mod tests {
266	use std::{collections::VecDeque, io, time::Duration};
267
268	use super::{Answer, AskOptions, ask_session};
269	use crate::{Options, Session, term::Terminal, ui};
270
271	struct Script(VecDeque<Vec<u8>>);
272
273	impl Terminal for Script {
274		fn write(&mut self, _: &[u8]) -> io::Result<()> {
275			Ok(())
276		}
277
278		fn read(&mut self, buf: &mut [u8], _: Option<Duration>) -> io::Result<Option<usize>> {
279			let Some(bytes) = self.0.pop_front() else {
280				return Ok(None);
281			};
282			buf[..bytes.len()].copy_from_slice(&bytes);
283			Ok(Some(bytes.len()))
284		}
285	}
286
287	#[test]
288	fn ask_runs_submit_handler_before_returning_submission() {
289		let term = Script(VecDeque::from([
290			b"\x1b_tsp;r;{\"r\":\"hello\",\"v\":1,\"credits\":2}\x1b\\".to_vec(),
291			b"\x1b_tsp;e;{\"ev\":\"action\",\"sf\":\"s1\",\"id\":\"main.0\",\"act\":\"submit\",\"values\":{\"size\":\"l\"}}\x1b\\".to_vec(),
292		]));
293		let mut session = Session::with_terminal(term, Options::new().record(None))
294			.unwrap()
295			.unwrap();
296		let view = ui::html::button("Submit")
297			.actions(ui::Actions::new().click("submit"))
298			.on_click(42)
299			.into();
300		let mut handled = false;
301		let answer = ask_session(&mut session, view, AskOptions::new(), |msg| {
302			assert_eq!(msg, 42);
303			handled = true;
304			Some(ui::text("Submitted").into())
305		})
306		.unwrap();
307		assert!(handled);
308		assert!(
309			matches!(answer, Answer::Submitted(s) if s.act == "submit" && s.values["size"] == "l")
310		);
311	}
312}