diff --git a/examples/widget-gallery/src/form.rs b/examples/widget-gallery/src/form.rs index afa745e5f..fa6345ad8 100644 --- a/examples/widget-gallery/src/form.rs +++ b/examples/widget-gallery/src/form.rs @@ -16,6 +16,7 @@ pub fn form(children: VTF) -> impl IntoView { .row_gap(20) .col_gap(10) .padding(30) + .max_size_full() }) .debug_name("Form") } diff --git a/examples/widget-gallery/src/main.rs b/examples/widget-gallery/src/main.rs index bdb5e6555..292404121 100644 --- a/examples/widget-gallery/src/main.rs +++ b/examples/widget-gallery/src/main.rs @@ -14,6 +14,7 @@ pub mod lists; pub mod radio_buttons; pub mod rich_text; pub mod slider; +pub mod table; use floem::{ event::{Event, EventListener}, @@ -33,6 +34,7 @@ fn app_view() -> impl IntoView { "Radio", "Input", "List", + "Table", "Menu", "RichText", "Image", @@ -53,6 +55,7 @@ fn app_view() -> impl IntoView { "Radio" => radio_buttons::radio_buttons_view().into_any(), "Input" => inputs::text_input_view().into_any(), "List" => lists::virt_list_view().into_any(), + "Table" => table::table_view().into_any(), "Menu" => context_menu::menu_view().into_any(), "RichText" => rich_text::rich_text_view().into_any(), "Image" => images::img_view().into_any(), diff --git a/examples/widget-gallery/src/table.rs b/examples/widget-gallery/src/table.rs new file mode 100644 index 000000000..925154937 --- /dev/null +++ b/examples/widget-gallery/src/table.rs @@ -0,0 +1,76 @@ +use floem::prelude::*; + +use crate::form::{form, form_item}; + +pub fn table_view() -> impl IntoView { + form((form_item( + "Virtualized Tip Percentage Table", + percentage_table(), + ),)) +} + +fn percentage_table() -> impl IntoView { + let base_prices = im::vector![ + 20.00, 50.00, 75.00, 36.00, 52.00, 99.00, 105.00, 42.99, 15.49, 89.99, 67.50, 23.99, + 129.99, 8.99, 45.75, 12.99, 199.99, 55.50, 33.25, 149.99, 28.75, 95.00, 82.49, 17.99, + 165.00, 39.99, 72.50, 19.99, 20.00, 50.00, 75.00, 36.00, 52.00, 99.00, 105.00, 42.99, + 15.49, 89.99, 67.50, 23.99, 129.99, 8.99, 45.75, 12.99, 199.99, 55.50, 33.25, 149.99, + 28.75, 95.00, 82.49, 17.99, 165.00, 39.99, 72.50, 19.99, 20.00, 50.00, 75.00, 36.00, 52.00, + 99.00, 105.00, 42.99, 15.49, 89.99, 67.50, 23.99, 129.99, 8.99, 45.75, 12.99, 199.99, + 55.50, 33.25, 149.99, 28.75, 95.00, 82.49, 17.99, 165.00, 39.99, 72.50, 19.99, 20.00, + 50.00, 75.00, 36.00, 52.00, 99.00, 105.00, 42.99, 15.49, 89.99, 67.50, 23.99, 129.99, 8.99, + 45.75, 12.99, 199.99, 55.50, 33.25, 149.99, 28.75, 95.00, 82.49, 17.99, 165.00, 39.99, + 72.50, 19.99, 20.00, 50.00, 75.00, 36.00, 52.00, 99.00, 105.00, 42.99, 15.49, 89.99, 67.50, + 23.99, 129.99, 8.99, 45.75, 12.99, 199.99, 55.50, 33.25, 149.99, 28.75, 95.00, 82.49, + 17.99, 165.00, 39.99, 72.50, 19.99, 20.00, 50.00, 75.00, 36.00, 52.00, 99.00, 105.00, + 42.99, 15.49, 89.99, 67.50, 23.99, 129.99, 8.99, 45.75, 12.99, 199.99, 55.50, 33.25, + 149.99, 28.75, 95.00, 82.49, 17.99, 165.00, 39.99, 72.50, 19.99, 20.00, 50.00, 75.00, + 36.00, 52.00, 99.00, 105.00, 42.99, 15.49, 89.99, 67.50, 23.99, 129.99, 8.99, 45.75, 12.99, + 199.99, 55.50, 33.25, 149.99, 28.75, 95.00, 82.49, 17.99, 165.00, 39.99, 72.50, 19.99, + 20.00, 50.00, 75.00, 36.00, 52.00, 99.00, 105.00, 42.99, 15.49, 89.99, 67.50, 23.99, + 129.99, 8.99, 45.75, 12.99, 199.99, 55.50, 33.25, 149.99, 28.75, 95.00, 82.49, 17.99, + 165.00, 39.99, 72.50, 19.99, 20.00, 50.00, 75.00, 36.00, 52.00, 99.00, 105.00, 42.99, + 15.49, 89.99, 67.50, 23.99, 129.99, 8.99, 45.75, 12.99, 199.99, 55.50, 33.25, 149.99, + 28.75, 95.00, 82.49, 17.99, 165.00, 39.99, 72.50, 19.99, + ]; + + // create a slice of tip percentages and then create a column from each + let columns = [15, 20, 25, 50, 75].iter().map(move |pc| { + // a column needs + // 1. a header which can be any view + // 2. a closure that can generate the data for the column given the row as input + // It is assumed that all rows, including the column headers have the same height. + // There wil be layout issues if this isn't respected + Column::new(format!("With {pc}% tip"), |(_idx, v)| { + let percent = *v * (*pc as f64 / 100. + 1.); + format!("${percent:.2}") + }) + }); + + // create a table by supplying the input data and a `key_fn`. + // The key function is used to determine if items are unique. + // Here we use the index of the item in the table as it's unique identifier. + // In other situations, other unique identifiers will need to be used + let table = table(move || base_prices.clone().enumerate(), |(idx, _p)| *idx) + // there are two ways to add colums. First by adding an individual column by passing in the title and closure + .column("Base price", |(_idx, p)| format!("${p:.2}")) + // or by supplying an iterator of columns + .columns(columns); + + table + .style(|s| { + s.row_gap(10) + .col_gap(30) + .flex_grow(1.) + .items_start() + // it is important that all rows have the same height. You can include gaps, padding, margins, etc but all rows must be the same height. + .class(LabelClass, |s| s.height(15)) + }) + .scroll() + .style(|s| { + s.border(1.0) + .padding_horiz(15) + .padding_right(20 + 15) + .height(400.) + }) +} diff --git a/reactive/src/lib.rs b/reactive/src/lib.rs index 6854424e8..b400f847f 100644 --- a/reactive/src/lib.rs +++ b/reactive/src/lib.rs @@ -24,7 +24,7 @@ pub use derived::{create_derived_rw_signal, DerivedRwSignal}; pub use effect::{batch, create_effect, create_stateful_updater, create_updater, untrack}; pub use memo::{create_memo, Memo}; pub use read::{ReadSignalValue, SignalGet, SignalRead, SignalTrack, SignalWith}; -pub use scope::{as_child_of_current_scope, with_scope, Scope}; +pub use scope::{as_child_of_current_scope, as_child_of_current_scope2, with_scope, Scope}; pub use signal::{create_rw_signal, create_signal, ReadSignal, RwSignal, WriteSignal}; pub use trigger::{create_trigger, Trigger}; pub use write::{SignalUpdate, SignalWrite, WriteSignalValue}; diff --git a/reactive/src/scope.rs b/reactive/src/scope.rs index f345054c7..2cc707466 100644 --- a/reactive/src/scope.rs +++ b/reactive/src/scope.rs @@ -173,3 +173,27 @@ where (result, scope) } } + +pub fn as_child_of_current_scope2(f: impl Fn(&T) -> U + 'static) -> impl Fn(&T) -> (U, Scope) +where + T: 'static, +{ + let current_scope = Scope::current(); + move |t| { + let scope = current_scope.create_child(); + let prev_scope = RUNTIME.with(|runtime| { + let mut current_scope = runtime.current_scope.borrow_mut(); + let prev_scope = *current_scope; + *current_scope = scope.0; + prev_scope + }); + + let result = f(t); + + RUNTIME.with(|runtime| { + *runtime.current_scope.borrow_mut() = prev_scope; + }); + + (result, scope) + } +} diff --git a/src/view.rs b/src/view.rs index 808a44dc1..4d91716c0 100644 --- a/src/view.rs +++ b/src/view.rs @@ -162,6 +162,22 @@ impl IntoView for i32 { } } +impl IntoView for f32 { + type V = crate::views::Label; + + fn into_view(self) -> Self::V { + crate::views::text(self) + } +} + +impl IntoView for f64 { + type V = crate::views::Label; + + fn into_view(self) -> Self::V { + crate::views::text(self) + } +} + impl IntoView for usize { type V = crate::views::Label; diff --git a/src/views/mod.rs b/src/views/mod.rs index fedf468f3..ef41df428 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -156,3 +156,6 @@ pub use checkbox::*; mod toggle_button; pub use toggle_button::*; + +mod table; +pub use table::*; diff --git a/src/views/table.rs b/src/views/table.rs new file mode 100644 index 000000000..2e54414ac --- /dev/null +++ b/src/views/table.rs @@ -0,0 +1,492 @@ +use std::collections::HashSet; + +use super::{ + dyn_stack::{diff, Diff, DiffOpAdd, FxIndexSet, HashRun}, + VirtualVector, +}; +use floem_reactive::{as_child_of_current_scope2, create_effect, Scope, WriteSignal}; +use peniko::kurbo::{Affine, Rect, Vec2}; + +use crate::{ + app_state::AppState, + context::{ComputeLayoutCx, UpdateCx}, + prelude::*, + prop_extractor, + style::{RowGap, Style}, + style_class, + view::{self}, + AnyView, ViewId, +}; + +pub struct Column { + title_id: ViewId, + view: Option, + func: Box (AnyView, Scope)>, +} +impl Column { + /// a column needs + /// 1. a header which can be any view + /// 2. a closure that can generate the data for the column given the row as input + /// It is assumed that all rows, including the column headers have the same height. + /// There wil be layout issues if this isn't respected + pub fn new(title: impl IntoView, func: impl Fn(&T) -> V + 'static) -> Self { + use crate::views::Decorators; + let title = title.into_view().class(ColumnHeaderClass); + let title_id = title.id(); + let view_fn = Box::new(as_child_of_current_scope2(move |e| func(e).into_any())); + + Self { + title_id, + view: Some(title.into_any()), + func: view_fn, + } + } +} + +prop_extractor! { + TableExtractor { + pub row_gap: RowGap + } +} + +pub struct Table { + id: ViewId, + style: TableExtractor, + columns: Vec>, + viewport: Rect, + set_viewport: WriteSignal, + row_views: Vec>, + // before_node: Option, + before_size: f64, + content_size: f64, + row_height: RwSignal>, + row_h: Option, + first_content_id: Option, +} + +pub fn table(data_fn: DF, key_fn: KF) -> Table +where + T: 'static + std::fmt::Debug, + DF: Fn() -> I + 'static, + I: VirtualVector, + KF: Fn(&T) -> K + 'static, + K: Eq + std::hash::Hash + 'static, +{ + let id = ViewId::new(); + let (viewport, set_viewport) = create_signal(Rect::ZERO); + let row_height = RwSignal::new(VirtualItemSize::Assume(None)); + + let table = Table { + id, + style: Default::default(), + columns: Vec::new(), + viewport: Rect::ZERO, + set_viewport, + row_views: Vec::new(), + before_size: 0.0, + // before_node: None, + content_size: 0.0, + row_height, + row_h: None, + first_content_id: None, + }; + + create_effect(move |prev| { + let mut items_vector = data_fn(); + let viewport = viewport.get(); + let viewport_start = viewport.y0; + let viewport_end = viewport.height() + viewport.y0; + let mut items = Vec::new(); + let total_num_rows = items_vector.total_len(); + + let mut before_size = 0.0; + let mut content_size = 0.0; + let mut start_idx = 0; + + row_height.with(|s| match s { + VirtualItemSize::Fixed(row_height) => { + let row_height = row_height(); + // Account for header row in viewport calculations + + start_idx = if row_height > 0.0 { + (viewport_start / row_height).floor().max(0.0) as usize + } else { + 0 + }; + + let end_idx = if row_height > 0.0 { + (((viewport_end - row_height) / row_height).ceil() as usize).min(total_num_rows) + } else { + usize::MAX + }; + + // Add visible content items + for item in items_vector.slice(start_idx..end_idx) { + items.push(item); + } + + // before_size represents space before visible items (after header) + before_size = row_height * (start_idx.min(total_num_rows) as f64); + // content_size includes header row plus all content rows + content_size = row_height * (total_num_rows as f64 + 1.); + } + VirtualItemSize::Assume(None) => { + // For initial run, render at least one item + if total_num_rows > 0 { + items.push(items_vector.slice(0..1).next().unwrap()); + before_size = 0.0; + // Add 1 to account for header row + content_size = (total_num_rows as f64) * 10.0; + } + } + VirtualItemSize::Assume(Some(row_height)) => { + // Account for header row in viewport calculations + + start_idx = if *row_height > 0.0 { + (viewport_start / row_height).floor().max(0.0) as usize + } else { + 0 + }; + + let end_idx = if *row_height > 0.0 { + (((viewport_end - row_height) / row_height).ceil() as usize).min(total_num_rows) + } else { + usize::MAX + }; + + // Add visible content items + for item in items_vector.slice(start_idx..end_idx) { + items.push(item); + } + + before_size = row_height * start_idx.min(total_num_rows) as f64; + // Add 1 to account for header row + content_size = row_height * (total_num_rows as f64 + 1.); + } + VirtualItemSize::Fn(size_fn) => { + let mut main_axis = 0.0; + // Start measuring after header height + let header_height = size_fn(&items_vector.slice(0..1).next().unwrap()); + main_axis += header_height; + content_size += header_height; + + for (idx, item) in items_vector.slice(0..total_num_rows).enumerate() { + let item_height = size_fn(&item); + content_size += item_height; + + if main_axis + item_height < viewport_start { + main_axis += item_height; + before_size += item_height; + start_idx = idx; + continue; + } + + if main_axis <= viewport_end { + main_axis += item_height; + items.push(item); + } + } + } + }); + + let hashed_items = items.iter().map(&key_fn).collect::>(); + + let (prev_before_size, prev_content_size, diff) = + if let Some((prev_before_size, prev_content_size, HashRun(prev_hashes))) = prev { + let mut diff = diff(&prev_hashes, &hashed_items); + for added in &mut diff.added { + added.view = Some(unsafe { std::ptr::read(&items[added.at]) }); + } + (prev_before_size, prev_content_size, diff) + } else { + let mut diff = Diff::default(); + for (i, item) in items.into_iter().enumerate() { + diff.added.push(DiffOpAdd { + at: i, + view: Some(item), + }); + } + (0.0, 0.0, diff) + }; + + if !diff.is_empty() || prev_before_size != before_size || prev_content_size != content_size + { + id.update_state(TableState { + diff, + first_idx: start_idx, + before_size, + content_size, + }); + } + + (before_size, content_size, HashRun(hashed_items)) + }); + + table +} + +struct TableState { + diff: Diff, + #[allow(unused)] + first_idx: usize, + before_size: f64, + content_size: f64, +} + +impl View for Table { + fn id(&self) -> ViewId { + self.id + } + + fn debug_name(&self) -> std::borrow::Cow<'static, str> { + "Table".into() + } + + fn style_pass(&mut self, cx: &mut crate::context::StyleCx<'_>) { + if self.style.read(cx) { + cx.app_state_mut().request_paint(self.id); + } + for child in self.id().children() { + cx.style_view(child); + } + } + + fn view_style(&self) -> Option