1use std::ffi::OsString;
5use std::io;
6
7use crate::host::{Command, LocalPty, Notify, PtyHost};
8use crate::pane::DEFAULT_SCROLLBACK;
9use crate::term::TermCore;
10use crate::web::{PageState, WebEngine, WebFrame, WebInput};
11
12pub const BROWSER_VARIABLE: &str = "RICH_EMBED_BROWSER";
16
17pub const KNOWN_BROWSERS: &[(&str, &[&str])] = &[
21 ("carbonyl", &["--"]),
22 ("cha", &[]),
23 ("browsh", &["--startup-url"]),
24 ("w3m", &[]),
25 ("lynx", &[]),
26];
27
28type Hosts = Box<dyn FnMut(&str) -> Box<dyn PtyHost>>;
29
30pub struct ProgramEngine {
43 program: Option<(OsString, Vec<OsString>)>,
44 hosts: Option<Hosts>,
45 core: Option<TermCore>,
46 notify: Option<Notify>,
47 size: (u16, u16),
48 history: Vec<String>,
49 at: usize,
50 shown: bool,
52 error: Option<String>,
53}
54
55impl ProgramEngine {
56 pub fn new(program: impl Into<OsString>) -> ProgramEngine {
58 ProgramEngine::with_program(Some((program.into(), Vec::new())))
59 }
60
61 fn with_program(program: Option<(OsString, Vec<OsString>)>) -> ProgramEngine {
62 ProgramEngine {
63 program,
64 hosts: None,
65 core: None,
66 notify: None,
67 size: (80, 24),
68 history: Vec::new(),
69 at: 0,
70 shown: false,
71 error: None,
72 }
73 }
74
75 #[must_use]
77 pub fn args<I: IntoIterator<Item = S>, S: Into<OsString>>(mut self, args: I) -> ProgramEngine {
78 if let Some((_, own)) = &mut self.program {
79 own.extend(args.into_iter().map(Into::into));
80 }
81 self
82 }
83
84 pub fn detect() -> ProgramEngine {
87 if let Some(value) = std::env::var_os(BROWSER_VARIABLE) {
88 let value = value.to_string_lossy().into_owned();
89 let mut words = value.split_whitespace().map(OsString::from);
90 if let Some(program) = words.next() {
91 return ProgramEngine::with_program(Some((program, words.collect())));
92 }
93 }
94 for (program, args) in KNOWN_BROWSERS {
95 if on_path(program) {
96 return ProgramEngine::new(*program).args(args.iter().copied());
97 }
98 }
99 ProgramEngine::with_program(None)
100 }
101
102 pub fn with_hosts(hosts: impl FnMut(&str) -> Box<dyn PtyHost> + 'static) -> ProgramEngine {
105 let mut engine = ProgramEngine::with_program(None);
106 engine.hosts = Some(Box::new(hosts));
107 engine
108 }
109
110 fn host(&mut self, url: &str) -> Option<Box<dyn PtyHost>> {
111 if let Some(hosts) = &mut self.hosts {
112 return Some(hosts(url));
113 }
114 let (program, args) = self.program.as_ref()?;
115 let command = Command::new(program.clone()).args(args.clone()).arg(url);
116 Some(Box::new(LocalPty::new(command)))
117 }
118
119 fn launch(&mut self) -> io::Result<()> {
121 let url = self.history[self.at].clone();
122 self.core = None;
123 self.shown = false;
124 let Some(host) = self.host(&url) else {
125 let names: Vec<&str> = KNOWN_BROWSERS.iter().map(|(name, _)| *name).collect();
126 self.error = Some(format!(
127 "no terminal browser: install one of {}, or name one in {BROWSER_VARIABLE}",
128 names.join(", ")
129 ));
130 return Err(io::Error::new(
131 io::ErrorKind::NotFound,
132 self.error.clone().unwrap_or_default(),
133 ));
134 };
135 self.error = None;
136 let mut core = TermCore::new(host, DEFAULT_SCROLLBACK);
137 if let Some(notify) = &self.notify {
138 core.set_notify(notify.clone());
139 }
140 core.fit(self.size.0, self.size.1);
141 self.error = core.error.clone();
142 self.core = Some(core);
143 if let Some(notify) = &self.notify {
144 notify();
145 }
146 Ok(())
147 }
148}
149
150fn check_address(url: &str) -> io::Result<()> {
153 let why = if url.starts_with('-') {
154 "it starts with '-'"
155 } else if url.chars().any(char::is_control) {
156 "it has a control character in it"
157 } else {
158 return Ok(());
159 };
160 Err(io::Error::new(
161 io::ErrorKind::InvalidInput,
162 format!("not an address to open: {why}"),
163 ))
164}
165
166fn on_path(program: &str) -> bool {
167 let Some(path) = std::env::var_os("PATH") else {
168 return false;
169 };
170 std::env::split_paths(&path).any(|dir| {
171 let candidate = dir.join(program);
172 candidate.is_file() || (cfg!(windows) && candidate.with_extension("exe").is_file())
173 })
174}
175
176impl WebEngine for ProgramEngine {
177 fn open(&mut self, url: &str) -> io::Result<()> {
178 check_address(url)?;
180 if !self.history.is_empty() {
181 self.history.truncate(self.at + 1);
182 }
183 self.history.push(url.to_string());
184 self.at = self.history.len() - 1;
185 self.launch()
186 }
187
188 fn resize(&mut self, columns: u16, rows: u16) -> io::Result<()> {
189 self.size = (columns, rows);
190 if let Some(core) = &mut self.core {
191 core.fit(columns, rows);
192 }
193 Ok(())
194 }
195
196 fn input(&mut self, input: WebInput) -> io::Result<()> {
197 let Some(core) = &mut self.core else {
198 return Ok(());
199 };
200 match input {
201 WebInput::Key(key) => core.key(key),
202 WebInput::Paste(text) => core.paste(&text),
203 WebInput::Mouse(mouse) => {
204 core.mouse(mouse);
205 }
206 }
207 Ok(())
208 }
209
210 fn back(&mut self) -> io::Result<()> {
211 if self.at == 0 || self.history.is_empty() {
212 return Ok(());
213 }
214 self.at -= 1;
215 self.launch()
216 }
217
218 fn forward(&mut self) -> io::Result<()> {
219 if self.at + 1 >= self.history.len() {
220 return Ok(());
221 }
222 self.at += 1;
223 self.launch()
224 }
225
226 fn reload(&mut self) -> io::Result<()> {
227 if self.history.is_empty() {
228 return Ok(());
229 }
230 self.launch()
231 }
232
233 fn poll(&mut self) -> Option<WebFrame> {
234 let core = self.core.as_mut()?;
235 let pumped = core.pump();
236 if !pumped.output && pumped.exit.is_none() {
237 return None;
238 }
239 self.shown |= pumped.output;
240 if let Some(exit) = pumped.exit
241 && !exit.success()
242 {
243 self.error = Some(format!("the browser {exit}"));
244 }
245 Some(WebFrame::Cells {
246 lines: core.lines(),
247 cursor: core.cursor(),
248 })
249 }
250
251 fn state(&self) -> PageState {
252 let title = match &self.program {
253 Some((program, _)) => program.to_string_lossy().into_owned(),
254 None => String::new(),
255 };
256 PageState {
257 url: self.history.get(self.at).cloned().unwrap_or_default(),
258 title,
259 loading: self.core.is_some() && !self.shown && self.error.is_none(),
260 can_go_back: self.at > 0,
261 can_go_forward: self.at + 1 < self.history.len(),
262 error: self.error.clone(),
263 }
264 }
265
266 fn set_notify(&mut self, notify: Notify) {
267 if let Some(core) = &mut self.core {
268 core.set_notify(notify.clone());
269 }
270 self.notify = Some(notify);
271 }
272}