#[cfg(not(target_arch = "wasm32"))]
use std::sync::mpsc::{Receiver, TryRecvError};
use std::time::Duration;
use eframe::egui::{self, Color32, Id, Vec2};
use pv::PvManager;
use crate::app::Location;
use crate::screen::{Compat, Node, Screen};
use crate::style;
use crate::widgets::{self, Cx, Request};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum WindowKey {
#[default]
Main,
Child(egui::ViewportId),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Origin {
pub window: WindowKey,
pub ctrl: bool,
pub shift: bool,
}
pub struct Drawn {
pub status: f32,
pub requests: Vec<(Origin, Request)>,
pub background: Option<egui::Response>,
}
pub struct ViewCx<'a> {
pub window: WindowKey,
pub touch: bool,
pub trends: &'a [(u64, String)],
}
const NOTICE_SECONDS: f64 = 8.0;
pub struct View {
pvs: PvManager,
pub screen: Option<Screen>,
pub current: Option<Location>,
pub back: Vec<Location>,
pub forward: Vec<Location>,
pub zoom: f32,
pub fit: bool,
error: Option<String>,
reloading: bool,
pub new_display: bool,
pub open_path: String,
loading: Option<Loading>,
notice_seen: (u64, f64),
pub launching: Vec<LaunchKey>,
pub shown: u64,
window_title: String,
}
#[cfg(not(target_arch = "wasm32"))]
type Loading = Receiver<Result<Screen, bob::Error>>;
#[cfg(target_arch = "wasm32")]
struct Loading;
pub fn window_title(title: &str, pinned: bool) -> String {
if pinned {
format!("📌 {title}")
} else {
title.to_string()
}
}
pub type LaunchKey = (Option<String>, Option<String>, String);
impl View {
pub fn new(pvs: &PvManager, zoom: Option<f32>) -> Self {
Self {
pvs: pvs.clone(),
screen: None,
current: None,
back: Vec::new(),
forward: Vec::new(),
zoom: zoom.unwrap_or(1.0),
fit: zoom.is_none(),
error: None,
reloading: false,
new_display: false,
open_path: String::new(),
loading: None,
launching: Vec::new(),
notice_seen: (0, 0.0),
shown: 0,
window_title: String::new(),
}
}
pub fn show(&mut self, loc: Location) {
self.screen = None;
self.pvs = self.pvs.retagged();
self.shown += 1;
self.open_path = loc.source.to_string();
#[cfg(target_arch = "wasm32")]
set_page_url(&loc);
self.loading = Some(self.start_loading(&loc));
self.error = None;
self.current = Some(loc);
#[cfg(not(target_arch = "wasm32"))]
if let Some(rx) = &self.loading
&& let Ok(loaded) = rx.recv_timeout(crate::screen::QUICK)
{
self.loading = None;
self.loaded(loaded);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn start_loading(&self, loc: &Location) -> Loading {
let (tx, rx) = std::sync::mpsc::channel();
let (source, macros, pvs) = (loc.source.clone(), loc.macros.clone(), self.pvs.clone());
let spawned = std::thread::Builder::new()
.name("display-loader".into())
.stack_size(crate::screen::BUILD_STACK)
.spawn(move || {
tx.send(Screen::load(&source, ¯os, &pvs)).ok();
});
if let Err(e) = spawned {
log::error!("cannot start the display loader: {e}");
}
rx
}
#[cfg(target_arch = "wasm32")]
fn start_loading(&self, _loc: &Location) -> Loading {
Loading
}
fn loaded(&mut self, loaded: Result<Screen, bob::Error>) {
match loaded {
Ok(screen) => {
self.screen = Some(screen);
self.error = None;
if !self.reloading {
self.new_display = true;
}
}
Err(e) => {
log::error!("{e}");
self.error = Some(e.to_string());
if !self.reloading {
self.new_display = true;
}
}
}
self.reloading = false;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn is_loading(&self) -> bool {
self.loading.is_some() || !self.launching.is_empty()
}
#[cfg(target_arch = "wasm32")]
pub fn poll_loading(&mut self, ctx: &egui::Context) {
if self.loading.is_none() {
return;
}
if !crate::fetch::pending()
&& let Some(cur) = &self.current
{
let loaded = Screen::load(&cur.source, &cur.macros, &self.pvs);
if !crate::fetch::pending() {
self.loading = None;
self.loaded(loaded);
return;
}
}
ctx.request_repaint_after(Duration::from_millis(50));
}
#[cfg(not(target_arch = "wasm32"))]
pub fn poll_loading(&mut self, ctx: &egui::Context) {
if let Some(screen) = &mut self.screen
&& crate::screen::poll_built(&mut screen.nodes)
{
ctx.request_repaint_after(Duration::from_millis(50));
}
let Some(rx) = &self.loading else { return };
match rx.try_recv() {
Err(TryRecvError::Empty) => {
ctx.request_repaint_after(Duration::from_millis(50));
}
Ok(loaded) => {
self.loading = None;
self.loaded(loaded);
}
Err(TryRecvError::Disconnected) => {
self.loading = None;
if let Some(cur) = &self.current {
let e = crate::screen::build_failed(&cur.source);
self.loaded(Err(e));
}
}
}
}
pub fn shows(&self, loc: &Location) -> bool {
let Some(cur) = &self.current else {
return false;
};
let Some(screen) = &self.screen else {
return cur.same_display(loc);
};
let defaults = |l: &Location| Location {
source: l.source.clone(),
macros: screen.display.macros.with(&l.macros),
};
defaults(cur).same_display(&defaults(loc))
}
pub fn saved_location(&self) -> Option<&Location> {
self.current
.iter()
.chain(self.back.iter().rev())
.find(|l| !crate::fetch::refused(&l.source))
}
pub fn open(&mut self, loc: Location) {
if let Some(cur) = self.current.take() {
self.back.push(cur);
}
self.forward.clear();
self.show(loc);
}
pub fn go_back(&mut self) {
if let Some(loc) = self.back.pop() {
if let Some(cur) = self.current.take() {
self.forward.push(cur);
}
self.show(loc);
}
}
pub fn go_forward(&mut self) {
if let Some(loc) = self.forward.pop() {
if let Some(cur) = self.current.take() {
self.back.push(cur);
}
self.show(loc);
}
}
pub fn reload(&mut self) {
if let Some(cur) = self.current.clone() {
crate::fetch::forget();
crate::symbol::forget();
bob::project::forget();
self.reloading = true;
self.show(cur);
}
}
pub fn zoom_by(&mut self, factor: f32) {
self.fit = false;
self.zoom = (self.zoom * factor).clamp(0.2, 5.0);
}
pub fn update_title(&mut self, ctx: &egui::Context, pinned: bool) {
let Some(title) = self.screen.as_ref().map(|s| window_title(&s.title, pinned)) else {
return;
};
if title != self.window_title {
self.window_title = title;
#[cfg(not(target_arch = "wasm32"))]
ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title.clone()));
#[cfg(target_arch = "wasm32")]
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
let _ = ctx;
doc.set_title(&self.window_title);
}
}
}
pub fn natural_size(&self, ui_zoom: f32) -> Option<Vec2> {
let shown = if self.fit { 1.0 } else { self.zoom } / ui_zoom;
self.screen
.as_ref()
.map(|s| Vec2::new(s.width as f32, s.height as f32) * shown)
}
pub fn draw(&mut self, ui: &mut egui::Ui, vcx: &ViewCx) -> Drawn {
let id = |name: &str| match vcx.window {
WindowKey::Main => Id::new(name),
WindowKey::Child(v) => Id::new((name, v)),
};
let status = egui::Panel::bottom(id("status"))
.show(ui, |ui| self.status(ui))
.response
.rect
.height();
let ui_zoom = ui.ctx().zoom_factor();
let mut requests = Vec::new();
let mut background = None;
egui::CentralPanel::default()
.frame(egui::Frame::NONE.fill(Color32::from_gray(200)))
.show(ui, |ui| {
if let WindowKey::Child(_) = vcx.window {
let rect = ui.max_rect();
background = Some(ui.interact(rect, id("background"), egui::Sense::click()));
}
let Some(screen) = self.screen.as_mut() else {
return;
};
let avail = ui.available_size();
if self.fit && screen.width > 0.0 {
let fit = (avail.x / screen.width as f32)
.min(avail.y / screen.height.max(1.0) as f32);
self.zoom = (fit * ui_zoom).clamp(0.2, 5.0);
}
let pinch = ui.input(|i| i.zoom_delta());
if pinch != 1.0 {
self.fit = false;
self.zoom = (self.zoom * pinch).clamp(0.2, 5.0);
}
let zoom = self.zoom / ui_zoom;
let size = Vec2::new(screen.width as f32, screen.height as f32) * zoom;
egui::ScrollArea::both().auto_shrink(false).show(ui, |ui| {
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
ui.painter()
.rect_filled(rect, 0.0, style::color(&screen.display.background));
let mut cx = Cx {
view: style::View {
origin: rect.min,
zoom,
},
clip: ui.clip_rect().intersect(rect),
pvs: &self.pvs,
requests: &mut requests,
depth: 0,
touch: vcx.touch,
trends: vcx.trends,
window: vcx.window,
};
widgets::draw_screen(ui, screen, &mut cx, id("display"));
});
});
let origin = Origin {
window: vcx.window,
ctrl: ui.input(|i| i.modifiers.command),
shift: ui.input(|i| i.modifiers.shift),
};
Drawn {
status,
requests: requests.into_iter().map(|r| (origin, r)).collect(),
background,
}
}
fn notice(&mut self, ui: &egui::Ui) -> Option<String> {
let (n, text) = self.pvs.notice();
let now = ui.input(|i| i.time);
if n != self.notice_seen.0 {
self.notice_seen = (n, now);
}
let left = NOTICE_SECONDS - (now - self.notice_seen.1);
if n == 0 || left <= 0.0 {
return None;
}
ui.ctx()
.request_repaint_after(std::time::Duration::from_secs_f64(left));
Some(text)
}
fn status(&mut self, ui: &mut egui::Ui) {
let notice = self.notice(ui);
ui.horizontal(|ui| {
if let Some(text) = notice {
ui.colored_label(Color32::from_rgb(0xb0, 0x30, 0x00), text);
}
if let Some(e) = &self.error {
ui.colored_label(Color32::RED, e);
} else if self.loading.is_some()
&& let Some(cur) = &self.current
{
ui.spinner();
ui.label(format!("Loading {}", cur.source));
} else if let Some(s) = &self.screen {
let (connected, total) = count_pvs(&s.nodes);
ui.label(format!("PVs {connected}/{total} connected"));
if let Some(text) = Compat::of(&s.nodes).text() {
ui.weak(format!("| {text}"))
.on_hover_text("Scripts are not run, and widget types bobview does not draw show as placeholders");
}
if let Some(cur) = &self.current {
ui.weak(cur.source.to_string());
}
}
});
}
}
fn count_pvs(nodes: &[Node]) -> (usize, usize) {
fn walk<'a>(nodes: &'a [Node], seen: &mut std::collections::HashMap<&'a pv::PvName, bool>) {
for n in nodes {
let traces = n.traces.iter().flat_map(|t| t.x.iter().chain(&t.y));
let rules = n.rules.iter().flat_map(|r| r.pvs());
for pv in n.pv.iter().chain(traces).chain(rules) {
seen.entry(pv.name()).or_insert_with(|| pv.connected());
}
walk(&n.children, seen);
n.tabs.iter().for_each(|t| walk(t, seen));
if let Some(s) = &n.embedded {
walk(&s.nodes, seen);
}
}
}
let mut seen = std::collections::HashMap::new();
walk(nodes, &mut seen);
(seen.values().filter(|c| **c).count(), seen.len())
}
#[cfg(target_arch = "wasm32")]
fn set_page_url(loc: &Location) {
let Some(window) = web_sys::window() else {
return;
};
let Some(url) = window
.location()
.href()
.ok()
.and_then(|href| web_sys::Url::new(&href).ok())
else {
return;
};
let path = url.pathname();
let dir = format!("{}{}", url.origin(), &path[..=path.rfind('/').unwrap_or(0)]);
let display = loc.source.to_string();
let params = url.search_params();
params.delete("token");
params.set("display", display.strip_prefix(&dir).unwrap_or(&display));
let macros: Vec<String> = loc
.macros
.iter()
.map(|(k, v)| format!("{k}={}", v.replace('\\', r"\\").replace(',', r"\,")))
.collect();
if macros.is_empty() {
params.delete("macros");
} else {
params.set("macros", ¯os.join(","));
}
if let Ok(history) = window.history() {
let _ = history.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(&url.href()));
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
#[test]
fn a_panicked_build_shows_an_error() {
let ctx = egui::Context::default();
let mut view = View::new(&PvManager::new(), Some(1.0));
view.current = Some(Location {
source: bob::Source::File("/x/a.bob".into()),
macros: Default::default(),
});
let (_, rx) = std::sync::mpsc::channel();
view.loading = Some(rx);
view.poll_loading(&ctx);
assert!(!view.is_loading() && view.screen.is_none());
assert_eq!(
view.error.as_deref(),
Some("cannot read /x/a.bob: the build failed (see the log)")
);
}
#[test]
fn a_refused_display_is_not_kept_in_a_layout() {
use crate::fetch::{self, tests};
let (private, _) = tests::serve();
let (public, _) = tests::serve_public("/opi/a.bob");
let at = |source: bob::Source| Location {
source,
macros: Default::default(),
};
let mut view = View::new(&PvManager::new(), Some(1.0));
assert_eq!(view.saved_location(), None);
let page = fetch::user_named(&format!("{public}/opi/a.bob"));
let d = fetch::load(&page).unwrap();
let link = fetch::resolve(d.source.as_ref(), &format!("{private}/opi/a.bob"));
assert!(fetch::load(&link).is_err());
view.current = Some(at(link.clone()));
view.back = vec![at(page.clone())];
assert_eq!(view.saved_location(), Some(&at(page)));
fetch::user_named(&link.to_string());
assert_eq!(view.saved_location(), Some(&at(link)));
}
}