use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use rich_embed::PageState;
use rich_intuituive::interact::keymap::{Keymap, try_keys};
use rich_intuituive::interact::{Key, KeyCode, MouseKind};
use rich_intuituive::prelude::*;
use rich_intuituive::{
Action, ImageView, Picture, Pixels, button_of, image_graphics, picture_bytes, picture_of,
scroll_virtual_with, slider, spawn_stream,
};
use crate::actions::{self, ACTIONS, keys_label};
use crate::adapt::{Adapt, RULES};
use crate::bookmarks::{Bookmark, Bookmarks};
use crate::cdp::CELL;
use crate::clean::{markup, truncate};
use crate::config::{Config, Images, Site, Target, View, remove_site_key, save_site_key};
use crate::engine::{Engine, HistoryStep, MediaOp, NodeId, Playback};
use crate::layout::{
self, Line, LogScroll, Marks, Piece, Projection, hint_labels, matches, scroll_log, to_text,
};
use crate::model::{Binding, MediaKind, Page, Role};
use crate::pictures::{self, Live};
use crate::tab::{Command, Intent, Refusal, Tab, Update};
use crate::video::{Encoder, Job, Pacing, VideoFrame};
use crate::widgets::{WebControl, web_widget};
type RegionsSent = (Vec<NodeId>, Vec<(NodeId, Duration)>, f64);
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>,
pub tunnel: Option<crate::ssh::TunnelLink>,
pub control: Option<crate::control::ControlLink>,
}
#[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<u64>,
pub layout: Memo<Rc<Projection>>,
pub found: Memo<Rc<Vec<(usize, usize)>>>,
pub chrome_y: Signal<f64>,
follow: Signal<Option<Timer>>,
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)>,
pub videos: Signal<Rc<HashMap<NodeId, Live>>>,
pacing: Signal<Rc<RefCell<Pacing>>>,
encoder: Signal<Rc<Encoder>>,
regions_sent: Signal<RegionsSent>,
pub adapt: Signal<Adapt>,
pub faithful: Signal<bool>,
pub logs: Signal<HashMap<NodeId, LogScroll>>,
pub pixels: Memo<Pixels>,
pub frozen: Signal<bool>,
pub live: Signal<Rc<HashMap<NodeId, Live>>>,
tab: Signal<Tab>,
waiting: Signal<HashMap<u64, Then>>,
controls: Signal<HashMap<NodeId, Rc<WebControl>>>,
pub start: Signal<bool>,
pub tunnel: Signal<Option<String>>,
tunnel_now: Signal<bool>,
pub reads: Signal<u64>,
pub read_only: Signal<bool>,
pub(crate) agent_pick: Signal<bool>,
pub(crate) screen: Signal<Rc<RefCell<crate::pixel_view::Screen>>>,
pub screen_tiles: Signal<Vec<rich_art::tiles::Tile>>,
pub screen_stats: Signal<crate::pixel_view::ScreenStats>,
}
impl Browser {
pub(crate) fn send(&self, command: Command) -> u64 {
if self.start.get_untracked() && crate::start::leaves(&command) {
self.start.set(false);
}
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);
}
}
}
pub(crate) 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 effective_adapt(&self) -> Adapt {
if self.faithful.get() {
Adapt::none()
} else {
self.adapt.get()
}
}
pub fn hint_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,
adapt: self.effective_adapt(),
logs: self.logs.get(),
..Marks::default()
}
}
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,
adapt: self.effective_adapt(),
logs: self.logs.get(),
}
}
pub(crate) fn say(&self, markup: impl Into<String>) {
self.message.set(markup.into());
}
}
pub(crate) fn lay_out(page: &Page, view: View, width: u16, marks: &Marks) -> Projection {
match view {
View::Reader => {
let mut lines = Vec::new();
for piece in layout::reader(page, width.saturating_sub(2), marks) {
match piece {
Piece::Text(text) => lines.extend(text),
other => lines.extend(std::iter::repeat_n(Line::default(), other.rows())),
}
}
let rows = lines.len().max(1) as f64;
let y = (0..lines.len())
.map(|r| r as f64 / rows * page.height)
.collect();
let keys: Vec<u64> = (0..lines.len() as u64).collect();
let rows = keys.iter().map(|&k| (k, k as usize)).collect();
Projection {
lines,
keys,
y,
rows,
..Projection::default()
}
}
_ => layout::project(page, width, (CELL.0 as f64, CELL.1 as f64), marks),
}
}
fn find_rows(b: Browser) -> Rc<Vec<(usize, usize)>> {
b.found.get_untracked()
}
fn follow_scroll(b: Browser, row: u64, cx: &mut Ctx) {
if let Some(timer) = b.follow.get_untracked() {
timer.cancel();
}
let timer = cx.after(FOLLOW, move |_| {
b.follow.set(None);
let to = match row {
0 => 0.0,
_ => b.layout.with_untracked(|l| l.y_of(row as usize)),
};
let from = b.chrome_y.get_untracked();
let screen = b.viewport.get_untracked().1 as f64 * CELL.1 as f64;
let delta = to - from;
if delta.abs() < 1.0 {
return;
}
b.chrome_y.set(to);
if delta.abs() > 3.0 * screen.max(1.0) {
b.send(Command::Scroll(to));
} else {
b.send(Command::Wheel(delta));
}
});
b.follow.set(Some(timer));
}
const MOTION: std::time::Duration = std::time::Duration::from_millis(25);
fn step_motion(b: Browser, motion: &mut pictures::Motion) {
let budget = byte_budget(b);
let videos = b.pacing.with_untracked(|p| {
let mut pacing = p.borrow_mut();
pacing.budget = budget;
pacing.moving()
});
motion.share_with(budget, videos);
let page = b.page.get_untracked();
let images = b.images.get_untracked();
let next = match page {
Some(page) if images != Images::None && b.view.get_untracked() != View::Reader => {
let height = usize::from(b.viewport.get_untracked().1);
b.layout.with_untracked(|l| {
let first = usize::try_from(b.scroll.get_untracked())
.unwrap_or(usize::MAX)
.min(l.lines.len().saturating_sub(height));
let rows = first..first.saturating_add(height);
motion.step(
&page,
&l.regions,
rows,
b.pixels.get_untracked(),
b.frozen.get_untracked(),
std::time::Instant::now(),
)
})
}
_ => HashMap::new(),
};
b.pacing
.with_untracked(|p| p.borrow_mut().others = motion.moving_count());
let mut next = next;
b.videos.with_untracked(|v| {
for (node, live) in v.iter() {
next.insert(*node, live.clone());
}
});
if b.live.with_untracked(|l| **l != next) {
b.live.set(Rc::new(next));
}
}
const FOLLOW: std::time::Duration = std::time::Duration::from_millis(120);
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"
| "media.seek_back" | "media.seek_forward" => {
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())
}
}
"log.older" | "log.newer"
if b.page
.with(|p| p.as_ref().is_none_or(|p| p.logs.is_empty())) =>
{
Err("No live log on this page.".into())
}
"view.pixels" if !crate::pixel_view::available(b) => {
Err(crate::pixel_view::NEEDS_KITTY.into())
}
"agent.send"
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 pick: the links and controls are what Ctrl+G sends."
.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) as u64;
match id {
"open" => open_prompt(b, String::new(), cx),
"address" => open_prompt(b, b.url(), cx),
"hints" => start_hints(b),
"agent.send" => {
start_hints(b);
b.agent_pick.set(true);
}
"view.pixels" => {
b.view.update(|v| {
*v = if *v == View::Pixels {
View::Projection
} else {
View::Pixels
}
});
b.say(format!(
"{} view · {} leaves",
b.view.get_untracked().name(),
markup(&keys_label_of(b, "view.pixels"))
));
}
"back" => drop(b.send(Command::History(HistoryStep::Back))),
"forward" => drop(b.send(Command::History(HistoryStep::Forward))),
"reload" => {
b.tunnel_now.set(true);
b.send(Command::History(HistoryStep::Reload));
}
"media.freeze" => {
let frozen = !b.frozen.get_untracked();
b.frozen.set(frozen);
send_regions(b);
b.say(if frozen {
"[yellow]Moving pictures frozen[/] · F4 resumes"
} else {
"Moving pictures resumed"
});
}
"find" => find_prompt(b, cx),
"find.next" | "find.previous" => {
let found = find_rows(b);
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 u64).saturating_sub(3));
b.say(format!("match {} of {}", next + 1, found.len()));
}
"scroll.down" => b.scroll.update(|s| *s = s.saturating_add(1)),
"scroll.up" => b.scroll.update(|s| *s = s.saturating_sub(1)),
"page.down" => b.scroll.update(|s| *s = s.saturating_add(rows)),
"page.up" => b.scroll.update(|s| *s = s.saturating_sub(rows)),
"top" => b.scroll.set(0),
"bottom" => b.scroll.set(u64::MAX),
"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),
"adapt" => {
b.faithful.update(|f| *f = !*f);
if b.faithful.get_untracked() {
b.say("[yellow]faithful[/] projection: no terminal adaptations");
} else {
let on = b.adapt.get_untracked().active().count();
b.say(format!("adapted projection: {on} adaptations on"));
}
}
"log.older" | "log.newer" => {
let Some((node, rows)) = current_log(b) else {
return;
};
let step = (rows / 2).max(1) as isize;
let by = if id == "log.older" { -step } else { step };
let next = b.page.with_untracked(|p| {
let log = p.as_ref()?.logs.iter().find(|l| l.node == node)?;
let now = b
.logs
.with_untracked(|l| l.get(&node).copied().unwrap_or_default());
Some(scroll_log(log, now, by))
});
if let Some(next) = next {
b.logs.update(|l| {
if next == LogScroll::Follow {
l.remove(&node);
} else {
l.insert(node, next);
}
});
b.say(if next == LogScroll::Follow {
"live log: following its newest entries".to_string()
} else {
format!(
"live log: [yellow]paused[/] · {} to follow again",
markup(&keys_label_of(b, "log.newer"))
)
});
}
}
"about" => drop(b.send(Command::Open(crate::about::ADDRESS.into()))),
"media.play_pause" | "media.mute" | "media.volume_up" | "media.volume_down"
| "media.seek_back" | "media.seek_forward" => {
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)),
"media.seek_back" => MediaOp::Seek(-SEEK),
"media.seek_forward" => MediaOp::Seek(SEEK),
_ => 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(),
_ => {}
}
}
fn keys_label_of(b: Browser, id: &str) -> String {
b.keymap.with_untracked(|k| keys_label(k, id))
}
pub fn current_log(b: Browser) -> Option<(NodeId, usize)> {
let first = b
.page
.with_untracked(|p| p.as_ref()?.logs.first().map(|l| (l.node, 10)));
if b.view.get_untracked() == View::Reader {
return first;
}
let top = b.scroll.get_untracked().min(usize::MAX as u64) as usize;
let height = b.viewport.get_untracked().1 as usize;
b.layout
.with_untracked(|l| {
let top = top.min(l.lines.len().saturating_sub(1));
l.logs
.iter()
.find(|(_, row, rows)| *row < top + height && top < row + rows)
.map(|(node, _, rows)| (*node, *rows))
})
.or(first)
}
fn about(b: Browser, answers: Vec<String>, product: String, cx: &mut Ctx) {
cx.modal(Size::Percent(90), Size::Percent(90), move || {
let answers = answers.clone();
let product = product.clone();
let body = text(move || {
let configured = b.adapt.get();
let faithful = b.faithful.get();
let mut lines = vec![
format!("[bold]Noctiluca[/] {}", env!("CARGO_PKG_VERSION")),
format!("Browser: {}", markup(&product)),
String::new(),
if faithful {
"[bold]Terminal adaptations[/]: [yellow]off, the faithful projection[/]".into()
} else {
"[bold]Terminal adaptations[/] (active rules):".into()
},
];
for rule in RULES {
let on = configured.on(rule.rule) && !faithful;
lines.push(format!(
" {} {:<20} [dim]{}[/]",
if on { "[green]on [/]" } else { "[dim]off[/]" },
rule.name,
markup(rule.what)
));
}
lines.push(String::new());
lines.push(
"[bold]Codecs[/]: what this browser says it can play (canPlayType). DRM playback is never tried."
.into(),
);
for ((name, kind), answer) in crate::about::CODECS.iter().zip(&answers) {
lines.push(format!(
" {:<14} {:<15} [dim]{}[/]",
name,
crate::about::verdict(answer),
markup(kind)
));
}
lines.push(String::new());
lines.push(format!(
"[dim]{} toggles adapted and faithful · \\[adapt] in the config or --adapt=-RULE switch one off · Esc closes[/]",
markup(&keys_label_of(b, "adapt"))
));
lines.join("\n")
});
scroll_with(body, signal(0u16))
.focusable()
.autofocus()
.on_key("esc q", |cx| cx.pop())
.panel(crate::about::TITLE)
});
}
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())));
}
});
}
use crate::bars::clock;
const SEEK: f64 = 5.0;
fn video_frame(b: Browser, frame: &VideoFrame) {
let Some(rect) = b.page.with_untracked(|p| {
p.as_ref()?
.media
.iter()
.find(|m| m.node == frame.node)
.map(|m| m.rect)
}) else {
return;
};
let cols = ((rect.width / CELL.0 as f64).ceil() as usize).clamp(1, 400);
let rows = ((rect.height / CELL.1 as f64).ceil() as usize).clamp(1, 200);
let (picture, pixels) = b.pacing.with_untracked(|p| {
let mut pacing = p.borrow_mut();
let picture = pacing.offer(frame, cols, rows, Instant::now(), picture_bytes);
(picture, pacing.pixels(frame.node))
});
match picture {
Some(picture) if pixels == Pixels::Cells => {
show_frame(b, frame.node, Live { picture, pixels })
}
Some(picture) => {
let (cols, rows) = drawn_cells(b, frame.node).unwrap_or((cols, rows));
b.encoder.with_untracked(|e| {
e.encode(Job {
node: frame.node,
picture,
pixels,
cols,
rows,
})
});
}
None => {}
}
send_regions(b);
}
fn drawn_cells(b: Browser, node: NodeId) -> Option<(usize, usize)> {
b.layout.with_untracked(|l| {
b.page.with_untracked(|p| {
let page = p.as_ref()?;
l.regions
.iter()
.find(|r| page.media.get(r.media).is_some_and(|m| m.node == node))
.map(|r| (r.cols, r.rows))
})
})
}
fn show_frame(b: Browser, node: NodeId, live: Live) {
if !b.pacing.with_untracked(|p| p.borrow().has(node)) {
return;
}
let newer = b.videos.with_untracked(|v| {
v.get(&node).is_some_and(|shown| {
shown.picture.id() == live.picture.id()
&& shown.picture.version() > live.picture.version()
})
});
if newer {
return;
}
let mut videos = (*b.videos.get_untracked()).clone();
videos.insert(node, live.clone());
b.videos.set(Rc::new(videos));
let mut shown = (*b.live.get_untracked()).clone();
shown.insert(node, live);
b.live.set(Rc::new(shown));
}
fn regions_on_screen(b: Browser) -> Vec<NodeId> {
let rows = b.viewport.get().1 as usize;
let first = b.scroll.get() as usize;
let (top, bottom) = b.layout.with(|l| {
let top = if first == 0 { 0.0 } else { l.y_of(first) };
let bottom = if first.saturating_add(rows) >= l.lines.len() {
f64::MAX
} else {
l.y_of(first + rows)
};
(top, bottom)
});
b.page.with(|p| {
p.as_ref()
.map(|p| {
p.media
.iter()
.filter(|m| m.kind == MediaKind::Video)
.filter(|m| m.rect.bottom() > top && m.rect.y < bottom)
.map(|m| m.node)
.collect()
})
.unwrap_or_default()
})
}
fn whole_rate(interval: Duration) -> Duration {
let fps = 1.0 / interval.as_secs_f64().max(f64::MIN_POSITIVE);
if fps >= 1.0 {
Duration::from_secs_f64(1.0 / fps.floor())
} else {
Duration::from_secs(interval.as_secs_f64().ceil() as u64)
}
}
fn send_regions(b: Browser) {
let on_screen = if b.frozen.get_untracked() {
Vec::new()
} else {
regions_on_screen(b)
};
let intervals: Vec<(NodeId, Duration)> = b.pacing.with_untracked(|p| {
p.borrow()
.intervals()
.into_iter()
.map(|(node, d)| (node, whole_rate(d)))
.collect()
});
let scale = screencast_scale(b, &on_screen);
let next = (on_screen, intervals, scale);
if b.regions_sent.with_untracked(|sent| *sent != next) {
b.regions_sent.set(next.clone());
b.send(Command::Regions {
on_screen: next.0,
intervals: next.1,
scale: next.2,
});
}
}
fn screencast_scale(b: Browser, on_screen: &[NodeId]) -> f64 {
let need = b.page.with_untracked(|p| {
let Some(page) = p.as_ref() else {
return 1.0;
};
b.pacing.with_untracked(|pacing| {
let pacing = pacing.borrow();
on_screen
.iter()
.filter_map(|node| page.media.iter().find(|m| m.node == *node))
.map(|m| {
if pacing.pixels(m.node) != Pixels::Cells {
return 1.0;
}
let cols = (m.rect.width / CELL.0 as f64).ceil().max(1.0);
let rows = (m.rect.height / CELL.1 as f64).ceil().max(1.0);
(cols / m.rect.width.max(1.0)).max(2.0 * rows / m.rect.height.max(1.0))
})
.fold(0.0, f64::max)
})
});
if need <= 0.0 {
return 1.0;
}
2f64.powi(need.log2().ceil() as i32).clamp(1.0 / 16.0, 1.0)
}
fn video_region(b: Browser, node: NodeId) -> Node {
let picture = switch(
move || shown_picture(b, node).map_or(b.pixels.get(), |(_, pixels)| pixels),
move |pixels| {
picture_of(
move || shown_picture(b, node).map(|(p, _)| p),
ImageView::new().pixels(pixels),
)
},
)
.flex(1);
let mark = text(move || {
let playing = b.page.with(|p| {
p.as_ref()
.and_then(|p| p.media.iter().find(|m| m.node == node)?.playback)
.is_some_and(|p| p.playing())
});
if playing {
String::new()
} else {
"[bold black on white] â–¶ [/]".into()
}
})
.no_wrap()
.fixed(1);
column([picture, mark])
}
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
&& 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
&& 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)
});
}
pub fn agent_text(page: &Page, index: usize) -> Option<String> {
let control = page.controls.get(index)?;
let mut text = format!(
"[Noctiluca] {} {:?}",
control.role.name(),
crate::clean::clean(&control.name)
);
if let Some(href) = &control.href {
text.push_str(&format!(" -> {}", crate::clean::clean(href)));
}
if let Some(value) = control.value.as_deref().filter(|v| !v.is_empty()) {
text.push_str(&format!(" = {:?}", crate::clean::clean(value)));
}
text.push_str(&format!(
"\non {} ({})\n",
crate::clean::clean(&page.url),
crate::clean::clean(&page.title)
));
Some(text)
}
fn send_to_agent(b: Browser, index: usize, cx: &mut Ctx) {
use crate::config::SendTo;
let Some(text) = b
.page
.with_untracked(|p| p.as_ref().and_then(|p| agent_text(p, index)))
else {
return;
};
let to = b.config.with_untracked(|c| c.agent.send.clone());
let pane = |host: &dyn rich_interact::host::TerminalHost| -> Result<rich_interact::host::Pane, String> {
match &to {
SendTo::Pane(id) => Ok(rich_interact::host::Pane::new(id.clone())),
_ => [
rich_interact::host::Direction::Right,
rich_interact::host::Direction::Left,
rich_interact::host::Direction::Down,
rich_interact::host::Direction::Up,
]
.into_iter()
.find_map(|d| host.neighbor(d).ok().flatten())
.ok_or_else(|| "no pane beside this one".to_string()),
}
};
match to {
SendTo::Clipboard => {
cx.copy(text);
b.say("[cyan]Sent to the clipboard[/] for the agent");
}
SendTo::Neighbor | SendTo::Pane(_) => {
let sent = rich_interact::host::detect()
.ok_or_else(|| "no terminal API here (tmux, WezTerm, kitty, a socket)".to_string())
.and_then(|host| {
let pane = pane(host.as_ref())?;
host.send_text(&pane, &text)
.map(|()| pane.id.clone())
.map_err(|e| e.to_string())
});
match sent {
Ok(id) => b.say(format!("[cyan]Sent to pane {}[/]", markup(&id))),
Err(why) => {
cx.copy(text);
cx.toast(format!(
"[yellow]Could not send to a pane ({}): copied instead.[/]",
markup(&why)
));
}
}
}
}
}
#[cfg(unix)]
fn serve_control(b: Browser, server: rich_interact::control::ControlServer) {
spawn_stream(move |stream| {
let proxy = std::sync::Mutex::new(stream.proxy().clone());
server.serve(move |request| {
let (reply, answer) = mpsc::channel();
let (verb, args) = (request.verb.clone(), request.args.clone());
let posted = proxy
.lock()
.unwrap_or_else(|e| e.into_inner())
.run_with(move |cx| {
let _ = reply.send(crate::control::handle(b, &verb, &args, cx));
});
if !posted {
return Err("the browser is closing".into());
}
answer
.recv_timeout(Duration::from_secs(10))
.unwrap_or_else(|_| Err("the browser did not answer in time".into()))
});
});
}
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);
b.agent_pick.set(false);
}
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);
if b.agent_pick.get_untracked() {
b.agent_pick.set(false);
send_to_agent(b, index, cx);
} else {
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);
b.agent_pick.set(false);
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 clicked = items.clone();
let first_choice = keys.first().map(|(_, choose)| choose.clone());
let list = list
.on_click_at(move |cx, _, row| {
let n = clicked().len();
let at = selected.get_untracked().min(n.saturating_sub(1));
let first = at.saturating_sub(room.saturating_sub(2));
let index = first + usize::from(row);
if index >= n {
return false;
}
if index == at {
if let Some(choose) = &first_choice {
choose(index, cx);
}
} else {
selected.set(index);
}
true
})
.on_mouse({
let count = items.clone();
move |_, mouse| match mouse.kind {
MouseKind::ScrollUp => {
selected.update(|s| *s = s.saturating_sub(1));
true
}
MouseKind::ScrollDown => {
let n = count().len();
selected.update(|s| *s = (*s + 1).min(n.saturating_sub(1)));
true
}
_ => false,
}
});
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)
.filter(|v| *v != View::Pixels || crate::pixel_view::available(b))
{
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(CHROME_ROWS)));
}
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.videos.set(Rc::new(HashMap::new()));
b.pacing.with_untracked(|p| p.borrow_mut().clear());
b.logs.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.reads.update(|n| *n += 1);
b.refresh_controls();
if new_document {
apply_site(b, &url);
}
}
Update::State(state) => b.state.set(state),
Update::Metrics(metrics) => b.chrome_y.set(metrics.scroll_y),
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::Frame(frame) => video_frame(b, &frame),
Update::Screen(frame) => crate::pixel_view::frame(b, &frame),
Update::Still(node) => {
b.pacing.with_untracked(|p| p.borrow_mut().still(node));
send_regions(b);
}
Update::Download { name, .. } => cx.toast(format!(
"[yellow]A download, {}, waits in quarantine.[/]",
markup(&name)
)),
Update::DownloadDone { .. } => {}
Update::About { answers, product } => about(b, answers, product, cx),
}
}
#[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 shown_picture(b: Browser, node: NodeId) -> Option<(Picture, Pixels)> {
if let Some(live) = b.live.with(|l| l.get(&node).cloned()) {
return Some((live.picture, live.pixels));
}
let (picture, video) = b.page.with(|p| {
let media = p.as_ref()?.media.iter().find(|m| m.node == node)?;
Some((
media.picture.clone()?,
layout::play_mark(media, 3).is_some(),
))
})?;
let pixels = b.pixels.get();
if video && pixels != Pixels::Cells {
return Some((marked(&picture), pixels));
}
Some((picture, pixels))
}
thread_local! {
static MARKED: std::cell::RefCell<HashMap<(u32, u32), Picture>> =
std::cell::RefCell::new(HashMap::new());
}
fn marked(picture: &Picture) -> Picture {
let key = (picture.id(), picture.version());
MARKED.with(|m| {
let mut made = m.borrow_mut();
if made.len() > 64 {
made.clear();
}
made.entry(key)
.or_insert_with(|| Picture::new(rich_art::graphics::play_mark(picture.image())))
.clone()
})
}
fn play_marks(
line: Vec<rich::Segment>,
width: usize,
row: usize,
regions: &[layout::Region],
page: &Page,
) -> Vec<rich::Segment> {
let mut line = line;
for region in regions {
let Some(media) = page.media.get(region.media) else {
continue;
};
let last = region.row + region.rows.saturating_sub(1);
if row != last || region.cols < 3 || region.column + 3 > width {
continue;
}
let Some(mark) = layout::play_mark(media, region.cols) else {
continue;
};
let style = rich::Style::parse(&mark.spans[0].style).ok();
let mut pieces = rich::Segment::divide(&line, &[region.column, region.column + 3, width]);
pieces.resize(3, Vec::new());
let mut joined = std::mem::take(&mut pieces[0]);
joined.push(rich::Segment::new(mark.spans[0].text.clone(), style));
joined.extend(std::mem::take(&mut pieces[2]));
line = joined;
}
line
}
fn reader_view(b: Browser) -> Node {
let reading = memo(move || {
let width = b.viewport.get().0;
let marks = b.hint_marks();
let images = b.images.get();
b.page.with(|p| {
p.as_ref()
.map(|p| {
let pieces = layout::reader(p, width.saturating_sub(2), &marks);
let keys = piece_keys(p, &pieces, images);
(pieces, keys)
})
.unwrap_or_default()
})
});
let build = move |key: PieceKey| -> Node {
match key {
PieceKey::Text(i, _) => {
let rows = reading.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()
};
reading.with(|(d, _)| {
let start: usize = d.iter().take(i).map(Piece::rows).sum();
marks.current = b.found.with(|f| f.get(b.find_at.get()).copied());
match d.get(i) {
Some(Piece::Text(lines)) => to_text(lines, &marks, start),
_ => rich::Text::new(""),
}
})
})
.on_click_at(move |cx, column, row| {
let control = reading.with_untracked(|(d, _)| match d.get(i) {
Some(Piece::Text(lines)) => lines
.get(usize::from(row))
.and_then(|line| layout::control_at(line, usize::from(column))),
_ => None,
});
control.is_some_and(|index| {
click_control(b, index, cx);
true
})
})
.fixed(rows)
}
PieceKey::Media(node, rows)
if b.page.with_untracked(|p| {
p.as_ref().is_some_and(|p| {
p.media
.iter()
.any(|m| m.node == node && m.kind == MediaKind::Video)
})
}) =>
{
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)
})
});
row([video_region(b, node).fixed(width), label("").flex(1)]).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 = rich_intuituive::ImageView::new().pixels(b.pixels.get_untracked());
let picture = picture_of(move || shown_picture(b, node).map(|(p, _)| p), view)
.on_mouse(move |cx, mouse| {
mouse.is_click()
&& click_media_at(
b,
node,
mouse.precise(),
(width.into(), rows.into()),
cx,
)
});
row([picture.fixed(width), label("").flex(1)]).fixed(rows)
}
PieceKey::Controls(nodes) => web_widget(b, nodes),
PieceKey::Player(node) => player(b, node),
}
};
let key_of = |key: &PieceKey| {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut h);
h.finish()
};
let rows_of = move |i: usize| -> u16 {
reading.with_untracked(|(d, k)| match k.get(i) {
Some(PieceKey::Text(at, _)) => d.get(*at).map_or(1, Piece::rows) as u16,
Some(PieceKey::Media(_, rows)) => *rows,
_ => 1,
})
};
scroll_virtual_with(
move || reading.with(|(_, k)| k.len()),
rows_of,
move |i| match reading.with_untracked(|(_, k)| k.get(i).cloned()) {
Some(key) => build(key),
None => label(""),
},
b.scroll,
)
.item_key(move |i| reading.with_untracked(|(_, k)| k.get(i).map_or(i as u64, key_of)))
.on_near_end(near_end(b), move |_| load_more(b))
.autofocus()
.name("page")
}
fn near_end(b: Browser) -> u64 {
b.viewport.get_untracked().1.max(1) as u64
}
fn load_more(b: Browser) {
if b.page.with_untracked(Option::is_some) {
b.send(Command::LoadMore);
}
}
fn click_row(b: Browser, row: usize, column: usize, cx: &mut Ctx) -> bool {
let (control, video) = b.layout.with_untracked(|l| {
let control = l
.lines
.get(row)
.and_then(|line| layout::control_at(line, column));
let video = l
.regions
.iter()
.find(|r| r.on(row) && (r.column..r.column + r.cols).contains(&column))
.map(|r| r.media);
(control, video)
});
if let Some(index) = control {
click_control(b, index, cx);
return true;
}
let video = video.and_then(|media| {
b.page.with_untracked(|p| {
let m = p.as_ref()?.media.get(media)?;
(m.kind == MediaKind::Video).then_some((m.node, m.playback))
})
});
match video {
Some((node, playback)) => {
let paused = playback.is_none_or(|p| p.paused || p.ended);
media_op(
b,
node,
if paused {
MediaOp::Play
} else {
MediaOp::Pause
},
);
true
}
None => false,
}
}
fn click_media_at(
b: Browser,
node: NodeId,
at: (f32, f32),
cells: (usize, usize),
_cx: &mut Ctx,
) -> bool {
let target = b.page.with_untracked(|p| {
let page = p.as_ref()?;
let media = page.media.iter().find(|m| m.node == node)?;
let pointable = matches!(
media.kind,
MediaKind::Image | MediaKind::Canvas | MediaKind::Svg | MediaKind::Frame
);
pointable.then(|| (page.document, media.rect.width, media.rect.height))
});
let Some((document, width, height)) = target else {
return false;
};
let (x, y) = layout::point_in(width, height, cells.0, cells.1, at);
let id = b.send(Command::ClickAt {
document,
node,
x,
y,
});
b.after(id, |result, cx| {
if let Err(refusal) = result {
cx.toast(format!("[red]{}[/]", markup(&refusal.to_string())));
}
});
true
}
fn click_control(b: Browser, index: usize, cx: &mut Ctx) {
b.hints.set(None);
b.message.set(String::new());
activate(b, index, cx);
}
fn projection_view(b: Browser) -> Node {
let cells = Rc::new(pictures::Cells::default());
let line = move |i: usize| {
let key = b
.layout
.with_untracked(|l| l.keys.get(i).copied().unwrap_or(i as u64));
let cells = cells.clone();
leaf(move |console, width, _| {
let marks = Marks {
find: b.find.get(),
current: b.found.with(|f| f.get(b.find_at.get()).copied()),
..Marks::default()
};
let width = usize::from(width.max(1));
let backdrop = if b.effective_adapt().on(crate::adapt::Rule::PageColors) {
None
} else {
let transparent = b.config.with(|c| c.transparent);
let page = b
.page
.with(|p| p.as_ref().and_then(|p| p.background.clone()));
crate::adapt::page_backdrop(
page.as_deref(),
transparent,
crate::adapt::background(),
)
};
let drawn = b.layout.with(|l| {
let row = l.rows.get(&key).copied().unwrap_or(i);
let Some(line) = l.lines.get(row) else {
return vec![Vec::new()];
};
let text = to_text(std::slice::from_ref(line), &marks, row);
let options = console.options().update_width(width);
let segments = console
.render_lines(&text, &options, false)
.into_iter()
.next()
.unwrap_or_default();
let inline: Vec<&(layout::Region, String)> =
l.inline.iter().filter(|(r, _)| r.on(row)).collect();
if b.images.get() == Images::None
|| (inline.is_empty() && !l.regions.iter().any(|r| r.on(row)))
{
return vec![segments];
}
let nodes: Vec<Option<NodeId>> = b.page.with(|p| {
l.regions
.iter()
.map(|r| p.as_ref()?.media.get(r.media).map(|m| m.node))
.collect()
});
let mut on_row: Vec<layout::Region> =
l.regions.iter().filter(|r| r.on(row)).copied().collect();
on_row.extend(inline.iter().map(|(r, _)| *r));
let pixels = b.pixels.get();
let small = |region: &layout::Region| -> Option<(Picture, Pixels)> {
let (_, src) = inline.iter().find(|(r, _)| r == region)?;
let picture = b
.page
.with(|p| p.as_ref()?.inline_pictures.get(src).cloned())?;
Some((picture, pixels))
};
let spliced =
pictures::splice(console, &cells, segments, width, row, &on_row, |region| {
if region.media == usize::MAX {
return small(region);
}
let at = l.regions.iter().position(|r| r == region)?;
shown_picture(b, nodes.get(at).copied().flatten()?)
});
vec![b.page.with(|p| match p {
Some(page) if pixels == Pixels::Cells => {
play_marks(spliced, width, row, &l.regions, page)
}
_ => spliced,
})]
});
match backdrop {
Some(backdrop) => drawn
.into_iter()
.map(|line| pictures::on_backdrop(line, width, &backdrop))
.collect(),
None => drawn,
}
})
.on_mouse(move |cx, mouse| {
if !mouse.is_click() {
return false;
}
let (x, y) = mouse.precise();
let column = usize::from(mouse.column);
let target = b.layout.with_untracked(|l| {
let row = l.rows.get(&key).copied().unwrap_or(i);
let line = l.lines.get(row)?;
if layout::control_at(line, column).is_some() {
return None;
}
let region = l
.regions
.iter()
.find(|r| r.on(row) && (r.column..r.column + r.cols).contains(&column))?;
let at = (
x - region.column as f32,
(row - region.row) as f32 + (y - f32::from(mouse.row)),
);
Some((region.media, at, (region.cols, region.rows)))
});
let Some((media, at, cells)) = target else {
return false;
};
let node = b
.page
.with_untracked(|p| p.as_ref()?.media.get(media).map(|m| m.node));
node.is_some_and(|node| click_media_at(b, node, at, cells, cx))
})
.on_click_at(move |cx, column, _| {
let row = b
.layout
.with_untracked(|l| l.rows.get(&key).copied().unwrap_or(i));
click_row(b, row, usize::from(column), cx)
})
.fixed(1)
};
scroll_virtual_with(
move || b.layout.with(|l| l.lines.len()),
|_| 1,
line,
b.scroll,
)
.item_key(move |i| {
b.layout
.with_untracked(|l| l.keys.get(i).copied().unwrap_or(i as u64))
})
.on_near_end(near_end(b), move |_| load_more(b))
.autofocus()
.name("page")
}
pub const CHROME_ROWS: u16 = 4;
pub fn byte_budget(b: Browser) -> rich_art::picture::ByteBudget {
let adapt = b.effective_adapt();
let measured = OUTPUT.with(|o| o.borrow_mut().rate());
b.config.with_untracked(|c| {
let mut budget = c.bandwidth.map_or_else(
|| {
let mut guess = rich_art::picture::ByteBudget::guess();
if let Some(rate) = measured {
guess.bytes_per_second = measured_budget(
guess.bytes_per_second,
rate,
rich_art::picture::over_ssh(),
);
}
guess
},
rich_art::picture::ByteBudget::new,
);
budget.max_fps = f64::from(c.fps).clamp(1.0, crate::video::MAX_FPS as f64);
if !adapt.on(crate::adapt::Rule::Bandwidth) {
budget.bytes_per_second = u64::MAX / 4;
budget.min_fps = 0.0;
}
budget
})
}
pub const LOCAL_CEILING: u64 = 32 << 20;
pub fn measured_budget(guess: u64, measured: u64, over_ssh: bool) -> u64 {
if over_ssh {
guess.min(measured)
} else {
measured.min(LOCAL_CEILING.max(guess))
}
}
thread_local! {
static OUTPUT: std::cell::RefCell<OutputRate> = std::cell::RefCell::default();
}
#[derive(Default)]
pub struct OutputRate {
stats: Option<rich_intuituive::StatsHandle>,
last: (u64, Duration),
rate: Option<f64>,
}
const RATE_SAMPLE: u64 = 64 * 1024;
impl OutputRate {
pub fn observe(&mut self, total_bytes: u64, total_write: Duration) {
let bytes = total_bytes.saturating_sub(self.last.0);
let write = total_write.saturating_sub(self.last.1);
if bytes < RATE_SAMPLE {
return;
}
self.last = (total_bytes, total_write);
if write.is_zero() {
return;
}
let rate = bytes as f64 / write.as_secs_f64();
self.rate = Some(self.rate.map_or(rate, |before| before * 0.7 + rate * 0.3));
}
pub fn rate(&mut self) -> Option<u64> {
if let Some(stats) = self.stats.as_ref().map(rich_intuituive::StatsHandle::get) {
self.observe(stats.total_bytes, stats.total_write);
}
self.rate.map(|r| r as u64)
}
}
fn video_pacing(config: &Config, images: Images) -> Pacing {
let mut budget = rich_art::picture::ByteBudget::guess();
budget.max_fps = (config.fps as f64).clamp(1.0, crate::video::MAX_FPS as f64);
let asked = (images != Images::Auto).then(|| pictures::pixels(images));
Pacing::new(budget, Pacing::start_for(asked, Pixels::detect()))
}
const TUNNEL_LOOK: Duration = Duration::from_millis(500);
const TUNNEL_WAIT: Duration = Duration::from_secs(2);
const TUNNEL_WAIT_MAX: Duration = Duration::from_secs(30);
struct TunnelWatch {
link: crate::ssh::TunnelLink,
host: String,
wait: Duration,
retry_at: Option<Instant>,
}
impl TunnelWatch {
fn new(link: crate::ssh::TunnelLink) -> TunnelWatch {
TunnelWatch {
host: link.host(),
link,
wait: TUNNEL_WAIT,
retry_at: None,
}
}
fn says(&self, state: &crate::ssh::TunnelState) -> String {
use crate::ssh::TunnelState;
let host = markup(&self.host);
match state {
TunnelState::Up => format!("[dim]via ssh {host}[/]"),
TunnelState::Connecting => format!("[yellow]ssh {host}: reconnecting…[/]"),
TunnelState::Down(why) => {
let left = self.retry_at.map_or(0, |at| {
at.saturating_duration_since(Instant::now()).as_secs()
});
format!(
"[red]ssh {host} dropped: {}[/] · retrying in {left} s (reload: now)",
markup(why)
)
}
}
}
fn look(&mut self, b: Browser) {
use crate::ssh::TunnelState;
let mut state = self.link.check();
if let TunnelState::Down(_) = &state {
let now = Instant::now();
let retry_at = *self.retry_at.get_or_insert(now + self.wait);
if now >= retry_at || b.tunnel_now.get_untracked() {
self.retry_at = None;
self.wait = (self.wait * 2).min(TUNNEL_WAIT_MAX);
state = match self.link.reconnect() {
Ok(()) => TunnelState::Connecting,
Err(error) => TunnelState::Down(error.to_string()),
};
}
} else if state == TunnelState::Up {
self.wait = TUNNEL_WAIT;
self.retry_at = None;
}
if b.tunnel_now.get_untracked() {
b.tunnel_now.set(false);
}
let says = self.says(&state);
if b.tunnel
.with_untracked(|t| t.as_deref() != Some(says.as_str()))
{
b.tunnel.set(Some(says));
}
}
}
pub fn app(engine: Box<dyn Engine>, options: Options) -> App {
let Options {
config,
bookmarks,
url,
view,
images,
tunnel,
control,
} = options;
let start_page = url.is_none() && config.home.is_none();
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);
let configured_adapt = config.adapt;
let app = 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 page: Signal<Option<Arc<Page>>> = signal(None);
let view: Signal<View> = signal(if first_view == View::Raster {
View::Projection
} else {
first_view
});
let read_only = control.as_ref().is_some_and(|c| c.is_read_only());
let hints: Signal<Option<Hints>> = signal(None);
let find: Signal<Option<String>> = signal(None);
let adapt = signal(configured_adapt);
let faithful = signal(false);
let logs: Signal<HashMap<NodeId, LogScroll>> = signal(HashMap::new());
let port = viewport();
let layout = memo(move || {
let width = port.get().0;
let marks = Marks {
hints: hints
.with(|h| {
h.as_ref()
.map(|h| h.labels.iter().map(|(l, c)| (*c, l.clone())).collect())
})
.unwrap_or_default(),
adapt: if faithful.get() {
Adapt::none()
} else {
adapt.get()
},
logs: logs.get(),
..Marks::default()
};
let view = view.get();
Rc::new(page.with(|p| {
p.as_ref()
.map(|p| lay_out(p, view, width, &marks))
.unwrap_or_default()
}))
});
let images_signal = signal(images);
let found = memo(move || {
let query = find.get().filter(|q| !q.is_empty());
Rc::new(match query {
Some(query) => layout.with(|l| {
l.lines
.iter()
.enumerate()
.flat_map(|(row, line)| {
(0..matches(&line.plain(), &query).len()).map(move |k| (row, k))
})
.collect()
}),
None => Vec::new(),
})
});
let pacing = video_pacing(&config, images);
let (prepared, ready) = mpsc::channel::<Job>();
let encoder = Encoder::spawn(move |job| drop(prepared.send(job)));
let b = Browser {
page,
state: signal(PageState::default()),
view,
hints,
find,
find_at: signal(usize::MAX),
scroll: signal(0),
layout,
found,
chrome_y: signal(0.0),
follow: signal(None),
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: images_signal,
images_default: signal(images),
viewport: port,
adapt,
faithful,
logs,
pixels: memo(move || pictures::pixels(images_signal.get())),
frozen: signal(false),
live: signal(Rc::new(HashMap::new())),
tab: signal(tab),
waiting: signal(HashMap::new()),
controls: signal(HashMap::new()),
videos: signal(Rc::new(HashMap::new())),
pacing: signal(Rc::new(RefCell::new(pacing))),
encoder: signal(Rc::new(encoder)),
regions_sent: signal((Vec::new(), Vec::new(), 1.0)),
start: signal(false),
tunnel: signal(None),
tunnel_now: signal(false),
reads: signal(0),
read_only: signal(read_only),
agent_pick: signal(false),
screen: signal(crate::pixel_view::state()),
screen_tiles: signal(Vec::new()),
screen_stats: signal(crate::pixel_view::ScreenStats::default()),
};
if b.view.get_untracked() == View::Pixels && !crate::pixel_view::available(b) {
b.view.set(View::Projection);
b.say(format!("[yellow]{}[/]", crate::pixel_view::NEEDS_KITTY));
}
if b.view.get_untracked() == View::Pixels {
b.send(Command::Pixels(true));
}
watch(
move || b.view.get() == View::Pixels,
move |on, _| {
b.send(Command::Pixels(on));
},
);
#[cfg(unix)]
if let Some(server) = control.as_ref().and_then(|c| c.take()) {
serve_control(b, server);
}
if let Some(link) = tunnel.clone() {
let mut watch = TunnelWatch::new(link);
b.tunnel.set(Some(watch.says(&crate::ssh::TunnelState::Up)));
every(TUNNEL_LOOK, move |_| watch.look(b));
}
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;
}
}
});
spawn_stream(move |stream| {
let proxy = stream.proxy().clone();
while let Ok(job) = ready.recv() {
let live = Live {
picture: job.picture,
pixels: job.pixels,
};
if !proxy.run_with(move |_| show_frame(b, job.node, live)) {
break;
}
}
});
let motion = Rc::new(std::cell::RefCell::new(pictures::Motion::new(byte_budget(
b,
))));
every(MOTION, move |_| step_motion(b, &mut motion.borrow_mut()));
watch(
move || b.viewport.get(),
move |(columns, rows), _| {
b.send(Command::Resize(columns, rows.saturating_sub(CHROME_ROWS)));
},
);
b.send(Command::Open(start.clone()));
b.start.set(start_page);
watch(move || regions_on_screen(b), move |_, _| send_regions(b));
watch(
move || b.scroll.get(),
move |row, cx| follow_scroll(b, row, cx),
);
let address = crate::bars::header(b);
let status = crate::bars::status(b);
let body = keyed(
move || b.view.get(),
move |view| match view {
View::Reader => reader_view(b),
View::Pixels => crate::pixel_view::view(b),
_ => projection_view(b),
},
)
.flex(1)
.hidden_when(move || b.start.get());
let start = crate::start::page(crate::start::StartPage {
keymap: keymap.clone(),
bookmarks: b.bookmarks,
open: Rc::new(move |url, _| {
b.send(Command::Open(url));
}),
about: Rc::new(move |cx| run(b, "about", cx)),
chrome_rows: CHROME_ROWS,
})
.flex(1)
.shown_when(move || b.start.get());
let mut root = column([address, start, 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 b.view.with_untracked(|v| *v == View::Pixels) && crate::pixel_view::passes(key) {
b.send(Command::Key(key));
return true;
}
if let Some(target) = site_key(b, key) {
b.message.set(String::new());
run_target(b, &target, cx);
return true;
}
false
})
})
.graphics(image_graphics());
let stats = app.stats_handle();
OUTPUT.with(|o| o.borrow_mut().stats = Some(stats));
app
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_output_rate_is_measured_once_enough_was_written() {
let mut rate = OutputRate::default();
rate.observe(10_000, Duration::from_millis(100));
assert_eq!(rate.rate(), None);
rate.observe(200_000, Duration::from_secs(1));
assert_eq!(rate.rate(), Some(200_000));
rate.observe(600_000, Duration::from_secs(2));
let now = rate.rate().unwrap();
assert!((200_000..400_000).contains(&now), "{now}");
rate.observe(900_000, Duration::from_secs(2));
assert_eq!(rate.rate(), Some(now));
}
}