diff --git a/Cargo.lock b/Cargo.lock index 992b222..0d0fd5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -481,6 +481,7 @@ version = "0.1.0" dependencies = [ "eframe", "egui", + "egui_extras", "env_logger", "log", "serde", @@ -771,6 +772,19 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_extras" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ffe3fe5c00295f91c2a61a74ee271c32f74049c94ba0b1cea8f26eb478bc07" +dependencies = [ + "egui", + "enum-map", + "log", + "mime_guess", + "serde", +] + [[package]] name = "egui_glow" version = "0.23.0" @@ -796,6 +810,27 @@ dependencies = [ "serde", ] +[[package]] +name = "enum-map" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c188012f8542dee7b3996e44dd89461d64aa471b0a7c71a1ae2f595d259e96e5" +dependencies = [ + "enum-map-derive", + "serde", +] + +[[package]] +name = "enum-map-derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d0b288e3bb1d861c4403c1774a6f7a798781dfc519b3647df2a3dd4ae95f25" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.16", +] + [[package]] name = "enumflags2" version = "0.7.7" @@ -1357,6 +1392,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1714,9 +1765,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.58" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa1fb82fc0c281dd9671101b66b771ebbe1eaf967b96ac8740dcba4b70005ca8" +checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" dependencies = [ "unicode-ident", ] @@ -2150,6 +2201,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.13" diff --git a/Cargo.toml b/Cargo.toml index 051717d..2248888 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ rust-version = "1.71" [dependencies] egui = "0.23.0" +egui_extras = "0.23.0" eframe = { version = "0.23.0", default-features = false, features = [ "accesskit", # Make egui comptaible with screen readers. NOTE: adds a lot of dependencies. "default_fonts", # Embed the default egui fonts. diff --git a/src/apps/app_windows.rs b/src/apps/app_windows.rs new file mode 100644 index 0000000..4c33e63 --- /dev/null +++ b/src/apps/app_windows.rs @@ -0,0 +1,193 @@ +use std::collections::BTreeSet; + +use egui::{Context, Modifiers, ScrollArea, Ui}; + +use super::App; +use super::View; + +// ---------------------------------------------------------------------------- + +#[derive(serde::Deserialize, serde::Serialize)] +struct Apps { + #[serde(skip)] // This how you opt-out of serialization of a field + apps: Vec>, + + open: BTreeSet, +} + +impl Default for Apps { + fn default() -> Self { + Self::from_apps(vec![Box::::default()]) + } +} + +impl Apps { + pub fn from_apps(apps: Vec>) -> Self { + let mut open = BTreeSet::new(); + open.insert( + super::scoreboard_app::ScoreBoardApp::default() + .name() + .to_owned(), + ); + + Self { apps, open } + } + + pub fn checkboxes(&mut self, ui: &mut Ui) { + let Self { apps, open } = self; + for app in apps { + let mut is_open = open.contains(app.name()); + ui.toggle_value(&mut is_open, app.name()); + set_open(open, app.name(), is_open); + } + } + + pub fn windows(&mut self, ctx: &Context) { + let Self { apps, open } = self; + for app in apps { + let mut is_open = open.contains(app.name()); + app.show(ctx, &mut is_open); + set_open(open, app.name(), is_open); + } + } +} + +// ---------------------------------------------------------------------------- + +fn set_open(open: &mut BTreeSet, key: &'static str, is_open: bool) { + if is_open { + if !open.contains(key) { + open.insert(key.to_owned()); + } + } else { + open.remove(key); + } +} + +// ---------------------------------------------------------------------------- + +#[derive(serde::Deserialize, serde::Serialize)] +pub struct AppWindows { + about_is_open: bool, + apps: Apps, +} + +impl Default for AppWindows { + fn default() -> Self { + Self { + about_is_open: true, + apps: Default::default(), + } + } +} + +impl AppWindows { + /// Show the app ui (menu bar and windows). + pub fn ui(&mut self, ctx: &Context) { + self.desktop_ui(ctx); + } + + fn desktop_ui(&mut self, ctx: &Context) { + egui::SidePanel::right("egui_app_panel") + .resizable(false) + .default_width(150.0) + .show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.heading("🎯 apps"); + }); + + ui.separator(); + + self.app_list_ui(ui); + + ui.separator(); + + use egui::special_emojis::{GITHUB, TWITTER}; + ui.hyperlink_to( + format!("{GITHUB} GitHub"), + "https://github.com/bitbrain-za/judge_2331-rs", + ); + ui.separator(); + }); + + egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { + egui::menu::bar(ui, |ui| { + file_menu_button(ui); + }); + }); + + self.show_windows(ctx); + } + + /// Show the open windows. + fn show_windows(&mut self, ctx: &Context) { + self.apps.windows(ctx); + } + + fn app_list_ui(&mut self, ui: &mut egui::Ui) { + ScrollArea::vertical().show(ui, |ui| { + ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { + // ui.toggle_value(&mut self.about_is_open, self.about.name()); + + ui.separator(); + self.apps.checkboxes(ui); + ui.separator(); + }); + }); + } +} + +// ---------------------------------------------------------------------------- + +fn file_menu_button(ui: &mut Ui) { + let organize_shortcut = + egui::KeyboardShortcut::new(Modifiers::CTRL | Modifiers::SHIFT, egui::Key::O); + let reset_shortcut = + egui::KeyboardShortcut::new(Modifiers::CTRL | Modifiers::SHIFT, egui::Key::R); + + // NOTE: we must check the shortcuts OUTSIDE of the actual "File" menu, + // or else they would only be checked if the "File" menu was actually open! + + if ui.input_mut(|i| i.consume_shortcut(&organize_shortcut)) { + ui.ctx().memory_mut(|mem| mem.reset_areas()); + } + + if ui.input_mut(|i| i.consume_shortcut(&reset_shortcut)) { + ui.ctx().memory_mut(|mem| *mem = Default::default()); + } + + ui.menu_button("File", |ui| { + ui.set_min_width(220.0); + ui.style_mut().wrap = Some(false); + + // On the web the browser controls the zoom + #[cfg(not(target_arch = "wasm32"))] + { + egui::gui_zoom::zoom_menu_buttons(ui, None); + ui.separator(); + } + + if ui + .add( + egui::Button::new("Organize Windows") + .shortcut_text(ui.ctx().format_shortcut(&organize_shortcut)), + ) + .clicked() + { + ui.ctx().memory_mut(|mem| mem.reset_areas()); + ui.close_menu(); + } + + if ui + .add( + egui::Button::new("Reset egui memory") + .shortcut_text(ui.ctx().format_shortcut(&reset_shortcut)), + ) + .on_hover_text("Forget scroll, positions, sizes etc") + .clicked() + { + ui.ctx().memory_mut(|mem| *mem = Default::default()); + ui.close_menu(); + } + }); +} diff --git a/src/apps/mod.rs b/src/apps/mod.rs new file mode 100644 index 0000000..abf665c --- /dev/null +++ b/src/apps/mod.rs @@ -0,0 +1,17 @@ +pub mod app_windows; +mod scoreboard_app; +pub use scoreboard_app::ScoreBoardApp; + +/// Something to view in the demo windows +pub trait View { + fn ui(&mut self, ui: &mut egui::Ui); +} + +/// Something to view +pub trait App { + /// `&'static` so we can also use it as a key to store open/close state. + fn name(&self) -> &'static str; + + /// Show windows, etc + fn show(&mut self, ctx: &egui::Context, open: &mut bool); +} diff --git a/src/apps/scoreboard_app.rs b/src/apps/scoreboard_app.rs new file mode 100644 index 0000000..6e673d4 --- /dev/null +++ b/src/apps/scoreboard_app.rs @@ -0,0 +1,247 @@ +#[derive(PartialEq, serde::Deserialize, serde::Serialize)] +enum DemoType { + Manual, + ManyHomogeneous, + ManyHeterogenous, +} + +#[derive(serde::Deserialize, serde::Serialize)] +pub struct ScoreBoardApp { + demo: DemoType, + striped: bool, + resizable: bool, + num_rows: usize, + scroll_to_row_slider: usize, + scroll_to_row: Option, +} + +impl Default for ScoreBoardApp { + fn default() -> Self { + Self { + demo: DemoType::Manual, + striped: true, + resizable: true, + num_rows: 10_000, + scroll_to_row_slider: 0, + scroll_to_row: None, + } + } +} + +impl super::App for ScoreBoardApp { + fn name(&self) -> &'static str { + "☰ Table Demo" + } + + fn show(&mut self, ctx: &egui::Context, open: &mut bool) { + egui::Window::new(self.name()) + .open(open) + .default_width(400.0) + .show(ctx, |ui| { + use super::View as _; + self.ui(ui); + }); + } +} + +const NUM_MANUAL_ROWS: usize = 20; + +impl super::View for ScoreBoardApp { + fn ui(&mut self, ui: &mut egui::Ui) { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.checkbox(&mut self.striped, "Striped"); + ui.checkbox(&mut self.resizable, "Resizable columns"); + }); + + ui.label("Table type:"); + ui.radio_value(&mut self.demo, DemoType::Manual, "Few, manual rows"); + ui.radio_value( + &mut self.demo, + DemoType::ManyHomogeneous, + "Thousands of rows of same height", + ); + ui.radio_value( + &mut self.demo, + DemoType::ManyHeterogenous, + "Thousands of rows of differing heights", + ); + + if self.demo != DemoType::Manual { + ui.add( + egui::Slider::new(&mut self.num_rows, 0..=100_000) + .logarithmic(true) + .text("Num rows"), + ); + } + + { + let max_rows = if self.demo == DemoType::Manual { + NUM_MANUAL_ROWS + } else { + self.num_rows + }; + + let slider_response = ui.add( + egui::Slider::new(&mut self.scroll_to_row_slider, 0..=max_rows) + .logarithmic(true) + .text("Row to scroll to"), + ); + if slider_response.changed() { + self.scroll_to_row = Some(self.scroll_to_row_slider); + } + } + }); + + ui.separator(); + + // Leave room for the source code link after the table demo: + use egui_extras::{Size, StripBuilder}; + StripBuilder::new(ui) + .size(Size::remainder().at_least(100.0)) // for the table + .size(Size::exact(10.5)) // for the source code link + .vertical(|mut strip| { + strip.cell(|ui| { + egui::ScrollArea::horizontal().show(ui, |ui| { + self.table_ui(ui); + }); + }); + strip.cell(|ui| { + ui.vertical_centered(|ui| {}); + }); + }); + } +} + +impl ScoreBoardApp { + fn table_ui(&mut self, ui: &mut egui::Ui) { + use egui_extras::{Column, TableBuilder}; + + let text_height = egui::TextStyle::Body.resolve(ui.style()).size; + + let mut table = TableBuilder::new(ui) + .striped(self.striped) + .resizable(self.resizable) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto()) + .column(Column::initial(100.0).range(40.0..=300.0)) + .column(Column::initial(100.0).at_least(40.0).clip(true)) + .column(Column::remainder()) + .min_scrolled_height(0.0); + + if let Some(row_nr) = self.scroll_to_row.take() { + table = table.scroll_to_row(row_nr, None); + } + + table + .header(20.0, |mut header| { + header.col(|ui| { + ui.strong("Row"); + }); + header.col(|ui| { + ui.strong("Expanding content"); + }); + header.col(|ui| { + ui.strong("Clipped text"); + }); + header.col(|ui| { + ui.strong("Content"); + }); + }) + .body(|mut body| match self.demo { + DemoType::Manual => { + for row_index in 0..NUM_MANUAL_ROWS { + let is_thick = thick_row(row_index); + let row_height = if is_thick { 30.0 } else { 18.0 }; + body.row(row_height, |mut row| { + row.col(|ui| { + ui.label(row_index.to_string()); + }); + row.col(|ui| { + expanding_content(ui); + }); + row.col(|ui| { + ui.label(long_text(row_index)); + }); + row.col(|ui| { + ui.style_mut().wrap = Some(false); + if is_thick { + ui.heading("Extra thick row"); + } else { + ui.label("Normal row"); + } + }); + }); + } + } + DemoType::ManyHomogeneous => { + body.rows(text_height, self.num_rows, |row_index, mut row| { + row.col(|ui| { + ui.label(row_index.to_string()); + }); + row.col(|ui| { + expanding_content(ui); + }); + row.col(|ui| { + ui.label(long_text(row_index)); + }); + row.col(|ui| { + ui.add( + egui::Label::new("Thousands of rows of even height").wrap(false), + ); + }); + }); + } + DemoType::ManyHeterogenous => { + fn row_thickness(row_index: usize) -> f32 { + if thick_row(row_index) { + 30.0 + } else { + 18.0 + } + } + body.heterogeneous_rows( + (0..self.num_rows).map(row_thickness), + |row_index, mut row| { + row.col(|ui| { + ui.label(row_index.to_string()); + }); + row.col(|ui| { + expanding_content(ui); + }); + row.col(|ui| { + ui.label(long_text(row_index)); + }); + row.col(|ui| { + ui.style_mut().wrap = Some(false); + if thick_row(row_index) { + ui.heading("Extra thick row"); + } else { + ui.label("Normal row"); + } + }); + }, + ); + } + }); + } +} + +fn expanding_content(ui: &mut egui::Ui) { + let width = ui.available_width().clamp(20.0, 200.0); + let height = ui.available_height(); + let (rect, _response) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + ui.painter().hline( + rect.x_range(), + rect.center().y, + (1.0, ui.visuals().text_color()), + ); +} + +fn long_text(row_index: usize) -> String { + format!("Row {row_index} has some long text that you may want to clip, or it will take up too much horizontal space!") +} + +fn thick_row(row_index: usize) -> bool { + row_index % 6 == 0 +} diff --git a/src/lib.rs b/src/lib.rs index fbae77a..eb7ac08 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ #![warn(clippy::all, rust_2018_idioms)] -mod app; -pub use app::TemplateApp; +mod wrap_app; +pub use wrap_app::CodeChallengeApp; + +pub mod apps; diff --git a/src/main.rs b/src/main.rs index 6bb25aa..11dfa53 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ #![warn(clippy::all, rust_2018_idioms)] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release +mod apps; + // When compiling natively: #[cfg(not(target_arch = "wasm32"))] fn main() -> eframe::Result<()> { @@ -14,7 +16,7 @@ fn main() -> eframe::Result<()> { eframe::run_native( "eframe template", native_options, - Box::new(|cc| Box::new(challenge_frontend::TemplateApp::new(cc))), + Box::new(|cc| Box::new(challenge_frontend::CodeChallengeApp::new(cc))), ) } @@ -31,7 +33,7 @@ fn main() { .start( "the_canvas_id", // hardcode it web_options, - Box::new(|cc| Box::new(challenge_frontend::TemplateApp::new(cc))), + Box::new(|cc| Box::new(challenge_frontend::CodeChallengeApp::new(cc))), ) .await .expect("failed to start eframe"); diff --git a/src/app.rs b/src/wrap_app.rs similarity index 66% rename from src/app.rs rename to src/wrap_app.rs index 5c236ae..134df9f 100644 --- a/src/app.rs +++ b/src/wrap_app.rs @@ -1,41 +1,33 @@ +use crate::apps; /// We derive Deserialize/Serialize so we can persist app state on shutdown. #[derive(serde::Deserialize, serde::Serialize)] #[serde(default)] // if we add new fields, give them default values when deserializing old state -pub struct TemplateApp { - // Example stuff: - label: String, - +pub struct CodeChallengeApp { + windows: apps::app_windows::AppWindows, #[serde(skip)] // This how you opt-out of serialization of a field value: f32, } -impl Default for TemplateApp { +impl Default for CodeChallengeApp { fn default() -> Self { Self { + windows: apps::app_windows::AppWindows::default(), // Example stuff: - label: "Hello World!".to_owned(), value: 2.7, } } } -impl TemplateApp { - /// Called once before the first frame. +impl CodeChallengeApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { - // This is also where you can customize the look and feel of egui using - // `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`. - - // Load previous app state (if any). - // Note that you must enable the `persistence` feature for this to work. if let Some(storage) = cc.storage { return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default(); } - Default::default() } } -impl eframe::App for TemplateApp { +impl eframe::App for CodeChallengeApp { /// Called by the frame work to save state before shutdown. fn save(&mut self, storage: &mut dyn eframe::Storage) { eframe::set_value(storage, eframe::APP_KEY, self); @@ -47,8 +39,6 @@ impl eframe::App for TemplateApp { // For inspiration and more examples, go to https://emilk.github.io/egui egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { - // The top panel is often a good place for a menu bar: - egui::menu::bar(ui, |ui| { #[cfg(not(target_arch = "wasm32"))] // no File->Quit on web pages! { @@ -64,27 +54,9 @@ impl eframe::App for TemplateApp { }); }); - egui::CentralPanel::default().show(ctx, |ui| { - // The central panel the region left after adding TopPanel's and SidePanel's - ui.heading("eframe template"); - - ui.horizontal(|ui| { - ui.label("Write something: "); - ui.text_edit_singleline(&mut self.label); - }); - - ui.add(egui::Slider::new(&mut self.value, 0.0..=10.0).text("value")); - if ui.button("Increment").clicked() { - self.value += 1.0; - } - - ui.separator(); - - ui.add(egui::github_link_file!( - "https://github.com/emilk/eframe_template/blob/master/", - "Source code." - )); + self.windows.ui(ctx); + egui::CentralPanel::default().show(ctx, |ui| { ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| { powered_by_egui_and_eframe(ui); egui::warn_if_debug_build(ui);