diff --git a/druid/src/lens/lens.rs b/druid/src/lens/lens.rs
index 178b9204b9..9808d037f2 100644
--- a/druid/src/lens/lens.rs
+++ b/druid/src/lens/lens.rs
@@ -567,3 +567,31 @@ impl Lens for Constant {
f(&mut tmp)
}
}
+
+/// A lens that combines two lenses into a tuple.
+#[derive(Debug, Copy, Clone)]
+pub struct Tuple2(pub L1, pub L2);
+
+impl Lens for Tuple2
+where
+ L1B: Clone,
+ L2B: Clone,
+ L1: Lens,
+ L2: Lens,
+{
+ fn with V>(&self, data: &A, f: F) -> V {
+ let l1b = self.0.with(data, |v| v.clone());
+ let l2b = self.1.with(data, |v| v.clone());
+ f(&(l1b, l2b))
+ }
+ fn with_mut V>(&self, data: &mut A, f: F) -> V {
+ let l1b = self.0.with(data, |v| v.clone());
+ let l2b = self.1.with(data, |v| v.clone());
+ let mut tuple = (l1b, l2b);
+ let out = f(&mut tuple);
+ let (l1b, l2b) = tuple;
+ self.0.with_mut(data, |v| *v = l1b);
+ self.1.with_mut(data, |v| *v = l2b);
+ out
+ }
+}
diff --git a/druid/src/lens/mod.rs b/druid/src/lens/mod.rs
index 3d99cac01e..b942608d5e 100644
--- a/druid/src/lens/mod.rs
+++ b/druid/src/lens/mod.rs
@@ -49,6 +49,6 @@
#[allow(clippy::module_inception)]
#[macro_use]
mod lens;
-pub use lens::{Constant, Deref, Field, Identity, InArc, Index, Map, Ref, Then, Unit};
+pub use lens::{Constant, Deref, Field, Identity, InArc, Index, Map, Ref, Then, Tuple2, Unit};
#[doc(hidden)]
pub use lens::{Lens, LensExt};
diff --git a/druid/src/widget/maybe.rs b/druid/src/widget/maybe.rs
new file mode 100644
index 0000000000..ded0bac011
--- /dev/null
+++ b/druid/src/widget/maybe.rs
@@ -0,0 +1,182 @@
+// Copyright 2021 The Druid Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! A widget for optional data, with different `Some` and `None` children.
+
+use druid::{
+ BoxConstraints, Data, Env, Event, EventCtx, LayoutCtx, LifeCycle, LifeCycleCtx, PaintCtx, Size,
+ UpdateCtx, Widget, WidgetExt, WidgetPod,
+};
+
+use druid::widget::SizedBox;
+
+/// A widget that switches between two possible child views, for `Data` that
+/// is `Option`.
+pub struct Maybe {
+ some_maker: Box Box>>,
+ none_maker: Box Box>>,
+ widget: MaybeWidget,
+}
+
+/// Internal widget, which is either the `Some` variant, or the `None` variant.
+#[allow(clippy::large_enum_variant)]
+enum MaybeWidget {
+ Some(WidgetPod>>),
+ None(WidgetPod<(), Box>>),
+}
+
+impl Maybe {
+ /// Create a new `Maybe` widget with a `Some` and a `None` branch.
+ pub fn new(
+ // we make these generic so that the caller doesn't have to explicitly
+ // box. We don't technically *need* to box, but it seems simpler.
+ some_maker: impl Fn() -> W1 + 'static,
+ none_maker: impl Fn() -> W2 + 'static,
+ ) -> Maybe
+ where
+ W1: Widget + 'static,
+ W2: Widget<()> + 'static,
+ {
+ let widget = MaybeWidget::Some(WidgetPod::new(some_maker().boxed()));
+ Maybe {
+ some_maker: Box::new(move || some_maker().boxed()),
+ none_maker: Box::new(move || none_maker().boxed()),
+ widget,
+ }
+ }
+
+ /// Create a new `Maybe` widget where the `None` branch is an empty widget.
+ pub fn or_empty + 'static>(some_maker: impl Fn() -> W1 + 'static) -> Maybe {
+ Self::new(some_maker, SizedBox::empty)
+ }
+
+ /// Re-create the internal widget, usually in response to the optional going `Some` -> `None`
+ /// or the reverse.
+ fn rebuild_widget(&mut self, is_some: bool) {
+ if is_some {
+ self.widget = MaybeWidget::Some(WidgetPod::new((self.some_maker)()));
+ } else {
+ self.widget = MaybeWidget::None(WidgetPod::new((self.none_maker)()));
+ }
+ }
+}
+
+impl Widget