/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ mod debug_flags; mod profiler; mod shell; mod textures; mod composite_view; use eframe::egui; use webrender_api::DebugFlags; use webrender_api::debugger::{DebuggerMessage, DebuggerTextureContent, ProfileCounterId, CompositorDebugInfo}; use crate::{command, net}; use std::collections::{HashMap, BTreeMap}; use std::fs; use std::io::Write; use std::sync::mpsc; use profiler::Graph; #[allow(dead_code)] enum ApplicationEvent { RunCommand(String), NetworkEvent(net::NetworkEvent), } struct DataModel { is_connected: bool, debug_flags: DebugFlags, cmd: String, log: Vec, documents: Vec, preview_doc_index: Option, profile_graphs: HashMap, } impl DataModel { fn new() -> Self { DataModel { is_connected: false, debug_flags: DebugFlags::empty(), cmd: String::new(), log: Vec::new(), documents: Vec::new(), preview_doc_index: None, profile_graphs: HashMap::new(), } } } #[derive(serde::Serialize, serde::Deserialize)] pub enum Tool { DebugFlags, Profiler, Shell, Documents, Preview, } impl egui_tiles::Behavior for Gui { fn tab_title_for_pane(&mut self, tool: &Tool) -> egui::WidgetText { let title = match tool { Tool::DebugFlags => { "Debug flags" } Tool::Profiler => { "Profiler" } Tool::Shell => { "Shell" } Tool::Documents => { "Documents" } Tool::Preview => { "Preview" } }; title.into() } fn pane_ui(&mut self, ui: &mut egui::Ui, _id: egui_tiles::TileId, tool: &mut Tool) -> egui_tiles::UiResponse { // Add a bit of margin around the panes, otherwise their content hugs the // border in an awkward way. There may be a setting somewhere in egui_tiles // rather than doing it manually. egui::Frame::new().inner_margin(egui::Margin::symmetric(5, 5)).show(ui, |ui| { match tool { Tool::Documents => { do_documents_ui(self, ui); } Tool::Preview => { do_preview_ui(self, ui); } Tool::DebugFlags => { debug_flags::ui(self, ui); } Tool::Profiler => { profiler::ui(self, ui); } Tool::Shell => { shell::ui(self, ui); } } }); Default::default() } fn simplification_options(&self) -> egui_tiles::SimplificationOptions { let mut options = egui_tiles::SimplificationOptions::default(); options.all_panes_must_have_tabs = true; options } fn tab_title_spacing(&self, _visuals: &egui::Visuals) -> f32 { 50.0 } fn gap_width(&self, _style: &egui::Style) -> f32 { 2.0 } fn tab_outline_stroke( &self, _visuals: &egui::Visuals, _tiles: &egui_tiles::Tiles, _tile_id: egui_tiles::TileId, _state: &egui_tiles::TabState, ) -> egui::Stroke { egui::Stroke::NONE } } pub struct Gui { data_model: DataModel, net: net::HttpConnection, cmd_list: command::CommandList, cmd_history: Vec, cmd_history_index: usize, doc_id: usize, event_receiver: mpsc::Receiver, ui_tiles: Option>, } impl Gui { pub fn new(host: &str, cmd_list: command::CommandList) -> Self { let net = net::HttpConnection::new(host); let data_model = DataModel::new(); let (event_sender, event_receiver) = mpsc::channel(); // Spawn network event thread let host_clone = host.to_string(); std::thread::spawn(move || { net::NetworkEventStream::spawn(&host_clone, move |event| { let _ = event_sender.send(ApplicationEvent::NetworkEvent(event)); }); }); // Try to load saved ui_tiles state, or create default layout let save = fs::read_to_string(config_path()) .ok() .and_then(|content| { serde_json::from_str::(&content) .map_err(|e| { eprintln!("Failed to deserialize ui_tiles state: {}", e); e }) .ok() }).unwrap_or_else(|| { // Create default layout let mut tiles = egui_tiles::Tiles::default(); let tabs = vec![ tiles.insert_pane(Tool::Profiler), tiles.insert_pane(Tool::Preview), ]; let side = vec![ tiles.insert_pane(Tool::DebugFlags), tiles.insert_pane(Tool::Documents), ]; let side = tiles.insert_vertical_tile(side); let main_tile = tiles.insert_tab_tile(tabs); let main_and_side = vec![ side, main_tile, ]; let main_and_side = tiles.insert_horizontal_tile(main_and_side); let v = vec![ main_and_side, tiles.insert_pane(Tool::Shell), ]; let root = tiles.insert_vertical_tile(v); GuiSavedState { cmd_history: Vec::new(), ui_tiles: egui_tiles::Tree::new("WR debugger", root, tiles), } }); Gui { data_model, net, cmd_list, cmd_history: save.cmd_history, cmd_history_index: 0, doc_id: 0, event_receiver, ui_tiles: Some(save.ui_tiles), } } pub fn run(self) { let native_options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([1280.0, 720.0]) .with_title("WebRender Debug UI"), ..Default::default() }; let _ = eframe::run_native( "WebRender Debug UI", native_options, Box::new(|cc| { // Load fonts let mut fonts = egui::FontDefinitions::default(); fonts.font_data.insert( "FiraSans".to_owned(), egui::FontData::from_static(include_bytes!("../../res/FiraSans-Regular.ttf")).into(), ); fonts.font_data.insert( "FiraCode".to_owned(), egui::FontData::from_static(include_bytes!("../../res/FiraCode-Regular.ttf")).into(), ); fonts.families.get_mut(&egui::FontFamily::Proportional).unwrap() .insert(0, "FiraSans".to_owned()); fonts.families.get_mut(&egui::FontFamily::Monospace).unwrap() .insert(0, "FiraCode".to_owned()); cc.egui_ctx.set_fonts(fonts); // Load layout settings if available if let Ok(_ini) = fs::read_to_string("default-layout.ini") { // egui uses a different state persistence mechanism // This would need to be adapted for egui's state system } Ok(Box::new(self)) }), ); } } impl eframe::App for Gui { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Process pending events while let Ok(event) = self.event_receiver.try_recv() { match event { ApplicationEvent::RunCommand(cmd_name) => { self.handle_command(&cmd_name); } ApplicationEvent::NetworkEvent(net_event) => { self.handle_network_event(net_event); } } } textures::prepare(self, ctx); // Main menu bar egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("Exit").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } }); ui.menu_button("Help", |ui| { ui.label("About"); }); // Connection status on the right ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let (msg, text_color, bg_color) = if self.data_model.is_connected { ("Connected", egui::Color32::from_rgb(48, 48, 48), egui::Color32::from_rgb(0, 255, 0)) } else { ("Disconnected", egui::Color32::WHITE, egui::Color32::from_rgb(255, 0, 0)) }; // TODO: measure the text instead. let mut area = ui.max_rect(); area.min.x = area.max.x - 85.0; area.max.x += 10.0; ui.painter().rect_filled(area, 0, bg_color); let label = egui::Label::new( egui::RichText::new(msg).color(text_color) ); ui.add(label).on_hover_text("Connection status"); }); }); }); let mut ui_tiles = self.ui_tiles.take().unwrap(); egui::CentralPanel::default().show(ctx, |ui| { ui.spacing_mut().window_margin = egui::Margin::ZERO; ui_tiles.ui(self, ui); }); self.ui_tiles = Some(ui_tiles); } } impl Gui { fn handle_command(&mut self, cmd_name: &str) { match self.cmd_list.get_mut(cmd_name) { Some(cmd) => { let mut ctx = command::CommandContext::new( BTreeMap::new(), &mut self.net, ); let output = cmd.run(&mut ctx); match output { command::CommandOutput::Log(msg) => { self.data_model.log.push(msg); } command::CommandOutput::Err(msg) => { self.data_model.log.push(msg); } command::CommandOutput::TextDocument { title, content } => { let title = format!("{} [id {}]", title, self.doc_id); self.doc_id += 1; self.data_model.preview_doc_index = Some(self.data_model.documents.len()); self.data_model.documents.push( Document { title, kind: DocumentKind::Text { content, } } ); } command::CommandOutput::SerdeDocument { kind, ref content } => { let title = format!("Compositor [id {}]", self.doc_id); self.doc_id += 1; self.data_model.preview_doc_index = Some(self.data_model.documents.len()); let kind = match kind.as_str() { "composite-view" => { let info = serde_json::from_str(content).unwrap(); DocumentKind::Compositor { info, } } _ => { unreachable!("unknown content"); } }; self.data_model.documents.push( Document { title, kind, } ); } command::CommandOutput::Textures(textures) => { textures::add_textures(self, textures); } } } None => { self.data_model.log.push( format!("Unknown command '{}'", cmd_name) ); } } } fn handle_network_event(&mut self, event: net::NetworkEvent) { match event { net::NetworkEvent::Connected => { self.data_model.is_connected = true; } net::NetworkEvent::Disconnected => { self.data_model.is_connected = false; } net::NetworkEvent::Message(msg) => { match msg { DebuggerMessage::SetDebugFlags(info) => { self.data_model.debug_flags = info.flags; } DebuggerMessage::InitProfileCounters(info) => { let selected_counters = [ "Frame building", "Renderer", ]; for counter in info.counters { if selected_counters.contains(&counter.name.as_str()) { println!("Add profile counter {:?}", counter.name); self.data_model.profile_graphs.insert( counter.id, Graph::new(&counter.name, 512), ); } } } DebuggerMessage::UpdateProfileCounters(info) => { for counter in &info.updates { if let Some(graph) = self.data_model .profile_graphs .get_mut(&counter.id) { graph.push(counter.value); } } } } } } } } impl Drop for Gui { fn drop(&mut self) { // Serialize and save UI state const MAX_HISTORY_SAVED: usize = 50; let hist_len = self.cmd_history.len(); let range = if hist_len > MAX_HISTORY_SAVED { hist_len - MAX_HISTORY_SAVED..hist_len } else { 0..hist_len }; let save = GuiSavedState { ui_tiles: self.ui_tiles.take().unwrap(), cmd_history: self.cmd_history.drain(range).collect(), }; match serde_json::to_string(&save) { Ok(serialized) => { if let Err(e) = fs::File::create(config_path()) .and_then(|mut file| file.write_all(serialized.as_bytes())) { eprintln!("Failed to save ui_tiles state: {}", e); } } Err(e) => { eprintln!("Failed to serialize ui_tiles: {}", e); } } } } pub enum DocumentKind { Text { content: String, }, Compositor { info: CompositorDebugInfo, }, Texture { content: DebuggerTextureContent, handle: Option, } } pub struct Document { pub title: String, pub kind: DocumentKind, } fn do_documents_ui(app: &mut Gui, ui: &mut egui::Ui) { let width = ui.available_width(); for (i, doc) in app.data_model.documents.iter().enumerate() { if let DocumentKind::Texture { .. } = doc.kind { // Handle textures separately below. continue; } let item = egui::Button::selectable( app.data_model.preview_doc_index == Some(i), &doc.title, ).min_size(egui::vec2(width, 20.0)); if ui.add(item).clicked() { app.data_model.preview_doc_index = Some(i); } } textures::texture_list_ui(app, ui); } fn do_preview_ui(app: &mut Gui, ui: &mut egui::Ui) { if let Some(idx) = app.data_model.preview_doc_index { if idx >= app.data_model.documents.len() { app.data_model.preview_doc_index = None; return; } let doc = &app.data_model.documents[idx]; match &doc.kind { DocumentKind::Text { content } => { egui::ScrollArea::both().show(ui, |ui| { ui.label(egui::RichText::new(content).monospace()); }); } DocumentKind::Compositor { .. } => { // We need to handle compositor separately due to borrow checker if let Some(Document { kind: DocumentKind::Compositor { info }, .. }) = app.data_model.documents.get_mut(idx) { composite_view::ui(ui, info); } } DocumentKind::Texture { content, handle } => { if let Some(handle) = handle { textures::texture_viewer_ui(ui, &content, &handle); } } } } } // TODO: It would be better to use confy::load/store instead of loading // files manually but for some reason serialization of the ui tiles panics // in confy. fn config_path() -> std::path::PathBuf { confy::get_configuration_file_path("wr_debugger", None).unwrap() } #[derive(serde::Serialize, serde::Deserialize)] struct GuiSavedState { cmd_history: Vec, ui_tiles: egui_tiles::Tree, }