use std::ffi::OsString;
use std::io;
use crate::host::{Command, LocalPty, Notify, PtyHost};
use crate::pane::DEFAULT_SCROLLBACK;
use crate::term::TermCore;
use crate::web::{PageState, WebEngine, WebFrame, WebInput};
pub const BROWSER_VARIABLE: &str = "RICH_EMBED_BROWSER";
pub const KNOWN_BROWSERS: &[(&str, &[&str])] = &[
("carbonyl", &["--"]),
("cha", &[]),
("browsh", &["--startup-url"]),
("w3m", &[]),
("lynx", &[]),
];
type Hosts = Box<dyn FnMut(&str) -> Box<dyn PtyHost>>;
pub struct ProgramEngine {
program: Option<(OsString, Vec<OsString>)>,
hosts: Option<Hosts>,
core: Option<TermCore>,
notify: Option<Notify>,
size: (u16, u16),
history: Vec<String>,
at: usize,
shown: bool,
error: Option<String>,
}
impl ProgramEngine {
pub fn new(program: impl Into<OsString>) -> ProgramEngine {
ProgramEngine::with_program(Some((program.into(), Vec::new())))
}
fn with_program(program: Option<(OsString, Vec<OsString>)>) -> ProgramEngine {
ProgramEngine {
program,
hosts: None,
core: None,
notify: None,
size: (80, 24),
history: Vec::new(),
at: 0,
shown: false,
error: None,
}
}
#[must_use]
pub fn args<I: IntoIterator<Item = S>, S: Into<OsString>>(mut self, args: I) -> ProgramEngine {
if let Some((_, own)) = &mut self.program {
own.extend(args.into_iter().map(Into::into));
}
self
}
pub fn detect() -> ProgramEngine {
if let Some(value) = std::env::var_os(BROWSER_VARIABLE) {
let value = value.to_string_lossy().into_owned();
let mut words = value.split_whitespace().map(OsString::from);
if let Some(program) = words.next() {
return ProgramEngine::with_program(Some((program, words.collect())));
}
}
for (program, args) in KNOWN_BROWSERS {
if on_path(program) {
return ProgramEngine::new(*program).args(args.iter().copied());
}
}
ProgramEngine::with_program(None)
}
pub fn with_hosts(hosts: impl FnMut(&str) -> Box<dyn PtyHost> + 'static) -> ProgramEngine {
let mut engine = ProgramEngine::with_program(None);
engine.hosts = Some(Box::new(hosts));
engine
}
fn host(&mut self, url: &str) -> Option<Box<dyn PtyHost>> {
if let Some(hosts) = &mut self.hosts {
return Some(hosts(url));
}
let (program, args) = self.program.as_ref()?;
let command = Command::new(program.clone()).args(args.clone()).arg(url);
Some(Box::new(LocalPty::new(command)))
}
fn launch(&mut self) -> io::Result<()> {
let url = self.history[self.at].clone();
self.core = None;
self.shown = false;
let Some(host) = self.host(&url) else {
let names: Vec<&str> = KNOWN_BROWSERS.iter().map(|(name, _)| *name).collect();
self.error = Some(format!(
"no terminal browser: install one of {}, or name one in {BROWSER_VARIABLE}",
names.join(", ")
));
return Err(io::Error::new(
io::ErrorKind::NotFound,
self.error.clone().unwrap_or_default(),
));
};
self.error = None;
let mut core = TermCore::new(host, DEFAULT_SCROLLBACK);
if let Some(notify) = &self.notify {
core.set_notify(notify.clone());
}
core.fit(self.size.0, self.size.1);
self.error = core.error.clone();
self.core = Some(core);
if let Some(notify) = &self.notify {
notify();
}
Ok(())
}
}
fn check_address(url: &str) -> io::Result<()> {
let why = if url.starts_with('-') {
"it starts with '-'"
} else if url.chars().any(char::is_control) {
"it has a control character in it"
} else {
return Ok(());
};
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("not an address to open: {why}"),
))
}
fn on_path(program: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| {
let candidate = dir.join(program);
candidate.is_file() || (cfg!(windows) && candidate.with_extension("exe").is_file())
})
}
impl WebEngine for ProgramEngine {
fn open(&mut self, url: &str) -> io::Result<()> {
check_address(url)?;
if !self.history.is_empty() {
self.history.truncate(self.at + 1);
}
self.history.push(url.to_string());
self.at = self.history.len() - 1;
self.launch()
}
fn resize(&mut self, columns: u16, rows: u16) -> io::Result<()> {
self.size = (columns, rows);
if let Some(core) = &mut self.core {
core.fit(columns, rows);
}
Ok(())
}
fn input(&mut self, input: WebInput) -> io::Result<()> {
let Some(core) = &mut self.core else {
return Ok(());
};
match input {
WebInput::Key(key) => core.key(key),
WebInput::Paste(text) => core.paste(&text),
WebInput::Mouse(mouse) => {
core.mouse(mouse);
}
}
Ok(())
}
fn back(&mut self) -> io::Result<()> {
if self.at == 0 || self.history.is_empty() {
return Ok(());
}
self.at -= 1;
self.launch()
}
fn forward(&mut self) -> io::Result<()> {
if self.at + 1 >= self.history.len() {
return Ok(());
}
self.at += 1;
self.launch()
}
fn reload(&mut self) -> io::Result<()> {
if self.history.is_empty() {
return Ok(());
}
self.launch()
}
fn poll(&mut self) -> Option<WebFrame> {
let core = self.core.as_mut()?;
let pumped = core.pump();
if !pumped.output && pumped.exit.is_none() {
return None;
}
self.shown |= pumped.output;
if let Some(exit) = pumped.exit
&& !exit.success()
{
self.error = Some(format!("the browser {exit}"));
}
Some(WebFrame::Cells {
lines: core.lines(),
cursor: core.cursor(),
})
}
fn state(&self) -> PageState {
let title = match &self.program {
Some((program, _)) => program.to_string_lossy().into_owned(),
None => String::new(),
};
PageState {
url: self.history.get(self.at).cloned().unwrap_or_default(),
title,
loading: self.core.is_some() && !self.shown && self.error.is_none(),
can_go_back: self.at > 0,
can_go_forward: self.at + 1 < self.history.len(),
error: self.error.clone(),
}
}
fn set_notify(&mut self, notify: Notify) {
if let Some(core) = &mut self.core {
core.set_notify(notify.clone());
}
self.notify = Some(notify);
}
}