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