From 0d758c74f174ad2d0aabaf1b7fed8ae900cde520 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Tue, 11 May 2021 11:18:58 -0700 Subject: [PATCH] Polish contingency matrix / risk screen - better color contrast - don't color neutral/no-change buckets - show "-" rather than "0" for cells which have been filtered out - bespoke, non-linear, bucket durations for x-axis - less buckets for y-axis - better copy for tooltips - label axes - show empty chart rather than no chart - remove "delays" which aren't really risks - clarify that risk visualizations are just for bikes for now - remove filter UI and only include bikes --- Cargo.lock | 1 + abstutil/src/utils.rs | 9 + game/Cargo.toml | 1 + game/src/edit/roads.rs | 4 +- game/src/info/trip.rs | 2 +- game/src/sandbox/dashboards/mod.rs | 4 +- game/src/sandbox/dashboards/risks.rs | 373 +++++++++++++++++++------- game/src/sandbox/minimap.rs | 4 +- widgetry/src/geom/geom_batch_stack.rs | 41 ++- widgetry/src/lib.rs | 4 +- widgetry/src/widgets/button.rs | 2 +- widgetry/src/widgets/mod.rs | 15 +- 12 files changed, 331 insertions(+), 129 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ffdddffcf..9a732cc9e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1324,6 +1324,7 @@ dependencies = [ "getrandom", "instant", "kml", + "lazy_static", "log", "lttb", "map_gui", diff --git a/abstutil/src/utils.rs b/abstutil/src/utils.rs index e8244eb217..f3cc492bb0 100644 --- a/abstutil/src/utils.rs +++ b/abstutil/src/utils.rs @@ -35,6 +35,15 @@ pub fn prettyprint_usize(x: usize) -> String { result } +pub fn abbreviated_format(x: usize) -> String { + if x >= 1000 { + let ks = x as f32 / 1000.0; + format!("{:.1}k", ks) + } else { + x.to_string() + } +} + pub fn basename>(path: I) -> String { std::path::Path::new(path.as_ref()) .file_stem() diff --git a/game/Cargo.toml b/game/Cargo.toml index 392fcfb870..80d35002d4 100644 --- a/game/Cargo.toml +++ b/game/Cargo.toml @@ -33,6 +33,7 @@ geom = { path = "../geom" } getrandom = { version = "0.2.2", optional = true } instant = "0.1.7" kml = { path = "../kml" } +lazy_static = "1.4.0" log = "0.4.14" lttb = "0.2.0" maplit = "1.0.2" diff --git a/game/src/edit/roads.rs b/game/src/edit/roads.rs index e085e10044..4efc704dc0 100644 --- a/game/src/edit/roads.rs +++ b/game/src/edit/roads.rs @@ -409,7 +409,7 @@ fn make_main_panel( .unwrap() .0, ]); - stack.spacing(20.0); + stack.set_spacing(20.0); if can_reverse(lt) { stack.push( @@ -455,7 +455,7 @@ fn make_main_panel( } let current_lanes_ltr = - Widget::evenly_spaced_row(current_lanes_ltr, 2).bg(Color::hex("#979797")); + Widget::evenly_spaced_row(2, current_lanes_ltr).bg(Color::hex("#979797")); let road_settings = Widget::row(vec![ Text::from_all(vec![ diff --git a/game/src/info/trip.rs b/game/src/info/trip.rs index 6f0db16362..a44e9afae1 100644 --- a/game/src/info/trip.rs +++ b/game/src/info/trip.rs @@ -441,7 +441,7 @@ fn describe_problems( ]); Widget::custom_row(vec![ - Line("Risk exposure") + Line("Risk Exposure") .secondary() .into_widget(ctx) .container() diff --git a/game/src/sandbox/dashboards/mod.rs b/game/src/sandbox/dashboards/mod.rs index 222ff48467..c2ac4a930b 100644 --- a/game/src/sandbox/dashboards/mod.rs +++ b/game/src/sandbox/dashboards/mod.rs @@ -35,7 +35,7 @@ impl DashTab { let mut choices = vec![ Choice::new("Trip Table", DashTab::TripTable), Choice::new("Trip Summaries", DashTab::TripSummaries), - Choice::new("Risk exposure", DashTab::RiskSummaries), + Choice::new("Risk Exposure", DashTab::RiskSummaries), Choice::new("Parking Overhead", DashTab::ParkingOverhead), Choice::new("Active Traffic", DashTab::ActiveTraffic), Choice::new("Transit Routes", DashTab::TransitRoutes), @@ -73,7 +73,7 @@ impl DashTab { DashTab::TripSummaries => { summaries::TripSummaries::new(ctx, app, summaries::Filter::new()) } - DashTab::RiskSummaries => risks::RiskSummaries::new(ctx, app, risks::Filter::new()), + DashTab::RiskSummaries => risks::RiskSummaries::new(ctx, app, false), DashTab::ParkingOverhead => parking_overhead::ParkingOverhead::new(ctx, app), DashTab::ActiveTraffic => misc::ActiveTraffic::new(ctx, app), DashTab::TransitRoutes => misc::TransitRoutes::new(ctx, app), diff --git a/game/src/sandbox/dashboards/risks.rs b/game/src/sandbox/dashboards/risks.rs index 17a6bdc564..fbecaa3471 100644 --- a/game/src/sandbox/dashboards/risks.rs +++ b/game/src/sandbox/dashboards/risks.rs @@ -1,17 +1,16 @@ use std::collections::BTreeSet; use std::fmt::Display; -use abstutil::prettyprint_usize; -use geom::{Duration, Polygon, Pt2D, Time}; +use abstutil::{abbreviated_format, prettyprint_usize}; +use geom::{Angle, Duration, Polygon, Pt2D, Time}; use map_gui::tools::ColorScale; use sim::{Problem, TripMode}; use widgetry::{ - DrawWithTooltips, EventCtx, GeomBatch, GfxCtx, Line, Outcome, Panel, State, Text, TextExt, - Toggle, Widget, + Color, DrawWithTooltips, EventCtx, GeomBatch, GeomBatchStack, GfxCtx, Image, Line, Outcome, + Panel, State, Text, TextExt, Toggle, Widget, }; use crate::app::{App, Transition}; -use crate::common::color_for_mode; use crate::sandbox::dashboards::DashTab; pub struct RiskSummaries { @@ -19,49 +18,66 @@ pub struct RiskSummaries { } impl RiskSummaries { - pub fn new(ctx: &mut EventCtx, app: &App, filter: Filter) -> Box> { - let mut filters = Vec::new(); - for mode in TripMode::all() { - filters.push(Toggle::colored_checkbox( - ctx, - mode.ongoing_verb(), - color_for_mode(app, mode), - filter.modes.contains(&mode), - )); - } + pub fn new(ctx: &mut EventCtx, app: &App, include_no_changes: bool) -> Box> { + let bike_filter = Filter { + modes: maplit::btreeset! { TripMode::Bike }, + include_no_changes, + }; Box::new(RiskSummaries { panel: Panel::new(Widget::col(vec![ DashTab::RiskSummaries.picker(ctx, app), Widget::col(vec![ "Filters".text_widget(ctx), - Widget::row(filters), Toggle::checkbox( ctx, "include trips without any changes", None, - filter.include_no_changes, + include_no_changes, ), ]) .section(ctx), Widget::row(vec![ - Widget::col(vec![ - "Delays at an intersection".text_widget(ctx), - safety_matrix(ctx, app, &filter, ProblemType::IntersectionDelay), - ]) - .section(ctx), - Widget::col(vec![ - "Large intersection crossings".text_widget(ctx), - safety_matrix(ctx, app, &filter, ProblemType::LargeIntersectionCrossing), - ]) - .section(ctx), - Widget::col(vec![ - "Cars wanting to over-take cyclists".text_widget(ctx), - safety_matrix(ctx, app, &filter, ProblemType::OvertakeDesired), - ]) - .section(ctx), + Image::from_path("system/assets/meters/bike.svg") + .dims(36.0) + .into_widget(ctx) + .centered_vert(), + Line(format!( + "Cyclist Risks - {} Finished Trips", + bike_filter.finished_trip_count(app) + )) + .big_heading_plain() + .into_widget(ctx) + .centered_vert(), ]) - .evenly_spaced(), + .margin_above(30), + Widget::evenly_spaced_row( + 32, + vec![ + Widget::col(vec![ + Line("Large intersection crossings") + .small_heading() + .into_widget(ctx) + .centered_horiz(), + safety_matrix( + ctx, + app, + &bike_filter, + ProblemType::LargeIntersectionCrossing, + ), + ]) + .section(ctx), + Widget::col(vec![ + Line("Cars wanting to over-take cyclists") + .small_heading() + .into_widget(ctx) + .centered_horiz(), + safety_matrix(ctx, app, &bike_filter, ProblemType::OvertakeDesired), + ]) + .section(ctx), + ], + ) + .margin_above(30), ])) .exact_size_percent(90, 90) .build(ctx), @@ -83,16 +99,8 @@ impl State for RiskSummaries { return t; } - let mut filter = Filter { - modes: BTreeSet::new(), - include_no_changes: self.panel.is_checked("include trips without any changes"), - }; - for m in TripMode::all() { - if self.panel.is_checked(m.ongoing_verb()) { - filter.modes.insert(m); - } - } - Transition::Replace(RiskSummaries::new(ctx, app, filter)) + let include_no_changes = self.panel.is_checked("include trips without any changes"); + Transition::Replace(RiskSummaries::new(ctx, app, include_no_changes)) } _ => Transition::Keep, } @@ -103,6 +111,10 @@ impl State for RiskSummaries { } } +lazy_static::lazy_static! { + static ref CLEAR_COLOR_SCALE: ColorScale = ColorScale(vec![Color::CLEAR, Color::CLEAR]); +} + fn safety_matrix( ctx: &mut EventCtx, app: &App, @@ -110,15 +122,18 @@ fn safety_matrix( problem_type: ProblemType, ) -> Widget { let points = filter.get_trips(app, problem_type); - if points.is_empty() { - return Widget::nothing(); - } - let num_buckets = 10; - let mut matrix = Matrix::new( - bucketize_duration(num_buckets, &points), - bucketize_isizes(num_buckets, &points), - ); + let duration_buckets = vec![ + Duration::ZERO, + Duration::minutes(5), + Duration::minutes(15), + Duration::minutes(30), + Duration::hours(1), + Duration::hours(2), + ]; + + let num_buckets = 7; + let mut matrix = Matrix::new(duration_buckets, bucketize_isizes(num_buckets, &points)); for (x, y) in points { matrix.add_pt(x, y); } @@ -126,31 +141,59 @@ fn safety_matrix( ctx, app, MatrixOptions { - total_width: 500.0, - total_height: 500.0, + total_width: 600.0, + total_height: 600.0, color_scale_for_bucket: Box::new(|app, _, n| { - if n <= 0 { + if n == 0 { + &CLEAR_COLOR_SCALE + } else if n < 0 { &app.cs.good_to_bad_green } else { &app.cs.good_to_bad_red } }), tooltip_for_bucket: Box::new(|(t1, t2), (problems1, problems2), count| { - let mut txt = Text::from(Line(format!("Trips between {} and {}", t1, t2))); - txt.add_line(if problems1 == 0 || problems2 == 0 { - Line("with no changes in number of problems encountered") - } else if problems1 < 0 { - Line(format!( - "with between {} and {} less problems encountered", - -problems2, -problems1 - )) + let trip_string = if count == 1 { + "1 trip".to_string() } else { - Line(format!( - "with between {} and {} more problems encountered", - problems1, problems2 - )) + format!("{} trips", prettyprint_usize(count)) + }; + let duration_string = match (t1, t2) { + (None, Some(end)) => format!("shorter than {}", end), + (Some(start), None) => format!("longer than {}", start), + (Some(start), Some(end)) => format!("between {} and {}", start, end), + (None, None) => { + unreachable!("at least one end of the duration range must be specified") + } + }; + let mut txt = Text::from(format!("{} {}", trip_string, duration_string)); + txt.add_line(if problems1 == 0 { + "had no change in the number of problems encountered.".to_string() + } else if problems1 < 0 { + if problems1.abs() == problems2.abs() + 1 { + if problems1.abs() == 1 { + "encountered 1 fewer problem.".to_string() + } else { + format!("encountered {} fewer problems.", problems1.abs()) + } + } else { + format!( + "encountered {}-{} fewer problems.", + problems2.abs() + 1, + problems1.abs() + ) + } + } else { + if problems1 == problems2 - 1 { + if problems1 == 1 { + "encountered 1 more problems.".to_string() + } else { + format!("encountered {} more problems.", problems1,) + } + } else { + format!("encountered {}-{} more problems.", problems1, problems2 - 1) + } }); - txt.add_line(Line(format!("Count: {} trips", prettyprint_usize(count)))); txt }), }, @@ -188,13 +231,6 @@ pub struct Filter { } impl Filter { - pub fn new() -> Filter { - Filter { - modes: TripMode::all().into_iter().collect(), - include_no_changes: false, - } - } - // Returns: // 1) trip duration after changes // 2) difference in number of matching problems, where positive means MORE problems after @@ -220,6 +256,19 @@ impl Filter { } points } + + fn finished_trip_count(&self, app: &App) -> usize { + let before = app.prebaked(); + let after = app.primary.sim.get_analytics(); + + let mut count = 0; + for (_, _, _, mode) in after.both_finished_trips(app.primary.sim.time(), before) { + if self.modes.contains(&mode) { + count += 1; + } + } + count + } } /// Aka a 2D histogram. Counts the number of matching points in each cell. @@ -275,33 +324,84 @@ impl Matrix Fewer Problems") + .change_fg(ctx.style().text_fg_color.dull(0.8)) + .render(ctx) + .rotate(Angle::degrees(-90.0)); + y_axis_label.autocrop_dims = true; + y_axis_label = y_axis_label.autocrop(); + + let x_axis_label = Text::from("Short Trips <--------> Long Trips") + .change_fg(ctx.style().text_fg_color.dull(0.8)) + .render(ctx); + + let vmargin = 32.0; + for (polygon, _) in tooltips.iter_mut() { + let mut translated = + polygon.translate(vmargin + y_axis_label.get_bounds().width(), 0.0); + std::mem::swap(&mut translated, polygon); + } + let mut row = GeomBatchStack::horizontal(vec![y_axis_label, batch]); + row.set_spacing(vmargin); + let mut chart = GeomBatchStack::vertical(vec![row.batch(), x_axis_label]); + chart.set_spacing(16); + + DrawWithTooltips::new(ctx, chart.batch(), tooltips, Box::new(|_| GeomBatch::new())) } } @@ -309,23 +409,88 @@ struct MatrixOptions { total_width: f64, total_height: f64, color_scale_for_bucket: Box &ColorScale>, - tooltip_for_bucket: Box Text>, + tooltip_for_bucket: Box, Option), (Y, Y), usize) -> Text>, } -fn bucketize_duration(num_buckets: usize, pts: &Vec<(Duration, isize)>) -> Vec { - let max = pts.iter().max_by_key(|(dt, _)| *dt).unwrap().0; - let (_, mins) = max.make_intervals_for_max(num_buckets); - mins.into_iter().map(|x| Duration::minutes(x)).collect() -} +fn bucketize_isizes(max_buckets: usize, pts: &Vec<(Duration, isize)>) -> Vec { + debug_assert!( + max_buckets % 2 == 1, + "num_buckets must be odd to have a symmetrical number of buckets around axis" + ); + debug_assert!(max_buckets >= 3, "num_buckets must be at least 3"); -fn bucketize_isizes(num_buckets: usize, pts: &Vec<(Duration, isize)>) -> Vec { - let min = pts.iter().min_by_key(|(_, cnt)| *cnt).unwrap().1; - let max = pts.iter().max_by_key(|(_, cnt)| *cnt).unwrap().1; - // TODO Rounding is wrong. We need to make sure to cover the min/max range... - let step_size = ((max - min).abs() as f64) / (num_buckets as f64); - let mut buckets = Vec::new(); - for i in 0..num_buckets { - buckets.push(min + ((i as f64) * step_size) as isize); + let positive_buckets = (max_buckets - 1) / 2; + // uniformly sized integer buckets + let max = match pts.iter().max_by_key(|(_, cnt)| cnt.abs()) { + Some(t) if (t.1.abs() as usize) >= positive_buckets => t.1.abs(), + _ => { + // Enforce a bucket width of at least 1. + let negative_buckets = positive_buckets as isize * -1; + return (negative_buckets..=(positive_buckets as isize + 1)).collect(); + } + }; + + let bucket_size = (max as f64 / positive_buckets as f64).ceil() as isize; + + // we start with a 0-based bucket, and build the other buckets out from that. + let mut buckets = vec![0]; + + for i in 0..=positive_buckets { + // the first positive bucket starts at `1`, to ensure that the 0 bucket stands alone + buckets.push(1 + (i as isize) * bucket_size); } + for i in 1..=positive_buckets { + buckets.push(-(i as isize) * bucket_size); + } + buckets.sort(); + debug!("buckets: {:?}", buckets); + buckets } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bucketize_isizes() { + let buckets = bucketize_isizes( + 7, + &vec![ + (Duration::minutes(3), -3), + (Duration::minutes(3), -3), + (Duration::minutes(3), -1), + (Duration::minutes(3), 2), + (Duration::minutes(3), 5), + ], + ); + // there should be an even number of buckets on either side of zero so as to center + // our x-axis. + // + // there should always be a 0-1 bucket, ensuring that only '0' falls into the zero-bucket. + // + // all other buckets edges should be evenly spaced from the zero bucket + assert_eq!(buckets, vec![-6, -4, -2, 0, 1, 3, 5, 7]) + } + + #[test] + fn test_bucketize_empty_isizes() { + let buckets = bucketize_isizes(7, &vec![]); + assert_eq!(buckets, vec![-2, -1, 0, 1, 2]) + } + + #[test] + fn test_bucketize_small_isizes() { + let buckets = bucketize_isizes( + 7, + &vec![ + (Duration::minutes(3), -1), + (Duration::minutes(3), -1), + (Duration::minutes(3), 0), + (Duration::minutes(3), -1), + (Duration::minutes(3), 0), + ], + ); + assert_eq!(buckets, vec![-3, -2, -1, 0, 1, 2, 3, 4]) + } +} diff --git a/game/src/sandbox/minimap.rs b/game/src/sandbox/minimap.rs index c162e623bb..61a814a3f2 100644 --- a/game/src/sandbox/minimap.rs +++ b/game/src/sandbox/minimap.rs @@ -148,7 +148,7 @@ fn make_agent_toggles(ctx: &mut EventCtx, app: &App, is_enabled: [bool; 4]) -> V checkbox.build_batch(ctx).expect("invalid svg").0, icon_batch.clone(), ]); - row.spacing(8.0); + row.set_spacing(8.0); let row_batch = row.batch(); let bounds = row_batch.get_bounds(); @@ -168,7 +168,7 @@ fn make_agent_toggles(ctx: &mut EventCtx, app: &App, is_enabled: [bool; 4]) -> V checkbox.build_batch(ctx).expect("invalid svg").0, icon_batch, ]); - row.spacing(8.0); + row.set_spacing(8.0); let row_batch = row.batch(); let bounds = row_batch.get_bounds(); diff --git a/widgetry/src/geom/geom_batch_stack.rs b/widgetry/src/geom/geom_batch_stack.rs index 7581888d65..2a1141b52d 100644 --- a/widgetry/src/geom/geom_batch_stack.rs +++ b/widgetry/src/geom/geom_batch_stack.rs @@ -6,6 +6,13 @@ pub enum Axis { Vertical, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Alignment { + Center, + Top, + // TODO: Bottom, Left, Right +} + /// Similar to [`Widget::row`]/[`Widget::column`], but for [`GeomBatch`]s instead of [`Widget`]s, /// and follows a builder pattern /// @@ -15,6 +22,7 @@ pub enum Axis { pub struct GeomBatchStack { batches: Vec, axis: Axis, + alignment: Alignment, spacing: f64, } @@ -22,9 +30,8 @@ impl Default for GeomBatchStack { fn default() -> Self { GeomBatchStack { batches: vec![], - // TODO: - // alignment: Alignment::Center, axis: Axis::Horizontal, + alignment: Alignment::Center, spacing: 0.0, } } @@ -47,10 +54,6 @@ impl GeomBatchStack { } } - pub fn set_axis(&mut self, new_value: Axis) { - self.axis = new_value; - } - pub fn push(&mut self, geom_batch: GeomBatch) { self.batches.push(geom_batch); } @@ -59,8 +62,16 @@ impl GeomBatchStack { self.batches.append(geom_batches); } - pub fn spacing(&mut self, spacing: f64) -> &mut Self { - self.spacing = spacing; + pub fn set_axis(&mut self, new_value: Axis) { + self.axis = new_value; + } + + pub fn set_alignment(&mut self, new_value: Alignment) { + self.alignment = new_value; + } + + pub fn set_spacing(&mut self, spacing: impl Into) -> &mut Self { + self.spacing = spacing.into(); self } @@ -83,9 +94,17 @@ impl GeomBatchStack { let mut stack_offset = 0.0; for mut batch in self.batches { let bounds = batch.get_bounds(); - let alignment_inset = match self.axis { - Axis::Vertical => (max_bound_for_axis.width() - bounds.width()) / 2.0, - Axis::Horizontal => (max_bound_for_axis.height() - bounds.height()) / 2.0, + let alignment_inset = match (self.alignment, self.axis) { + (Alignment::Center, Axis::Vertical) => { + (max_bound_for_axis.width() - bounds.width()) / 2.0 + } + (Alignment::Center, Axis::Horizontal) => { + (max_bound_for_axis.height() - bounds.height()) / 2.0 + } + (Alignment::Top, Axis::Vertical) => { + unreachable!("cannot top-align a vertical stack") + } + (Alignment::Top, Axis::Horizontal) => 0.0, }; let (dx, dy) = match self.axis { diff --git a/widgetry/src/lib.rs b/widgetry/src/lib.rs index 1badc5e2e9..b4f7485fda 100644 --- a/widgetry/src/lib.rs +++ b/widgetry/src/lib.rs @@ -37,7 +37,9 @@ pub use crate::color::{Color, Fill, LinearGradient, Texture}; pub use crate::drawing::{GfxCtx, Prerender}; pub use crate::event::{hotkeys, lctrl, Event, Key, MultiKey}; pub use crate::event_ctx::{EventCtx, UpdateType}; -pub use crate::geom::geom_batch_stack::{Axis, GeomBatchStack}; +pub use crate::geom::geom_batch_stack::{ + Alignment as StackAlignment, Axis as StackAxis, GeomBatchStack, +}; pub use crate::geom::{GeomBatch, RewriteColor}; pub use crate::input::UserInput; pub use crate::runner::{run, Settings}; diff --git a/widgetry/src/widgets/button.rs b/widgetry/src/widgets/button.rs index 702c7b088d..af30ed0eb0 100644 --- a/widgetry/src/widgets/button.rs +++ b/widgetry/src/widgets/button.rs @@ -656,7 +656,7 @@ impl<'b, 'a: 'b, 'c> ButtonBuilder<'a, 'c> { if let Some(stack_axis) = self.stack_axis { stack.set_axis(stack_axis); } - stack.spacing(self.stack_spacing); + stack.set_spacing(self.stack_spacing); let mut button_widget = stack .batch() diff --git a/widgetry/src/widgets/mod.rs b/widgetry/src/widgets/mod.rs index 5e28c8b766..d49841f67b 100644 --- a/widgetry/src/widgets/mod.rs +++ b/widgetry/src/widgets/mod.rs @@ -414,11 +414,11 @@ impl Widget { /// Creates a row with the specified widgets. Every member gets a default horizontal margin. pub fn row(widgets: Vec) -> Widget { - Widget::evenly_spaced_row(widgets, 10) + Widget::evenly_spaced_row(10, widgets) } /// Creates a row with the specified widgets, with a `spacing` sized margin between members - pub fn evenly_spaced_row(widgets: Vec, spacing: usize) -> Widget { + pub fn evenly_spaced_row(spacing: usize, widgets: Vec) -> Widget { let mut new = Vec::new(); let len = widgets.len(); // TODO Time for that is_last iterator? @@ -437,8 +437,8 @@ impl Widget { Widget::new(Box::new(Container::new(false, widgets))) } - /// Creates a column with the specified widgets. Every member gets a default vertical margin. - pub fn col(widgets: Vec) -> Widget { + /// Creates a column with the specified widgets, with a `spacing` sized margin between members + pub fn evenly_spaced_col(spacing: usize, widgets: Vec) -> Widget { let mut new = Vec::new(); let len = widgets.len(); // TODO Time for that is_last iterator? @@ -446,12 +446,17 @@ impl Widget { if idx == len - 1 { new.push(w); } else { - new.push(w.margin_below(10)); + new.push(w.margin_below(spacing)); } } Widget::new(Box::new(Container::new(false, new))) } + /// Creates a column with the specified widgets. Every member gets a default vertical margin. + pub fn col(widgets: Vec) -> Widget { + Self::evenly_spaced_col(10, widgets) + } + pub fn nothing() -> Widget { Widget::new(Box::new(Nothing {})) }