use std::collections::HashMap;
use std::rc::Rc;
use std::sync::mpsc;
use std::sync::Arc;
use rich_embed::PageState;
use rich_intuituive::interact::keymap::{try_keys, Keymap};
use rich_intuituive::interact::{Key, KeyCode};
use rich_intuituive::prelude::*;
use rich_intuituive::{
button_of, image_bytes_of, image_graphics, slider, spawn_stream, Action, ImageView, Pixels,
};
use crate::actions::{self, keys_label, ACTIONS};
use crate::bookmarks::{Bookmark, Bookmarks};
use crate::cdp::CELL;
use crate::clean::{markup, truncate};
use crate::config::{remove_site_key, save_site_key, Config, Images, Site, Target, View};
use crate::engine::{Engine, HistoryStep, MediaOp, NodeId, Playback};
use crate::layout::{self, hint_labels, matches, to_text, Line, Marks, Piece};
use crate::model::{Binding, MediaKind, Page, Role};
use crate::tab::{Command, Intent, Refusal, Tab, Update};
use crate::widgets::{web_widget, WebControl};
type Then = Box<dyn FnOnce(Result<(), Refusal>, &mut Ctx)>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Hints {
pub labels: Vec<(String, usize)>,
pub typed: String,
}
#[derive(Clone, Debug)]
pub struct Options {
pub config: Config,
pub bookmarks: Bookmarks,
pub url: Option<String>,
pub view: Option<View>,
pub images: Option<Images>,
}
#[derive(Clone, Copy)]
pub struct Browser {
pub page: Signal<Option<Arc<Page>>>,
pub state: Signal<PageState>,
pub view: Signal<View>,
pub hints: Signal<Option<Hints>>,
pub find: Signal<Option<String>>,
pub find_at: Signal<usize>,
pub scroll: Signal<u16>,
pub message: Signal<String>,
pub config: Signal<Config>,
pub keymap: Signal<Keymap>,
pub bookmarks: Signal<Bookmarks>,
pub images: Signal<Images>,
pub images_default: Signal<Images>,
pub viewport: Memo<(u16, u16)>,
tab: Signal<Tab>,
waiting: Signal<HashMap<u64, Then>>,
controls: Signal<HashMap<NodeId, Rc<WebControl>>>,
}
impl Browser {
fn send(&self, command: Command) -> u64 {
self.tab.with_untracked(|t| t.send(command))
}
pub fn act(
&self,
binding: Binding,
intent: Intent,
then: impl FnOnce(Result<(), Refusal>, &mut Ctx) + 'static,
) {
let id = self.send(Command::Act { binding, intent });
self.waiting.update(|w| {
w.insert(id, Box::new(then));
});
}
fn after(&self, id: u64, then: impl FnOnce(Result<(), Refusal>, &mut Ctx) + 'static) {
self.waiting.update(|w| {
w.insert(id, Box::new(then));
});
}
pub fn web_control(&self, control: &crate::model::Control) -> Rc<WebControl> {
let mut found = None;
self.controls.update(|all| {
found = Some(
all.entry(control.node)
.or_insert_with(|| Rc::new(WebControl::new(control)))
.clone(),
);
});
found.expect("made")
}
pub fn refresh_controls(&self) {
let page = self.page.with_untracked(Clone::clone);
let Some(page) = page else { return };
let all: Vec<(NodeId, Rc<WebControl>)> = self
.controls
.with_untracked(|all| all.iter().map(|(n, w)| (*n, w.clone())).collect());
for (node, web) in all {
if let Some(control) = page.control(node) {
web.sync(&page, control);
}
}
}
fn url(&self) -> String {
self.page
.with(|p| p.as_ref().map(|p| p.url.clone()))
.unwrap_or_else(|| self.state.with(|s| crate::clean::clean(&s.url)))
}
fn site(&self) -> Option<Site> {
let url = self.url();
self.config.with(|c| c.site(&url).cloned())
}
pub fn marks(&self) -> Marks {
let hints = self
.hints
.with(|h| {
h.as_ref()
.map(|h| h.labels.iter().map(|(l, c)| (*c, l.clone())).collect())
})
.unwrap_or_default();
Marks {
hints,
find: self.find.get(),
current: None,
}
}
fn say(&self, markup: impl Into<String>) {
self.message.set(markup.into());
}
}
fn view_lines(b: Browser, width: u16) -> Vec<Line> {
let marks = Marks::default();
b.page.with_untracked(|p| {
let Some(page) = p.as_ref() else {
return Vec::new();
};
match b.view.get_untracked() {
View::Reader => {
let mut out = Vec::new();
for piece in layout::reader(page, width.saturating_sub(2), &marks) {
match piece {
Piece::Text(lines) => out.extend(lines),
other => out.extend(std::iter::repeat_n(Line::default(), other.rows())),
}
}
out
}
_ => layout::projection(page, width, (CELL.0 as f64, CELL.1 as f64), &marks),
}
})
}
fn find_rows(b: Browser, width: u16) -> Vec<(usize, usize)> {
let Some(query) = b.find.get_untracked().filter(|q| !q.is_empty()) else {
return Vec::new();
};
view_lines(b, width)
.iter()
.enumerate()
.flat_map(|(row, line)| (0..matches(&line.plain(), &query).len()).map(move |k| (row, k)))
.collect()
}
pub fn availability(b: Browser, id: &str) -> Result<(), String> {
let state = b.state.get();
let has_page = b.page.with(Option::is_some);
match id {
"back" if !state.can_go_back => Err("Nothing to go back to.".into()),
"forward" if !state.can_go_forward => Err("Nothing to go forward to.".into()),
"open_tab" | "tab.next" | "tab.previous" => {
Err("One tab for now: tabs come with rs-rich-embed's several pages.".into())
}
"hints"
if !b.page.with(|p| {
p.as_ref()
.is_some_and(|p| p.controls.iter().any(|c| !c.disabled))
}) =>
{
Err("Nothing on this page to follow or use.".into())
}
"find.next" | "find.previous"
if b.find.with(|f| f.as_ref().is_none_or(String::is_empty)) =>
{
Err("Find something first.".into())
}
"media.play_pause" | "media.mute" | "media.volume_up" | "media.volume_down" => {
if current_media(b).is_some() {
Ok(())
} else if b.page.with(|p| {
p.as_ref().is_some_and(|p| {
p.media
.iter()
.any(|m| matches!(m.kind, MediaKind::Video | MediaKind::Audio))
})
}) {
Err("The media is not ready yet.".into())
} else {
Err("No video or audio on this page.".into())
}
}
"bookmark" | "reload" | "address" | "site.bind" | "site.keys" if !has_page => {
Err("No page yet.".into())
}
"site.bind" | "site.keys" if b.config.with(|c| c.path.is_none()) => {
Err("No config file to keep site keys in.".into())
}
"site.bind" | "site.keys" if crate::url::host(&b.url()).is_none() => {
Err("This page has no site (a local file): site keys need a host.".into())
}
_ => Ok(()),
}
}
fn target_availability(b: Browser, target: &Target) -> Result<(), String> {
match target {
Target::Action(id) => match actions::get(id) {
None => Err(format!("{id:?} is not an action.")),
Some(_) => availability(b, id),
},
Target::Element { role, name, nth } => b.page.with(|p| {
let found = p.as_ref().map_or(0, |p| p.find(role, name).len());
if found >= *nth {
Ok(())
} else if found == 0 {
Err(format!(
"No {role} named {:?} on this page.",
truncate(name, 40)
))
} else {
Err(format!(
"Only {found} {role}s named {:?} on this page.",
truncate(name, 40)
))
}
}),
Target::Sequence(all) => all.iter().try_for_each(|t| target_availability(b, t)),
}
}
pub fn run(b: Browser, id: &str, cx: &mut Ctx) {
if let Err(why) = availability(b, id) {
cx.toast(format!("[yellow]{}[/]", markup(&why)));
return;
}
let rows = b.viewport.get_untracked().1.saturating_sub(4).max(1);
let width = b.viewport.get_untracked().0;
let total = || view_lines(b, width).len() as u16;
match id {
"open" => open_prompt(b, String::new(), cx),
"address" => open_prompt(b, b.url(), cx),
"hints" => start_hints(b),
"back" => drop(b.send(Command::History(HistoryStep::Back))),
"forward" => drop(b.send(Command::History(HistoryStep::Forward))),
"reload" => drop(b.send(Command::History(HistoryStep::Reload))),
"find" => find_prompt(b, cx),
"find.next" | "find.previous" => {
let found = find_rows(b, width);
if found.is_empty() {
cx.toast("[yellow]No matches.[/]");
return;
}
let at = b.find_at.get_untracked();
let next = match (id, at) {
("find.next", usize::MAX) => 0,
("find.next", at) => (at + 1) % found.len(),
(_, usize::MAX) => found.len() - 1,
(_, at) => (at % found.len() + found.len() - 1) % found.len(),
};
b.find_at.set(next);
b.scroll.set((found[next].0 as u16).saturating_sub(3));
b.say(format!("match {} of {}", next + 1, found.len()));
}
"scroll.down" => b
.scroll
.update(|s| *s = (*s + 1).min(total().saturating_sub(1))),
"scroll.up" => b.scroll.update(|s| *s = s.saturating_sub(1)),
"page.down" => b
.scroll
.update(|s| *s = (*s + rows).min(total().saturating_sub(1))),
"page.up" => b.scroll.update(|s| *s = s.saturating_sub(rows)),
"top" => b.scroll.set(0),
"bottom" => b.scroll.set(total().saturating_sub(rows)),
"reader" => {
b.view.update(|v| {
*v = if *v == View::Reader {
View::Projection
} else {
View::Reader
}
});
b.scroll.set(0);
b.say(format!("{} view", b.view.get_untracked().name()));
}
"bookmark" => {
let (url, title) = b.page.with_untracked(|p| {
p.as_ref()
.map(|p| (p.url.clone(), p.title.clone()))
.unwrap_or_default()
});
let mut result = Ok(());
b.bookmarks.update(|marks| {
result = marks.add(Bookmark {
url,
title: title.clone(),
keyword: None,
});
});
match result {
Ok(()) => cx.toast(format!("Bookmarked {}.", markup(&title))),
Err(e) => cx.toast(format!("[red]{}[/]", markup(&e.to_string()))),
}
}
"bookmarks" => bookmarks_list(b, cx),
"media.play_pause" | "media.mute" | "media.volume_up" | "media.volume_down" => {
let Some((node, playback)) = current_media(b) else {
return;
};
let op = match id {
"media.play_pause" if playback.paused => MediaOp::Play,
"media.play_pause" => MediaOp::Pause,
"media.mute" => MediaOp::Mute(!playback.muted),
"media.volume_up" => MediaOp::Volume((playback.volume + 0.1).min(1.0)),
_ => MediaOp::Volume((playback.volume - 0.1).max(0.0)),
};
media_op(b, node, op);
}
"site.bind" => bind_picker(b, cx),
"site.keys" => site_keys_list(b, cx),
"actions" => overlay(b, cx),
"quit" => cx.quit(),
_ => {}
}
}
pub fn current_media(b: Browser) -> Option<(NodeId, Playback)> {
b.page.with(|p| {
let page = p.as_ref()?;
let ready = || {
page.media
.iter()
.filter_map(|m| Some((m.node, m.playback?)))
};
ready().find(|(_, p)| !p.paused).or_else(|| ready().next())
})
}
fn media_op(b: Browser, node: NodeId, op: MediaOp) {
let Some(document) = b.page.with_untracked(|p| p.as_ref().map(|p| p.document)) else {
return;
};
let id = b.send(Command::Media { document, node, op });
b.after(id, |result, cx| {
if let Err(refusal) = result {
cx.toast(format!("[red]{}[/]", markup(&refusal.to_string())));
}
});
}
fn clock(seconds: f64) -> String {
let s = seconds.max(0.0).floor() as u64;
format!("{}:{:02}", s / 60, s % 60)
}
fn player(b: Browser, node: NodeId) -> Node {
let playback = move || {
b.page
.with(|p| p.as_ref()?.media.iter().find(|m| m.node == node)?.playback)
};
let volume = signal(
b.page
.with_untracked(|p| p.as_ref()?.media.iter().find(|m| m.node == node)?.playback)
.map_or(100.0, |p| (p.volume * 100.0).round()),
);
watch(
move || playback().map(|p| (p.volume * 100.0).round() as i64),
move |v, _| {
if let Some(v) = v {
if volume.get_untracked() != v as f64 {
volume.set(v as f64);
}
}
},
);
on_write(volume, move |v, _| {
let now = b
.page
.with_untracked(|p| p.as_ref()?.media.iter().find(|m| m.node == node)?.playback);
if now.is_some_and(|p| ((p.volume * 100.0).round() - v).abs() >= 1.0) {
media_op(b, node, MediaOp::Volume(v / 100.0));
}
});
let play = button_of(
move || {
if playback().is_some_and(|p| !p.paused) {
"Pause".into()
} else {
"Play".into()
}
},
move |_| {
if let Some(p) = playback() {
media_op(
b,
node,
if p.paused {
MediaOp::Play
} else {
MediaOp::Pause
},
);
}
},
)
.disabled_when(move || playback().is_none())
.auto();
let mute = button_of(
move || {
if playback().is_some_and(|p| p.muted) {
"Unmute".into()
} else {
"Mute".into()
}
},
move |_| {
if let Some(p) = playback() {
media_op(b, node, MediaOp::Mute(!p.muted));
}
},
)
.disabled_when(move || playback().is_none())
.auto();
let status = text(move || {
let sound = if b.config.with(|c| c.audio) {
""
} else {
" [dim]sound is off: start with --audio[/]"
};
match playback() {
None => format!("[dim]not ready[/]{sound}"),
Some(p) => format!(
" {} / {}{}{sound}",
clock(p.time),
p.duration.map_or("live".into(), clock),
if p.muted { " [yellow]muted[/]" } else { "" }
),
}
})
.auto();
row([
play,
label(" ").fixed(1),
mute,
label(" Volume ").auto(),
slider(volume, 0.0..=100.0, 10.0).fixed(16),
status,
label("").flex(1),
])
.fixed(1)
.name(&format!("player-{node}"))
}
fn run_target(b: Browser, target: &Target, cx: &mut Ctx) {
if let Err(why) = target_availability(b, target) {
cx.toast(format!("[yellow]{}[/]", markup(&why)));
return;
}
match target {
Target::Action(id) => run(b, id, cx),
Target::Element { role, name, nth } => {
let id = b.send(Command::Activate {
role: role.clone(),
name: name.clone(),
nth: nth.saturating_sub(1),
});
b.after(id, |result, cx| {
if let Err(refusal) = result {
cx.toast(format!("[red]{}[/]", markup(&refusal.to_string())));
}
});
}
Target::Sequence(all) => {
for t in all {
run_target(b, t, cx);
}
}
}
}
fn start_hints(b: Browser) {
let hintable: Vec<usize> = b.page.with_untracked(|p| {
p.as_ref()
.map(|p| {
p.controls
.iter()
.enumerate()
.filter(|(_, c)| !c.disabled)
.map(|(i, _)| i)
.collect()
})
.unwrap_or_default()
});
let labels = hint_labels(hintable.len())
.into_iter()
.zip(hintable)
.collect();
b.hints.set(Some(Hints {
labels,
typed: String::new(),
}));
}
fn activate(b: Browser, index: usize, cx: &mut Ctx) {
let page = b.page.with_untracked(Clone::clone);
let Some(page) = page else { return };
let Some(control) = page.controls.get(index).cloned() else {
return;
};
let clicks = matches!(
control.role,
Role::Link
| Role::Button
| Role::CheckBox
| Role::Switch
| Role::Radio
| Role::Tab
| Role::MenuItem
);
if clicks {
b.act(control.binding(&page), Intent::Click, |result, cx| {
if let Err(refusal) = result {
cx.toast(format!("[red]{}[/]", markup(&refusal.to_string())));
}
});
return;
}
if b.view.get_untracked() == View::Reader {
if let Some(id) = b.web_control(&control).widget.get() {
cx.focus(id);
return;
}
}
let node = control.node;
let title = markup(&crate::tab::describe(&control));
cx.modal(Size::Percent(70), Size::Fixed(3 + 2), move || {
web_widget(b, vec![node])
.on_key("esc", |cx| cx.pop())
.panel(&title)
});
}
fn hint_key(b: Browser, key: Key, cx: &mut Ctx) -> bool {
let Some(mut hints) = b.hints.get_untracked() else {
return false;
};
match key.code {
KeyCode::Escape => {
b.hints.set(None);
}
KeyCode::Backspace => {
hints.typed.pop();
b.hints.set(Some(hints));
}
KeyCode::Char(c) if !key.modifiers.ctrl && !key.modifiers.alt => {
hints.typed.push(c);
if let Some((_, index)) = hints.labels.iter().find(|(l, _)| *l == hints.typed) {
let index = *index;
b.hints.set(None);
activate(b, index, cx);
} else if hints
.labels
.iter()
.any(|(l, _)| l.starts_with(&hints.typed))
{
b.hints.set(Some(hints));
} else {
b.hints.set(None);
cx.toast(format!("[yellow]No hint {}.[/]", markup(&hints.typed)));
}
}
_ => return false,
}
true
}
fn site_key(b: Browser, key: Key) -> Option<Target> {
let site = b.site()?;
site.keys
.iter()
.find(|k| try_keys(&k.keys).is_ok_and(|keys| keys == [key]))
.map(|k| k.target.clone())
}
fn prompt(title: &str, initial: String, cx: &mut Ctx, submit: impl Fn(String, &mut Ctx) + 'static) {
let title = title.to_string();
let text = signal(initial);
cx.modal(Size::Percent(80), Size::Fixed(3), move || {
input(text)
.on_submit(move |value, cx| {
let value = value.to_string();
cx.pop();
submit(value, cx);
})
.on_key("esc", |cx| cx.pop())
.autofocus()
.panel(&title)
});
}
fn open_prompt(b: Browser, initial: String, cx: &mut Ctx) {
prompt(
"Open: an address, a keyword, or words to search",
initial,
cx,
move |typed, _| {
let url = b.url();
let destination = b.config.with_untracked(|c| {
b.bookmarks
.with_untracked(|m| c.destination(&typed, &url, &m.items))
});
b.send(Command::Open(destination.url().to_string()));
b.say(format!("opening {}", markup(destination.url())));
},
);
}
fn find_prompt(b: Browser, cx: &mut Ctx) {
let initial = b.find.get_untracked().unwrap_or_default();
prompt("Find in page", initial, cx, move |query, cx| {
b.find.set((!query.is_empty()).then_some(query));
b.find_at.set(usize::MAX);
if b.find.get_untracked().is_some() {
run(b, "find.next", cx);
}
});
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
pub text: String,
pub unavailable: Option<String>,
}
type Choose = Rc<dyn Fn(usize, &mut Ctx)>;
fn chooser(
title: &str,
hint: &str,
items: Rc<dyn Fn() -> Vec<Entry>>,
keys: Vec<(&'static str, Choose)>,
cx: &mut Ctx,
) {
let title = title.to_string();
let hint = hint.to_string();
cx.modal(Size::Percent(90), Size::Percent(85), move || {
let selected = signal(0usize);
let shown = items.clone();
let room = (viewport().get_untracked().1 as usize * 85 / 100)
.saturating_sub(4)
.max(3);
let list = text(move || {
let all = shown();
let at = selected.get().min(all.len().saturating_sub(1));
let first = at.saturating_sub(room.saturating_sub(2));
let mut out = Vec::new();
for (i, entry) in all.iter().enumerate().skip(first).take(room) {
let mark = if i == at { "[reverse]▸" } else { " " };
let end = if i == at { "[/]" } else { "" };
let body = match &entry.unavailable {
None => entry.text.clone(),
Some(why) => format!("[dim]{} — {}[/]", entry.text, markup(why)),
};
out.push(format!("{mark} {body}{end}"));
}
if out.is_empty() {
out.push("[dim]Nothing here yet.[/]".into());
}
out.join("\n")
})
.live();
let count = items.clone();
let mut node = column([list.flex(1), label(format!("[dim]{}[/]", hint)).fixed(1)])
.focusable()
.autofocus()
.on_key("up k", move |_| {
selected.update(|s| *s = s.saturating_sub(1))
})
.on_key("down j", move |_| {
let n = count().len();
selected.update(|s| *s = (*s + 1).min(n.saturating_sub(1)))
})
.on_key("esc", |cx| cx.pop());
for (keys, choose) in keys.clone() {
node = node.on_key(keys, move |cx| choose(selected.get_untracked(), cx));
}
node.panel(&title)
});
}
pub fn overlay_actions(b: Browser) -> Vec<Action> {
let keymap = b.keymap.get();
let mut out = Vec::new();
if let Some(site) = b.site() {
for (n, key) in site.keys.iter().enumerate() {
out.push(Action {
id: format!("site:{n}"),
label: format!("{} [site {}]", key.target.describe(), site.pattern),
keys: try_keys(&key.keys).unwrap_or_default(),
unavailable: target_availability(b, &key.target).err(),
});
}
}
for action in ACTIONS {
let keys = keymap
.bindings()
.into_iter()
.find(|binding| binding.action == action.id)
.map(|binding| binding.keys)
.unwrap_or_default();
let label = match (action.id, current_media(b)) {
("media.play_pause", Some((_, p))) if p.paused => "Play the media",
("media.play_pause", Some(_)) => "Pause the media",
("media.mute", Some((_, p))) if p.muted => "Unmute the media",
("media.mute", Some(_)) => "Mute the media",
_ => action.label,
};
out.push(Action {
id: action.id.to_string(),
label: label.to_string(),
keys,
unavailable: availability(b, action.id).err(),
});
}
out
}
fn overlay(b: Browser, cx: &mut Ctx) {
cx.action_palette(
move || overlay_actions(b),
move |id, cx| match id
.strip_prefix("site:")
.and_then(|n| n.parse::<usize>().ok())
{
Some(n) => {
if let Some(target) = b
.site()
.and_then(|s| s.keys.get(n).map(|k| k.target.clone()))
{
run_target(b, &target, cx);
}
}
None => run(b, id, cx),
},
);
}
fn bind_picker(b: Browser, cx: &mut Ctx) {
let targets = move || -> Vec<(Entry, Target, Option<String>)> {
let mut out = Vec::new();
b.page.with(|p| {
if let Some(page) = p {
for (i, c) in page.controls.iter().enumerate() {
if c.name.is_empty() {
continue;
}
let same = page.find(c.role.name(), &c.name);
let nth = same
.iter()
.position(|o| std::ptr::eq(*o, &page.controls[i]))
.unwrap_or(0)
+ 1;
let note = (same.len() > 1).then(|| {
format!(
"{} elements are named {:?}; this binds match {nth}.",
same.len(),
truncate(&c.name, 40)
)
});
out.push((
Entry {
text: format!("{} {}", c.role.name(), markup(&format!("{:?}", c.name))),
unavailable: None,
},
Target::Element {
role: c.role.name().to_string(),
name: c.name.clone(),
nth,
},
note,
));
}
}
});
for action in ACTIONS {
out.push((
Entry {
text: format!("action {} [dim]{}[/]", action.id, markup(action.label)),
unavailable: None,
},
Target::Action(action.id.to_string()),
None,
));
}
out
};
let items: Rc<dyn Fn() -> Vec<Entry>> =
Rc::new(move || targets().into_iter().map(|(e, _, _)| e).collect());
let choose: Choose = Rc::new(move |i, cx| {
let Some((_, target, note)) = targets().into_iter().nth(i) else {
return;
};
cx.pop();
capture_key(b, target, note, cx);
});
chooser(
"Bind a key on this site: to what?",
"Enter chooses · Esc closes",
items,
vec![("enter", choose)],
cx,
);
}
fn capture_key(b: Browser, target: Target, note: Option<String>, cx: &mut Ctx) {
let Some(host) = crate::url::host(&b.url()) else {
return;
};
let key: Signal<Option<Key>> = signal(None);
let describe = target.describe();
cx.modal(Size::Percent(80), Size::Fixed(8), move || {
let host2 = host.clone();
let target2 = target.clone();
let prompt = text(move || {
let mut lines = vec![format!(
"Bind a key to [bold]{}[/] on [bold]{}[/].",
markup(&describe),
markup(&host2)
)];
if let Some(note) = ¬e {
lines.push(format!("[yellow]{}[/]", markup(note)));
}
match key.get() {
None => lines.push("Press the key. (Esc cancels.)".into()),
Some(k) => {
if let Some(now) = conflict(b, k) {
lines.push(format!(
"[yellow]{} already does {} here: Enter replaces it.[/]",
markup(&k.to_string()),
markup(&now)
));
}
lines.push(format!(
"{}: Enter binds it on {} · w on *.{} · Esc cancels",
markup(&k.to_string()),
markup(&host2),
markup(&host2)
));
}
}
lines.join("\n")
});
let host3 = host.clone();
prompt
.focusable()
.autofocus()
.on_any_key(move |cx, pressed| {
match (key.get_untracked(), pressed.code) {
(_, KeyCode::Escape) => cx.pop(),
(None, _) => key.set(Some(pressed)),
(Some(k), KeyCode::Enter) => save_binding(b, &host3, k, &target2, cx),
(Some(k), KeyCode::Char('w')) => {
save_binding(b, &format!("*.{host3}"), k, &target2, cx)
}
_ => {}
}
true
})
.panel("Bind a key on this site")
});
}
fn conflict(b: Browser, key: Key) -> Option<String> {
if let Some(target) = site_key(b, key) {
return Some(target.describe());
}
b.keymap.with_untracked(|k| actions::action_for(k, key))
}
fn save_binding(b: Browser, pattern: &str, key: Key, target: &Target, cx: &mut Ctx) {
cx.pop();
let Some(path) = b.config.with_untracked(|c| c.path.clone()) else {
return;
};
match save_site_key(&path, pattern, &key.to_string(), target) {
Ok(_) => {
reload_config(b);
cx.toast(format!(
"{} now does {} on {}.",
markup(&key.to_string()),
markup(&target.describe()),
markup(pattern)
));
}
Err(e) => cx.toast(format!("[red]{}[/]", markup(&e.to_string()))),
}
}
fn reload_config(b: Browser) {
let path = b.config.with_untracked(|c| c.path.clone());
if let Ok(config) = Config::load(path.as_deref()) {
b.config.set(config);
}
}
fn site_keys_list(b: Browser, cx: &mut Ctx) {
let rows = move || -> Vec<(String, String, Target)> {
b.site()
.map(|s| {
s.keys
.iter()
.map(|k| (s.pattern.clone(), k.keys.clone(), k.target.clone()))
.collect()
})
.unwrap_or_default()
};
let items: Rc<dyn Fn() -> Vec<Entry>> = Rc::new(move || {
rows()
.into_iter()
.map(|(pattern, keys, target)| Entry {
text: format!(
"{:>10} {} [cyan]\\[{}][/]",
markup(&keys),
markup(&target.describe()),
markup(&pattern)
),
unavailable: target_availability(b, &target).err(),
})
.collect()
});
let test: Choose = Rc::new(move |i, cx| {
if let Some((_, _, target)) = rows().into_iter().nth(i) {
cx.pop();
run_target(b, &target, cx);
}
});
let path = move || b.config.with_untracked(|c| c.path.clone());
let remove: Choose = Rc::new(move |i, cx| {
let (Some((pattern, keys, _)), Some(path)) = (rows().into_iter().nth(i), path()) else {
return;
};
match remove_site_key(&path, &pattern, &keys) {
Ok(_) => {
reload_config(b);
cx.toast(format!(
"Removed {} from {}.",
markup(&keys),
markup(&pattern)
));
}
Err(e) => cx.toast(format!("[red]{}[/]", markup(&e.to_string()))),
}
});
let edit: Choose = Rc::new(move |i, cx| {
let (Some((pattern, keys, target)), Some(path)) = (rows().into_iter().nth(i), path())
else {
return;
};
let _ = remove_site_key(&path, &pattern, &keys);
reload_config(b);
cx.pop();
capture_key(b, target, None, cx);
});
let scope: Choose = Rc::new(move |i, cx| {
let (Some((pattern, keys, target)), Some(path)) = (rows().into_iter().nth(i), path())
else {
return;
};
let other = match pattern.strip_prefix("*.") {
Some(host) => host.to_string(),
None => format!("*.{pattern}"),
};
let moved = save_site_key(&path, &other, &keys, &target)
.and_then(|_| remove_site_key(&path, &pattern, &keys));
reload_config(b);
match moved {
Ok(_) => cx.toast(format!(
"{} now applies on {}.",
markup(&keys),
markup(&other)
)),
Err(e) => cx.toast(format!("[red]{}[/]", markup(&e.to_string()))),
}
});
chooser(
"Site shortcuts",
"Enter tests · d removes · e binds another key · s: this host or *.host · Esc closes",
items,
vec![("enter", test), ("d", remove), ("e", edit), ("s", scope)],
cx,
);
}
fn bookmarks_list(b: Browser, cx: &mut Ctx) {
let items: Rc<dyn Fn() -> Vec<Entry>> = Rc::new(move || {
b.bookmarks.with(|m| {
m.items
.iter()
.map(|bm| Entry {
text: format!(
"{} [dim]{}[/]{}",
markup(&bm.title),
markup(&bm.url),
bm.keyword
.as_ref()
.map(|k| format!(" [cyan]{}[/]", markup(k)))
.unwrap_or_default()
),
unavailable: None,
})
.collect()
})
});
let open: Choose = Rc::new(move |i, cx| {
if let Some(url) = b
.bookmarks
.with_untracked(|m| m.items.get(i).map(|bm| bm.url.clone()))
{
cx.pop();
b.send(Command::Open(url));
}
});
let remove: Choose = Rc::new(move |i, _| {
if let Some(url) = b
.bookmarks
.with_untracked(|m| m.items.get(i).map(|bm| bm.url.clone()))
{
b.bookmarks.update(|m| {
let _ = m.remove(&url);
});
}
});
chooser(
"Bookmarks",
"Enter opens · d removes · Esc closes",
items,
vec![("enter", open), ("d", remove)],
cx,
);
}
fn apply_site(b: Browser, url: &str) {
let site = b.config.with_untracked(|c| c.site(url).cloned());
let base = b.images_default.get_untracked();
let images = match site.as_ref().and_then(|s| s.images) {
Some(false) => Images::None,
_ => base,
};
if b.images.get_untracked() != images {
b.images.set(images);
}
if let Some(view) = site
.as_ref()
.and_then(|s| s.view)
.filter(|v| *v != View::Raster)
{
b.view.set(view);
}
let (columns, rows) = b.viewport.get_untracked();
let width = site.as_ref().and_then(|s| s.width).unwrap_or(columns);
b.send(Command::Resize(width, rows.saturating_sub(2)));
}
fn apply(b: Browser, update: Update, cx: &mut Ctx) {
match update {
Update::Page(page) => {
let new_document =
b.page.with_untracked(|p| p.as_ref().map(|p| p.document)) != Some(page.document);
if new_document {
b.controls.update(HashMap::clear);
b.scroll.set(0);
b.hints.set(None);
b.find_at.set(usize::MAX);
}
let url = page.url.clone();
if b.config
.with_untracked(|c| c.site(&url).is_some_and(|s| s.audio == Some(false)))
{
for media in &page.media {
if media.playback.is_some_and(|p| !p.muted) {
b.send(Command::Media {
document: page.document,
node: media.node,
op: MediaOp::Mute(true),
});
}
}
}
b.page.set(Some(page));
b.refresh_controls();
if new_document {
apply_site(b, &url);
}
}
Update::State(state) => b.state.set(state),
Update::Done(id, result) => {
let mut then = None;
b.waiting.update(|w| then = w.remove(&id));
match then {
Some(then) => then(result, cx),
None => {
if let Err(refusal) = result {
cx.toast(format!("[red]{}[/]", markup(&refusal.to_string())));
}
}
}
}
Update::Download { name, .. } => cx.toast(format!(
"[yellow]A download, {}, waits in quarantine.[/]",
markup(&name)
)),
Update::DownloadDone { .. } => {}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum PieceKey {
Text(usize, u64),
Media(NodeId, u16),
Controls(Vec<NodeId>),
Player(NodeId),
}
fn piece_keys(page: &Page, pieces: &[Piece], images: Images) -> Vec<PieceKey> {
use std::hash::{Hash, Hasher};
pieces
.iter()
.enumerate()
.filter_map(|(i, piece)| match piece {
Piece::Text(lines) => {
let mut h = std::collections::hash_map::DefaultHasher::new();
lines.hash(&mut h);
Some(PieceKey::Text(i, h.finish()))
}
Piece::Media { index, rows } => {
(images != Images::None).then(|| PieceKey::Media(page.media[*index].node, *rows))
}
Piece::Controls(c) => Some(PieceKey::Controls(
c.iter().map(|i| page.controls[*i].node).collect(),
)),
Piece::Player(i) => Some(PieceKey::Player(page.media[*i].node)),
})
.collect()
}
fn pixels(images: Images) -> Pixels {
match images {
Images::Auto => Pixels::detect(),
Images::Kitty => Pixels::Kitty,
Images::Sixel => Pixels::Sixel,
Images::Iterm2 => Pixels::Iterm,
Images::Ansi | Images::None => Pixels::Cells,
}
}
fn reader_view(b: Browser) -> Node {
let doc = memo(move || {
let width = b.viewport.get().0;
let marks = b.marks();
b.page.with(|p| {
p.as_ref()
.map(|p| layout::reader(p, width.saturating_sub(2), &marks))
.unwrap_or_default()
})
});
let keys = move || {
b.page.with(|p| {
p.as_ref()
.map(|p| doc.with(|d| piece_keys(p, d, b.images.get())))
.unwrap_or_default()
})
};
let build = move |key: PieceKey| -> Node {
match key {
PieceKey::Text(i, _) => {
let rows = doc.with_untracked(|d| d.get(i).map_or(1, Piece::rows)) as u16;
renderable(move || {
let mut marks = Marks {
find: b.find.get(),
..Marks::default()
};
doc.with(|d| {
let start: usize = d.iter().take(i).map(Piece::rows).sum();
let found = find_rows(b, b.viewport.get_untracked().0);
marks.current = found.get(b.find_at.get()).copied();
match d.get(i) {
Some(Piece::Text(lines)) => to_text(lines, &marks, start),
_ => rich::Text::new(""),
}
})
})
.fixed(rows)
}
PieceKey::Media(node, rows) => {
let width = b.page.with_untracked(|p| {
p.as_ref()
.and_then(|p| p.media.iter().find(|m| m.node == node))
.map_or(10, |m| {
((m.rect.width / CELL.0 as f64).ceil() as u16).clamp(4, 120)
})
});
let view = ImageView::new().pixels(pixels(b.images.get_untracked()));
let picture = image_bytes_of(
move || {
b.page.with(|p| {
p.as_ref()?
.media
.iter()
.find(|m| m.node == node)?
.still
.clone()
})
},
view,
);
row([picture.fixed(width), label("").flex(1)]).fixed(rows)
}
PieceKey::Controls(nodes) => web_widget(b, nodes),
PieceKey::Player(node) => player(b, node),
}
};
scroll_with(each(keys, build), b.scroll)
.autofocus()
.name("page")
}
fn projection_view(b: Browser) -> Node {
let lines = memo(move || {
let width = b.viewport.get().0;
let marks = b.marks();
b.page.with(|p| {
p.as_ref()
.map(|p| layout::projection(p, width, (CELL.0 as f64, CELL.1 as f64), &marks))
.unwrap_or_default()
})
});
let canvas = renderable(move || {
let found = find_rows(b, b.viewport.get_untracked().0);
let marks = Marks {
find: b.find.get(),
current: found.get(b.find_at.get()).copied(),
..Marks::default()
};
lines.with(|l| to_text(l, &marks, 0))
});
scroll_with(canvas, b.scroll).autofocus().name("page")
}
pub fn app(engine: Box<dyn Engine>, options: Options) -> App {
let Options {
config,
bookmarks,
url,
view,
images,
} = options;
let start = url
.or_else(|| config.home.clone())
.unwrap_or_else(|| "about:blank".into());
let first_view = view.unwrap_or(config.view);
let images = images.unwrap_or(config.images);
App::new(move || {
let (keymap, warnings) = actions::keymap(config.preset, &config.keys);
let mut warnings = [config.warnings.clone(), warnings].concat();
warnings.truncate(3);
let (send, receive) = mpsc::channel::<Update>();
let tab = Tab::spawn(engine, move |update| {
let _ = send.send(update);
});
let b = Browser {
page: signal(None),
state: signal(PageState::default()),
view: signal(if first_view == View::Raster {
View::Projection
} else {
first_view
}),
hints: signal(None),
find: signal(None),
find_at: signal(usize::MAX),
scroll: signal(0),
message: signal(
warnings
.iter()
.map(|w| format!("[yellow]config: {}[/]", markup(w)))
.collect::<Vec<_>>()
.join(" · "),
),
config: signal(config),
keymap: signal(keymap.clone()),
bookmarks: signal(bookmarks),
images: signal(images),
images_default: signal(images),
viewport: viewport(),
tab: signal(tab),
waiting: signal(HashMap::new()),
controls: signal(HashMap::new()),
};
spawn_stream(move |stream| {
let proxy = stream.proxy().clone();
while let Ok(update) = receive.recv() {
if !proxy.run_with(move |cx| apply(b, update, cx)) {
break;
}
}
});
watch(
move || b.viewport.get(),
move |(columns, rows), _| {
b.send(Command::Resize(columns, rows.saturating_sub(2)));
},
);
b.send(Command::Open(start.clone()));
let address = text(move || {
let state = b.state.get();
let back = if state.can_go_back {
"◀"
} else {
"[dim]◀[/]"
};
let forward = if state.can_go_forward {
"▶"
} else {
"[dim]▶[/]"
};
let loading = if state.loading { " [yellow]…[/]" } else { "" };
let (title, url) = b.page.with(|p| {
p.as_ref()
.map(|p| (p.title.clone(), p.url.clone()))
.unwrap_or_default()
});
let view = b.view.get().name();
format!(
"{back}{forward}{loading} [bold]{}[/] [dim]{}[/] [cyan]{view}[/]",
markup(&truncate(&title, 60)),
markup(&truncate(&url, 120))
)
})
.no_wrap()
.fixed(1)
.name("address");
let keys_for = move |id: &str| b.keymap.with(|k| keys_label(k, id));
let status = text(move || {
if let Some(hints) = b.hints.get() {
return format!(
"[bold black on yellow] hints [/] type a label: {} [dim]Esc cancels[/]",
markup(&hints.typed)
);
}
let message = b.message.get();
if !message.is_empty() {
return message;
}
if let Some(find) = b.find.get() {
return format!(
"find: {} [dim]{} next · {} previous[/]",
markup(&find),
markup(&keys_for("find.next")),
markup(&keys_for("find.previous"))
);
}
format!(
"[dim]{} actions · {} hints · {} open · {} reader · {} quit[/]",
markup(&keys_for("actions")),
markup(&keys_for("hints")),
markup(&keys_for("open")),
markup(&keys_for("reader")),
markup(&keys_for("quit"))
)
})
.no_wrap()
.fixed(1)
.name("status");
let body = keyed(
move || b.view.get(),
move |view| match view {
View::Reader => reader_view(b),
_ => projection_view(b),
},
)
.flex(1);
let mut root = column([address, body, status]);
for action in ACTIONS {
let Some((keys, sequence)) = actions::keys_of(&keymap, action.id) else {
continue;
};
let id = action.id;
let handler = move |cx: &mut Ctx| {
b.message.set(String::new());
if let Some(target) = cx.key().and_then(|key| site_key(b, key)) {
run_target(b, &target, cx);
return;
}
run(b, id, cx);
};
root = if sequence {
root.bind_sequence(&keys, action.label, handler)
} else {
root.bind(&keys, action.label, handler)
};
}
column([root.flex(1)])
.previews_keys()
.on_any_key(move |cx, key| {
if b.hints.with_untracked(Option::is_some) {
return hint_key(b, key, cx);
}
if let Some(target) = site_key(b, key) {
b.message.set(String::new());
run_target(b, &target, cx);
return true;
}
false
})
})
.graphics(image_graphics())
}