diff --git a/Cargo.lock b/Cargo.lock index b3c897f9..70c7c15e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -890,6 +890,16 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_datepicker" +version = "0.15.0" +dependencies = [ + "chrono", + "egui", + "egui_dynamic_grid", + "serde", +] + [[package]] name = "egui_demo_app" version = "0.15.0" @@ -905,6 +915,7 @@ dependencies = [ "chrono", "criterion", "egui", + "egui_datepicker", "ehttp", "enum-map", "epi", @@ -914,6 +925,13 @@ dependencies = [ "unicode_names2", ] +[[package]] +name = "egui_dynamic_grid" +version = "0.15.0" +dependencies = [ + "egui", +] + [[package]] name = "egui_glium" version = "0.15.0" diff --git a/Cargo.toml b/Cargo.toml index 53b381e2..ee0b9ef3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,10 @@ [workspace] resolver = "2" members = [ + "egui_datepicker", "egui_demo_app", "egui_demo_lib", + "egui_dynamic_grid", "egui_glium", "egui_glow", "egui_web", diff --git a/egui_datepicker/Cargo.toml b/egui_datepicker/Cargo.toml new file mode 100644 index 00000000..a9692058 --- /dev/null +++ b/egui_datepicker/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "egui_datepicker" +version = "0.15.0" +edition = "2018" + +[dependencies] +chrono = "0.4" +egui = { version = "0.15.0", path = "../egui", default-features = false } +egui_dynamic_grid = { version = "0.15.0", path = "../egui_dynamic_grid" } +serde = { version = "1", features = ["derive"] } diff --git a/egui_datepicker/src/lib.rs b/egui_datepicker/src/lib.rs new file mode 100644 index 00000000..6ec5d897 --- /dev/null +++ b/egui_datepicker/src/lib.rs @@ -0,0 +1,569 @@ +use chrono::{Date, Datelike, Duration, NaiveDate, Utc, Weekday}; +use egui::{ + Align, Area, Button, Color32, ComboBox, Direction, Frame, Id, Key, Label, Layout, Order, + RichText, Ui, Widget, +}; +use egui_dynamic_grid::{GridBuilder, Padding, Size, TableBuilder}; +use serde::{Deserialize, Serialize}; + +#[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 +} + +#[derive(Default, Clone, Serialize, Deserialize)] +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) + } +} + +struct DatePickerPopup<'a> { + selection: &'a mut Date, + button_id: Id, + combo_boxes: bool, + arrows: bool, + calendar: bool, + calendar_week: bool, +} + +impl<'a> DatePickerPopup<'a> { + 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; + GridBuilder::new(ui, Padding::new(2.0, 0.0)).vertical(|builder| { + builder + .rows( + Size::Absolute(height), + match (self.combo_boxes, self.arrows) { + (true, true) => 2, + (true, false) | (false, true) => 1, + (false, false) => 0, + }, + ) + .rows( + Size::Absolute(2.0 + (height + 2.0) * weeks.len() as f32), + if self.calendar { 1 } else { 0 }, + ) + .row(Size::Absolute(height)) + .build(|mut grid| { + if self.combo_boxes { + grid.horizontal_noclip(|builder| { + builder.columns(Size::Remainder, 3).build(|mut grid| { + grid.cell_noclip(|ui| { + ComboBox::from_id_source("date_picker_year") + .selected_text(format!("{}", popup_state.year)) + .show_ui(ui, |ui| { + for year in today.year() - 5..today.year() + 10 { + if ui + .selectable_value( + &mut popup_state.year, + year, + format!("{}", year), + ) + .changed() + { + ui.memory() + .data + .insert_persisted(id, popup_state.clone()); + } + } + }); + }); + grid.cell_noclip(|ui| { + ComboBox::from_id_source("date_picker_month") + .selected_text(format!("{}", popup_state.month)) + .show_ui(ui, |ui| { + for month in 1..=12 { + if ui + .selectable_value( + &mut popup_state.month, + month, + format!("{}", month), + ) + .changed() + { + ui.memory() + .data + .insert_persisted(id, popup_state.clone()); + } + } + }); + }); + grid.cell_noclip(|ui| { + ComboBox::from_id_source("date_picker_day") + .selected_text(format!("{}", popup_state.day)) + .show_ui(ui, |ui| { + for day in 1..=popup_state.last_day_of_month() { + if ui + .selectable_value( + &mut popup_state.day, + day, + format!("{}", day), + ) + .changed() + { + ui.memory() + .data + .insert_persisted(id, popup_state.clone()); + } + } + }); + }); + }) + }); + } + + if self.arrows { + grid.horizontal(|builder| { + builder.columns(Size::Remainder, 6).build(|mut grid| { + grid.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()); + } + }, + ); + }); + grid.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()); + } + }, + ); + }); + grid.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()); + } + }, + ); + }); + grid.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()); + } + }, + ); + }); + grid.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()); + } + }, + ); + }); + grid.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 { + grid.cell(|ui| { + TableBuilder::new(ui, Padding::new(2.0, 0.0)) + .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")); + }, + ); + }); + } + for name in ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] { + 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(format!("{}", week.number))); + }); + } + for day in week.days { + row.col(|ui| { + ui.with_layout( + Layout::top_down_justified(Align::Center), + |ui| { + let fill_color = if popup_state.year + == day.year() + && popup_state.month == day.month() + && popup_state.day == day.day() + { + Color32::DARK_BLUE + } 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(format!( + "{}", + day.day() + )) + .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(), + ); + } + }, + ); + }); + } + }); + } + }); + }); + } + + grid.horizontal(|builder| { + builder.columns(Size::Remainder, 3).build(|mut grid| { + grid.empty(); + grid.cell(|ui| { + ui.with_layout(Layout::top_down_justified(Align::Center), |ui| { + if ui.button("Abbrechen").clicked() { + close = true; + } + }); + }); + grid.cell(|ui| { + ui.with_layout(Layout::top_down_justified(Align::Center), |ui| { + if ui.button("Speichern").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; + } + } +} + +#[derive(Default, Clone, Serialize, Deserialize)] +struct DatePickerButtonState { + 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("%d.%m.%Y"))); + 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_padding.x; + if pos.x + width_with_padding > ui.clip_rect().right() { + pos.x = button_response.rect.right() - width_with_padding; + } + + 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_demo_lib/Cargo.toml b/egui_demo_lib/Cargo.toml index 75bbef7a..835793f2 100644 --- a/egui_demo_lib/Cargo.toml +++ b/egui_demo_lib/Cargo.toml @@ -10,12 +10,7 @@ readme = "README.md" repository = "https://github.com/emilk/egui/tree/master/egui_demo_lib" categories = ["gui", "graphics"] keywords = ["glium", "egui", "gui", "gamedev"] -include = [ - "../LICENSE-APACHE", - "../LICENSE-MIT", - "**/*.rs", - "Cargo.toml", -] +include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"] [package.metadata.docs.rs] all-features = true @@ -25,6 +20,7 @@ all-features = true [dependencies] egui = { version = "0.15.0", path = "../egui", default-features = false } epi = { version = "0.15.0", path = "../epi" } +egui_datepicker = { version = "0.15.0", path = "../egui_datepicker", optional = true } chrono = { version = "0.4", features = ["js-sys", "wasmbind"], optional = true } enum-map = { version = "1", features = ["serde"] } @@ -32,10 +28,15 @@ unicode_names2 = { version = "0.4.0", default-features = false } # feature "http": ehttp = { version = "0.1.0", optional = true } -image = { version = "0.23", default-features = false, features = ["jpeg", "png"], optional = true } +image = { version = "0.23", default-features = false, features = [ + "jpeg", + "png", +], optional = true } # feature "syntax_highlighting": -syntect = { version = "4", default-features = false, features = ["default-fancy"], optional = true } +syntect = { version = "4", default-features = false, features = [ + "default-fancy", +], optional = true } # feature "persistence": serde = { version = "1", features = ["derive"], optional = true } @@ -44,7 +45,8 @@ serde = { version = "1", features = ["derive"], optional = true } criterion = { version = "0.3", default-features = false } [features] -default = ["chrono"] +default = ["datetime"] +datetime = ["egui_datepicker", "chrono"] # Enable additional checks if debug assertions are enabled (debug builds). extra_debug_asserts = ["egui/extra_debug_asserts"] @@ -53,7 +55,7 @@ extra_asserts = ["egui/extra_asserts"] http = ["ehttp", "image"] persistence = ["egui/persistence", "epi/persistence", "serde"] -serialize = ["egui/serialize", "serde"] +serialize = ["egui/serialize", "serde"] syntax_highlighting = ["syntect"] [[bench]] diff --git a/egui_demo_lib/src/apps/demo/widget_gallery.rs b/egui_demo_lib/src/apps/demo/widget_gallery.rs index 8b2d5409..3ea1bdea 100644 --- a/egui_demo_lib/src/apps/demo/widget_gallery.rs +++ b/egui_demo_lib/src/apps/demo/widget_gallery.rs @@ -1,3 +1,8 @@ +use egui_datepicker::DatePickerButton; + +#[cfg(feature = "datetime")] +mod serde_date_format; + #[derive(Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] enum Enum { @@ -17,6 +22,9 @@ pub struct WidgetGallery { string: String, color: egui::Color32, animate_progress_bar: bool, + #[cfg(feature = "datetime")] + #[serde(with = "serde_date_format")] + date: chrono::Date, } impl Default for WidgetGallery { @@ -30,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(), } } } @@ -99,6 +109,7 @@ impl WidgetGallery { string, color, animate_progress_bar, + date, } = self; ui.add(doc_link_label("Label", "label,heading")); @@ -195,6 +206,13 @@ impl WidgetGallery { } ui.end_row(); + #[cfg(feature = "datetime")] + { + ui.add(doc_link_label("DatePickerButton", "DatePickerButton")); + ui.add(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..9c0e1704 --- /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 = "%d.%m.%Y"; + +pub fn serialize(date: &Date, serializer: S) -> Result +where + S: Serializer, +{ + let s = format!("{}", date.format(FORMAT)); + 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_dynamic_grid/Cargo.toml b/egui_dynamic_grid/Cargo.toml new file mode 100644 index 00000000..b65069d5 --- /dev/null +++ b/egui_dynamic_grid/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "egui_dynamic_grid" +version = "0.15.0" +edition = "2018" +description = "Dynamic grid and table for egui" +authors = [ + "René Rössler ", + "Dominik Rössler ", +] +license = "MIT OR Apache-2.0" +homepage = "https://github.com/emilk/egui/tree/master/egui_dynamic_grid" +readme = "README.md" +repository = "https://github.com/emilk/egui/tree/master/egui_dynamic_grid" +categories = ["gui", "graphics"] +keywords = ["glium", "egui", "gui", "gamedev"] +include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"] + +[dependencies] +egui = { version = "0.15.0", path = "../egui", default-features = false } diff --git a/egui_dynamic_grid/src/grid.rs b/egui_dynamic_grid/src/grid.rs new file mode 100644 index 00000000..29caea66 --- /dev/null +++ b/egui_dynamic_grid/src/grid.rs @@ -0,0 +1,26 @@ +mod horizontal; +mod vertical; + +use crate::Padding; +use egui::Ui; +pub use horizontal::*; +pub use vertical::*; + +pub struct GridBuilder<'a> { + ui: &'a mut Ui, + padding: Padding, +} + +impl<'a> GridBuilder<'a> { + pub fn new(ui: &'a mut Ui, padding: Padding) -> Self { + Self { ui, padding } + } + + pub fn horizontal(self, horizontal_grid_builder: impl FnOnce(HorizontalGridBuilder)) { + horizontal_grid_builder(HorizontalGridBuilder::new(self.ui, self.padding)); + } + + pub fn vertical(self, vertical_grid_builder: impl FnOnce(VerticalGridBuilder)) { + vertical_grid_builder(VerticalGridBuilder::new(self.ui, self.padding)); + } +} diff --git a/egui_dynamic_grid/src/grid/horizontal.rs b/egui_dynamic_grid/src/grid/horizontal.rs new file mode 100644 index 00000000..78818b92 --- /dev/null +++ b/egui_dynamic_grid/src/grid/horizontal.rs @@ -0,0 +1,147 @@ +use crate::{ + layout::{CellSize, LineDirection}, + sizing::Sizing, + Layout, Padding, Size, +}; +use egui::Ui; + +use super::VerticalGridBuilder; + +pub struct HorizontalGridBuilder<'a> { + ui: &'a mut Ui, + padding: Padding, + sizing: Sizing, +} + +impl<'a> HorizontalGridBuilder<'a> { + pub(crate) fn new(ui: &'a mut Ui, padding: Padding) -> Self { + let layouter = Sizing::new( + ui.available_rect_before_wrap().width() - 2.0 * padding.outer, + padding.inner, + ); + + Self { + ui, + padding, + sizing: layouter, + } + } + + pub fn column(mut self, size: Size) -> Self { + self.sizing.add_size(size); + self + } + + pub fn columns(mut self, size: Size, count: usize) -> Self { + for _ in 0..count { + self.sizing.add_size(size.clone()); + } + self + } + + pub fn build(self, horizontal_grid: F) + where + F: for<'b> FnOnce(HorizontalGrid<'a, 'b>), + { + let widths = self.sizing.into_lengths(); + let mut layout = Layout::new(self.ui, self.padding.clone(), LineDirection::TopToBottom); + let grid = HorizontalGrid { + layout: &mut layout, + padding: self.padding.clone(), + widths, + }; + horizontal_grid(grid); + layout.done(); + } +} + +pub struct HorizontalGrid<'a, 'b> { + layout: &'b mut Layout<'a>, + padding: Padding, + widths: Vec, +} + +impl<'a, 'b> HorizontalGrid<'a, 'b> { + pub fn empty(&mut self) { + assert!( + !self.widths.is_empty(), + "Tried using more grid cells then available." + ); + + self.layout.empty( + CellSize::Absolute(self.widths.remove(0)), + CellSize::Remainder, + ); + } + + pub fn _cell(&mut self, clip: bool, add_contents: impl FnOnce(&mut Ui)) { + assert!( + !self.widths.is_empty(), + "Tried using more grid cells then available." + ); + + self.layout.add( + CellSize::Absolute(self.widths.remove(0)), + CellSize::Remainder, + clip, + add_contents, + ); + } + + pub fn cell(&mut self, add_contents: impl FnOnce(&mut Ui)) { + self._cell(true, add_contents); + } + + pub fn cell_noclip(&mut self, add_contents: impl FnOnce(&mut Ui)) { + self._cell(false, add_contents); + } + + pub fn _horizontal( + &mut self, + clip: bool, + horizontal_grid_builder: impl FnOnce(HorizontalGridBuilder), + ) { + let padding = self.padding.clone(); + self._cell(clip, |ui| { + horizontal_grid_builder(HorizontalGridBuilder::new(ui, padding)); + }); + } + + pub fn horizontal(&mut self, horizontal_grid_builder: impl FnOnce(HorizontalGridBuilder)) { + self._horizontal(true, horizontal_grid_builder) + } + + pub fn horizontal_noclip( + &mut self, + horizontal_grid_builder: impl FnOnce(HorizontalGridBuilder), + ) { + self._horizontal(false, horizontal_grid_builder) + } + + pub fn _vertical( + &mut self, + clip: bool, + vertical_grid_builder: impl FnOnce(VerticalGridBuilder), + ) { + let padding = self.padding.clone(); + self._cell(clip, |ui| { + vertical_grid_builder(VerticalGridBuilder::new(ui, padding)); + }); + } + + pub fn vertical(&mut self, vertical_grid_builder: impl FnOnce(VerticalGridBuilder)) { + self._vertical(true, vertical_grid_builder); + } + + pub fn vertical_noclip(&mut self, vertical_grid_builder: impl FnOnce(VerticalGridBuilder)) { + self._vertical(false, vertical_grid_builder); + } +} + +impl<'a, 'b> Drop for HorizontalGrid<'a, 'b> { + fn drop(&mut self) { + while !self.widths.is_empty() { + self.empty(); + } + } +} diff --git a/egui_dynamic_grid/src/grid/vertical.rs b/egui_dynamic_grid/src/grid/vertical.rs new file mode 100644 index 00000000..06a7c272 --- /dev/null +++ b/egui_dynamic_grid/src/grid/vertical.rs @@ -0,0 +1,147 @@ +use crate::{layout::CellSize, sizing::Sizing, Layout, Padding, Size}; +use egui::Ui; + +use super::HorizontalGridBuilder; + +pub struct VerticalGridBuilder<'a> { + ui: &'a mut Ui, + padding: Padding, + sizing: Sizing, +} + +impl<'a> VerticalGridBuilder<'a> { + pub(crate) fn new(ui: &'a mut Ui, padding: Padding) -> Self { + let layouter = Sizing::new( + ui.available_rect_before_wrap().height() - 2.0 * padding.outer, + padding.inner, + ); + + Self { + ui, + padding, + sizing: layouter, + } + } + + pub fn row(mut self, size: Size) -> Self { + self.sizing.add_size(size); + self + } + + pub fn rows(mut self, size: Size, count: usize) -> Self { + for _ in 0..count { + self.sizing.add_size(size.clone()); + } + self + } + + pub fn build(self, vertical_grid: F) + where + F: for<'b> FnOnce(VerticalGrid<'a, 'b>), + { + let heights = self.sizing.into_lengths(); + let mut layout = Layout::new( + self.ui, + self.padding.clone(), + crate::layout::LineDirection::LeftToRight, + ); + let grid = VerticalGrid { + layout: &mut layout, + padding: self.padding.clone(), + heights, + }; + vertical_grid(grid); + layout.done(); + } +} + +pub struct VerticalGrid<'a, 'b> { + layout: &'b mut Layout<'a>, + padding: Padding, + heights: Vec, +} + +impl<'a, 'b> VerticalGrid<'a, 'b> { + pub fn empty(&mut self) { + assert!( + !self.heights.is_empty(), + "Tried using more grid cells then available." + ); + + self.layout.empty( + CellSize::Remainder, + CellSize::Absolute(self.heights.remove(0)), + ); + } + + pub fn _cell(&mut self, clip: bool, add_contents: impl FnOnce(&mut Ui)) { + assert!( + !self.heights.is_empty(), + "Tried using more grid cells then available." + ); + + self.layout.add( + CellSize::Remainder, + CellSize::Absolute(self.heights.remove(0)), + clip, + add_contents, + ); + } + + pub fn cell(&mut self, add_contents: impl FnOnce(&mut Ui)) { + self._cell(true, add_contents); + } + + pub fn cell_noclip(&mut self, add_contents: impl FnOnce(&mut Ui)) { + self._cell(false, add_contents); + } + + pub fn _horizontal( + &mut self, + clip: bool, + horizontal_grid_builder: impl FnOnce(HorizontalGridBuilder), + ) { + let padding = self.padding.clone(); + self._cell(clip, |ui| { + horizontal_grid_builder(HorizontalGridBuilder::new(ui, padding)); + }); + } + + pub fn horizontal(&mut self, horizontal_grid_builder: impl FnOnce(HorizontalGridBuilder)) { + self._horizontal(true, horizontal_grid_builder) + } + + pub fn horizontal_noclip( + &mut self, + horizontal_grid_builder: impl FnOnce(HorizontalGridBuilder), + ) { + self._horizontal(false, horizontal_grid_builder) + } + + pub fn _vertical( + &mut self, + clip: bool, + vertical_grid_builder: impl FnOnce(VerticalGridBuilder), + ) { + let padding = self.padding.clone(); + self._cell(clip, |ui| { + vertical_grid_builder(VerticalGridBuilder::new(ui, padding)); + }); + } + + pub fn vertical(&mut self, vertical_grid_builder: impl FnOnce(VerticalGridBuilder)) { + self._vertical(true, vertical_grid_builder); + } + + pub fn vertical_noclip(&mut self, vertical_grid_builder: impl FnOnce(VerticalGridBuilder)) { + self._vertical(false, vertical_grid_builder); + } +} + +impl<'a, 'b> Drop for VerticalGrid<'a, 'b> { + fn drop(&mut self) { + while !self.heights.is_empty() { + self.empty(); + } + } +} diff --git a/egui_dynamic_grid/src/layout.rs b/egui_dynamic_grid/src/layout.rs new file mode 100644 index 00000000..004407b9 --- /dev/null +++ b/egui_dynamic_grid/src/layout.rs @@ -0,0 +1,163 @@ +use crate::Padding; +use egui::{Pos2, Rect, Response, Rgba, Sense, Ui, Vec2}; + +pub(crate) enum CellSize { + Absolute(f32), + Remainder, +} + +pub(crate) enum LineDirection { + /// Cells go from top to bottom + LeftToRight, + /// Cells go from left to right + TopToBottom, +} + +pub struct Layout<'a> { + ui: &'a mut Ui, + padding: Padding, + direction: LineDirection, + rect: Rect, + pos: Pos2, + max: Pos2, +} + +impl<'a> Layout<'a> { + pub(crate) fn new(ui: &'a mut Ui, padding: Padding, direction: LineDirection) -> Self { + let mut rect = ui.available_rect_before_wrap(); + rect.set_left(rect.left() + padding.outer + padding.inner); + rect.set_top(rect.top() + padding.outer + padding.inner); + rect.set_width(rect.width() - 2.0 * padding.outer); + rect.set_height(rect.height() - 2.0 * padding.outer); + let pos = rect.left_top(); + + Self { + ui, + padding, + 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(), + }, + y: match height { + CellSize::Absolute(height) => self.pos.y + height, + CellSize::Remainder => self.rect.bottom(), + }, + }, + } + } + + fn set_pos(&mut self, rect: Rect) { + match self.direction { + LineDirection::LeftToRight => { + self.pos.y = rect.bottom() + self.padding.inner; + } + LineDirection::TopToBottom => { + self.pos.x = rect.right() + self.padding.inner; + } + } + + self.max.x = self.max.x.max(rect.right() + self.padding.inner); + self.max.y = self.max.y.max(rect.bottom() + self.padding.inner); + } + + 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); + *rect.top_mut() -= self.padding.inner; + *rect.left_mut() -= self.padding.inner; + + let text_color: Rgba = self.ui.visuals().text_color().into(); + self.ui + .painter() + .rect_filled(rect, 0.0, text_color.multiply(0.2)); + + 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 { + LineDirection::LeftToRight => { + self.pos.x = self.max.x; + self.pos.y = self.rect.top(); + } + LineDirection::TopToBottom => { + self.pos.y = self.max.y; + self.pos.x = self.rect.left(); + } + } + } + + fn set_rect(&mut self) { + let mut rect = self.rect; + rect.set_right(self.max.x); + rect.set_bottom(self.max.y); + + self.ui + .allocate_rect(rect, Sense::focusable_noninteractive()); + } + + pub fn done(&mut self) { + self.set_rect(); + } + + pub fn done_ui(mut self) -> &'a mut Ui { + self.set_rect(); + self.ui + } + + 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 - Vec2::new(self.padding.inner, self.padding.inner)); + clip_rect.max = clip_rect + .max + .min(rect.max + Vec2::new(self.padding.inner, self.padding.inner)); + child_ui.set_clip_rect(clip_rect); + } + + add_contents(&mut child_ui) + } +} diff --git a/egui_dynamic_grid/src/lib.rs b/egui_dynamic_grid/src/lib.rs new file mode 100644 index 00000000..01d327e8 --- /dev/null +++ b/egui_dynamic_grid/src/lib.rs @@ -0,0 +1,11 @@ +mod grid; +mod layout; +mod padding; +mod sizing; +mod table; + +pub use grid::*; +pub(crate) use layout::Layout; +pub use padding::Padding; +pub use sizing::Size; +pub use table::*; diff --git a/egui_dynamic_grid/src/padding.rs b/egui_dynamic_grid/src/padding.rs new file mode 100644 index 00000000..f4103faf --- /dev/null +++ b/egui_dynamic_grid/src/padding.rs @@ -0,0 +1,27 @@ +#[derive(Clone, Debug)] +pub struct Padding { + pub(crate) inner: f32, + pub(crate) outer: f32, +} + +impl Padding { + pub fn new(inner: f32, outer: f32) -> Self { + Self { inner, outer } + } + + pub fn inner(mut self, inner: f32) -> Self { + self.inner = inner; + self + } + + pub fn outer(mut self, outer: f32) -> Self { + self.outer = outer; + self + } +} + +impl Default for Padding { + fn default() -> Self { + Self::new(5.0, 10.0) + } +} diff --git a/egui_dynamic_grid/src/sizing.rs b/egui_dynamic_grid/src/sizing.rs new file mode 100644 index 00000000..e0a1d916 --- /dev/null +++ b/egui_dynamic_grid/src/sizing.rs @@ -0,0 +1,90 @@ +#[derive(Clone, Debug)] +pub enum Size { + /// in points + Absolute(f32), + /// 0.0 to 1.0 + Relative(f32), + RelativeMinimum { + /// 0.0 to 1.0 + relative: f32, + /// in points + minimum: f32, + }, + /// multiple remainders get each the same space + Remainder, + /// multiple remainders get each the same space, at least the minimum + RemainderMinimum(f32), +} + +pub struct Sizing { + length: f32, + inner_padding: f32, + sizes: Vec, +} + +impl Sizing { + pub fn new(length: f32, inner_padding: f32) -> Self { + Self { + length, + inner_padding, + sizes: vec![], + } + } + + pub fn add_size(&mut self, size: Size) { + self.sizes.push(size); + } + + pub fn into_lengths(self) -> Vec { + let mut remainders = 0; + let length = self.length; + 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::() + + self.inner_padding * (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); + self.sizes.iter().for_each(|size| { + if let Size::RemainderMinimum(minimum) = size { + if *minimum > avg_remainder_length { + remainder_length -= minimum - avg_remainder_length; + } + } + }); + 0.0f32.max(remainder_length / remainders as f32) + }; + + 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() + } +} diff --git a/egui_dynamic_grid/src/table.rs b/egui_dynamic_grid/src/table.rs new file mode 100644 index 00000000..3ec1a5b0 --- /dev/null +++ b/egui_dynamic_grid/src/table.rs @@ -0,0 +1,270 @@ +use crate::{ + layout::{CellSize, LineDirection}, + sizing::Sizing, + Layout, Padding, Size, +}; + +use egui::{Response, Ui}; +use std::cmp; + +pub struct TableBuilder<'a> { + ui: &'a mut Ui, + padding: Padding, + sizing: Sizing, + scroll: bool, + striped: bool, +} + +impl<'a> TableBuilder<'a> { + pub fn new(ui: &'a mut Ui, padding: Padding) -> Self { + let sizing = Sizing::new( + ui.available_rect_before_wrap().width() - 2.0 * padding.outer, + padding.inner, + ); + + Self { + ui, + padding, + sizing, + scroll: true, + striped: false, + } + } + + pub fn scroll(mut self, scroll: bool) -> Self { + self.scroll = scroll; + self + } + + pub fn striped(mut self, striped: bool) -> Self { + self.striped = striped; + self + } + + pub fn column(mut self, width: Size) -> Self { + self.sizing.add_size(width); + self + } + + pub fn columns(mut self, size: Size, count: usize) -> Self { + for _ in 0..count { + self.sizing.add_size(size.clone()); + } + self + } + + pub fn header(self, height: f32, header: F) -> Table<'a> + where + F: for<'b> FnOnce(TableRow<'a, 'b>), + { + let widths = self.sizing.into_lengths(); + let mut layout = Layout::new(self.ui, self.padding.clone(), LineDirection::TopToBottom); + { + let row = TableRow { + layout: &mut layout, + widths: widths.clone(), + striped: false, + height, + clicked: false, + }; + header(row); + } + let ui = layout.done_ui(); + + Table { + ui, + padding: self.padding, + widths, + scroll: self.scroll, + striped: self.striped, + } + } + + pub fn body(self, body: F) + where + F: for<'b> FnOnce(TableBody<'b>), + { + let widths = self.sizing.into_lengths(); + + Table { + ui: self.ui, + padding: self.padding, + widths, + scroll: self.scroll, + striped: self.striped, + } + .body(body) + } +} + +pub struct Table<'a> { + ui: &'a mut Ui, + padding: Padding, + widths: Vec, + scroll: bool, + striped: bool, +} + +impl<'a> Table<'a> { + pub fn body(self, body: F) + where + F: for<'b> FnOnce(TableBody<'b>), + { + let padding = self.padding; + let ui = self.ui; + let widths = self.widths; + let striped = self.striped; + let start_y = ui.available_rect_before_wrap().top(); + let end_y = ui.available_rect_before_wrap().bottom(); + + egui::ScrollArea::new([false, self.scroll]).show(ui, move |ui| { + let layout = Layout::new(ui, padding, LineDirection::TopToBottom); + + body(TableBody { + layout, + widths, + striped, + odd: true, + start_y, + end_y, + }); + }); + } +} + +pub struct TableBody<'b> { + layout: Layout<'b>, + widths: Vec, + striped: bool, + odd: bool, + start_y: f32, + end_y: f32, +} + +impl<'a> TableBody<'a> { + 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; + let mut row = TableRow { + layout: &mut self.layout, + widths: self.widths.clone(), + striped: self.striped && self.odd, + height: skip_height, + clicked: false, + }; + + row.col(|_| {}); + } + + let max_height = self.end_y - self.start_y; + let count = (max_height / height).ceil() as usize; + let end = cmp::min(start + count, rows); + + if start % 2 != 0 { + self.odd = false; + } + + for idx in start..end { + row( + idx, + TableRow { + layout: &mut self.layout, + widths: self.widths.clone(), + striped: self.striped && self.odd, + height, + clicked: false, + }, + ); + self.odd = !self.odd; + } + + if rows - end > 0 { + let skip_height = (rows - end) as f32 * height; + + let mut row = TableRow { + layout: &mut self.layout, + widths: self.widths.clone(), + striped: self.striped && self.odd, + height: skip_height, + clicked: false, + }; + + row.col(|_| {}); + } + } + + pub fn row<'b>(&'b mut self, height: f32, row: impl FnOnce(TableRow<'a, 'b>)) { + row(TableRow { + layout: &mut self.layout, + widths: self.widths.clone(), + striped: self.striped && self.odd, + height, + clicked: false, + }); + + self.odd = !self.odd; + } +} + +impl<'a> Drop for TableBody<'a> { + fn drop(&mut self) { + self.layout.done(); + } +} + +pub struct TableRow<'a, 'b> { + layout: &'b mut Layout<'a>, + widths: Vec, + striped: bool, + height: f32, + clicked: bool, +} + +impl<'a, 'b> TableRow<'a, 'b> { + pub fn clicked(&self) -> bool { + self.clicked + } + + fn _col(&mut self, clip: bool, add_contents: impl FnOnce(&mut Ui)) -> Response { + assert!( + !self.widths.is_empty(), + "Tried using more table columns then available." + ); + + let width = CellSize::Absolute(self.widths.remove(0)); + 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 + } + + pub fn col(&mut self, add_contents: impl FnOnce(&mut Ui)) -> Response { + self._col(true, add_contents) + } + + pub fn col_noclip(&mut self, add_contents: impl FnOnce(&mut Ui)) -> Response { + self._col(false, add_contents) + } +} + +impl<'a, 'b> Drop for TableRow<'a, 'b> { + fn drop(&mut self) { + self.layout.end_line(); + } +}