From 1d32670cf3b37b75e232d5a945843b2261e32726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20R=C3=B6ssler?= Date: Thu, 31 Mar 2022 21:13:25 +0200 Subject: [PATCH] Dynamic sized strips, tables, and date picker (#963) --- Cargo.lock | 52 ++- egui/src/style.rs | 4 +- egui_demo_lib/Cargo.toml | 12 +- .../src/apps/demo/demo_app_windows.rs | 2 + egui_demo_lib/src/apps/demo/mod.rs | 2 + egui_demo_lib/src/apps/demo/strip_demo.rs | 110 ++++++ egui_demo_lib/src/apps/demo/table_demo.rs | 109 ++++++ egui_demo_lib/src/apps/demo/widget_gallery.rs | 16 + .../demo/widget_gallery/serde_date_format.rs | 23 ++ egui_demo_lib/src/lib.rs | 4 +- egui_extras/Cargo.toml | 17 +- egui_extras/src/datepicker.rs | 34 ++ egui_extras/src/datepicker/button.rs | 132 +++++++ egui_extras/src/datepicker/popup.rs | 363 ++++++++++++++++++ egui_extras/src/layout.rs | 162 ++++++++ egui_extras/src/lib.rs | 14 + egui_extras/src/sizing.rs | 120 ++++++ egui_extras/src/strip.rs | 183 +++++++++ egui_extras/src/table.rs | 335 ++++++++++++++++ 19 files changed, 1658 insertions(+), 36 deletions(-) create mode 100644 egui_demo_lib/src/apps/demo/strip_demo.rs create mode 100644 egui_demo_lib/src/apps/demo/table_demo.rs create mode 100644 egui_demo_lib/src/apps/demo/widget_gallery/serde_date_format.rs create mode 100644 egui_extras/src/datepicker.rs create mode 100644 egui_extras/src/datepicker/button.rs create mode 100644 egui_extras/src/datepicker/popup.rs create mode 100644 egui_extras/src/layout.rs create mode 100644 egui_extras/src/sizing.rs create mode 100644 egui_extras/src/strip.rs create mode 100644 egui_extras/src/table.rs diff --git a/Cargo.lock b/Cargo.lock index 2d4bb847..6112e122 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "ab_glyph" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61caed9aec6daeee1ea38ccf5fb225e4f96c1eeead1b4a5c267324a63cf02326" +checksum = "d54a65e0d4f66f8536c98cb3ca81ca33b7e2ca43442465507a3a62291ec0d9e4" dependencies = [ "ab_glyph_rasterizer", "owned_ttf_parser", @@ -423,9 +423,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "295b6eb918a60a25fec0b23a5e633e74fddbaf7bb04411e65a10c366aca4b5cd" +checksum = "5e068cb2806bbc15b439846dc16c5f89f8599f2c3e4d73d4449d38f9b2f0b6c5" dependencies = [ "smallvec", ] @@ -1080,9 +1080,11 @@ dependencies = [ name = "egui_extras" version = "0.17.0" dependencies = [ + "chrono", "egui", "image", "resvg", + "serde", "tiny-skia", "usvg", ] @@ -1164,9 +1166,9 @@ dependencies = [ [[package]] name = "enum-map" -version = "2.0.2" +version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7848397e7221a27d81cb7f07498d563f09b23fcd52ce9f74a6a110ed28f7cd4f" +checksum = "82605a2a3d13a9661b07ba27f39d00496aa347c9c236b1a3b8201c1b6d761408" dependencies = [ "enum-map-derive", "serde", @@ -1305,14 +1307,14 @@ dependencies = [ [[package]] name = "fontdb" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db856ee8cca3b9f23dd11c13bf3d4854b663ae86ed0c4a627a354431fc265f66" +checksum = "122fa73a5566372f9df09768a16e8e3dad7ad18abe07835f1f0b71f84078ba4c" dependencies = [ "fontconfig-parser", "log", "memmap2 0.5.3", - "ttf-parser 0.15.0", + "ttf-parser", ] [[package]] @@ -1425,9 +1427,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418d37c8b1d42553c93648be529cb70f920d3baf8ef469b74b9638df426e0b4c" +checksum = "d39cd93900197114fa1fcb7ae84ca742095eed9442088988ae74fa744e930e77" dependencies = [ "cfg-if 1.0.0", "libc", @@ -2297,11 +2299,11 @@ dependencies = [ [[package]] name = "owned_ttf_parser" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ef05f2882a8b3e7acc10c153ade2631f7bfc8ce00d2bf3fb8f4e9d2ae6ea5c3" +checksum = "4fb1e509cfe7a12db2a90bfa057dfcdbc55a347f5da677c506b53dd099cfec9d" dependencies = [ - "ttf-parser 0.14.0", + "ttf-parser", ] [[package]] @@ -2450,9 +2452,9 @@ dependencies = [ [[package]] name = "png" -version = "0.17.3" +version = "0.17.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e8f1882177b17c98ec33a51f5910ecbf4db92ca0def706781a1f8d0c661f393" +checksum = "02cd7d51cea7e2fa6bbcb8af5fbcad15b871451bfc2d20ed72dff2f4ae072a84" dependencies = [ "bitflags", "crc32fast", @@ -2490,9 +2492,9 @@ checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" [[package]] name = "proc-macro-crate" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dada8c9981fcf32929c3c0f0cd796a9284aca335565227ed88c83babb1d43dc" +checksum = "e17d47ce914bf4de440332250b0edd23ce48c005f59fab39d3335866b114f11a" dependencies = [ "thiserror", "toml", @@ -2775,7 +2777,7 @@ dependencies = [ "bitflags", "bytemuck", "smallvec", - "ttf-parser 0.15.0", + "ttf-parser", "unicode-bidi-mirroring", "unicode-ccc", "unicode-general-category", @@ -2836,9 +2838,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0486718e92ec9a68fbed73bb5ef687d71103b142595b406835649bebd33f72c7" +checksum = "a4a3381e03edd24287172047536f20cabde766e2cd3e65e6b00fb3af51c4f38d" [[package]] name = "serde" @@ -3337,12 +3339,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "ttf-parser" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ccbe8381883510b6a2d8f1e32905bddd178c11caef8083086d0c0c9ab0ac281" - [[package]] name = "ttf-parser" version = "0.15.0" @@ -3488,7 +3484,7 @@ dependencies = [ "simplecss", "siphasher", "svgtypes", - "ttf-parser 0.15.0", + "ttf-parser", "unicode-bidi", "unicode-script", "unicode-vo", diff --git a/egui/src/style.rs b/egui/src/style.rs index d7ffbabb..20340b43 100644 --- a/egui/src/style.rs +++ b/egui/src/style.rs @@ -636,7 +636,7 @@ impl Visuals { widgets: Widgets::default(), selection: Selection::default(), hyperlink_color: Color32::from_rgb(90, 170, 255), - faint_bg_color: Color32::from_gray(24), + faint_bg_color: Color32::from_gray(35), extreme_bg_color: Color32::from_gray(10), // e.g. TextEdit background code_bg_color: Color32::from_gray(64), window_rounding: Rounding::same(6.0), @@ -658,7 +658,7 @@ impl Visuals { widgets: Widgets::light(), selection: Selection::light(), hyperlink_color: Color32::from_rgb(0, 155, 255), - faint_bg_color: Color32::from_gray(245), + faint_bg_color: Color32::from_gray(242), extreme_bg_color: Color32::from_gray(255), // e.g. TextEdit background code_bg_color: Color32::from_gray(230), window_shadow: Shadow::big_light(), diff --git a/egui_demo_lib/Cargo.toml b/egui_demo_lib/Cargo.toml index a41f3059..272e56db 100644 --- a/egui_demo_lib/Cargo.toml +++ b/egui_demo_lib/Cargo.toml @@ -20,15 +20,21 @@ all-features = true [features] -default = ["chrono"] +default = ["datetime"] # Enable additional checks if debug assertions are enabled (debug builds). extra_debug_asserts = ["egui/extra_debug_asserts"] # Always enable additional checks. extra_asserts = ["egui/extra_asserts"] +datetime = ["egui_extras/chrono", "chrono"] http = ["egui_extras", "ehttp", "image", "poll-promise"] -persistence = ["egui/persistence", "epi/persistence", "serde"] +persistence = [ + "egui/persistence", + "epi/persistence", + "egui_extras/persistence", + "serde", +] serialize = ["egui/serialize", "serde"] syntax_highlighting = ["syntect"] @@ -45,6 +51,7 @@ unicode_names2 = { version = "0.5.0", default-features = false } # feature "http": egui_extras = { version = "0.17.0", path = "../egui_extras", optional = true, features = [ "image", + "datepicker", ] } ehttp = { version = "0.2.0", optional = true } image = { version = "0.24", optional = true, default-features = false, features = [ @@ -64,7 +71,6 @@ serde = { version = "1", optional = true, features = ["derive"] } [dev-dependencies] criterion = { version = "0.3", default-features = false } - [[bench]] name = "benchmark" harness = false diff --git a/egui_demo_lib/src/apps/demo/demo_app_windows.rs b/egui_demo_lib/src/apps/demo/demo_app_windows.rs index f6eb1600..a501f22b 100644 --- a/egui_demo_lib/src/apps/demo/demo_app_windows.rs +++ b/egui_demo_lib/src/apps/demo/demo_app_windows.rs @@ -29,6 +29,8 @@ impl Default for Demos { Box::new(super::plot_demo::PlotDemo::default()), Box::new(super::scrolling::Scrolling::default()), Box::new(super::sliders::Sliders::default()), + Box::new(super::strip_demo::StripDemo::default()), + Box::new(super::table_demo::TableDemo::default()), Box::new(super::text_edit::TextEdit::default()), Box::new(super::widget_gallery::WidgetGallery::default()), Box::new(super::window_options::WindowOptions::default()), diff --git a/egui_demo_lib/src/apps/demo/mod.rs b/egui_demo_lib/src/apps/demo/mod.rs index 61118f7d..76ee6e12 100644 --- a/egui_demo_lib/src/apps/demo/mod.rs +++ b/egui_demo_lib/src/apps/demo/mod.rs @@ -21,6 +21,8 @@ pub mod password; pub mod plot_demo; pub mod scrolling; pub mod sliders; +pub mod strip_demo; +pub mod table_demo; pub mod tests; pub mod text_edit; pub mod toggle_switch; diff --git a/egui_demo_lib/src/apps/demo/strip_demo.rs b/egui_demo_lib/src/apps/demo/strip_demo.rs new file mode 100644 index 00000000..5bdd0731 --- /dev/null +++ b/egui_demo_lib/src/apps/demo/strip_demo.rs @@ -0,0 +1,110 @@ +use egui::Color32; +use egui_extras::{Size, StripBuilder}; + +/// Shows off a table with dynamic layout +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[derive(Default)] +pub struct StripDemo {} + +impl super::Demo for StripDemo { + fn name(&self) -> &'static str { + "▣ Strip Demo" + } + + fn show(&mut self, ctx: &egui::Context, open: &mut bool) { + egui::Window::new(self.name()) + .open(open) + .resizable(true) + .default_width(400.0) + .show(ctx, |ui| { + use super::View as _; + self.ui(ui); + }); + } +} + +impl super::View for StripDemo { + fn ui(&mut self, ui: &mut egui::Ui) { + StripBuilder::new(ui) + .size(Size::Absolute(50.0)) + .size(Size::Remainder) + .size(Size::RelativeMinimum { + relative: 0.5, + minimum: 60.0, + }) + .size(Size::Absolute(10.0)) + .vertical(|mut strip| { + strip.cell_clip(|ui| { + ui.painter() + .rect_filled(ui.available_rect_before_wrap(), 0.0, Color32::BLUE); + ui.label("Full width and 50px height"); + }); + strip.strip(|builder| { + builder.sizes(Size::Remainder, 2).horizontal(|mut strip| { + strip.cell_clip(|ui| { + ui.painter().rect_filled( + ui.available_rect_before_wrap(), + 0.0, + Color32::RED, + ); + ui.label("remaining height and 50% of the width"); + }); + strip.strip(|builder| { + builder.sizes(Size::Remainder, 3).vertical(|mut strip| { + strip.empty(); + strip.cell_clip(|ui| { + ui.painter().rect_filled( + ui.available_rect_before_wrap(), + 0.0, + Color32::YELLOW, + ); + ui.label("one third of the box left of me but same width"); + }); + }); + }); + }); + }); + strip.strip(|builder| { + builder + .size(Size::Remainder) + .size(Size::Absolute(60.0)) + .size(Size::Remainder) + .size(Size::Absolute(70.0)) + .horizontal(|mut strip| { + strip.empty(); + strip.strip(|builder| { + builder + .size(Size::Remainder) + .size(Size::Absolute(60.0)) + .size(Size::Remainder) + .vertical(|mut strip| { + strip.empty(); + strip.cell_clip(|ui| { + ui.painter().rect_filled( + ui.available_rect_before_wrap(), + 0.0, + Color32::GOLD, + ); + ui.label("60x60"); + }); + }); + }); + strip.empty(); + strip.cell_clip(|ui| { + ui.painter().rect_filled( + ui.available_rect_before_wrap(), + 0.0, + Color32::GREEN, + ); + ui.label("height: half the available - at least 60px, width: 70px"); + }); + }); + }); + strip.cell_clip(|ui| { + ui.vertical_centered(|ui| { + ui.add(crate::__egui_github_link_file!()); + }); + }); + }); + } +} diff --git a/egui_demo_lib/src/apps/demo/table_demo.rs b/egui_demo_lib/src/apps/demo/table_demo.rs new file mode 100644 index 00000000..4374e51a --- /dev/null +++ b/egui_demo_lib/src/apps/demo/table_demo.rs @@ -0,0 +1,109 @@ +use egui_extras::{Size, StripBuilder, TableBuilder}; + +/// Shows off a table with dynamic layout +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[derive(Default)] +pub struct TableDemo { + virtual_scroll: bool, +} + +impl super::Demo for TableDemo { + fn name(&self) -> &'static str { + "☰ Table Demo" + } + + fn show(&mut self, ctx: &egui::Context, open: &mut bool) { + egui::Window::new(self.name()) + .open(open) + .resizable(true) + .default_width(400.0) + .show(ctx, |ui| { + use super::View as _; + self.ui(ui); + }); + } +} + +impl super::View for TableDemo { + fn ui(&mut self, ui: &mut egui::Ui) { + ui.checkbox(&mut self.virtual_scroll, "Virtual scroll demo"); + + // Leave room for the source code link after the table demo: + StripBuilder::new(ui) + .size(Size::Remainder) // for the table + .size(Size::Absolute(10.0)) // for the source code link + .vertical(|mut strip| { + strip.cell_clip(|ui| { + self.table_ui(ui); + }); + strip.cell(|ui| { + ui.vertical_centered(|ui| { + ui.add(crate::__egui_github_link_file!()); + }); + }); + }); + } +} + +impl TableDemo { + fn table_ui(&mut self, ui: &mut egui::Ui) { + TableBuilder::new(ui) + .striped(true) + .column(Size::Absolute(120.0)) + .column(Size::RemainderMinimum(180.0)) + .column(Size::Absolute(100.0)) + .header(20.0, |mut header| { + header.col(|ui| { + ui.heading("Left"); + }); + header.col(|ui| { + ui.heading("Middle"); + }); + header.col(|ui| { + ui.heading("Right"); + }); + }) + .body(|mut body| { + if self.virtual_scroll { + body.rows(20.0, 100_000, |index, mut row| { + row.col(|ui| { + ui.label(index.to_string()); + }); + row.col_clip(|ui| { + ui.add( + egui::Label::new("virtual scroll, easily with thousands of rows!") + .wrap(false), + ); + }); + row.col(|ui| { + ui.label(index.to_string()); + }); + }); + } else { + for i in 0..100 { + let height = match i % 8 { + 0 => 25.0, + 4 => 30.0, + _ => 20.0, + }; + body.row(height, |mut row| { + row.col(|ui| { + ui.label(i.to_string()); + }); + row.col_clip(|ui| { + ui.add( + egui::Label::new( + format!("Normal scroll, each row can have a different height. Height: {}", height), + ) + .wrap(false), + ); + }); + row.col(|ui| { + ui.label(i.to_string()); + }); + }); + } + } + }); + } +} diff --git a/egui_demo_lib/src/apps/demo/widget_gallery.rs b/egui_demo_lib/src/apps/demo/widget_gallery.rs index 48628c2f..5c79c46e 100644 --- a/egui_demo_lib/src/apps/demo/widget_gallery.rs +++ b/egui_demo_lib/src/apps/demo/widget_gallery.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "datetime")] +mod serde_date_format; + #[derive(Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] enum Enum { @@ -17,6 +20,9 @@ pub struct WidgetGallery { string: String, color: egui::Color32, animate_progress_bar: bool, + #[cfg(feature = "datetime")] + #[serde(with = "serde_date_format")] + date: chrono::Date, #[cfg_attr(feature = "serde", serde(skip))] texture: Option, } @@ -32,6 +38,8 @@ impl Default for WidgetGallery { string: Default::default(), color: egui::Color32::LIGHT_BLUE.linear_multiply(0.5), animate_progress_bar: false, + #[cfg(feature = "datetime")] + date: chrono::offset::Utc::now().date(), texture: None, } } @@ -102,6 +110,7 @@ impl WidgetGallery { string, color, animate_progress_bar, + date, texture, } = self; @@ -201,6 +210,13 @@ impl WidgetGallery { } ui.end_row(); + #[cfg(feature = "datetime")] + { + ui.add(doc_link_label("DatePickerButton", "DatePickerButton")); + ui.add(egui_extras::DatePickerButton::new(date)); + ui.end_row(); + } + ui.add(doc_link_label("Separator", "separator")); ui.separator(); ui.end_row(); diff --git a/egui_demo_lib/src/apps/demo/widget_gallery/serde_date_format.rs b/egui_demo_lib/src/apps/demo/widget_gallery/serde_date_format.rs new file mode 100644 index 00000000..4a70ec7c --- /dev/null +++ b/egui_demo_lib/src/apps/demo/widget_gallery/serde_date_format.rs @@ -0,0 +1,23 @@ +use chrono::{Date, NaiveDate, Utc}; +use serde::{self, Deserialize, Deserializer, Serializer}; + +const FORMAT: &str = "%Y-%m-%d"; + +pub fn serialize(date: &Date, serializer: S) -> Result +where + S: Serializer, +{ + let s = date.format(FORMAT).to_string(); + serializer.serialize_str(&s) +} + +pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + + NaiveDate::parse_from_str(&s, FORMAT) + .map(|naive_date| Date::from_utc(naive_date, Utc)) + .map_err(serde::de::Error::custom) +} diff --git a/egui_demo_lib/src/lib.rs b/egui_demo_lib/src/lib.rs index 87e9b2d9..fcb16c2f 100644 --- a/egui_demo_lib/src/lib.rs +++ b/egui_demo_lib/src/lib.rs @@ -100,7 +100,7 @@ fn test_egui_zero_window_size() { /// Time of day as seconds since midnight. Used for clock in demo app. pub(crate) fn seconds_since_midnight() -> Option { - #[cfg(feature = "chrono")] + #[cfg(feature = "datetime")] { use chrono::Timelike; let time = chrono::Local::now().time(); @@ -108,6 +108,6 @@ pub(crate) fn seconds_since_midnight() -> Option { time.num_seconds_from_midnight() as f64 + 1e-9 * (time.nanosecond() as f64); Some(seconds_since_midnight) } - #[cfg(not(feature = "chrono"))] + #[cfg(not(feature = "datetime"))] None } diff --git a/egui_extras/Cargo.toml b/egui_extras/Cargo.toml index 7988b340..172dbbdd 100644 --- a/egui_extras/Cargo.toml +++ b/egui_extras/Cargo.toml @@ -1,7 +1,11 @@ [package] name = "egui_extras" version = "0.17.0" -authors = ["Emil Ernerfeldt "] +authors = [ + "Dominik Rössler ", + "Emil Ernerfeldt ", + "René Rössler ", +] description = "Extra functionality and widgets for the egui GUI library" edition = "2021" rust-version = "1.56" @@ -25,12 +29,20 @@ default = [] # Support loading svg images svg = ["resvg", "tiny-skia", "usvg"] +# Datepicker widget +datepicker = ["chrono"] + +# Persistence +persistence = ["serde"] [dependencies] egui = { version = "0.17.0", path = "../egui", default-features = false } # Optional dependencies: +# Date operations needed for datepicker widget +chrono = { version = "0.4", optional = true } + # Add support for loading images with the `image` crate. # You also need to ALSO opt-in to the image formats you want to support, like so: # image = { version = "0.24", features = ["jpeg", "png"] } @@ -40,3 +52,6 @@ image = { version = "0.24", optional = true, default-features = false } resvg = { version = "0.22", optional = true } tiny-skia = { version = "0.6", optional = true } usvg = { version = "0.22", optional = true } + +# feature "persistence": +serde = { version = "1", features = ["derive"], optional = true } diff --git a/egui_extras/src/datepicker.rs b/egui_extras/src/datepicker.rs new file mode 100644 index 00000000..b163a3a7 --- /dev/null +++ b/egui_extras/src/datepicker.rs @@ -0,0 +1,34 @@ +mod button; +mod popup; + +pub use button::DatePickerButton; +use chrono::{Date, Datelike, Duration, NaiveDate, Utc, Weekday}; + +#[derive(Debug)] +struct Week { + number: u8, + days: Vec>, +} + +fn month_data(year: i32, month: u32) -> Vec { + let first = Date::from_utc(NaiveDate::from_ymd(year, month, 1), Utc); + let mut start = first; + while start.weekday() != Weekday::Mon { + start = start.checked_sub_signed(Duration::days(1)).unwrap(); + } + let mut weeks = vec![]; + let mut week = vec![]; + while start < first || start.month() == first.month() || start.weekday() != Weekday::Mon { + week.push(start); + + if start.weekday() == Weekday::Sun { + weeks.push(Week { + number: start.iso_week().week() as u8, + days: week.drain(..).collect(), + }); + } + start = start.checked_add_signed(Duration::days(1)).unwrap(); + } + + weeks +} diff --git a/egui_extras/src/datepicker/button.rs b/egui_extras/src/datepicker/button.rs new file mode 100644 index 00000000..a017a850 --- /dev/null +++ b/egui_extras/src/datepicker/button.rs @@ -0,0 +1,132 @@ +use super::popup::DatePickerPopup; +use chrono::{Date, Utc}; +use egui::{Area, Button, Frame, Key, Order, RichText, Ui, Widget}; + +#[derive(Default, Clone)] +#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] +pub(crate) struct DatePickerButtonState { + pub picker_visible: bool, +} + +pub struct DatePickerButton<'a> { + selection: &'a mut Date, + id_source: Option<&'a str>, + combo_boxes: bool, + arrows: bool, + calendar: bool, + calendar_week: bool, +} + +impl<'a> DatePickerButton<'a> { + pub fn new(selection: &'a mut Date) -> Self { + Self { + selection, + id_source: None, + combo_boxes: true, + arrows: true, + calendar: true, + calendar_week: true, + } + } + + /// Add id source. + /// Must be set if multiple date picker buttons are in the same Ui. + pub fn id_source(mut self, id_source: &'a str) -> Self { + self.id_source = Some(id_source); + self + } + + /// Show combo boxes in date picker popup. (Default: true) + pub fn combo_boxes(mut self, combo_boxes: bool) -> Self { + self.combo_boxes = combo_boxes; + self + } + + /// Show arrows in date picker popup. (Default: true) + pub fn arrows(mut self, arrows: bool) -> Self { + self.arrows = arrows; + self + } + + /// Show calendar in date picker popup. (Default: true) + pub fn calendar(mut self, calendar: bool) -> Self { + self.calendar = calendar; + self + } + + /// Show calendar week in date picker popup. (Default: true) + pub fn calendar_week(mut self, week: bool) -> Self { + self.calendar_week = week; + self + } +} + +impl<'a> Widget for DatePickerButton<'a> { + fn ui(self, ui: &mut Ui) -> egui::Response { + let id = ui.make_persistent_id(&self.id_source); + let mut button_state = ui + .memory() + .data + .get_persisted::(id) + .unwrap_or_default(); + + let mut text = RichText::new(format!("{} 📆", self.selection.format("%Y-%m-%d"))); + let visuals = ui.visuals().widgets.open; + if button_state.picker_visible { + text = text.color(visuals.text_color()); + } + let mut button = Button::new(text); + if button_state.picker_visible { + button = button.fill(visuals.bg_fill).stroke(visuals.bg_stroke); + } + let button_response = ui.add(button); + if button_response.clicked() { + button_state.picker_visible = true; + ui.memory().data.insert_persisted(id, button_state.clone()); + } + + if button_state.picker_visible { + let width = 333.0; + let mut pos = button_response.rect.left_bottom(); + let width_with_padding = width + + ui.style().spacing.item_spacing.x + + ui.style().spacing.window_margin.left + + ui.style().spacing.window_margin.right; + if pos.x + width_with_padding > ui.clip_rect().right() { + pos.x = button_response.rect.right() - width_with_padding; + } + //TODO: Better positioning + + let area_response = Area::new(ui.make_persistent_id(&self.id_source)) + .order(Order::Foreground) + .fixed_pos(pos) + .show(ui.ctx(), |ui| { + let frame = Frame::popup(ui.style()); + frame.show(ui, |ui| { + ui.set_min_width(width); + ui.set_max_width(width); + + DatePickerPopup { + selection: self.selection, + button_id: id, + combo_boxes: self.combo_boxes, + arrows: self.arrows, + calendar: self.calendar, + calendar_week: self.calendar_week, + } + .draw(ui); + }) + }) + .response; + + if !button_response.clicked() + && (ui.input().key_pressed(Key::Escape) || area_response.clicked_elsewhere()) + { + button_state.picker_visible = false; + ui.memory().data.insert_persisted(id, button_state); + } + } + + button_response + } +} diff --git a/egui_extras/src/datepicker/popup.rs b/egui_extras/src/datepicker/popup.rs new file mode 100644 index 00000000..74cb8b8e --- /dev/null +++ b/egui_extras/src/datepicker/popup.rs @@ -0,0 +1,363 @@ +use super::{button::DatePickerButtonState, month_data}; +use crate::{Size, StripBuilder, TableBuilder}; +use chrono::{Date, Datelike, NaiveDate, Utc, Weekday}; +use egui::{Align, Button, Color32, ComboBox, Direction, Id, Label, Layout, RichText, Ui, Vec2}; + +#[derive(Default, Clone)] +#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] +struct DatePickerPopupState { + year: i32, + month: u32, + day: u32, + setup: bool, +} + +impl DatePickerPopupState { + fn last_day_of_month(&self) -> u32 { + let date: Date = Date::from_utc(NaiveDate::from_ymd(self.year, self.month, 1), Utc); + date.with_day(31) + .map(|_| 31) + .or_else(|| date.with_day(30).map(|_| 30)) + .or_else(|| date.with_day(29).map(|_| 29)) + .unwrap_or(28) + } +} + +pub(crate) struct DatePickerPopup<'a> { + pub selection: &'a mut Date, + pub button_id: Id, + pub combo_boxes: bool, + pub arrows: bool, + pub calendar: bool, + pub calendar_week: bool, +} + +impl<'a> DatePickerPopup<'a> { + pub fn draw(&mut self, ui: &mut Ui) { + let id = ui.make_persistent_id("date_picker"); + let today = chrono::offset::Utc::now().date(); + let mut popup_state = ui + .memory() + .data + .get_persisted::(id) + .unwrap_or_default(); + if !popup_state.setup { + popup_state.year = self.selection.year(); + popup_state.month = self.selection.month(); + popup_state.day = self.selection.day(); + popup_state.setup = true; + ui.memory().data.insert_persisted(id, popup_state.clone()); + } + + let weeks = month_data(popup_state.year, popup_state.month); + let mut close = false; + let height = 20.0; + let spacing = 2.0; + ui.spacing_mut().item_spacing = Vec2::splat(spacing); + StripBuilder::new(ui) + .sizes( + Size::Absolute(height), + match (self.combo_boxes, self.arrows) { + (true, true) => 2, + (true, false) | (false, true) => 1, + (false, false) => 0, + }, + ) + .sizes( + Size::Absolute((spacing + height) * (weeks.len() + 1) as f32), + if self.calendar { 1 } else { 0 }, + ) + .size(Size::Absolute(height)) + .vertical(|mut strip| { + if self.combo_boxes { + strip.strip_clip(|builder| { + builder.sizes(Size::Remainder, 3).horizontal(|mut strip| { + strip.cell(|ui| { + ComboBox::from_id_source("date_picker_year") + .selected_text(popup_state.year.to_string()) + .show_ui(ui, |ui| { + for year in today.year() - 5..today.year() + 10 { + if ui + .selectable_value( + &mut popup_state.year, + year, + year.to_string(), + ) + .changed() + { + ui.memory() + .data + .insert_persisted(id, popup_state.clone()); + } + } + }); + }); + strip.cell(|ui| { + ComboBox::from_id_source("date_picker_month") + .selected_text(popup_state.month.to_string()) + .show_ui(ui, |ui| { + for month in 1..=12 { + if ui + .selectable_value( + &mut popup_state.month, + month, + month.to_string(), + ) + .changed() + { + ui.memory() + .data + .insert_persisted(id, popup_state.clone()); + } + } + }); + }); + strip.cell(|ui| { + ComboBox::from_id_source("date_picker_day") + .selected_text(popup_state.day.to_string()) + .show_ui(ui, |ui| { + for day in 1..=popup_state.last_day_of_month() { + if ui + .selectable_value( + &mut popup_state.day, + day, + day.to_string(), + ) + .changed() + { + ui.memory() + .data + .insert_persisted(id, popup_state.clone()); + } + } + }); + }); + }); + }); + } + + if self.arrows { + strip.strip(|builder| { + builder.sizes(Size::Remainder, 6).horizontal(|mut strip| { + strip.cell(|ui| { + ui.with_layout(Layout::top_down_justified(Align::Center), |ui| { + if ui + .button("<<<") + .on_hover_text("substract one year") + .clicked() + { + popup_state.year -= 1; + popup_state.day = + popup_state.day.min(popup_state.last_day_of_month()); + ui.memory().data.insert_persisted(id, popup_state.clone()); + } + }); + }); + strip.cell(|ui| { + ui.with_layout(Layout::top_down_justified(Align::Center), |ui| { + if ui + .button("<<") + .on_hover_text("substract one month") + .clicked() + { + popup_state.month -= 1; + if popup_state.month == 0 { + popup_state.month = 12; + popup_state.year -= 1; + } + popup_state.day = + popup_state.day.min(popup_state.last_day_of_month()); + ui.memory().data.insert_persisted(id, popup_state.clone()); + } + }); + }); + strip.cell(|ui| { + ui.with_layout(Layout::top_down_justified(Align::Center), |ui| { + if ui.button("<").on_hover_text("substract one day").clicked() { + popup_state.day -= 1; + if popup_state.day == 0 { + popup_state.month -= 1; + if popup_state.month == 0 { + popup_state.year -= 1; + popup_state.month = 12; + } + popup_state.day = popup_state.last_day_of_month(); + } + ui.memory().data.insert_persisted(id, popup_state.clone()); + } + }); + }); + strip.cell(|ui| { + ui.with_layout(Layout::top_down_justified(Align::Center), |ui| { + if ui.button(">").on_hover_text("add one day").clicked() { + popup_state.day += 1; + if popup_state.day > popup_state.last_day_of_month() { + popup_state.day = 1; + popup_state.month += 1; + if popup_state.month > 12 { + popup_state.month = 1; + popup_state.year += 1; + } + } + ui.memory().data.insert_persisted(id, popup_state.clone()); + } + }); + }); + strip.cell(|ui| { + ui.with_layout(Layout::top_down_justified(Align::Center), |ui| { + if ui.button(">>").on_hover_text("add one month").clicked() { + popup_state.month += 1; + if popup_state.month > 12 { + popup_state.month = 1; + popup_state.year += 1; + } + popup_state.day = + popup_state.day.min(popup_state.last_day_of_month()); + ui.memory().data.insert_persisted(id, popup_state.clone()); + } + }); + }); + strip.cell(|ui| { + ui.with_layout(Layout::top_down_justified(Align::Center), |ui| { + if ui.button(">>>").on_hover_text("add one year").clicked() { + popup_state.year += 1; + popup_state.day = + popup_state.day.min(popup_state.last_day_of_month()); + ui.memory().data.insert_persisted(id, popup_state.clone()); + } + }); + }); + }); + }); + } + + if self.calendar { + strip.cell(|ui| { + ui.spacing_mut().item_spacing = Vec2::new(1.0, 2.0); + TableBuilder::new(ui) + .scroll(false) + .columns(Size::Remainder, if self.calendar_week { 8 } else { 7 }) + .header(height, |mut header| { + if self.calendar_week { + header.col(|ui| { + ui.with_layout( + Layout::centered_and_justified(Direction::TopDown), + |ui| { + ui.add(Label::new("Week")); + }, + ); + }); + } + + //TODO: Locale + for name in ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] { + header.col(|ui| { + ui.with_layout( + Layout::centered_and_justified(Direction::TopDown), + |ui| { + ui.add(Label::new(name)); + }, + ); + }); + } + }) + .body(|mut body| { + for week in weeks { + body.row(height, |mut row| { + if self.calendar_week { + row.col(|ui| { + ui.add(Label::new(week.number.to_string())); + }); + } + for day in week.days { + row.col(|ui| { + ui.with_layout( + Layout::top_down_justified(Align::Center), + |ui| { + //TODO: Colors from egui style + let fill_color = if popup_state.year + == day.year() + && popup_state.month == day.month() + && popup_state.day == day.day() + { + ui.visuals().selection.bg_fill + } else if day.weekday() == Weekday::Sat + || day.weekday() == Weekday::Sun + { + Color32::DARK_RED + } else { + Color32::BLACK + }; + let text_color = if day == today { + Color32::RED + } else if day.month() == popup_state.month { + Color32::WHITE + } else { + Color32::from_gray(80) + }; + + let button = Button::new( + RichText::new(day.day().to_string()) + .color(text_color), + ) + .fill(fill_color); + + if ui.add(button).clicked() { + popup_state.year = day.year(); + popup_state.month = day.month(); + popup_state.day = day.day(); + ui.memory().data.insert_persisted( + id, + popup_state.clone(), + ); + } + }, + ); + }); + } + }); + } + }); + }); + } + + strip.strip(|builder| { + builder.sizes(Size::Remainder, 3).horizontal(|mut strip| { + strip.empty(); + strip.cell(|ui| { + ui.with_layout(Layout::top_down_justified(Align::Center), |ui| { + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + strip.cell(|ui| { + ui.with_layout(Layout::top_down_justified(Align::Center), |ui| { + if ui.button("Save").clicked() { + *self.selection = Date::from_utc( + NaiveDate::from_ymd( + popup_state.year, + popup_state.month, + popup_state.day, + ), + Utc, + ); + close = true; + } + }); + }); + }); + }); + }); + + if close { + popup_state.setup = false; + ui.memory().data.insert_persisted(id, popup_state); + + ui.memory() + .data + .get_persisted_mut_or_default::(self.button_id) + .picker_visible = false; + } + } +} diff --git a/egui_extras/src/layout.rs b/egui_extras/src/layout.rs new file mode 100644 index 00000000..47952aa7 --- /dev/null +++ b/egui_extras/src/layout.rs @@ -0,0 +1,162 @@ +use egui::{Pos2, Rect, Response, Sense, Ui}; + +#[derive(Clone, Copy)] +pub(crate) enum CellSize { + /// Absolute size in points + Absolute(f32), + /// Take all available space + Remainder, +} + +/// Cells are positioned in two dimensions, cells go in one direction and form lines. +/// +/// In a strip there's only one line which goes in the direction of the strip: +/// +/// In a horizontal strip, a `[StripLayout]` with horizontal `[CellDirection]` is used. +/// Its cells go from left to right inside this `[StripLayout]`. +/// +/// In a table there's a `[StripLayout]` for each table row with a horizontal `[CellDirection]`. +/// Its cells go from left to right. And the lines go from top to bottom. +pub(crate) enum CellDirection { + /// Cells go from left to right + Horizontal, + /// Cells go from top to bottom + Vertical, +} + +/// Positions cells in `[CellDirection]` and starts a new line on `[StripLayout::end_line]` +pub struct StripLayout<'l> { + ui: &'l mut Ui, + direction: CellDirection, + rect: Rect, + pos: Pos2, + max: Pos2, +} + +impl<'l> StripLayout<'l> { + pub(crate) fn new(ui: &'l mut Ui, direction: CellDirection) -> Self { + let rect = ui.available_rect_before_wrap(); + let pos = rect.left_top(); + + Self { + ui, + rect, + pos, + max: pos, + direction, + } + } + + pub fn current_y(&self) -> f32 { + self.rect.top() + } + + fn cell_rect(&self, width: &CellSize, height: &CellSize) -> Rect { + Rect { + min: self.pos, + max: Pos2 { + x: match width { + CellSize::Absolute(width) => self.pos.x + width, + CellSize::Remainder => self.rect.right() - self.ui.spacing().item_spacing.x, + }, + y: match height { + CellSize::Absolute(height) => self.pos.y + height, + CellSize::Remainder => self.rect.bottom() - self.ui.spacing().item_spacing.y, + }, + }, + } + } + + fn set_pos(&mut self, rect: Rect) { + match self.direction { + CellDirection::Horizontal => { + self.pos.x = rect.right() + self.ui.spacing().item_spacing.x; + } + CellDirection::Vertical => { + self.pos.y = rect.bottom() + self.ui.spacing().item_spacing.y; + } + } + + self.max.x = self + .max + .x + .max(rect.right() + self.ui.spacing().item_spacing.x); + self.max.y = self + .max + .y + .max(rect.bottom() + self.ui.spacing().item_spacing.y); + } + + pub(crate) fn empty(&mut self, width: CellSize, height: CellSize) { + self.set_pos(self.cell_rect(&width, &height)); + } + + pub(crate) fn add( + &mut self, + width: CellSize, + height: CellSize, + clip: bool, + add_contents: impl FnOnce(&mut Ui), + ) -> Response { + let rect = self.cell_rect(&width, &height); + self.cell(rect, clip, add_contents); + self.set_pos(rect); + + self.ui.allocate_rect(rect, Sense::click()) + } + + pub(crate) fn add_striped( + &mut self, + width: CellSize, + height: CellSize, + clip: bool, + add_contents: impl FnOnce(&mut Ui), + ) -> Response { + let mut rect = self.cell_rect(&width, &height); + // Make sure we don't have a gap in the stripe background + *rect.top_mut() -= self.ui.spacing().item_spacing.y; + *rect.left_mut() -= self.ui.spacing().item_spacing.x; + + self.ui + .painter() + .rect_filled(rect, 0.0, self.ui.visuals().faint_bg_color); + + self.add(width, height, clip, add_contents) + } + + /// only needed for layouts with multiple lines, like Table + pub fn end_line(&mut self) { + match self.direction { + CellDirection::Horizontal => { + self.pos.y = self.max.y; + self.pos.x = self.rect.left(); + } + CellDirection::Vertical => { + self.pos.x = self.max.x; + self.pos.y = self.rect.top(); + } + } + } + + fn cell(&mut self, rect: Rect, clip: bool, add_contents: impl FnOnce(&mut Ui)) { + let mut child_ui = self.ui.child_ui(rect, *self.ui.layout()); + + if clip { + let mut clip_rect = child_ui.clip_rect(); + clip_rect.min = clip_rect.min.max(rect.min); + clip_rect.max = clip_rect.max.min(rect.max); + child_ui.set_clip_rect(clip_rect); + } + + add_contents(&mut child_ui); + } + + /// Allocate the rect in [`Self::ui`] so that the scrollview knows about our size + pub fn allocate_rect(&mut self) -> Response { + let mut rect = self.rect; + rect.set_right(self.max.x); + rect.set_bottom(self.max.y); + + self.ui.allocate_rect(rect, Sense::hover()) + } +} diff --git a/egui_extras/src/lib.rs b/egui_extras/src/lib.rs index c7111442..014b75dd 100644 --- a/egui_extras/src/lib.rs +++ b/egui_extras/src/lib.rs @@ -3,6 +3,20 @@ #![allow(clippy::float_cmp)] #![allow(clippy::manual_range_contains)] +#[cfg(feature = "chrono")] +mod datepicker; + pub mod image; +mod layout; +mod sizing; +mod strip; +mod table; + +#[cfg(feature = "chrono")] +pub use crate::datepicker::DatePickerButton; pub use crate::image::RetainedImage; +pub(crate) use crate::layout::StripLayout; +pub use crate::sizing::Size; +pub use crate::strip::*; +pub use crate::table::*; diff --git a/egui_extras/src/sizing.rs b/egui_extras/src/sizing.rs new file mode 100644 index 00000000..cd057d9c --- /dev/null +++ b/egui_extras/src/sizing.rs @@ -0,0 +1,120 @@ +/// Size hint for table column/strip cell +#[derive(Clone, Debug, Copy)] +pub enum Size { + /// Absolute size in points + Absolute(f32), + /// Relative size relative to all available space. Values must be in range `0.0..=1.0` + Relative(f32), + /// [`Size::Relative`] with a minimum size in points + RelativeMinimum { + /// Relative size relative to all available space. Values must be in range `0.0..=1.0` + relative: f32, + /// Absolute minimum size in points + minimum: f32, + }, + /// Multiple remainders each get the same space + Remainder, + /// [`Size::Remainder`] with a minimum size in points + RemainderMinimum(f32), +} + +#[derive(Clone)] +pub struct Sizing { + sizes: Vec, +} + +impl Sizing { + pub fn new() -> Self { + Self { sizes: vec![] } + } + + pub fn add(&mut self, size: Size) { + self.sizes.push(size); + } + + pub fn into_lengths(self, length: f32, spacing: f32) -> Vec { + let mut remainders = 0; + let sum_non_remainder = self + .sizes + .iter() + .map(|size| match size { + Size::Absolute(absolute) => *absolute, + Size::Relative(relative) => { + assert!(*relative > 0.0, "Below 0.0 is not allowed."); + assert!(*relative <= 1.0, "Above 1.0 is not allowed."); + length * relative + } + Size::RelativeMinimum { relative, minimum } => { + assert!(*relative > 0.0, "Below 0.0 is not allowed."); + assert!(*relative <= 1.0, "Above 1.0 is not allowed."); + minimum.max(length * relative) + } + Size::Remainder | Size::RemainderMinimum(..) => { + remainders += 1; + 0.0 + } + }) + .sum::() + + spacing * (self.sizes.len() - 1) as f32; + + let avg_remainder_length = if remainders == 0 { + 0.0 + } else { + let mut remainder_length = length - sum_non_remainder; + let avg_remainder_length = 0.0f32.max(remainder_length / remainders as f32).floor(); + self.sizes.iter().for_each(|size| { + if let Size::RemainderMinimum(minimum) = size { + if *minimum > avg_remainder_length { + remainder_length -= minimum; + remainders -= 1; + } + } + }); + if remainders > 0 { + 0.0f32.max(remainder_length / remainders as f32) + } else { + 0.0 + } + }; + + self.sizes + .into_iter() + .map(|size| match size { + Size::Absolute(absolute) => absolute, + Size::Relative(relative) => length * relative, + Size::RelativeMinimum { relative, minimum } => minimum.max(length * relative), + Size::Remainder => avg_remainder_length, + Size::RemainderMinimum(minimum) => minimum.max(avg_remainder_length), + }) + .collect() + } +} + +impl From> for Sizing { + fn from(sizes: Vec) -> Self { + Self { sizes } + } +} + +#[test] +fn test_sizing() { + let sizing: Sizing = vec![Size::RemainderMinimum(20.0), Size::Remainder].into(); + assert_eq!(sizing.clone().into_lengths(50.0, 0.0), vec![25.0, 25.0]); + assert_eq!(sizing.clone().into_lengths(30.0, 0.0), vec![20.0, 10.0]); + assert_eq!(sizing.clone().into_lengths(20.0, 0.0), vec![20.0, 0.0]); + assert_eq!(sizing.clone().into_lengths(10.0, 0.0), vec![20.0, 0.0]); + assert_eq!(sizing.into_lengths(20.0, 10.0), vec![20.0, 0.0]); + + let sizing: Sizing = vec![ + Size::RelativeMinimum { + relative: 0.5, + minimum: 10.0, + }, + Size::Absolute(10.0), + ] + .into(); + assert_eq!(sizing.clone().into_lengths(50.0, 0.0), vec![25.0, 10.0]); + assert_eq!(sizing.clone().into_lengths(30.0, 0.0), vec![15.0, 10.0]); + assert_eq!(sizing.clone().into_lengths(20.0, 0.0), vec![10.0, 10.0]); + assert_eq!(sizing.into_lengths(10.0, 0.0), vec![10.0, 10.0]); +} diff --git a/egui_extras/src/strip.rs b/egui_extras/src/strip.rs new file mode 100644 index 00000000..131c55cb --- /dev/null +++ b/egui_extras/src/strip.rs @@ -0,0 +1,183 @@ +use crate::{ + layout::{CellDirection, CellSize, StripLayout}, + sizing::Sizing, + Size, +}; +use egui::{Response, Ui}; + +/// Builder for creating a new [`Strip`]. +/// +/// This can be used to do dynamic layouts. +/// +/// In contrast to normal egui behavior, strip cells do *not* grow with its children! +/// +/// After adding size hints with `[Self::column]`/`[Self::columns]` the strip can be build with `[Self::horizontal]`/`[Self::vertical]`. +/// +/// ### Example +/// ``` +/// # egui::__run_test_ui(|ui| { +/// use egui_extras::{StripBuilder, Size}; +/// StripBuilder::new(ui) +/// .size(Size::RemainderMinimum(100.0)) +/// .size(Size::Absolute(40.0)) +/// .vertical(|mut strip| { +/// strip.strip(|builder| { +/// builder.sizes(Size::Remainder, 2).horizontal(|mut strip| { +/// strip.cell(|ui| { +/// ui.label("Top Left"); +/// }); +/// strip.cell(|ui| { +/// ui.label("Top Right"); +/// }); +/// }); +/// }); +/// strip.cell(|ui| { +/// ui.label("Fixed"); +/// }); +/// }); +/// # }); +/// ``` +pub struct StripBuilder<'a> { + ui: &'a mut Ui, + sizing: Sizing, +} + +impl<'a> StripBuilder<'a> { + /// Create new strip builder. + pub fn new(ui: &'a mut Ui) -> Self { + let sizing = Sizing::new(); + + Self { ui, sizing } + } + + /// Add size hint for one column/row. + pub fn size(mut self, size: Size) -> Self { + self.sizing.add(size); + self + } + + /// Add size hint for several columns/rows at once. + pub fn sizes(mut self, size: Size, count: usize) -> Self { + for _ in 0..count { + self.sizing.add(size); + } + self + } + + /// Build horizontal strip: Cells are positions from left to right. + /// Takes the available horizontal width, so there can't be anything right of the strip or the container will grow slowly! + /// + /// Returns a `[egui::Response]` for hover events. + pub fn horizontal(self, strip: F) -> Response + where + F: for<'b> FnOnce(Strip<'a, 'b>), + { + let widths = self.sizing.into_lengths( + self.ui.available_rect_before_wrap().width() - self.ui.spacing().item_spacing.x, + self.ui.spacing().item_spacing.x, + ); + let mut layout = StripLayout::new(self.ui, CellDirection::Horizontal); + strip(Strip { + layout: &mut layout, + direction: CellDirection::Horizontal, + sizes: &widths, + }); + layout.allocate_rect() + } + + /// Build vertical strip: Cells are positions from top to bottom. + /// Takes the full available vertical height, so there can't be anything below of the strip or the container will grow slowly! + /// + /// Returns a `[egui::Response]` for hover events. + pub fn vertical(self, strip: F) -> Response + where + F: for<'b> FnOnce(Strip<'a, 'b>), + { + let heights = self.sizing.into_lengths( + self.ui.available_rect_before_wrap().height() - self.ui.spacing().item_spacing.y, + self.ui.spacing().item_spacing.y, + ); + let mut layout = StripLayout::new(self.ui, CellDirection::Vertical); + strip(Strip { + layout: &mut layout, + direction: CellDirection::Vertical, + sizes: &heights, + }); + layout.allocate_rect() + } +} + +/// A Strip of cells which go in one direction. Each cell has a fixed size. +/// In contrast to normal egui behavior, strip cells do *not* grow with its children! +pub struct Strip<'a, 'b> { + layout: &'b mut StripLayout<'a>, + direction: CellDirection, + sizes: &'b [f32], +} + +impl<'a, 'b> Strip<'a, 'b> { + fn next_cell_size(&mut self) -> (CellSize, CellSize) { + let size = self.sizes[0]; + self.sizes = &self.sizes[1..]; + + match self.direction { + CellDirection::Horizontal => (CellSize::Absolute(size), CellSize::Remainder), + CellDirection::Vertical => (CellSize::Remainder, CellSize::Absolute(size)), + } + } + + /// Add empty cell + pub fn empty(&mut self) { + assert!( + !self.sizes.is_empty(), + "Tried using more strip cells than available." + ); + + let (width, height) = self.next_cell_size(); + self.layout.empty(width, height); + } + + fn cell_impl(&mut self, clip: bool, add_contents: impl FnOnce(&mut Ui)) { + assert!( + !self.sizes.is_empty(), + "Tried using more strip cells than available." + ); + + let (width, height) = self.next_cell_size(); + self.layout.add(width, height, clip, add_contents); + } + + /// Add cell, content is wrapped + pub fn cell(&mut self, add_contents: impl FnOnce(&mut Ui)) { + self.cell_impl(false, add_contents); + } + + /// Add cell, content is clipped + pub fn cell_clip(&mut self, add_contents: impl FnOnce(&mut Ui)) { + self.cell_impl(true, add_contents); + } + + fn strip_impl(&mut self, clip: bool, strip_builder: impl FnOnce(StripBuilder<'_>)) { + self.cell_impl(clip, |ui| { + strip_builder(StripBuilder::new(ui)); + }); + } + + /// Add strip as cell + pub fn strip(&mut self, strip_builder: impl FnOnce(StripBuilder<'_>)) { + self.strip_impl(false, strip_builder); + } + + /// Add strip as cell, content is clipped + pub fn strip_clip(&mut self, strip_builder: impl FnOnce(StripBuilder<'_>)) { + self.strip_impl(true, strip_builder); + } +} + +impl<'a, 'b> Drop for Strip<'a, 'b> { + fn drop(&mut self) { + while !self.sizes.is_empty() { + self.empty(); + } + } +} diff --git a/egui_extras/src/table.rs b/egui_extras/src/table.rs new file mode 100644 index 00000000..b105a886 --- /dev/null +++ b/egui_extras/src/table.rs @@ -0,0 +1,335 @@ +//! Table view with (optional) fixed header and scrolling body. +//! Cell widths are precalculated with given size hints so we can have tables like this: +//! | fixed size | all available space/minimum | 30% of available width | fixed size | +//! Takes all available height, so if you want something below the table, put it in a strip. + +use crate::{ + layout::{CellDirection, CellSize}, + sizing::Sizing, + Size, StripLayout, +}; + +use egui::{Response, Ui}; +use std::cmp; + +/// Builder for a [`Table`] with (optional) fixed header and scrolling body. +/// +/// Cell widths are precalculated with given size hints so we can have tables like this: +/// +/// | fixed size | all available space/minimum | 30% of available width | fixed size | +/// +/// In contrast to normal egui behavior, columns/rows do *not* grow with its children! +/// Takes all available height, so if you want something below the table, put it in a strip. +/// +/// ### Example +/// ``` +/// # egui::__run_test_ui(|ui| { +/// use egui_extras::{TableBuilder, Size}; +/// TableBuilder::new(ui) +/// .column(Size::RemainderMinimum(100.0)) +/// .column(Size::Absolute(40.0)) +/// .header(20.0, |mut header| { +/// header.col(|ui| { +/// ui.heading("Growing"); +/// }); +/// header.col(|ui| { +/// ui.heading("Fixed"); +/// }); +/// }) +/// .body(|mut body| { +/// body.row(30.0, |mut row| { +/// row.col(|ui| { +/// ui.label("first row growing cell"); +/// }); +/// row.col_clip(|ui| { +/// ui.button("action"); +/// }); +/// }); +/// }); +/// # }); +/// ``` +pub struct TableBuilder<'a> { + ui: &'a mut Ui, + sizing: Sizing, + scroll: bool, + striped: bool, +} + +impl<'a> TableBuilder<'a> { + pub fn new(ui: &'a mut Ui) -> Self { + let sizing = Sizing::new(); + + Self { + ui, + sizing, + scroll: true, + striped: false, + } + } + + /// Enable scrollview in body (default: true) + pub fn scroll(mut self, scroll: bool) -> Self { + self.scroll = scroll; + self + } + + /// Enable striped row background (default: false) + pub fn striped(mut self, striped: bool) -> Self { + self.striped = striped; + self + } + + /// Add size hint for column + pub fn column(mut self, width: Size) -> Self { + self.sizing.add(width); + self + } + + /// Add size hint for several columns at once. + pub fn columns(mut self, size: Size, count: usize) -> Self { + for _ in 0..count { + self.sizing.add(size); + } + self + } + + fn available_width(&self) -> f32 { + self.ui.available_rect_before_wrap().width() + - 2.0 * self.ui.spacing().item_spacing.x + - if self.scroll { + self.ui.spacing().scroll_bar_width + } else { + 0.0 + } + } + + /// Create a header row which always stays visible and at the top + pub fn header(self, height: f32, header: impl FnOnce(TableRow<'_, '_>)) -> Table<'a> { + let available_width = self.available_width(); + let widths = self + .sizing + .into_lengths(available_width, self.ui.spacing().item_spacing.x); + let ui = self.ui; + { + let mut layout = StripLayout::new(ui, CellDirection::Horizontal); + header(TableRow { + layout: &mut layout, + widths: &widths, + striped: false, + height, + clicked: false, + }); + layout.allocate_rect(); + } + + Table { + ui, + widths, + scroll: self.scroll, + striped: self.striped, + } + } + + /// Create table body without a header row + pub fn body(self, body: F) + where + F: for<'b> FnOnce(TableBody<'b>), + { + let available_width = self.available_width(); + let widths = self + .sizing + .into_lengths(available_width, self.ui.spacing().item_spacing.x); + + Table { + ui: self.ui, + widths, + scroll: self.scroll, + striped: self.striped, + } + .body(body); + } +} + +/// Table struct which can construct a [`TableBody`]. +/// +/// Is created by [`TableBuilder`] by either calling [`TableBuilder::body`] or after creating a header row with [`TableBuilder::header`]. +pub struct Table<'a> { + ui: &'a mut Ui, + widths: Vec, + scroll: bool, + striped: bool, +} + +impl<'a> Table<'a> { + /// Create table body after adding a header row + pub fn body(self, body: F) + where + F: for<'b> FnOnce(TableBody<'b>), + { + let Table { + ui, + widths, + scroll, + striped, + } = self; + + let start_y = ui.available_rect_before_wrap().top(); + let end_y = ui.available_rect_before_wrap().bottom(); + + egui::ScrollArea::new([false, scroll]).show(ui, move |ui| { + let layout = StripLayout::new(ui, CellDirection::Horizontal); + + body(TableBody { + layout, + widths, + striped, + row_nr: 0, + start_y, + end_y, + }); + }); + } +} + +/// The body of a table. +/// Is created by calling `body` on a [`Table`] (after adding a header row) or [`TableBuilder`] (without a header row). +pub struct TableBody<'a> { + layout: StripLayout<'a>, + widths: Vec, + striped: bool, + row_nr: usize, + start_y: f32, + end_y: f32, +} + +impl<'a> TableBody<'a> { + /// Add rows with same height. + /// + /// Is a lot more performant than adding each individual row as non visible rows must not be rendered + pub fn rows(mut self, height: f32, rows: usize, mut row: impl FnMut(usize, TableRow<'_, '_>)) { + let delta = self.layout.current_y() - self.start_y; + let mut start = 0; + + if delta < 0.0 { + start = (-delta / height).floor() as usize; + + let skip_height = start as f32 * height; + TableRow { + layout: &mut self.layout, + widths: &self.widths, + striped: false, + height: skip_height, + clicked: false, + } + .col(|_| ()); // advances the cursor + } + + let max_height = self.end_y - self.start_y; + let count = (max_height / height).ceil() as usize; + let end = cmp::min(start + count, rows); + + for idx in start..end { + row( + idx, + TableRow { + layout: &mut self.layout, + widths: &self.widths, + striped: self.striped && idx % 2 == 0, + height, + clicked: false, + }, + ); + } + + if rows - end > 0 { + let skip_height = (rows - end) as f32 * height; + + TableRow { + layout: &mut self.layout, + widths: &self.widths, + striped: false, + height: skip_height, + clicked: false, + } + .col(|_| ()); // advances the cursor + } + } + + /// Add row with individual height + pub fn row(&mut self, height: f32, row: impl FnOnce(TableRow<'a, '_>)) { + row(TableRow { + layout: &mut self.layout, + widths: &self.widths, + striped: self.striped && self.row_nr % 2 == 0, + height, + clicked: false, + }); + + self.row_nr += 1; + } +} + +impl<'a> Drop for TableBody<'a> { + fn drop(&mut self) { + self.layout.allocate_rect(); + } +} + +/// The row of a table. +/// Is created by [`TableRow`] for each created [`TableBody::row`] or each visible row in rows created by calling [`TableBody::rows`]. +pub struct TableRow<'a, 'b> { + layout: &'b mut StripLayout<'a>, + widths: &'b [f32], + striped: bool, + height: f32, + clicked: bool, +} + +impl<'a, 'b> TableRow<'a, 'b> { + /// Check if row was clicked + pub fn clicked(&self) -> bool { + self.clicked + } + + /// Add column, content is wrapped + pub fn col(&mut self, add_contents: impl FnOnce(&mut Ui)) -> Response { + self.column(false, add_contents) + } + + /// Add column, content is clipped + pub fn col_clip(&mut self, add_contents: impl FnOnce(&mut Ui)) -> Response { + self.column(true, add_contents) + } + + fn column(&mut self, clip: bool, add_contents: impl FnOnce(&mut Ui)) -> Response { + assert!( + !self.widths.is_empty(), + "Tried using more table columns than available." + ); + + let width = self.widths[0]; + self.widths = &self.widths[1..]; + let width = CellSize::Absolute(width); + let height = CellSize::Absolute(self.height); + + let response; + + if self.striped { + response = self.layout.add_striped(width, height, clip, add_contents); + } else { + response = self.layout.add(width, height, clip, add_contents); + } + + if response.clicked() { + self.clicked = true; + } + + response + } +} + +impl<'a, 'b> Drop for TableRow<'a, 'b> { + fn drop(&mut self) { + self.layout.end_line(); + } +}