Skip to main content

rich_embed/
program.rs

1//! [`ProgramEngine`]: a terminal browser (Carbonyl, Browsh, Chawan, w3m,
2//! lynx) run in a terminal pane, as the web view's engine.
3
4use 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
12/// The environment variable [`ProgramEngine::detect`] reads first: a
13/// browser and its arguments, separated by spaces (`"w3m -o
14/// confirm_qq=false"`).
15pub const BROWSER_VARIABLE: &str = "RICH_EMBED_BROWSER";
16
17/// Terminal browsers [`ProgramEngine::detect`] looks for on `PATH`, in
18/// order: (program, arguments before the address). Carbonyl, a Chromium,
19/// takes `--` as the end of its options.
20pub 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
30/// A terminal browser, named by the app, run in a terminal pane: its
31/// screen is the page. Nothing of ours ships with it, and nothing is
32/// downloaded; install the browser you want.
33///
34/// The browser is started with the address as its last argument. An
35/// address that starts with `-`, or has a control character in it, is
36/// refused ([`open`](WebEngine::open) fails), so that it is never read as
37/// one of the browser's options. Its own keys work as they do in a
38/// terminal (following links, its own history). The view's back, forward
39/// and reload restart it at an address from the engine's own history of
40/// what was opened; the address shown is the last one opened, since a
41/// terminal browser does not say where its links led.
42pub 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    /// Output has arrived since the browser was last started.
51    shown: bool,
52    error: Option<String>,
53}
54
55impl ProgramEngine {
56    /// Run `program` with the address as its argument.
57    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    /// Arguments before the address.
76    #[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    /// The browser in [`BROWSER_VARIABLE`], else the first of
85    /// [`KNOWN_BROWSERS`] on `PATH`. With none, the view says so.
86    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    /// Run each page in a host `hosts` makes from its address, instead of
103    /// a local program: a browser on another machine, or a test.
104    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    /// Start the browser at the history's current address.
120    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
150/// Refuse an address that a browser would read as an option (it starts
151/// with `-`), or that has a control character in it.
152fn 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        // It goes on the browser's command line.
179        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}