use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use bob::{Macros, Source};
use eframe::egui::{self, Id, Pos2, Rect, Vec2};
use parking_lot::Mutex;
use pv::PvManager;
use crate::chrome::{self, Chrome, UiScale};
use crate::icons::{self, Icon};
use crate::layout::{self, Geometry, Layout, TrendLayout, WindowLayout, Zoom};
use crate::pvmenu;
use crate::selection;
use crate::style;
use crate::tree::{self, Navigator};
use crate::trend::Trend;
use crate::view::{LaunchKey, Origin, View, ViewCx, WindowKey, window_title};
use crate::widgets::{self, Request};
use archiveview::ArchiverClient;
#[derive(Clone, Debug, PartialEq)]
pub struct Location {
pub source: Source,
pub macros: Macros,
}
impl Location {
pub(crate) fn same_display(&self, other: &Location) -> bool {
self.source == other.source && self.effective() == other.effective()
}
pub(crate) fn effective(&self) -> std::collections::BTreeMap<&str, &str> {
let opi = self
.source
.to_string()
.to_ascii_lowercase()
.ends_with(".opi");
self.macros
.iter()
.filter(|(k, _)| !(opi && *k == "DID"))
.collect()
}
}
pub struct BobApp {
pvs: PvManager,
repaints: HashMap<egui::ViewportId, Arc<Repaint>>,
main: View,
geometry: Geometry,
windows: Vec<DisplayWindow>,
next_window: u64,
main_pinned: bool,
main_used: Used,
main_pinned_at: u64,
clock: u64,
can_windows: bool,
layout_name: Option<String>,
pending_layout: Option<(Layout, Option<String>)>,
monitors: Vec<Rect>,
#[cfg_attr(
target_arch = "wasm32",
expect(dead_code, reason = "a browser has one window")
)]
monitors_at: Option<f64>,
main_frame: Option<Frame>,
restoring: Option<(Geometry, Option<f64>)>,
settings: layout::Settings,
save_as: Option<SaveAs>,
home: Option<Location>,
show_open: bool,
tree: Navigator,
tree_width: f32,
tree_toggled: bool,
touch: bool,
details: Option<(WindowKey, pvmenu::Details)>,
layouts: LayoutList,
trends: Vec<Trend>,
next_trend: u64,
archiver: Option<ArchiverClient>,
launched: (
std::sync::mpsc::Sender<Launched>,
std::sync::mpsc::Receiver<Launched>,
),
chrome: Chrome,
primary: Box<dyn selection::Primary>,
reader: Option<selection::Reader<SelectionRead>>,
make_reader: Option<selection::MakeRead>,
clicks: u64,
}
type SelectionRead = (u64, u64, archiveview::plot::MiddleClick);
struct Repaint {
ctx: egui::Context,
viewport: egui::ViewportId,
asked: AtomicBool,
}
impl Repaint {
fn new(ctx: &egui::Context, viewport: egui::ViewportId) -> Arc<Self> {
Arc::new(Self {
ctx: ctx.clone(),
viewport,
asked: AtomicBool::new(false),
})
}
fn notifier(self: &Arc<Self>) -> pv::Notifier {
let this = self.clone();
Arc::new(move || {
if !this.asked.swap(true, Ordering::Relaxed) {
repaint_after(&this.ctx, this.viewport, Duration::from_millis(100));
}
})
}
fn begin(&self) {
self.asked.store(false, Ordering::Relaxed);
}
}
fn repaint_after(ctx: &egui::Context, viewport: egui::ViewportId, delay: Duration) {
let viewport = if ctx.embed_viewports() {
egui::ViewportId::ROOT
} else {
viewport
};
ctx.request_repaint_after_for(delay, viewport);
}
struct Launched {
key: LaunchKey,
origin: Origin,
shown: u64,
target: Option<Location>,
}
#[derive(Default)]
struct LayoutList {
names: Vec<String>,
frame: Option<u64>,
}
impl LayoutList {
fn get(&mut self, ctx: &egui::Context) -> Vec<String> {
let frame = ctx.cumulative_frame_nr();
if self.frame.is_none_or(|f| f + 1 < frame) {
self.names = layout::list();
}
self.frame = Some(frame);
self.names.clone()
}
fn refresh(&mut self) {
self.frame = None;
}
}
struct DisplayWindow {
id: egui::ViewportId,
view: View,
builder: egui::ViewportBuilder,
fit_next: bool,
toolbar: bool,
geometry: Geometry,
frame: Option<Frame>,
beside: Option<Frame>,
pinned: bool,
pinned_at: u64,
used: Used,
close: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct Frame {
outer: Rect,
border: Vec2,
}
fn beside(origin: Frame, pinned: &[Rect], size: Vec2, monitors: &[Rect]) -> Option<Pos2> {
let monitor = |r: &Rect| {
monitors
.iter()
.max_by(|a, b| overlap(a, r).total_cmp(&overlap(b, r)))
.copied()
};
let o = origin.outer;
let m = monitor(&o)?;
let outer = size + origin.border;
let clear: Vec<Rect> = std::iter::once(o).chain(pinned.iter().copied()).collect();
let anchors = pinned.first().into_iter().chain(&clear);
let mut places = Vec::new();
for a in anchors {
let Some(m) = monitor(a) else { continue };
let x = a.min.x.min(m.max.x - outer.x).max(m.min.x);
let y = a.min.y.min(m.max.y - outer.y).max(m.min.y);
let sides = [
Pos2::new(a.max.x, y),
Pos2::new(x, a.max.y),
Pos2::new(a.min.x - outer.x, y),
Pos2::new(x, a.min.y - outer.y),
];
places.extend(
sides
.into_iter()
.filter(|p| m.contains_rect(Rect::from_min_size(*p, outer))),
);
}
let covered = |p: &Pos2| {
let r = Rect::from_min_size(*p, outer);
clear.iter().map(|c| overlap(c, &r)).sum::<f32>()
};
if let Some(p) = places.iter().find(|p| covered(p) == 0.0) {
return Some(*p);
}
let over = if m.max.x - o.max.x >= o.min.x - m.min.x {
m.max.x - outer.x
} else {
m.min.x
};
let y = o.min.y.min(m.max.y - outer.y).max(m.min.y);
places.push(Pos2::new(over.max(m.min.x), y));
places
.into_iter()
.min_by(|a, b| covered(a).total_cmp(&covered(b)))
}
struct Desk {
monitors: Vec<Rect>,
pinned: Vec<(WindowKey, Rect)>,
}
impl Desk {
fn clear_of(&self, id: egui::ViewportId) -> Vec<Rect> {
self.pinned
.iter()
.filter(|(key, _)| *key != WindowKey::Child(id))
.map(|(_, r)| *r)
.collect()
}
}
fn overlap(a: &Rect, b: &Rect) -> f32 {
let i = a.intersect(*b);
if i.is_positive() { i.area() } else { 0.0 }
}
#[derive(Clone, Copy, Debug, Default)]
struct Used {
focused: bool,
at: u64,
}
impl Used {
fn focus(&mut self, focused: bool, clock: &mut u64) {
if focused && !self.focused {
self.touch(clock);
}
self.focused = focused;
}
fn touch(&mut self, clock: &mut u64) {
*clock += 1;
self.at = *clock;
}
}
#[derive(Default)]
struct SaveAs {
name: String,
confirm: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Route {
InPlace,
Raise(WindowKey),
Viewer(WindowKey),
NewWindow,
}
fn route(
windows: bool,
origin: Origin,
pinned: bool,
showing: Option<WindowKey>,
viewer: Option<WindowKey>,
) -> Route {
if !windows || origin.ctrl {
return Route::InPlace;
}
match (showing, viewer) {
(Some(window), _) if window == origin.window => Route::InPlace,
(Some(window), _) => Route::Raise(window),
_ if origin.shift => Route::NewWindow,
_ if !pinned => Route::InPlace,
(None, Some(window)) => Route::Viewer(window),
(None, None) => Route::NewWindow,
}
}
impl BobApp {
pub fn new(
ctx: &egui::Context,
start: Option<Location>,
zoom: Option<f32>,
ui_scale: UiScale,
) -> Self {
style::install_fonts(ctx);
ctx.set_theme(egui::Theme::Light);
ctx.options_mut(|o| o.zoom_with_keyboard = false);
let pvs = PvManager::new();
let repaint = Repaint::new(ctx, egui::ViewportId::ROOT);
let mut main = View::new(&pvs.tagged(repaint.notifier()), zoom);
main.open_path = start
.as_ref()
.map(|l| l.source.to_string())
.unwrap_or_default();
let mut app = Self {
pvs,
repaints: HashMap::from([(egui::ViewportId::ROOT, repaint)]),
main,
geometry: Geometry::default(),
windows: Vec::new(),
next_window: 0,
main_pinned: true,
main_used: Used::default(),
main_pinned_at: 0,
clock: 0,
can_windows: !cfg!(target_arch = "wasm32"),
layout_name: None,
pending_layout: None,
monitors: Vec::new(),
monitors_at: None,
main_frame: None,
restoring: None,
settings: layout::Settings::load(),
save_as: None,
home: start.clone(),
show_open: start.is_none(),
tree: Navigator::default(),
tree_width: 0.0,
tree_toggled: false,
touch: false,
details: None,
layouts: LayoutList::default(),
trends: Vec::new(),
next_trend: 0,
archiver: None,
launched: std::sync::mpsc::channel(),
chrome: Chrome::new(ui_scale),
primary: selection::system(),
reader: None,
make_reader: selection::system_reader(),
clicks: 0,
};
if let Some(loc) = start {
app.main.show(loc);
}
app
}
#[cfg_attr(
target_arch = "wasm32",
expect(dead_code, reason = "a browser's PVs come from the server's policy")
)]
pub fn set_read_only(&mut self, on: bool) {
self.pvs.set_read_only(on);
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_primary(&mut self, primary: Box<dyn selection::Primary>) {
self.primary = primary;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_selection_reader(&mut self, make: Option<selection::MakeRead>) {
self.reader = None;
self.make_reader = make;
}
fn trend_selections(&mut self, ctx: &egui::Context) {
let now = web_time::Instant::now();
for trend in &mut self.trends {
let Some(click) = trend.take_middle_click() else {
continue;
};
if self.reader.is_none()
&& let Some(make) = self.make_reader.take()
{
let ctx = ctx.clone();
let wake = move |&(id, ..): &SelectionRead| {
repaint_after(&ctx, trend_viewport(id), Duration::ZERO);
};
self.reader = Some(selection::Reader::spawn(make, wake));
}
if let Some(reader) = &self.reader {
self.clicks += 1;
reader.read((trend.id, self.clicks, click));
trend.reading(self.clicks, now);
let wait = crate::trend::SELECTION_WAIT + Duration::from_millis(50);
repaint_after(ctx, trend_viewport(trend.id), wait);
}
}
for trend in &mut self.trends {
if trend.time_out_reading(now) {
repaint_after(ctx, trend_viewport(trend.id), Duration::ZERO);
}
}
if let Some(reader) = &self.reader {
while let Some(((id, n, click), text)) = reader.answer() {
if let Some(trend) = self.trends.iter_mut().find(|t| t.id == id) {
trend.selected(n, click, text);
repaint_after(ctx, trend_viewport(id), Duration::ZERO);
}
}
}
for trend in &mut self.trends {
match trend.take_search_selection(now) {
(Some(text), _) => {
if let Err(e) = self.primary.set(&text) {
log::debug!("no primary selection: {e}");
}
}
(None, Some(wait)) => repaint_after(ctx, trend_viewport(trend.id), wait),
(None, None) => {}
}
}
}
pub fn set_archiver(&mut self, archiver: Option<ArchiverClient>, remember: bool) {
let outgoing = self.archiver.as_ref().map(ArchiverClient::base);
if remember
&& self
.settings
.remember_archiver(archiver.as_ref().map(ArchiverClient::base), outgoing)
{
self.settings.save();
}
self.archiver = archiver;
for t in &mut self.trends {
t.set_archiver(self.archiver.as_ref(), &self.settings.recent_archivers);
}
}
fn switch_archiver(&mut self, ctx: &egui::Context, archiver: Option<ArchiverClient>) {
log::info!(
"trend history from {}",
archiver
.as_ref()
.map_or("no archiver", ArchiverClient::base)
);
self.set_archiver(archiver, true);
for t in &self.trends {
repaint_after(ctx, trend_viewport(t.id), Duration::ZERO);
}
}
fn new_trend(&mut self, ctx: &egui::Context, names: &[String]) -> Trend {
let pvs = self.pvs_for(ctx, trend_viewport(self.next_trend));
let archiver = self.archiver.clone();
let trend = Trend::new(
self.next_trend,
names,
&pvs,
archiver,
&self.settings.recent_archivers,
);
self.next_trend += 1;
trend
}
#[cfg_attr(
target_arch = "wasm32",
expect(dead_code, reason = "a browser has one window")
)]
pub fn restore_layout(&mut self, layout: Layout, name: Option<String>) {
if layout.windows.iter().any(|w| w.main) {
self.show_open = false;
}
self.pending_layout = Some((layout, name));
}
fn pvs_for(&mut self, ctx: &egui::Context, viewport: egui::ViewportId) -> PvManager {
let repaint = self
.repaints
.entry(viewport)
.or_insert_with(|| Repaint::new(ctx, viewport));
self.pvs.tagged(repaint.notifier())
}
fn begin_pass(&self, viewport: egui::ViewportId) {
if let Some(r) = self.repaints.get(&viewport) {
r.begin();
}
}
fn view_mut(&mut self, window: WindowKey) -> Option<&mut View> {
match window {
WindowKey::Main => Some(&mut self.main),
WindowKey::Child(id) => self
.windows
.iter_mut()
.find(|w| w.id == id && !w.close)
.map(|w| &mut w.view),
}
}
fn frame(&self, window: WindowKey) -> Option<Frame> {
match window {
WindowKey::Main => self.main_frame,
WindowKey::Child(id) => self.windows.iter().find(|w| w.id == id)?.frame,
}
}
fn pinned_frames(&self) -> Vec<(WindowKey, Rect)> {
let main = self
.main_frame
.filter(|_| self.main_pinned)
.map(|f| (WindowKey::Main, f.outer, self.main_pinned_at));
let children = self
.windows
.iter()
.filter(|w| w.pinned && !w.close)
.filter_map(|w| Some((WindowKey::Child(w.id), w.frame?.outer, w.pinned_at)));
let mut pinned: Vec<_> = main.into_iter().chain(children).collect();
pinned.sort_by_key(|(_, _, at)| std::cmp::Reverse(*at));
pinned.into_iter().map(|(key, r, _)| (key, r)).collect()
}
fn monitors(&self, ctx: &egui::Context) -> Vec<Rect> {
if !self.monitors.is_empty() {
return self.monitors.clone();
}
let zoom = ctx.zoom_factor();
ctx.input(|i| i.viewport().monitor_size)
.map(|m| vec![Rect::from_min_size(Pos2::ZERO, m * zoom)])
.unwrap_or_default()
}
fn pinned(&self, window: WindowKey) -> bool {
match window {
WindowKey::Main => self.main_pinned,
WindowKey::Child(id) => self.windows.iter().any(|w| w.id == id && w.pinned),
}
}
fn viewer(&self) -> Option<WindowKey> {
let main = (!self.main_pinned).then_some((WindowKey::Main, self.main_used.at));
let children = self
.windows
.iter()
.filter(|w| !w.close && !w.pinned)
.map(|w| (WindowKey::Child(w.id), w.used.at));
main.into_iter()
.chain(children)
.max_by_key(|(_, at)| *at)
.map(|(window, _)| window)
}
fn showing(&self, loc: &Location) -> Option<WindowKey> {
if self.main.shows(loc) {
return Some(WindowKey::Main);
}
self.windows
.iter()
.find(|w| !w.close && w.view.shows(loc))
.map(|w| WindowKey::Child(w.id))
}
fn follow(&mut self, ctx: &egui::Context, origin: Origin, loc: Location, target: &str) {
let pinned = self.pinned(origin.window);
let own = self.view_mut(origin.window).is_some_and(|v| v.shows(&loc));
let showing = if own {
Some(origin.window)
} else {
self.showing(&loc)
};
let viewer = self.viewer();
match route(self.can_windows, origin, pinned, showing, viewer) {
Route::InPlace if own => {
log::info!("reload {} ({target})", loc.source);
if let Some(view) = self.view_mut(origin.window) {
view.reload();
}
}
Route::InPlace => {
log::info!("open {} ({target})", loc.source);
if let Some(view) = self.view_mut(origin.window) {
view.open(loc);
}
}
Route::Raise(window) => {
log::info!("raise {}", loc.source);
raise(ctx, window);
}
Route::Viewer(window) => {
log::info!("open {} (viewer)", loc.source);
if let Some(view) = self.view_mut(window) {
view.open(loc);
}
let frame = self.frame(origin.window).filter(|_| !on_wayland());
if let Some(w) = self
.windows
.iter_mut()
.find(|w| WindowKey::Child(w.id) == window)
{
w.fit_next = true;
w.beside = frame;
}
raise(ctx, window);
}
Route::NewWindow => {
log::info!("open {} (new window)", loc.source);
let window = self.open_window(ctx, loc, Geometry::default(), false, false);
self.place_beside(ctx, window, origin.window);
}
}
}
fn open_window(
&mut self,
ctx: &egui::Context,
loc: Location,
geometry: Geometry,
toolbar: bool,
pinned: bool,
) -> WindowKey {
let id = egui::ViewportId::from_hash_of(("display", self.next_window));
self.next_window += 1;
let mut view = View::new(&self.pvs_for(ctx, id), None);
view.show(loc);
let zoom = ctx.zoom_factor();
let size = geometry.size.map(|s| s / zoom).or_else(|| {
let monitor = ctx.input(|i| i.viewport().monitor_size);
let display = view.natural_size(zoom)?;
Some(chrome::window_size(display, CHILD_STATUS, monitor))
});
let mut builder = egui::ViewportBuilder::default()
.with_app_id("bobview")
.with_inner_size(size.unwrap_or(chrome::DEFAULT_WINDOW / zoom))
.with_min_inner_size([160.0, 100.0]);
if let Some(pos) = geometry.position {
builder = builder.with_position(Pos2::ZERO + pos.to_vec2() / zoom);
}
let mut used = Used::default();
used.touch(&mut self.clock);
let pinned_at = if pinned { used.at } else { 0 };
self.windows.push(DisplayWindow {
id,
view,
builder,
fit_next: geometry.size.is_none(),
toolbar,
geometry,
frame: None,
beside: None,
pinned,
pinned_at,
used,
close: false,
});
WindowKey::Child(id)
}
fn place_beside(&mut self, ctx: &egui::Context, window: WindowKey, origin: WindowKey) {
let Some(frame) = self.frame(origin).filter(|_| !on_wayland()) else {
return;
};
let monitors = self.monitors(ctx);
let pinned: Vec<Rect> = self
.pinned_frames()
.into_iter()
.filter(|(key, _)| *key != window)
.map(|(_, r)| r)
.collect();
let zoom = ctx.zoom_factor();
let Some(w) = self
.windows
.iter_mut()
.find(|w| WindowKey::Child(w.id) == window)
else {
return;
};
let size = w
.builder
.inner_size
.unwrap_or(chrome::DEFAULT_WINDOW / zoom)
* zoom;
if let Some(pos) = beside(frame, &pinned, size, &monitors) {
w.builder = w
.builder
.clone()
.with_position(Pos2::ZERO + pos.to_vec2() / zoom);
}
w.beside = Some(frame);
}
#[cfg(not(target_arch = "wasm32"))]
pub fn is_loading(&self) -> bool {
self.main.is_loading()
|| self.windows.iter().any(|w| w.view.is_loading())
|| crate::screen::building()
|| self.trends.iter().any(Trend::is_loading)
}
#[cfg(not(target_arch = "wasm32"))]
fn launch(
&mut self,
ctx: &egui::Context,
origin: Origin,
base: Option<Source>,
dir: Option<Source>,
cmd: String,
) {
let tx = self.launched.0.clone();
let Some(view) = self.view_mut(origin.window) else {
return;
};
let key = (
base.as_ref().map(|s| s.to_string()),
dir.as_ref().map(|s| s.to_string()),
cmd.clone(),
);
if view.launching.contains(&key) {
return;
}
view.launching.push(key.clone());
let (ctx, shown) = (ctx.clone(), view.shown);
std::thread::spawn(move || {
let target = match crate::launch::command_target(base.as_ref(), dir, &cmd) {
Some((file, macros)) => {
let source = Source::File(file);
log::info!("open {source} (window) from '{cmd}'");
Some(Location { source, macros })
}
None => {
log::warn!("command '{cmd}' is not supported");
None
}
};
tx.send(Launched {
key,
origin,
shown,
target,
})
.ok();
ctx.request_repaint_of(egui::ViewportId::ROOT);
});
}
#[cfg(target_arch = "wasm32")]
fn launch(
&mut self,
_: &egui::Context,
_: Origin,
_: Option<Source>,
_: Option<Source>,
cmd: String,
) {
log::warn!("command '{cmd}' is not run in a browser");
}
fn poll_launched(&mut self, ctx: &egui::Context) {
while let Ok(done) = self.launched.1.try_recv() {
let Some(view) = self.view_mut(done.origin.window) else {
continue;
};
view.launching.retain(|k| *k != done.key);
let current = done.shown == view.shown;
match done.target {
Some(loc) if current => self.follow(ctx, done.origin, loc, "window"),
Some(loc) => log::info!("drop {}: the display has changed", loc.source),
None => {}
}
}
}
fn toolbar(&mut self, ui: &mut egui::Ui) {
let compact = ui.available_width() < chrome::FULL_TOOLBAR_WIDTH;
let windows = self.can_windows;
let was_pinned = self.main_pinned;
let mut action = None;
let mut actual_size = false;
let mut all_windows = None;
let others = !self.windows.is_empty() || !self.trends.is_empty();
ui.spacing_mut().button_padding = Vec2::new(10.0, 6.0);
ui.spacing_mut().item_spacing.x = 8.0;
let button = icons::height(ui);
ui.spacing_mut().interact_size.y = button;
let row = Vec2::new(ui.available_width(), icons::group_height(ui));
let along = egui::Layout::left_to_right(egui::Align::Min);
ui.allocate_ui_with_layout(row, along, |ui| {
let view = &mut self.main;
icons::group(ui, true, |ui| {
let back = icons::button(ui, Icon::Back, false, !view.back.is_empty())
.on_hover_text("Back (Alt+←)");
self.chrome.button = back.rect.height();
if back.clicked() {
view.go_back();
}
if !compact
&& icons::button(ui, Icon::Forward, false, !view.forward.is_empty())
.on_hover_text("Forward (Alt+→)")
.clicked()
{
view.go_forward();
}
if icons::button(ui, Icon::Home, false, self.home.is_some())
.on_hover_text("Home")
.clicked()
&& let Some(home) = self.home.clone()
{
view.open(home);
}
if !compact
&& icons::button(ui, Icon::Reload, false, true)
.on_hover_text("Reload (Ctrl+R)")
.clicked()
{
view.reload();
}
});
icons::group(ui, true, |ui| {
if !compact
&& icons::button(ui, Icon::ZoomOut, false, true)
.on_hover_text("Zoom out")
.clicked()
{
view.zoom_by(1.0 / 1.25);
}
let readout = egui::Button::new(format!("{:.0}%", view.zoom * 100.0))
.min_size(Vec2::new(56.0, button));
actual_size = ui
.add(readout)
.on_hover_text("Actual size: 100%, and the window sized to fit the display")
.clicked();
if !compact
&& icons::button(ui, Icon::ZoomIn, false, true)
.on_hover_text("Zoom in")
.clicked()
{
view.zoom_by(1.25);
}
if ui
.add(egui::Button::selectable(view.fit, "Fit").min_size(Vec2::new(0.0, button)))
.on_hover_text("Fit the display to the window")
.clicked()
{
view.fit = !view.fit;
}
});
if !compact {
icons::group(ui, false, |ui| {
if icons::button(ui, Icon::Navigator, self.tree.open, true)
.on_hover_text(NAVIGATOR_HELP)
.clicked()
{
self.tree.open = !self.tree.open;
self.tree_toggled = true;
}
if icons::button(ui, Icon::Open, self.show_open, true)
.on_hover_text("Open a display file")
.clicked()
{
self.show_open = !self.show_open;
}
});
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
let menu = icons::group(ui, false, |ui| icons::button(ui, Icon::Menu, false, true))
.on_hover_text("More");
egui::Popup::menu(&menu).show(|ui| {
if compact {
if ui
.add_enabled(!view.forward.is_empty(), egui::Button::new("Forward"))
.clicked()
{
view.go_forward();
}
if ui.button("Reload").clicked() {
view.reload();
}
if ui.button("Zoom in").clicked() {
view.zoom_by(1.25);
}
if ui.button("Zoom out").clicked() {
view.zoom_by(1.0 / 1.25);
}
ui.separator();
if ui.checkbox(&mut self.tree.open, "Navigator").changed() {
self.tree_toggled = true;
}
ui.checkbox(&mut self.show_open, "Open…");
}
let mut auto_size = self.chrome.auto_size;
if ui
.checkbox(&mut auto_size, "Auto-size window")
.on_hover_text("Resize the window to fit each display opened")
.changed()
{
toggle_auto_size(&mut self.chrome);
}
ui.separator();
all_windows = window_items(ui, others);
if windows {
ui.separator();
if compact {
ui.checkbox(&mut self.main_pinned, "Pinned")
.on_hover_text(PIN_HELP);
ui.menu_button("Load layout", |ui| {
action =
layout_list(ui, self.layout_name.as_deref(), &mut self.layouts);
});
}
action = layout_menu(
ui,
self.layout_name.as_deref(),
&mut self.settings,
&mut self.layouts,
)
.or(action.take());
}
});
if windows && !compact {
icons::group(ui, false, |ui| {
egui::ComboBox::from_id_salt("layout")
.selected_text(self.layout_name.as_deref().unwrap_or("Layout"))
.show_ui(ui, |ui| {
action =
layout_list(ui, self.layout_name.as_deref(), &mut self.layouts);
})
.response
.on_hover_text("Load a saved layout");
});
let pin = icons::group(ui, false, |ui| {
icons::button(ui, Icon::Pin, self.main_pinned, true)
});
if pin.on_hover_text(PIN_HELP).clicked() {
self.main_pinned = !self.main_pinned;
}
}
if let Some(s) = &view.screen {
let rest = Vec2::new(ui.available_width(), icons::group_height(ui));
let centred =
egui::Layout::centered_and_justified(egui::Direction::LeftToRight);
ui.allocate_ui_with_layout(rest, centred, |ui| {
ui.add(egui::Label::new(egui::RichText::new(&s.title).strong()).truncate())
.on_hover_text(&s.title);
});
}
});
});
if let Some(req) = all_windows {
let origin = Origin {
window: WindowKey::Main,
ctrl: false,
shift: false,
};
self.request(ui.ctx(), origin, req);
}
if actual_size {
self.main.fit = false;
self.main.zoom = 1.0;
self.chrome.fit_window();
}
if was_pinned && !self.main_pinned {
self.main_used.touch(&mut self.clock);
}
if !was_pinned && self.main_pinned {
self.clock += 1;
self.main_pinned_at = self.clock;
}
if let Some(action) = action {
self.layout_action(action);
ui.ctx().request_repaint();
}
}
fn layout_action(&mut self, action: LayoutAction) {
let done = match action {
LayoutAction::Load(name) => layout::load(&name).map(|l| {
self.pending_layout = Some((l, Some(name)));
}),
LayoutAction::Save(name) => self.save_layout(&name),
LayoutAction::SaveAs => {
self.save_as = Some(SaveAs {
name: self.layout_name.clone().unwrap_or_default(),
confirm: false,
});
Ok(())
}
LayoutAction::Delete(name) => layout::delete(&name).map(|()| {
self.layouts.refresh();
log::info!("deleted layout {name}");
if self.layout_name.as_deref() == Some(name.as_str()) {
self.layout_name = None;
}
}),
};
if let Err(e) = done {
log::error!("{e}");
}
}
fn layout(&self) -> Layout {
let mut windows = Vec::new();
if let Some(loc) = self.main.saved_location() {
let mut w = WindowLayout::new(loc, self.geometry);
w.main = true;
w.pinned = Some(self.main_pinned);
w.tree = self.tree.open;
w.zoom = Some(Zoom::new(self.main.fit, self.main.zoom));
windows.push(w);
}
for dw in self.windows.iter().filter(|w| !w.close) {
if let Some(loc) = dw.view.saved_location() {
let mut w = WindowLayout::new(loc, dw.geometry);
w.toolbar = dw.toolbar;
w.pinned = Some(dw.pinned);
windows.push(w);
}
}
let trends: Vec<TrendLayout> = self
.trends
.iter()
.map(|t| TrendLayout::new(t.view(), t.geometry))
.collect();
let unplaced = windows.iter().any(|w| w.position.is_none())
|| trends.iter().any(|t| t.position.is_none());
if on_wayland() && unplaced {
positions_unknown();
}
Layout::new(windows, trends)
}
fn save_layout(&mut self, name: &str) -> Result<(), String> {
layout::save(name, &self.layout())?;
self.layouts.refresh();
self.layout_name = Some(name.trim().to_string());
Ok(())
}
fn finish_restoring(&mut self, ctx: &egui::Context) {
let Some((want, since)) = self.restoring else {
return;
};
let time = ctx.input(|i| i.time);
let since = since.unwrap_or(time);
self.restoring = Some((want, Some(since)));
if time - since > RESTORE_WAIT {
self.restoring = None;
return;
}
let (Some(size), Some(now)) = (want.size, self.geometry.size) else {
return;
};
let near = |a: Vec2, b: Vec2| (a - b).length() < 2.0;
let there = match (want.position, self.geometry.position) {
(Some(p), Some(q)) => near(p.to_vec2(), q.to_vec2()),
_ => true,
};
if !there {
return;
}
self.restoring = None;
if !near(size, now) {
log::info!("the main window is {now:?}, not {size:?}: resizing it");
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size / ctx.zoom_factor()));
}
}
fn apply_layout(&mut self, ctx: &egui::Context, layout: Layout, name: Option<String>) {
let zoom = ctx.zoom_factor();
let monitors = self.monitors(ctx);
self.windows.clear();
self.restoring = None;
for w in &layout.windows {
let geometry = w.geometry(&monitors);
if w.position.is_some() && geometry.position.is_none() {
log::info!("{}: its saved position is off screen", w.display);
}
let loc = w.location();
if !w.main {
self.open_window(ctx, loc, geometry, w.toolbar, layout.pinned(w));
continue;
}
log::debug!(
"restore the main window: size {:?}, position {:?}, navigator {}",
geometry.size,
geometry.position,
if w.tree { "shown" } else { "hidden" }
);
if let Some(pos) = geometry.position {
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(
Pos2::ZERO + pos.to_vec2() / zoom,
));
}
if let Some(size) = geometry.size {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size / zoom));
self.chrome.hold_size();
}
self.restoring = geometry.size.map(|_| (geometry, None));
if let Some(z) = &w.zoom {
self.main.fit = z.factor().is_none();
self.main.zoom = z.factor().unwrap_or(self.main.zoom);
}
if self.home.is_none() {
self.home = Some(loc.clone());
}
self.show_open = false;
self.main_pinned = layout.pinned(w);
self.tree.open = w.tree;
self.main.open(loc);
}
self.trends.clear();
for t in &layout.trends {
let mut trend = self.new_trend(ctx, &[]);
trend.set_view(&t.view());
trend.place = t.geometry(&monitors);
self.trends.push(trend);
}
let placed = layout.windows.iter().any(|w| w.position.is_some())
|| layout.trends.iter().any(|t| t.position.is_some());
if placed && on_wayland() {
positions_unknown();
}
self.layout_name = name;
}
fn save_as_window(&mut self, ui: &mut egui::Ui) {
let Some(dialog) = self.save_as.as_mut() else {
return;
};
let mut open = true;
let mut save = None;
egui::Window::new("Save layout as")
.open(&mut open)
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO)
.show(ui.ctx(), |ui| {
let name = dialog.name.trim().to_string();
if dialog.confirm {
ui.label(format!("Replace the layout {name}?"));
ui.horizontal(|ui| {
if ui.button("Replace").clicked() {
save = Some(name);
}
if ui.button("Cancel").clicked() {
dialog.confirm = false;
}
});
return;
}
let edit = ui.text_edit_singleline(&mut dialog.name);
let enter = edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let valid = layout::valid_name(&name);
let clicked = ui.add_enabled(valid, egui::Button::new("Save")).clicked();
if valid && (clicked || enter) {
if layout::exists(&name) {
dialog.confirm = true;
} else {
save = Some(name);
}
}
});
if let Some(name) = save {
if let Err(e) = self.save_layout(&name) {
log::error!("{e}");
}
open = false;
}
if !open {
self.save_as = None;
}
}
fn open_panel(&mut self, ui: &mut egui::Ui) {
let mut chosen = None;
let path = &mut self.main.open_path;
ui.horizontal(|ui| {
ui.label("File or URL:");
let edit = ui.add(egui::TextEdit::singleline(&mut *path).desired_width(f32::INFINITY));
if edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
chosen = Some(crate::fetch::user_named(path));
}
});
let dir = match Source::parse(path) {
Source::Url(_) => PathBuf::new(),
Source::File(path) if bob::project::locate(&path).is_dir() => path,
Source::File(path) => path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from(".")),
};
egui::ScrollArea::vertical()
.max_height(200f32.min(ui.ctx().content_rect().height() * 0.35))
.show(ui, |ui| {
if let Some(parent) = dir.parent()
&& ui.button("📁 ..").clicked()
{
*path = format!("{}/", parent.display());
}
for (entry, is_dir) in list_dir(&dir) {
let name = entry
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if is_dir {
if ui.button(format!("📁 {name}")).clicked() {
*path = format!("{}/", entry.display());
}
} else if ui.button(format!("🗋 {name}")).clicked() {
chosen = Some(Source::File(entry));
}
}
});
if let Some(source) = chosen {
self.show_open = false;
if self.home.is_none() {
self.home = Some(Location {
source: source.clone(),
macros: Macros::default(),
});
}
self.main.open(Location {
source,
macros: Macros::default(),
});
}
}
}
fn toggle_auto_size(chrome: &mut Chrome) {
chrome.auto_size = !chrome.auto_size;
if chrome.auto_size {
chrome.display_changed();
}
}
const CHILD_STATUS: f32 = 24.0;
fn can_multi_window(ctx: &egui::Context, touch: bool) -> bool {
let zoom = ctx.zoom_factor();
let small = ctx.input(|i| chrome::small_screen(i.viewport(), zoom));
!cfg!(target_arch = "wasm32") && !ctx.embed_viewports() && !touch && !small
}
const RESTORE_WAIT: f64 = 2.0;
const NAVIGATOR_HELP: &str = "Navigator: a tree of the displays the home display links to, \
and those they link to. Click one to open it as a link would (shift and ctrl too).";
const PIN_HELP: &str = "Pinned: links open their displays in the unpinned window used last, \
or a new one (shift+click: a new window, ctrl+click: in place). \
Unpinned: links open in place. \
A display already open in another window is raised.";
enum LayoutAction {
Load(String),
Save(String),
SaveAs,
Delete(String),
}
fn layout_list(
ui: &mut egui::Ui,
current: Option<&str>,
layouts: &mut LayoutList,
) -> Option<LayoutAction> {
let names = layouts.get(ui.ctx());
if names.is_empty() {
ui.weak("No saved layouts");
}
let mut action = None;
for name in names {
if ui
.selectable_label(current == Some(name.as_str()), &name)
.clicked()
{
action = Some(LayoutAction::Load(name));
}
}
action
}
fn layout_menu(
ui: &mut egui::Ui,
current: Option<&str>,
settings: &mut layout::Settings,
layouts: &mut LayoutList,
) -> Option<LayoutAction> {
let mut action = None;
let save = match current {
Some(name) => format!("Save layout {name}"),
None => "Save layout".to_string(),
};
if ui
.add_enabled(current.is_some(), egui::Button::new(save))
.clicked()
&& let Some(name) = current
{
action = Some(LayoutAction::Save(name.to_string()));
}
if ui.button("Save layout as…").clicked() {
action = Some(LayoutAction::SaveAs);
}
if ui.button("Save as default").clicked() {
action = Some(LayoutAction::Save(layout::DEFAULT.to_string()));
}
ui.menu_button("Delete layout", |ui| {
let names = layouts.get(ui.ctx());
if names.is_empty() {
ui.weak("No saved layouts");
}
for name in names {
if ui.button(&name).clicked() {
action = Some(LayoutAction::Delete(name));
}
}
});
if ui
.checkbox(&mut settings.save_default_on_exit, "Save default on exit")
.changed()
{
settings.save();
}
action
}
fn on_wayland() -> bool {
!cfg!(target_arch = "wasm32")
&& std::env::var_os("WAYLAND_DISPLAY").is_some_and(|d| !d.is_empty())
}
fn positions_unknown() {
static SAID: std::sync::Once = std::sync::Once::new();
SAID.call_once(|| {
log::info!(
"window positions are left to the compositor on Wayland \
(start bobview with WAYLAND_DISPLAY= to use XWayland, which keeps them)"
);
});
}
fn list_dir(dir: &Path) -> Vec<(PathBuf, bool)> {
let real = bob::project::locate(dir);
let mut out: Vec<(PathBuf, bool)> = std::fs::read_dir(&real)
.map(|rd| {
rd.filter_map(Result::ok)
.map(|e| (dir.join(e.file_name()), e.path().is_dir()))
.filter(|(p, d)| {
let hidden = p
.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with('.'));
let display = p.extension().is_some_and(|x| x == "bob" || x == "opi");
!hidden && (*d || display)
})
.collect()
})
.unwrap_or_default();
for (name, _) in bob::project::links(&real).unwrap_or_default() {
let link = dir.join(name);
if !out.iter().any(|(p, _)| *p == link) {
out.push((link, true));
}
}
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
out
}
pub struct App(Arc<Mutex<BobApp>>);
#[derive(Clone, Copy)]
enum Child {
Display(egui::ViewportId),
Trend(u64),
}
impl App {
pub fn new(app: BobApp) -> Self {
Self(Arc::new(Mutex::new(app)))
}
pub fn draw(&self, ui: &mut egui::Ui) {
let children = self.0.lock().draw_main(ui);
for (id, builder, child) in children {
let app = self.0.clone();
ui.ctx()
.show_viewport_deferred(id, builder, move |ui, class| match child {
Child::Display(id) => app.lock().display_pass(ui, id, class),
Child::Trend(id) => app.lock().trend_pass(ui, id, class),
});
}
}
}
impl eframe::App for App {
fn logic(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.0.lock().begin_pass(egui::ViewportId::ROOT);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
#[cfg(not(target_arch = "wasm32"))]
self.0.lock().find_monitors(ui, frame);
#[cfg(target_arch = "wasm32")]
let _ = frame;
self.draw(ui);
}
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
self.0.lock().on_exit();
}
}
#[derive(PartialEq)]
struct Shape {
main: u64,
windows: Vec<(egui::ViewportId, u64, String, bool)>,
trends: Vec<(u64, String)>,
details: Option<WindowKey>,
}
impl BobApp {
#[cfg(not(target_arch = "wasm32"))]
fn find_monitors(&mut self, ui: &egui::Ui, frame: &eframe::Frame) {
let now = ui.input(|i| i.time);
if (self.pending_layout.is_some() || self.monitors_at.is_none_or(|t| now - t > 3.0))
&& let Some(window) = frame.winit_window()
{
self.monitors_at = Some(now);
let scale = window.scale_factor() as f32;
self.monitors = window
.available_monitors()
.map(|m| {
let (pos, size) = (m.position(), m.size());
Rect::from_min_size(
Pos2::new(pos.x as f32, pos.y as f32) / scale,
Vec2::new(size.width as f32, size.height as f32) / scale,
)
})
.collect();
}
}
fn on_exit(&mut self) {
if !self.settings.save_default_on_exit {
return;
}
let layout = self.layout();
if let Some(main) = layout.windows.iter().find(|w| w.main) {
log::debug!(
"save the default layout: main window size {:?}, position {:?}, navigator {}",
main.size,
main.position,
if main.tree { "shown" } else { "hidden" }
);
}
if let Err(e) = layout::save(layout::DEFAULT, &layout) {
log::error!("{e}");
}
}
fn shape(&self) -> Shape {
Shape {
main: self.main.shown,
windows: self
.windows
.iter()
.map(|w| (w.id, w.view.shown, self.child_title(w), w.close))
.collect(),
trends: self.trend_titles(),
details: self.details.as_ref().map(|(w, _)| *w),
}
}
fn repaint_changed(&self, ctx: &egui::Context, before: &Shape, drawn: egui::ViewportId) {
if ctx.embed_viewports() {
return;
}
let after = self.shape();
if after == *before {
return;
}
let repaint = |id: egui::ViewportId| {
if id != drawn {
ctx.request_repaint_of(id);
}
};
repaint(egui::ViewportId::ROOT);
for (id, shown, ..) in &after.windows {
if before
.windows
.iter()
.any(|(b, s, ..)| b == id && s != shown)
{
repaint(*id);
}
}
for (id, title) in &after.trends {
if before.trends.iter().any(|(b, t)| b == id && t != title) {
repaint(trend_viewport(*id));
}
}
if after.details != before.details
&& let Some(window) = after.details
{
repaint(viewport(window));
}
}
fn child_title(&self, w: &DisplayWindow) -> String {
w.view.screen.as_ref().map_or_else(
|| "bobview".to_string(),
|s| window_title(&s.title, w.pinned && self.can_windows),
)
}
fn trend_titles(&self) -> Vec<(u64, String)> {
self.trends.iter().map(|t| (t.id, t.title())).collect()
}
}
impl BobApp {
#[cfg_attr(
target_arch = "wasm32",
expect(dead_code, reason = "a browser takes no snapshots")
)]
pub fn draw(&mut self, ui: &mut egui::Ui) {
let children = self.draw_main(ui);
debug_assert!(
children.is_empty(),
"windows to draw in passes of their own"
);
}
fn draw_main(
&mut self,
ui: &mut egui::Ui,
) -> Vec<(egui::ViewportId, egui::ViewportBuilder, Child)> {
let ctx = ui.ctx().clone();
let embedded = ctx.embed_viewports();
let before = self.shape();
self.begin_pass(egui::ViewportId::ROOT);
self.main.poll_loading(&ctx);
self.poll_launched(&ctx);
if !self.touch && ui.input(|i| i.any_touches()) {
self.touch = true;
}
widgets::touch_targets(ui.ctx(), self.touch);
self.can_windows = can_multi_window(&ctx, self.touch);
self.main_used
.focus(ui.input(|i| i.focused), &mut self.clock);
self.chrome.begin(ui.ctx());
self.geometry = geometry(ui);
self.finish_restoring(&ctx);
if self.chrome.settled()
&& let Some((layout, name)) = self.pending_layout.take()
{
self.apply_layout(&ctx, layout, name);
}
if shortcuts(ui, &mut self.main).close {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
self.main_frame = frame(ui);
self.main
.update_title(ui.ctx(), self.main_pinned && self.can_windows);
let toolbar = egui::Panel::top("toolbar").show(ui, |ui| self.toolbar(ui));
self.chrome.toolbar = toolbar.response.rect.height();
let mut panels = self.chrome.toolbar;
if self.show_open {
panels += egui::Panel::top("open")
.show(ui, |ui| self.open_panel(ui))
.response
.rect
.height();
}
let (side, clicked) = self.navigator(ui);
let grow = side - std::mem::replace(&mut self.tree_width, side);
if std::mem::take(&mut self.tree_toggled) && grow != 0.0 {
self.resize_main_by(&ctx, Vec2::new(grow, 0.0));
}
let trends = self.trend_titles();
let vcx = ViewCx {
window: WindowKey::Main,
touch: self.touch,
trends: &trends,
};
let drawn = self.main.draw(ui, &vcx);
self.chrome.panels = panels + drawn.status;
if std::mem::take(&mut self.main.new_display) {
self.chrome.display_changed();
}
let display = self.main.natural_size(ui.ctx().zoom_factor());
self.chrome
.end(ui.ctx(), display.map(|d| d + Vec2::new(side, 0.0)));
if let Some(clicked) = clicked {
self.open_from_tree(&ctx, clicked);
}
let mut requests = drawn.requests;
if embedded {
requests.extend(self.embedded_windows(&ctx, &trends));
}
for (origin, req) in requests {
self.request(ui.ctx(), origin, req);
}
let closed = |w: WindowKey| match w {
WindowKey::Main => false,
WindowKey::Child(id) => !self.windows.iter().any(|dw| dw.id == id && !dw.close),
};
if self.details.as_ref().is_some_and(|(w, _)| closed(*w)) {
self.details = None;
}
details_dialog(ui.ctx(), WindowKey::Main, &mut self.details);
self.save_as_window(ui);
let mut children = Vec::new();
if embedded {
self.embedded_trends(&ctx);
}
self.trend_selections(&ctx);
if !embedded {
children = self.display_viewports();
children.extend(self.trend_viewports(&ctx));
}
let (windows, trends) = (&self.windows, &self.trends);
let open = |id: &egui::ViewportId| {
*id == egui::ViewportId::ROOT
|| windows.iter().any(|w| w.id == *id)
|| trends.iter().any(|t| trend_viewport(t.id) == *id)
};
self.repaints.retain(|id, _| open(id));
self.repaint_changed(&ctx, &before, egui::ViewportId::ROOT);
children
}
fn resize_main_by(&self, ctx: &egui::Context, by: Vec2) {
let info = ctx.input(|i| i.viewport().clone());
if !chrome::can_resize(&info, ctx.zoom_factor()) {
return;
}
let Some(inner) = info.inner_rect else {
return;
};
let mut size = inner.size() + by;
if let Some(monitor) = info.monitor_size {
size = size.min(monitor);
}
log::debug!(
"resize the window from {:?} to {size:?} for the navigator",
inner.size()
);
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
}
fn navigator(&mut self, ui: &mut egui::Ui) -> (f32, Option<tree::Clicked>) {
let root = self.home.clone().or_else(|| self.main.current.clone());
let Some(root) = root.filter(|_| self.tree.open) else {
return (0.0, None);
};
let (main, windows) = (&self.main, &self.windows);
let here = |loc: &Location| {
std::iter::once(main)
.chain(windows.iter().filter(|w| !w.close).map(|w| &w.view))
.any(|v| v.shows(loc))
};
let panel = egui::Panel::left("navigator")
.resizable(true)
.default_size(220.0)
.show(ui, |ui| self.tree.ui(ui, &root, &here));
(panel.response.rect.width(), panel.inner)
}
fn open_from_tree(&mut self, ctx: &egui::Context, clicked: tree::Clicked) {
let origin = Origin {
window: WindowKey::Main,
ctrl: clicked.ctrl,
shift: clicked.shift,
};
match clicked.target {
tree::Target::Display(loc) if self.main.shows(&loc) => {}
tree::Target::Display(loc) => self.follow(ctx, origin, loc, "navigator"),
tree::Target::Command {
base,
directory,
command,
} => self.launch(ctx, origin, base, directory, command),
}
}
fn request(&mut self, ctx: &egui::Context, origin: Origin, req: Request) {
let window = origin.window;
match req {
Request::Open {
source,
macros,
target,
} => self.follow(ctx, origin, Location { source, macros }, &target),
Request::Copy(text) => {
log::debug!("copy {text}");
ctx.copy_text(text.clone());
if let Err(e) = self.primary.set(&text) {
log::debug!("no primary selection: {e}");
}
let now = ctx.input(|i| i.time);
if let Some(view) = self.view_mut(window) {
view.copied(text, now);
}
ctx.request_repaint();
}
Request::Details(details) => {
self.details = Some((window, details));
ctx.request_repaint();
}
Request::Trend(names) => {
log::info!("trend {}", names.join(", "));
let trend = self.new_trend(ctx, &names);
self.trends.push(trend);
}
Request::TrendAdd(id, names) => {
if let Some(trend) = self.trends.iter_mut().find(|t| t.id == id) {
log::info!("add {} to trend {id}", names.join(", "));
trend.extend(&names);
raise_trend(ctx, id);
}
}
Request::Command {
base,
directory,
command,
} => self.launch(ctx, origin, base, directory, command),
Request::Close => match window {
WindowKey::Main if !self.main.back.is_empty() => self.main.go_back(),
WindowKey::Main => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
WindowKey::Child(id) => {
if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) {
w.close = true;
}
}
},
Request::RaiseAll => {
for w in &self.windows {
raise(ctx, WindowKey::Child(w.id));
}
for t in &self.trends {
raise_trend(ctx, t.id);
}
raise(ctx, WindowKey::Main);
if window != WindowKey::Main {
raise(ctx, window);
}
}
Request::CloseOthers => {
log::info!("close all other windows");
for w in &mut self.windows {
w.close |= WindowKey::Child(w.id) != window;
}
self.trends.clear();
}
Request::Webpage(url) => open_webpage(&url),
}
}
fn embedded_windows(
&mut self,
ctx: &egui::Context,
trends: &[(u64, String)],
) -> Vec<(Origin, Request)> {
let mut requests = Vec::new();
self.windows.retain(|w| !w.close);
let titles: Vec<String> = self.windows.iter().map(|w| self.child_title(w)).collect();
let desk = Desk {
monitors: self.monitors(ctx),
pinned: self.pinned_frames(),
};
for (w, title) in self.windows.iter_mut().zip(titles) {
if let Some(r) = self.repaints.get(&w.id) {
r.begin();
}
w.view.poll_loading(ctx);
let key = WindowKey::Child(w.id);
let vcx = ViewCx {
window: key,
touch: self.touch,
trends,
};
let mut open = true;
egui::Window::new(&title)
.id(display_window_id(w.id))
.open(&mut open)
.collapsible(false)
.default_size(Vec2::new(640.0, 420.0))
.show(ctx, |ui| {
requests.extend(child_ui(
ui,
w,
&self.home,
&vcx,
None,
&mut self.clock,
&desk,
));
});
details_dialog(ctx, key, &mut self.details);
w.close |= !open;
}
self.windows.retain(|w| !w.close);
requests
}
fn embedded_trends(&mut self, ctx: &egui::Context) {
let screen = ctx.content_rect();
let repaints = &self.repaints;
let mut switched = None;
self.trends.retain_mut(|trend| {
if let Some(r) = repaints.get(&trend_viewport(trend.id)) {
r.begin();
}
let mut open = true;
let step = (trend.id % 8) as f32 * 24.0;
let size = TREND_SIZE.min(screen.size() * 0.9);
egui::Window::new(trend.title())
.id(trend_window_id(trend.id))
.open(&mut open)
.collapsible(false)
.default_pos(screen.left_top() + Vec2::new(40.0, 60.0) + Vec2::splat(step))
.default_size(size)
.show(ctx, |ui| trend.ui(ui));
if trend.take_archiver_change() {
switched = Some(trend.archiver().cloned());
}
open
});
if let Some(archiver) = switched {
self.switch_archiver(ctx, archiver);
}
}
fn display_viewports(&mut self) -> Vec<(egui::ViewportId, egui::ViewportBuilder, Child)> {
self.windows.retain(|w| !w.close);
self.windows
.iter()
.map(|w| {
let builder = w.builder.clone().with_title(self.child_title(w));
(w.id, builder, Child::Display(w.id))
})
.collect()
}
fn trend_viewports(
&self,
ctx: &egui::Context,
) -> Vec<(egui::ViewportId, egui::ViewportBuilder, Child)> {
let zoom = ctx.zoom_factor();
self.trends
.iter()
.map(|trend| {
let size = trend.place.size.map_or(TREND_SIZE, |s| s / zoom);
let mut builder = egui::ViewportBuilder::default()
.with_title(trend.title())
.with_app_id("bobview")
.with_inner_size(size)
.with_min_inner_size([400.0, 280.0]);
if let Some(pos) = trend.place.position {
builder = builder.with_position(Pos2::ZERO + pos.to_vec2() / zoom);
}
(trend_viewport(trend.id), builder, Child::Trend(trend.id))
})
.collect()
}
fn display_pass(
&mut self,
ui: &mut egui::Ui,
id: egui::ViewportId,
class: egui::ViewportClass,
) {
let ctx = ui.ctx().clone();
let before = self.shape();
let Some(i) = self.windows.iter().position(|w| w.id == id && !w.close) else {
return;
};
self.begin_pass(id);
let trends = self.trend_titles();
let desk = Desk {
monitors: self.monitors(&ctx),
pinned: self.pinned_frames(),
};
let key = WindowKey::Child(id);
let vcx = ViewCx {
window: key,
touch: self.touch,
trends: &trends,
};
let native =
(class != egui::ViewportClass::EmbeddedWindow).then_some(self.chrome.auto_size);
let w = &mut self.windows[i];
w.view.poll_loading(&ctx);
let requests = child_ui(ui, w, &self.home, &vcx, native, &mut self.clock, &desk);
details_dialog(&ctx, key, &mut self.details);
if ui.input(|i| i.viewport().close_requested()) {
w.close = true;
}
for (origin, req) in requests {
self.request(&ctx, origin, req);
}
self.repaint_changed(&ctx, &before, id);
}
fn trend_pass(&mut self, ui: &mut egui::Ui, id: u64, class: egui::ViewportClass) {
let ctx = ui.ctx().clone();
let before = self.shape();
let Some(i) = self.trends.iter().position(|t| t.id == id) else {
return;
};
self.begin_pass(trend_viewport(id));
let trend = &mut self.trends[i];
if class == egui::ViewportClass::EmbeddedWindow {
trend.ui(ui);
} else {
trend.geometry = geometry(ui);
egui::CentralPanel::default().show(ui, |ui| trend.ui(ui));
if ui.input(|i| i.viewport().close_requested()) {
self.trends.remove(i);
}
}
self.trend_selections(&ctx);
if let Some(trend) = self.trends.get_mut(i).filter(|t| t.id == id)
&& trend.take_archiver_change()
{
let archiver = trend.archiver().cloned();
self.switch_archiver(&ctx, archiver);
}
self.repaint_changed(&ctx, &before, trend_viewport(id));
}
}
fn details_dialog(
ctx: &egui::Context,
window: WindowKey,
details: &mut Option<(WindowKey, pvmenu::Details)>,
) {
if details.as_ref().is_some_and(|(w, _)| *w == window) {
details_window(ctx, details);
}
}
fn details_window(ctx: &egui::Context, details: &mut Option<(WindowKey, pvmenu::Details)>) {
let mut open = details.is_some();
if let Some((_, d)) = details.as_ref() {
egui::Window::new("PV details")
.open(&mut open)
.collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO)
.show(ctx, |ui| pvmenu::details_ui(ui, d));
}
if !open {
*details = None;
}
}
#[cfg(not(target_arch = "wasm32"))]
fn open_webpage(url: &str) {
if let Err(e) = std::process::Command::new("xdg-open").arg(url).spawn() {
log::warn!("cannot open {url}: {e}");
}
}
#[cfg(target_arch = "wasm32")]
fn open_webpage(url: &str) {
if let Some(w) = web_sys::window()
&& w.open_with_url_and_target(url, "_blank").is_err()
{
log::warn!("cannot open {url}");
}
}
fn viewport(window: WindowKey) -> egui::ViewportId {
match window {
WindowKey::Main => egui::ViewportId::ROOT,
WindowKey::Child(id) => id,
}
}
fn display_window_id(id: egui::ViewportId) -> Id {
Id::new(("display window", id))
}
fn window_items(ui: &mut egui::Ui, others: bool) -> Option<Request> {
let raise = ui
.add_enabled(others, egui::Button::new("Bring all windows to front"))
.clicked();
let close = ui
.add_enabled(others, egui::Button::new("Close all other windows"))
.on_hover_text("Close every display and trend window but the main one and this one")
.clicked();
if raise {
Some(Request::RaiseAll)
} else if close {
Some(Request::CloseOthers)
} else {
None
}
}
fn raise(ctx: &egui::Context, window: WindowKey) {
match window {
WindowKey::Child(id) if ctx.embed_viewports() => ctx.move_to_top(egui::LayerId::new(
egui::Order::Middle,
display_window_id(id),
)),
_ => ctx.send_viewport_cmd_to(viewport(window), egui::ViewportCommand::Focus),
}
}
fn geometry(ui: &egui::Ui) -> Geometry {
let zoom = ui.ctx().zoom_factor();
ui.input(|i| {
let info = i.viewport();
Geometry {
position: info.outer_rect.map(|r| Pos2::ZERO + r.min.to_vec2() * zoom),
size: info.inner_rect.map(|r| r.size() * zoom),
}
})
}
fn frame(ui: &egui::Ui) -> Option<Frame> {
let zoom = ui.ctx().zoom_factor();
ui.input(|i| {
let info = i.viewport();
let (outer, inner) = (info.outer_rect?, info.inner_rect?);
Some(Frame {
outer: Rect::from_min_size(
Pos2::ZERO + outer.min.to_vec2() * zoom,
outer.size() * zoom,
),
border: (outer.size() - inner.size()) * zoom,
})
})
}
#[derive(Default)]
struct Shortcuts {
close: bool,
toolbar: bool,
}
fn shortcuts(ui: &egui::Ui, view: &mut View) -> Shortcuts {
use egui::{Key, Modifiers};
if ui.ctx().text_edit_focused() {
return Shortcuts::default();
}
ui.input_mut(|i| {
if i.consume_key(Modifiers::COMMAND, Key::R) {
view.reload();
}
if i.consume_key(Modifiers::ALT, Key::ArrowLeft) {
view.go_back();
}
if i.consume_key(Modifiers::ALT, Key::ArrowRight) {
view.go_forward();
}
Shortcuts {
close: i.consume_key(Modifiers::COMMAND, Key::W),
toolbar: i.consume_key(Modifiers::COMMAND, Key::T),
}
})
}
fn child_ui(
ui: &mut egui::Ui,
w: &mut DisplayWindow,
home: &Option<Location>,
vcx: &ViewCx,
native: Option<bool>,
clock: &mut u64,
desk: &Desk,
) -> Vec<(Origin, Request)> {
if native.is_some() {
w.used.focus(ui.input(|i| i.focused), clock);
let keys = shortcuts(ui, &mut w.view);
w.close |= keys.close;
w.toolbar ^= keys.toolbar;
}
let mut panels = 0.0;
if w.toolbar {
panels += egui::Panel::top(Id::new(("toolbar", w.id)))
.show(ui, |ui| display_toolbar(ui, &mut w.view, home))
.response
.rect
.height();
}
let mut drawn = w.view.draw(ui, vcx);
panels += drawn.status;
if let Some(background) = &drawn.background {
background.context_menu(|ui| {
let label = if w.toolbar {
"Hide toolbar"
} else {
"Show toolbar"
};
if ui.button(label).clicked() {
w.toolbar = !w.toolbar;
}
if ui.button("Close").clicked() {
w.close = true;
}
ui.separator();
if let Some(req) = window_items(ui, true) {
let origin = Origin {
window: WindowKey::Child(w.id),
ctrl: false,
shift: false,
};
drawn.requests.push((origin, req));
}
});
}
let Some(auto_size) = native else {
w.view.new_display = false;
return drawn.requests;
};
corner(ui, w, clock);
w.geometry = geometry(ui);
w.frame = frame(ui);
let info = ui.input(|i| i.viewport().clone());
if w.view.new_display && info.monitor_size.is_some() {
w.view.new_display = false;
let zoom = ui.ctx().zoom_factor();
let display = w.view.natural_size(zoom);
let fit = w.fit_next;
w.fit_next = auto_size || fit && display.is_none();
let origin = w.beside.take();
if fit
&& chrome::can_resize(&info, zoom)
&& let Some(display) = display
{
let size = chrome::window_size(display, panels, info.monitor_size);
let inner = ui.input(|i| i.content_rect().size());
if (size - inner).length() >= 1.0 {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
}
if let Some(origin) = origin
&& let Some(pos) = beside(origin, &desk.clear_of(w.id), size * zoom, &desk.monitors)
{
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::OuterPosition(
Pos2::ZERO + pos.to_vec2() / zoom,
));
}
}
}
drawn.requests
}
fn corner(ui: &egui::Ui, w: &mut DisplayWindow, clock: &mut u64) {
if !ui.input(|i| i.pointer.has_pointer()) {
return;
}
egui::Area::new(Id::new(("chevron", w.id)))
.order(egui::Order::Foreground)
.anchor(egui::Align2::RIGHT_TOP, Vec2::new(-4.0, 4.0))
.show(ui.ctx(), |ui| {
ui.spacing_mut().item_spacing.x = 3.0;
ui.horizontal(|ui| {
if !w.toolbar {
let enabled = !w.view.back.is_empty();
let resp = corner_button(ui, false, enabled);
corner_glyph(ui, &resp, Icon::Back);
if resp.on_hover_text("Back (Alt+←)").clicked() {
w.view.go_back();
}
}
let resp = corner_button(ui, w.pinned, true);
corner_glyph(ui, &resp, Icon::Pin);
if resp.on_hover_text(PIN_HELP).clicked() {
w.pinned = !w.pinned;
if w.pinned {
*clock += 1;
w.pinned_at = *clock;
} else {
w.used.touch(clock);
}
}
let resp = corner_button(ui, false, true);
let visuals = ui.style().interact(&resp);
let c = resp.rect.center();
let (dx, dy) = (5.0, if w.toolbar { -2.5 } else { 2.5 });
ui.painter().line(
vec![
c + Vec2::new(-dx, -dy),
c + Vec2::new(0.0, dy),
c + Vec2::new(dx, -dy),
],
visuals.fg_stroke,
);
let tip = if w.toolbar {
"Hide the toolbar (Ctrl+T)"
} else {
"Show the toolbar (Ctrl+T)"
};
if resp.on_hover_text(tip).clicked() {
w.toolbar = !w.toolbar;
}
});
});
}
fn corner_button(ui: &mut egui::Ui, selected: bool, enabled: bool) -> egui::Response {
let sense = if enabled {
egui::Sense::click()
} else {
egui::Sense::hover()
};
let (rect, resp) = ui.allocate_exact_size(Vec2::new(22.0, 16.0), sense);
let fill = if selected {
ui.visuals().selection.bg_fill
} else {
ui.style().interact(&resp).weak_bg_fill
};
ui.painter().rect_filled(rect, 3.0, fill);
resp
}
fn corner_glyph(ui: &egui::Ui, resp: &egui::Response, icon: Icon) {
let color = if resp.sense.senses_click() {
ui.style().interact(resp).fg_stroke.color
} else {
ui.visuals().weak_text_color()
};
icon.paint(ui.painter(), resp.rect, 12.0, color);
}
fn display_toolbar(ui: &mut egui::Ui, view: &mut View, home: &Option<Location>) {
ui.horizontal(|ui| {
if ui
.add_enabled(!view.back.is_empty(), egui::Button::new("◀"))
.on_hover_text("Back")
.clicked()
{
view.go_back();
}
if ui
.add_enabled(!view.forward.is_empty(), egui::Button::new("▶"))
.on_hover_text("Forward")
.clicked()
{
view.go_forward();
}
if ui.button("⟳").on_hover_text("Reload").clicked() {
view.reload();
}
ui.separator();
if ui.button("−").clicked() {
view.zoom_by(1.0 / 1.25);
}
if ui.selectable_label(view.fit, "Fit").clicked() {
view.fit = !view.fit;
}
if ui.button("+").clicked() {
view.zoom_by(1.25);
}
ui.label(format!("{:.0}%", view.zoom * 100.0));
ui.separator();
if ui
.add_enabled(home.is_some(), egui::Button::new("⌂"))
.on_hover_text("Home")
.clicked()
&& let Some(home) = home.clone()
{
view.open(home);
}
});
}
const TREND_SIZE: Vec2 = Vec2::new(760.0, 480.0);
fn trend_viewport(id: u64) -> egui::ViewportId {
egui::ViewportId::from_hash_of(("trend", id))
}
fn trend_window_id(id: u64) -> Id {
Id::new(("trend", id))
}
fn raise_trend(ctx: &egui::Context, id: u64) {
if ctx.embed_viewports() {
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, trend_window_id(id)));
} else {
ctx.send_viewport_cmd_to(trend_viewport(id), egui::ViewportCommand::Focus);
}
}
pub(crate) fn plain_name(name: &pv::PvName) -> String {
match name.scheme {
pv::Scheme::Ca => name.base().to_string(),
pv::Scheme::Formula | pv::Scheme::Unsupported => name.to_string(),
_ => {
let full = name.to_string();
let prefix = &full[..full.len() - name.pv.len()];
format!("{prefix}{}", name.base())
}
}
}
#[cfg(test)]
mod tests {
use super::plain_name;
use pv::PvName;
#[test]
fn lists_displays_and_project_links() {
let tmp = std::env::temp_dir().join(format!("bobview-list-{}", std::process::id()));
let (real, proj) = (tmp.join("real"), tmp.join("proj"));
std::fs::create_dir_all(real.join("sub")).unwrap();
std::fs::create_dir_all(&proj).unwrap();
for f in ["a.opi", "b.bob", "c.txt"] {
std::fs::write(real.join(f), "").unwrap();
}
std::fs::write(
proj.join(".project"),
format!(
"<projectDescription><linkedResources><link><name>m</name><location>{}</location></link></linkedResources></projectDescription>",
real.display()
),
)
.unwrap();
let names = |dir: &std::path::Path| -> Vec<(std::path::PathBuf, bool)> {
super::list_dir(dir)
.into_iter()
.map(|(p, d)| (p.strip_prefix(&tmp).unwrap().to_path_buf(), d))
.collect()
};
assert_eq!(names(&proj), [("proj/m".into(), true)]);
assert_eq!(
names(&proj.join("m")),
[
("proj/m/sub".into(), true),
("proj/m/a.opi".into(), false),
("proj/m/b.bob".into(), false)
]
);
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn launches_once_and_drops_stale_results() {
use super::{BobApp, Launched, Location, Origin, WindowKey};
let ctx = eframe::egui::Context::default();
let mut app = BobApp::new(&ctx, None, Some(1.0), crate::chrome::UiScale::Auto);
let main = Origin {
window: WindowKey::Main,
..Origin::default()
};
app.launch(&ctx, main, None, None, "no-such-launcher".into());
app.launch(&ctx, main, None, None, "no-such-launcher".into());
assert_eq!(app.main.launching.len(), 1);
assert!(app.main.is_loading());
while app.main.is_loading() {
std::thread::sleep(std::time::Duration::from_millis(5));
app.poll_launched(&ctx);
}
let loc = Location {
source: bob::Source::File("/no/such.bob".into()),
macros: Default::default(),
};
let key = (None, None, "x".to_string());
app.main.launching.push(key.clone());
let stale = app.main.shown;
app.main.shown += 1;
app.launched
.0
.send(Launched {
key,
origin: main,
shown: stale,
target: Some(loc.clone()),
})
.unwrap();
app.poll_launched(&ctx);
assert!(app.main.current.is_none() && !app.main.is_loading());
app.launched
.0
.send(Launched {
key: (None, None, "y".to_string()),
origin: Origin { ctrl: true, ..main },
shown: app.main.shown,
target: Some(loc.clone()),
})
.unwrap();
app.poll_launched(&ctx);
assert!(app.windows.is_empty());
assert_eq!(app.main.current, Some(loc));
}
#[test]
fn links_route_by_pin_modifier_and_open_windows() {
use super::{Origin, Route, WindowKey, route};
let child = WindowKey::Child(eframe::egui::ViewportId::from_hash_of("x"));
let viewer = WindowKey::Child(eframe::egui::ViewportId::from_hash_of("v"));
let plain = Origin::default();
let ctrl = Origin {
ctrl: true,
..plain
};
let shift = Origin {
shift: true,
..plain
};
assert_eq!(route(false, plain, true, None, None), Route::InPlace);
assert_eq!(route(false, shift, true, Some(child), None), Route::InPlace);
assert_eq!(
route(true, plain, true, None, Some(viewer)),
Route::Viewer(viewer)
);
assert_eq!(route(true, plain, true, None, None), Route::NewWindow);
assert_eq!(
route(true, plain, false, None, Some(viewer)),
Route::InPlace
);
assert_eq!(
route(true, shift, false, None, Some(viewer)),
Route::NewWindow
);
assert_eq!(
route(true, shift, true, None, Some(viewer)),
Route::NewWindow
);
for (origin, pinned) in [(plain, true), (plain, false), (shift, true)] {
assert_eq!(
route(true, origin, pinned, Some(child), Some(viewer)),
Route::Raise(child)
);
}
let from_child = Origin {
window: child,
..plain
};
assert_eq!(
route(true, from_child, true, Some(WindowKey::Main), None),
Route::Raise(WindowKey::Main)
);
for (origin, pinned) in [(plain, true), (plain, false), (shift, true)] {
assert_eq!(
route(true, origin, pinned, Some(WindowKey::Main), Some(viewer)),
Route::InPlace
);
}
assert_eq!(
route(true, from_child, true, Some(child), None),
Route::InPlace
);
assert_eq!(
route(true, ctrl, true, Some(child), Some(viewer)),
Route::InPlace
);
}
#[test]
fn new_windows_open_beside_their_origin_then_below() {
use super::{Frame, beside};
use eframe::egui::{Pos2, Rect, Vec2};
let beside_alone = |o, size, monitors: &[Rect]| beside(o, &[], size, monitors);
let left = Rect::from_min_size(Pos2::ZERO, Vec2::new(1920.0, 1080.0));
let right = Rect::from_min_size(Pos2::new(1920.0, 0.0), Vec2::new(1920.0, 1080.0));
let at = |x: f32, y: f32, w: f32| Frame {
outer: Rect::from_min_size(Pos2::new(x, y), Vec2::new(w, 600.0)),
border: Vec2::new(0.0, 30.0),
};
let size = Vec2::new(800.0, 500.0);
assert_eq!(
beside_alone(at(100.0, 50.0, 600.0), size, &[left]),
Some(Pos2::new(700.0, 50.0))
);
assert_eq!(
beside_alone(at(1000.0, 50.0, 600.0), size, &[left]),
Some(Pos2::new(200.0, 50.0))
);
assert_eq!(
beside_alone(at(300.0, 50.0, 1400.0), size, &[left]),
Some(Pos2::new(0.0, 50.0))
);
assert_eq!(
beside_alone(at(2100.0, 900.0, 600.0), size, &[left, right]),
Some(Pos2::new(2700.0, 550.0))
);
assert_eq!(
beside_alone(at(3000.0, 0.0, 600.0), size, &[left, right]),
Some(Pos2::new(2200.0, 0.0))
);
let short = |x: f32, y: f32, w: f32| Frame {
outer: Rect::from_min_size(Pos2::new(x, y), Vec2::new(w, 300.0)),
border: Vec2::new(0.0, 30.0),
};
assert_eq!(
beside_alone(short(1000.0, 0.0, 600.0), size, &[left]),
Some(Pos2::new(1000.0, 300.0))
);
assert_eq!(
beside_alone(short(1300.0, 0.0, 600.0), size, &[left]),
Some(Pos2::new(1120.0, 300.0))
);
assert_eq!(
beside_alone(short(300.0, 600.0, 1400.0), size, &[left]),
Some(Pos2::new(300.0, 70.0))
);
assert_eq!(beside_alone(at(0.0, 0.0, 600.0), size, &[]), None);
let tall = Rect::from_min_size(Pos2::ZERO, Vec2::new(1920.0, 1200.0));
let main = at(0.0, 0.0, 600.0);
let small = Vec2::new(300.0, 270.0);
let first = Rect::from_min_size(Pos2::new(600.0, 0.0), Vec2::new(300.0, 300.0));
assert_eq!(
beside(main, &[first], small, &[tall]),
Some(Pos2::new(900.0, 0.0))
);
let right_of_main = Rect::from_min_size(Pos2::new(600.0, 0.0), Vec2::new(800.0, 600.0));
let below_main = Rect::from_min_size(Pos2::new(0.0, 600.0), Vec2::new(600.0, 600.0));
assert_eq!(
beside(main, &[right_of_main], size, &[tall]),
Some(Pos2::new(600.0, 600.0))
);
let bottom = Rect::from_min_size(Pos2::new(0.0, 600.0), Vec2::new(1920.0, 600.0));
assert_eq!(
beside(main, &[bottom], size, &[tall]),
Some(Pos2::new(600.0, 0.0))
);
assert_eq!(
beside(main, &[right_of_main, below_main], size, &[tall]),
Some(Pos2::new(600.0, 600.0))
);
let rest = Rect::from_min_size(Pos2::new(600.0, 600.0), Vec2::new(1320.0, 600.0));
let far = Rect::from_min_size(Pos2::new(1400.0, 0.0), Vec2::new(520.0, 600.0));
let pos = beside(main, &[right_of_main, below_main, rest, far], size, &[tall]);
assert!(
pos.is_some_and(|p| tall.contains_rect(Rect::from_min_size(p, size + main.border)))
);
}
#[test]
fn same_display_compares_effective_macros() {
use super::Location;
use bob::{Macros, Source};
let loc = |pairs: &[(&str, &str)]| Location {
source: Source::parse("a.bob"),
macros: Macros::from_pairs(pairs.iter().copied()),
};
let flat = loc(&[("P", "X"), ("R", "1")]);
assert!(flat.same_display(&loc(&[("R", "0"), ("P", "X"), ("R", "1")])));
assert!(!flat.same_display(&loc(&[("P", "X"), ("R", "2")])));
let other = Location {
source: Source::parse("b.bob"),
..flat.clone()
};
assert!(!flat.same_display(&other));
let opi = |pairs: &[(&str, &str)]| Location {
source: Source::parse("a.opi"),
..loc(pairs)
};
assert!(opi(&[("P", "X"), ("DID", "DID_1")]).same_display(&opi(&[("P", "X")])));
assert!(!opi(&[("P", "X"), ("DID", "DID_1")]).same_display(&opi(&[("P", "Y")])));
assert!(!loc(&[("DID", "1")]).same_display(&loc(&[("DID", "2")])));
}
#[test]
fn requests_act_on_their_own_window() {
use super::{BobApp, Location, Origin, WindowKey};
use crate::widgets::Request;
let ctx = eframe::egui::Context::default();
let mut app = BobApp::new(&ctx, None, Some(1.0), crate::chrome::UiScale::Auto);
let display = |name: &str| Location {
source: bob::Source::File(format!("/no/such/{name}.bob").into()),
macros: bob::Macros::from_pairs([("P", name)]),
};
let open = |app: &mut BobApp, origin: Origin, loc: Location| {
let req = Request::Open {
source: loc.source,
macros: loc.macros,
target: "replace".into(),
};
app.request(&ctx, origin, req);
};
let plain = |window| Origin {
window,
..Origin::default()
};
let ctrl = |window| Origin {
window,
ctrl: true,
shift: false,
};
let shift = |window| Origin {
window,
ctrl: false,
shift: true,
};
let main = WindowKey::Main;
let current = |app: &BobApp, i: usize| app.windows[i].view.current.clone();
open(&mut app, ctrl(main), display("a"));
assert_eq!(app.main.current, Some(display("a")));
open(&mut app, plain(main), display("b"));
assert_eq!(app.windows.len(), 1);
let first = WindowKey::Child(app.windows[0].id);
open(&mut app, plain(main), display("b"));
assert_eq!(app.windows.len(), 1);
let mut other = display("b");
other.macros = bob::Macros::from_pairs([("P", "c")]);
open(&mut app, plain(main), other.clone());
assert_eq!(app.windows.len(), 1);
assert_eq!(current(&app, 0), Some(other.clone()));
assert_eq!(app.windows[0].view.back, [display("b")]);
open(&mut app, shift(main), display("d"));
assert_eq!(app.windows.len(), 2);
let second = WindowKey::Child(app.windows[1].id);
open(&mut app, plain(main), display("e"));
assert_eq!(current(&app, 1), Some(display("e")));
assert_eq!(current(&app, 0), Some(other));
open(&mut app, plain(first), display("f"));
assert_eq!(current(&app, 0), Some(display("f")));
app.windows[1].pinned = true;
open(&mut app, plain(main), display("g"));
assert_eq!(current(&app, 0), Some(display("g")));
open(&mut app, plain(second), display("h"));
assert_eq!(current(&app, 0), Some(display("h")));
assert_eq!(current(&app, 1), Some(display("e")));
app.windows[0].pinned = true;
open(&mut app, plain(main), display("i"));
assert_eq!(app.windows.len(), 3);
open(&mut app, ctrl(second), display("j"));
assert_eq!(current(&app, 1), Some(display("j")));
app.main_pinned = false;
app.main_used.touch(&mut app.clock);
open(&mut app, plain(second), display("k"));
assert_eq!(app.main.current, Some(display("k")));
app.can_windows = false;
open(&mut app, shift(second), display("l"));
assert_eq!(current(&app, 1), Some(display("l")));
assert_eq!(app.windows.len(), 3);
app.request(&ctx, plain(second), Request::Close);
assert!(app.view_mut(second).is_none());
app.request(&ctx, plain(main), Request::Close);
assert_eq!(app.main.current, Some(display("a")));
let open_windows = |app: &BobApp| app.windows.iter().filter(|w| !w.close).count();
assert_eq!(open_windows(&app), 2);
app.request(&ctx, plain(first), Request::CloseOthers);
assert_eq!(open_windows(&app), 1);
assert!(app.view_mut(first).is_some());
app.request(&ctx, plain(main), Request::CloseOthers);
assert_eq!(open_windows(&app), 0);
assert_eq!(app.main.current, Some(display("a")));
}
#[test]
fn a_link_to_its_own_windows_display_reloads_it() {
use super::{BobApp, Location, Origin, WindowKey};
use crate::tree::{Clicked, Target};
use crate::widgets::Request;
let _cache = crate::fetch::tests::CACHE_USERS.lock();
let ctx = eframe::egui::Context::default();
let mut app = BobApp::new(&ctx, None, Some(1.0), crate::chrome::UiScale::Auto);
let display = |name: &str| Location {
source: bob::Source::File(format!("/no/such/{name}.bob").into()),
macros: bob::Macros::from_pairs([("P", name)]),
};
let open = |app: &mut BobApp, window, ctrl, loc: Location| {
let req = Request::Open {
source: loc.source,
macros: loc.macros,
target: "replace".into(),
};
let origin = Origin {
window,
ctrl,
shift: false,
};
app.request(&ctx, origin, req);
};
let main = WindowKey::Main;
open(&mut app, main, true, display("a"));
open(&mut app, main, false, display("b"));
let child = WindowKey::Child(app.windows[0].id);
open(&mut app, child, true, display("a"));
assert_eq!(app.windows[0].view.back, [display("b")]);
let shown = (app.main.shown, app.windows[0].view.shown);
open(&mut app, child, false, display("a"));
open(&mut app, child, false, display("a"));
assert_eq!(app.windows[0].view.current, Some(display("a")));
assert_eq!(app.windows[0].view.back, [display("b")]);
assert_eq!(app.windows[0].view.shown, shown.1 + 2);
assert_eq!(app.main.shown, shown.0);
assert_eq!(app.windows.len(), 1);
open(&mut app, main, false, display("a"));
assert!(app.main.back.is_empty());
assert_eq!(app.main.shown, shown.0 + 1);
for (ctrl, shift) in [(false, false), (true, false), (false, true)] {
app.open_from_tree(
&ctx,
Clicked {
target: Target::Display(display("a")),
ctrl,
shift,
},
);
}
assert_eq!(app.main.shown, shown.0 + 1);
assert!(app.main.back.is_empty());
assert_eq!(app.windows.len(), 1);
}
#[test]
fn a_link_to_its_own_display_counts_the_displays_own_macros() {
use super::{BobApp, Location, Origin, WindowKey};
use crate::widgets::Request;
let dir = std::env::temp_dir().join(format!("bobview-own-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("own.bob");
std::fs::write(
&file,
r#"<display version="2.0.0"><name>Own</name><macros><X>1</X></macros></display>"#,
)
.unwrap();
let at = |pairs: &[(&str, &str)]| Location {
source: bob::Source::File(file.clone()),
macros: bob::Macros::from_pairs(pairs.iter().copied()),
};
let _cache = crate::fetch::tests::CACHE_USERS.lock();
let ctx = eframe::egui::Context::default();
let mut app = BobApp::new(&ctx, None, Some(1.0), crate::chrome::UiScale::Auto);
let open = |app: &mut BobApp, ctrl, loc: Location| {
let req = Request::Open {
source: loc.source,
macros: loc.macros,
target: "replace".into(),
};
let origin = Origin {
window: WindowKey::Main,
ctrl,
shift: false,
};
app.request(&ctx, origin, req);
};
open(&mut app, true, at(&[("P", "a")]));
wait_loaded(&ctx, &mut app.main);
assert!(app.main.screen.is_some());
let shown = app.main.shown;
open(&mut app, false, at(&[("X", "1"), ("P", "a")]));
wait_loaded(&ctx, &mut app.main);
std::fs::remove_dir_all(&dir).ok();
assert!(app.main.back.is_empty());
assert_eq!(app.main.shown, shown + 1);
assert!(app.windows.is_empty());
}
#[test]
fn plain_names_drop_the_ca_prefix_and_type() {
let t = |n: &str| plain_name(&PvName::parse(n));
assert_eq!(t("loc://t1<VDouble>(1.5)"), "loc://t1");
assert_eq!(t("ca://BL01T:X"), "BL01T:X");
assert_eq!(t("BL01T:X"), "BL01T:X");
assert_eq!(t("pva://BL01T:Y"), "pva://BL01T:Y");
assert_eq!(t("=(pv(\"X\") < 5)"), "=(pv(\"X\") < 5)");
assert_eq!(t("sys://time"), "sys://time");
}
fn wait_loaded(ctx: &eframe::egui::Context, view: &mut crate::view::View) {
let until = std::time::Instant::now() + std::time::Duration::from_secs(20);
while view.is_loading() {
assert!(std::time::Instant::now() < until, "never loaded");
std::thread::sleep(std::time::Duration::from_millis(5));
view.poll_loading(ctx);
}
}
fn run(
ctx: &eframe::egui::Context,
app: &mut super::BobApp,
size: &mut eframe::egui::Vec2,
frames: usize,
) {
use eframe::egui::{self, Pos2, Rect, Vec2};
let monitor = Vec2::new(1920.0, 1080.0);
let until = std::time::Instant::now() + std::time::Duration::from_secs(20);
let mut drawn = 0;
while drawn < frames
|| (std::time::Instant::now() < until
&& (app.main.is_loading() || app.windows.iter().any(|w| w.view.is_loading())))
{
drawn += 1;
let zoom = ctx.zoom_factor();
let screen = Rect::from_min_size(Pos2::ZERO, *size / zoom);
let mut input = egui::RawInput {
screen_rect: Some(screen),
..Default::default()
};
let info = input.viewports.entry(egui::ViewportId::ROOT).or_default();
info.native_pixels_per_point = Some(1.0);
info.inner_rect = Some(screen);
info.outer_rect = Some(screen);
info.monitor_size = Some(monitor / zoom);
let mut output = ctx.run_ui(input, |ui| app.draw(ui));
output.textures_delta.clear();
if let Some(vp) = output.viewport_output.get(&egui::ViewportId::ROOT) {
for cmd in &vp.commands {
if let egui::ViewportCommand::InnerSize(s) = cmd {
*size = (*s * zoom).round().min(monitor);
}
}
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
struct FakePrimary(std::sync::Arc<parking_lot::Mutex<Vec<String>>>);
impl crate::selection::Primary for FakePrimary {
fn set(&mut self, text: &str) -> Result<(), String> {
self.0.lock().push(text.to_string());
Ok(())
}
}
struct FakeRead(Option<String>);
impl crate::selection::ReadPrimary for FakeRead {
fn get(&mut self) -> Option<String> {
self.0.clone()
}
}
struct SlowRead(std::sync::mpsc::Receiver<()>);
impl crate::selection::ReadPrimary for SlowRead {
fn get(&mut self) -> Option<String> {
self.0.recv().ok();
Some("LATE:PV".into())
}
}
#[test]
fn a_selection_answering_after_the_wait_is_dropped() {
use super::BobApp;
use crate::chrome::UiScale;
use eframe::egui::{self, Event, Modifiers, PointerButton, Pos2};
let ctx = egui::Context::default();
let mut app = BobApp::new(&ctx, None, Some(1.0), UiScale::Fixed(1.0));
let req = crate::widgets::Request::Trend(vec!["LATE:A".into()]);
app.request(&ctx, super::Origin::default(), req);
let mut size = crate::chrome::DEFAULT_WINDOW;
run(&ctx, &mut app, &mut size, 5);
let plot = ctx
.memory(|m| m.area_rect(super::trend_window_id(0)))
.expect("the trend is drawn")
.center();
let (answer, wait) = std::sync::mpsc::channel();
app.set_selection_reader(Some(Box::new(move || Box::new(SlowRead(wait)))));
let press = |pressed| Event::PointerButton {
pos: plot,
button: PointerButton::Middle,
pressed,
modifiers: Modifiers::NONE,
};
for events in [
vec![Event::PointerMoved(plot)],
vec![press(true)],
vec![press(false)],
] {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, size)),
events,
..Default::default()
};
ctx.run_ui(input, |ui| app.draw(ui)).textures_delta.clear();
}
assert!(app.trends[0].is_reading(), "asked for the selection");
std::thread::sleep(crate::trend::SELECTION_WAIT);
answer.send(()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
run(&ctx, &mut app, &mut size, 2);
assert!(!app.trends[0].is_reading());
assert_eq!(
app.trends[0].names(),
["LATE:A"],
"the late answer is dropped"
);
let (text, error) = app.trends[0].notice().unwrap();
assert!(error && text.contains("Could not read"), "{text}");
}
#[test]
fn a_middle_click_on_a_trend_adds_the_selected_pvs() {
use super::BobApp;
use crate::chrome::UiScale;
use eframe::egui::{self, Event, Modifiers, PointerButton, Pos2};
let ctx = egui::Context::default();
let mut app = BobApp::new(&ctx, None, Some(1.0), UiScale::Fixed(1.0));
let req = crate::widgets::Request::Trend(vec!["loc://mid_a(0)".into()]);
app.request(&ctx, super::Origin::default(), req);
let mut size = crate::chrome::DEFAULT_WINDOW;
run(&ctx, &mut app, &mut size, 5);
let plot = ctx
.memory(|m| m.area_rect(super::trend_window_id(0)))
.expect("the trend is drawn")
.center();
let mut middle_click = |app: &mut BobApp, selected: Option<&str>| {
let fake = FakeRead(selected.map(str::to_string));
app.set_selection_reader(Some(Box::new(move || Box::new(fake))));
let press = |pressed| Event::PointerButton {
pos: plot,
button: PointerButton::Middle,
pressed,
modifiers: Modifiers::NONE,
};
for events in [
vec![Event::PointerMoved(plot)],
vec![press(true)],
vec![press(false)],
] {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, size)),
events,
..Default::default()
};
ctx.run_ui(input, |ui| app.draw(ui)).textures_delta.clear();
}
assert!(app.trends[0].is_reading(), "asked for the selection");
let until = std::time::Instant::now() + std::time::Duration::from_secs(10);
while app.trends[0].is_reading() {
assert!(std::time::Instant::now() < until, "never answered");
std::thread::sleep(std::time::Duration::from_millis(5));
run(&ctx, app, &mut size, 1);
}
run(&ctx, app, &mut size, 1);
};
middle_click(&mut app, Some("MID:B MID:C, INFO:root:x"));
assert_eq!(app.trends[0].names(), ["loc://mid_a(0)", "MID:B", "MID:C"]);
assert_eq!(app.trends[0].notice(), Some(("Added 2 PVs", false)));
middle_click(&mut app, None);
assert_eq!(app.trends[0].names().len(), 3);
let (text, error) = app.trends[0].notice().unwrap();
assert!(error && text.contains("empty"), "{text}");
middle_click(&mut app, Some("just some words"));
assert_eq!(app.trends[0].names().len(), 3);
let (text, error) = app.trends[0].notice().unwrap();
assert!(error && text.contains("No PV names"), "{text}");
app.set_selection_reader(None);
let press = |pressed| Event::PointerButton {
pos: plot,
button: PointerButton::Middle,
pressed,
modifiers: Modifiers::NONE,
};
for events in [vec![press(true)], vec![press(false)]] {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, size)),
events,
..Default::default()
};
ctx.run_ui(input, |ui| app.draw(ui)).textures_delta.clear();
}
assert!(!app.trends[0].is_reading());
assert_eq!(app.trends[0].names().len(), 3);
}
#[test]
fn a_middle_click_copies_the_pv_name_to_both_selections() {
use super::{BobApp, Location};
use crate::chrome::UiScale;
use eframe::egui::{self, Event, Modifiers, OutputCommand, PointerButton, Pos2};
let dir = std::env::temp_dir().join(format!("bobview-copy-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("copy.bob");
std::fs::write(
&file,
"<display><macros><P>copy_t</P></macros><widget type=\"textupdate\">\
<width>600</width><height>300</height>\
<pv_name>loc://$(P)<VDouble>(1)</pv_name></widget></display>",
)
.unwrap();
let display = Location {
source: bob::Source::File(file),
macros: bob::Macros::default(),
};
let ctx = egui::Context::default();
let mut app = BobApp::new(&ctx, Some(display), Some(1.0), UiScale::Fixed(1.0));
let primary = std::sync::Arc::new(parking_lot::Mutex::new(Vec::new()));
app.primary = Box::new(FakePrimary(primary.clone()));
let mut size = crate::chrome::DEFAULT_WINDOW;
run(&ctx, &mut app, &mut size, 5);
let pos = Pos2::new(300.0, 200.0);
let mut copied = Vec::new();
for button in [PointerButton::Primary, PointerButton::Middle] {
let press = |pressed| Event::PointerButton {
pos,
button,
pressed,
modifiers: Modifiers::NONE,
};
for events in [
vec![Event::PointerMoved(pos)],
vec![press(true)],
vec![press(false)],
] {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, size)),
events,
..Default::default()
};
let out = ctx.run_ui(input, |ui| app.draw(ui));
copied.extend(
out.platform_output
.commands
.into_iter()
.filter_map(|c| match c {
OutputCommand::CopyText(t) => Some(t),
_ => None,
}),
);
}
if button == PointerButton::Primary {
assert!(copied.is_empty() && primary.lock().is_empty());
}
}
assert_eq!(copied, ["loc://copy_t"]);
assert_eq!(*primary.lock(), ["loc://copy_t"]);
assert!(app.main.copied.is_some());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_saved_navigator_is_restored_with_its_room() {
use super::{BobApp, Location};
use crate::chrome::UiScale;
let display = Location {
source: bob::Source::File(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/displays/index.bob"),
),
macros: bob::Macros::default(),
};
let ctx = eframe::egui::Context::default();
let mut app = BobApp::new(&ctx, Some(display), None, UiScale::Fixed(1.0));
let mut size = crate::chrome::DEFAULT_WINDOW;
run(&ctx, &mut app, &mut size, 40);
let alone = size;
app.tree.open = true;
app.tree_toggled = true;
run(&ctx, &mut app, &mut size, 20);
let with_tree = size;
assert!(with_tree.x > alone.x + 100.0, "{alone:?} -> {with_tree:?}");
let layout = app.layout();
let ctx = eframe::egui::Context::default();
let mut app = BobApp::new(&ctx, None, None, UiScale::Fixed(1.0));
app.restore_layout(layout, Some("default".into()));
let mut size = crate::chrome::DEFAULT_WINDOW;
run(&ctx, &mut app, &mut size, 60);
assert!(app.tree.open);
assert_eq!(size, with_tree);
}
#[test]
fn a_restored_window_cut_to_its_first_monitor_is_sized_again() {
use super::BobApp;
use crate::layout::Geometry;
use eframe::egui::{Pos2, Vec2};
let ctx = eframe::egui::Context::default();
let mut app = BobApp::new(&ctx, None, None, crate::chrome::UiScale::Fixed(1.0));
app.restoring = Some((
Geometry {
position: Some(Pos2::ZERO),
size: Some(Vec2::new(1471.0, 900.0)),
},
None,
));
let mut size = Vec2::new(1200.0, 900.0);
run(&ctx, &mut app, &mut size, 3);
assert_eq!(size, Vec2::new(1471.0, 900.0));
assert!(app.restoring.is_none());
}
#[test]
fn a_restored_window_that_never_gets_there_is_left_alone() {
use super::{BobApp, RESTORE_WAIT};
use crate::layout::Geometry;
use eframe::egui::{Pos2, Vec2};
let ctx = eframe::egui::Context::default();
let mut app = BobApp::new(&ctx, None, None, crate::chrome::UiScale::Fixed(1.0));
app.restoring = Some((
Geometry {
position: Some(Pos2::new(500.0, 300.0)),
size: Some(Vec2::new(1471.0, 900.0)),
},
None,
));
let mut size = Vec2::new(1200.0, 900.0);
run(&ctx, &mut app, &mut size, 3);
assert!(app.restoring.is_some());
let frames = (RESTORE_WAIT * 60.0) as usize + 10;
run(&ctx, &mut app, &mut size, frames);
assert!(app.restoring.is_none());
assert_eq!(size, Vec2::new(1200.0, 900.0));
}
struct Desktop {
ctx: eframe::egui::Context,
app: super::App,
asked: std::sync::Arc<parking_lot::Mutex<std::collections::HashMap<ViewportId, f64>>>,
windows: std::collections::HashMap<ViewportId, std::sync::Arc<Callback>>,
passes: std::collections::HashMap<ViewportId, usize>,
time: f64,
clock: std::sync::Arc<parking_lot::Mutex<f64>>,
}
type Callback = eframe::egui::viewport::DeferredViewportUiCallback;
use eframe::egui::ViewportId;
impl Desktop {
fn new(start: super::Location) -> Self {
let ctx = eframe::egui::Context::default();
ctx.set_embed_viewports(false);
let asked = std::sync::Arc::new(parking_lot::Mutex::new(std::collections::HashMap::<
ViewportId,
f64,
>::new()));
let app =
super::BobApp::new(&ctx, Some(start), None, crate::chrome::UiScale::Fixed(1.0));
let mut desk = Self {
ctx,
app: super::App::new(app),
asked,
windows: Default::default(),
passes: Default::default(),
time: 0.0,
clock: Default::default(),
};
let (asked, clock) = (desk.asked.clone(), desk.clock.clone());
desk.ctx.set_request_repaint_callback(move |info| {
let due = *clock.lock() + info.delay.as_secs_f64().min(1e6);
let mut asked = asked.lock();
let at = asked.entry(info.viewport_id).or_insert(due);
*at = at.min(due);
});
desk.pass(ViewportId::ROOT, Vec::new());
desk
}
fn rect(id: ViewportId) -> eframe::egui::Rect {
use eframe::egui::{Pos2, Rect, Vec2};
let i = if id == ViewportId::ROOT {
0.0
} else {
1.0 + (id.0.value() % 7) as f32
};
Rect::from_min_size(Pos2::new(i * 10.0, i * 10.0), Vec2::new(500.0, 400.0))
}
fn pass(&mut self, id: ViewportId, events: Vec<eframe::egui::Event>) {
use eframe::egui;
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, Self::rect(id).size());
let mut input = egui::RawInput {
viewport_id: id,
screen_rect: Some(screen),
time: Some(self.time),
events,
..Default::default()
};
for vp in std::iter::once(ViewportId::ROOT).chain(self.windows.keys().copied()) {
let info = input.viewports.entry(vp).or_default();
info.parent = (vp != ViewportId::ROOT).then_some(ViewportId::ROOT);
info.native_pixels_per_point = Some(1.0);
info.inner_rect = Some(Self::rect(vp));
info.outer_rect = Some(Self::rect(vp));
info.monitor_size = Some(egui::Vec2::new(1920.0, 1080.0));
}
*self.clock.lock() = self.time;
self.asked.lock().remove(&id);
let mut output = if id == ViewportId::ROOT {
self.ctx.run_ui(input, |ui| self.app.draw(ui))
} else {
let Some(cb) = self.windows.get(&id).cloned() else {
return;
};
self.ctx.run_ui(input, |ui| cb(ui))
};
output.textures_delta.clear();
*self.passes.entry(id).or_default() += 1;
let open: Vec<ViewportId> = output.viewport_output.keys().copied().collect();
self.windows.retain(|vp, _| open.contains(vp));
for (vp, out) in output.viewport_output {
if let Some(cb) = out.viewport_ui_cb
&& self.windows.insert(vp, cb).is_none()
{
self.asked.lock().insert(vp, self.time);
}
}
}
fn settle(&mut self) {
for _ in 0..2000 {
let next = {
let asked = self.asked.lock();
asked.values().copied().fold(f64::INFINITY, f64::min)
};
if next > self.time + 0.5 {
return;
}
self.time = self.time.max(next);
let mut due: Vec<ViewportId> = self
.asked
.lock()
.iter()
.filter(|(_, at)| **at <= self.time)
.map(|(id, _)| *id)
.collect();
due.sort_by_key(|id| *id != ViewportId::ROOT);
for id in due {
self.pass(id, Vec::new());
}
std::thread::sleep(std::time::Duration::from_millis(1));
self.time += 1.0 / 60.0;
}
panic!("the windows never stop repainting");
}
fn count(&mut self) -> std::collections::HashMap<ViewportId, usize> {
std::mem::take(&mut self.passes)
}
fn children(&self) -> Vec<ViewportId> {
self.app.0.lock().windows.iter().map(|w| w.id).collect()
}
}
fn pv_display(dir: &std::path::Path, name: &str, pvs: &[&str], link: &str) -> super::Location {
let mut widgets = String::new();
for (i, pv) in pvs.iter().enumerate() {
widgets += &format!(
r#"<widget type="textupdate" version="2.0.0"><name>t{i}</name><pv_name>{pv}</pv_name><x>10</x><y>{}</y><width>100</width><height>20</height></widget>"#,
60 + 30 * i
);
}
let file = dir.join(format!("{name}.bob"));
std::fs::write(
&file,
format!(
r#"<display version="2.0.0"><name>{name}</name><width>200</width><height>150</height>
<widget type="action_button" version="3.0.0"><name>link</name><actions><action type="open_display"><file>{link}</file><target>replace</target></action></actions><x>0</x><y>0</y><width>200</width><height>50</height></widget>
{widgets}</display>"#
),
)
.unwrap();
super::Location {
source: bob::Source::File(file),
macros: bob::Macros::default(),
}
}
fn three_windows(tag: &str) -> (Desktop, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("bobview-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let pv = |n: &str| format!("loc://{tag}_{n}(0)");
let m = pv_display(&dir, "m", &[&pv("m")], "c.bob");
let a = pv_display(&dir, "a", &[&pv("a"), &pv("shared")], "c.bob");
let b = pv_display(&dir, "b", &[&pv("b"), &pv("shared")], "c.bob");
pv_display(&dir, "c", &[&pv("c")], "a.bob");
pv_display(&dir, "m2", &[&pv("m2")], "a.bob");
let mut desk = Desktop::new(m);
desk.settle();
for loc in [a, b] {
let origin = super::Origin {
window: super::WindowKey::Main,
ctrl: false,
shift: true,
};
let req = crate::widgets::Request::Open {
source: loc.source,
macros: loc.macros,
target: "replace".into(),
};
desk.app.0.lock().request(&desk.ctx, origin, req);
}
desk.asked.lock().insert(ViewportId::ROOT, desk.time);
desk.settle();
assert_eq!(desk.children().len(), 2);
assert_eq!(
desk.windows.len(),
2,
"each display window drawn on its own"
);
(desk, dir)
}
#[test]
fn the_pointer_in_one_window_redraws_only_that_window() {
use eframe::egui::{Event, Pos2};
let (mut desk, dir) = three_windows("pointer");
let [a, b] = desk.children()[..] else {
unreachable!()
};
desk.count();
for i in 0..20 {
let pos = Pos2::new(100.0 + i as f32 * 5.0, 200.0);
desk.pass(a, vec![Event::PointerMoved(pos)]);
desk.settle();
}
let passes = desk.count();
std::fs::remove_dir_all(&dir).ok();
assert!(passes.get(&a).is_some_and(|n| *n >= 20), "{passes:?}");
assert_eq!(passes.get(&ViewportId::ROOT), None, "{passes:?}");
assert_eq!(passes.get(&b), None, "{passes:?}");
}
#[test]
fn a_pv_change_repaints_only_the_windows_showing_it() {
let (mut desk, dir) = three_windows("pvchange");
let [a, b] = desk.children()[..] else {
unreachable!()
};
let pvs = desk.app.0.lock().pvs.clone();
let write = |desk: &mut Desktop, name: &str, value: &str| {
desk.count();
pvs.subscribe(&format!("loc://pvchange_{name}(0)"))
.write(value);
desk.settle();
desk.count()
};
let passes = write(&mut desk, "a", "1");
assert!(passes.contains_key(&a), "{passes:?}");
assert!(
!passes.contains_key(&b) && !passes.contains_key(&ViewportId::ROOT),
"{passes:?}"
);
let passes = write(&mut desk, "shared", "1");
assert!(
passes.contains_key(&a) && passes.contains_key(&b),
"{passes:?}"
);
assert!(!passes.contains_key(&ViewportId::ROOT), "{passes:?}");
let passes = write(&mut desk, "m", "1");
assert!(passes.contains_key(&ViewportId::ROOT), "{passes:?}");
assert!(
!passes.contains_key(&a) && !passes.contains_key(&b),
"{passes:?}"
);
desk.count();
for i in 0..50 {
pvs.subscribe("loc://pvchange_a(0)").write(i.to_string());
}
desk.settle();
let passes = desk.count();
assert!(passes.get(&a).is_some_and(|n| *n <= 3), "{passes:?}");
let origin = super::Origin::default();
let req = crate::widgets::Request::Trend(vec!["loc://pvchange_b(0)".into()]);
desk.app.0.lock().request(&desk.ctx, origin, req);
desk.asked.lock().insert(ViewportId::ROOT, desk.time);
desk.settle();
let trend = super::trend_viewport(0);
assert!(desk.windows.contains_key(&trend));
let passes = write(&mut desk, "b", "2");
assert!(
passes.contains_key(&b) && passes.contains_key(&trend),
"{passes:?}"
);
assert!(
!passes.contains_key(&a) && !passes.contains_key(&ViewportId::ROOT),
"{passes:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn windows_opened_or_changed_from_another_are_drawn() {
use eframe::egui::{Event, Modifiers, PointerButton, Pos2};
let (mut desk, dir) = three_windows("opened");
let [a, b] = desk.children()[..] else {
unreachable!()
};
let pos = Pos2::new(10.0, 10.0);
let click = |pressed| Event::PointerButton {
pos,
button: PointerButton::Primary,
pressed,
modifiers: Modifiers::SHIFT,
};
desk.count();
let shift = Event::ModifiersChanged(Modifiers::SHIFT);
desk.pass(a, vec![shift, Event::PointerMoved(pos), click(true)]);
desk.pass(a, vec![click(false)]);
desk.pass(a, vec![Event::ModifiersChanged(Modifiers::NONE)]);
desk.settle();
let passes = desk.count();
let children = desk.children();
assert_eq!(children.len(), 3, "{passes:?}");
let c = children[2];
assert!(desk.windows.contains_key(&c), "the new window is open");
assert!(
passes.contains_key(&ViewportId::ROOT) && passes.contains_key(&c),
"{passes:?}"
);
assert!(!passes.contains_key(&b), "{passes:?}");
let to = |name: &str| super::Location {
source: bob::Source::File(dir.join(format!("{name}.bob"))),
macros: bob::Macros::default(),
};
{
let app = desk.app.0.lock();
let done = super::Launched {
key: (None, None, "x".into()),
origin: super::Origin::default(),
shown: app.main.shown,
target: Some(to("a")),
};
app.launched.0.send(done).unwrap();
}
desk.asked.lock().insert(ViewportId::ROOT, desk.time);
desk.settle();
let passes = desk.count();
assert!(!passes.contains_key(&b), "{passes:?}");
{
let app = desk.app.0.lock();
let done = super::Launched {
key: (None, None, "y".into()),
origin: super::Origin::default(),
shown: app.main.shown,
target: Some(to("m2")),
};
app.launched.0.send(done).unwrap();
}
desk.asked.lock().insert(ViewportId::ROOT, desk.time);
desk.settle();
let passes = desk.count();
let app = desk.app.0.lock();
let viewer = app
.windows
.iter()
.find(|w| w.view.current == Some(to("m2")));
let viewer = viewer.map(|w| w.id).expect("shown in the viewer");
assert!(passes.contains_key(&viewer), "{passes:?}");
assert!(!passes.contains_key(&b) || viewer == b, "{passes:?}");
drop(app);
let ctrl_w = Event::Key {
key: eframe::egui::Key::W,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Modifiers::COMMAND,
};
desk.pass(c, vec![ctrl_w]);
desk.settle();
assert_eq!(desk.children().len(), 2);
assert!(!desk.windows.contains_key(&c));
std::fs::remove_dir_all(&dir).ok();
}
}