Repository: zng-ui/zng (language: rust)

Issue:
Implement progress indicator widget
Styleable widget that has a `value` property that is `impl IntoVar<Option<Factor>>`. Provides context to support both linear and circular styles.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a118cae45..c41930747 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
 # Unreleased
 
+* Changed how relative lengths are computed in `offset`, `x` and `y`, now uses the maximum bounded length from constraint, the same as `size` properties.
+* Fix display print of `FactorPercent` not rounding.
+* `ChildInsert::{Over, Under}` now allows insert to affect layout size, like other inserts.
+    - Use `background` and `foreground` properties as *layout passive* alternatives for `child_under` and `child_over`.
+* Add `zng::container::child`.
+* **Breaking** `zng_wgt_container::child` can now be used as a standalone property that behaves the same as `child_under`.
+    - Note that this is only a breaking change for direct dependents of `zng-wgt-container`.
+* Fix warning on `flood` with 0 area.
+* Add `zng::task::Progress` value type for reporting a task progress status.
+* Add `zng::progress` with `ProgressView` widget for displaying a task progress status.
 
 # 0.12.5
 
diff --git a/crates/README.md b/crates/README.md
index cbe4c7f01..dc3fd2e97 100644
--- a/crates/README.md
+++ b/crates/README.md
@@ -114,6 +114,7 @@ or for custom properties that deeply integrate with a widget.
 - `zng-wgt-ansi-text`
 - `zng-wgt-tooltip`
 - `zng-wgt-markdown`
+- `zng-wgt-progress`
 - `zng-wgt-material-icons`
 - `zng-wgt-webrender-debug`
 
diff --git a/crates/zng-task/Cargo.toml b/crates/zng-task/Cargo.toml
index 2c3d4765d..fd2901bf0 100644
--- a/crates/zng-task/Cargo.toml
+++ b/crates/zng-task/Cargo.toml
@@ -25,7 +25,6 @@ ipc = [
     "dep:ipc-channel",
     "dep:bincode",
     "dep:zng-unique-id",
-    "dep:zng-txt",
 ]
 
 # Enables http tasks.
@@ -36,7 +35,6 @@ http = [
     "dep:http-cache-semantics",
     "dep:http-serde",
     "dep:once_cell",
-    "dep:zng-txt",
     "dep:async-recursion",
     "dep:async-trait",
     "dep:sha2",
@@ -56,9 +54,10 @@ zng-app-context = { path = "../zng-app-context", version = "0.5.11" }
 zng-time = { path = "../zng-time", version = "0.2.23" }
 zng-var = { path = "../zng-var", version = "0.5.8" }
 zng-env = { path = "../zng-env", version = "0.3.6" }
+zng-txt = { path = "../zng-txt", version = "0.2.8" }
+zng-state-map = { path = "../zng-state-map", version = "0.3.6" }
 
 zng-unique-id = { path = "../zng-unique-id", version = "0.4.6", default-features = false, optional = true }
-zng-txt = { path = "../zng-txt", version = "0.2.8", optional = true }
 
 tracing = "0.1"
 pretty-type-name = "1.0"
diff --git a/crates/zng-task/src/http.rs b/crates/zng-task/src/http.rs
index 20f3a4ca3..4c3c4aa5b 100644
--- a/crates/zng-task/src/http.rs
+++ b/crates/zng-task/src/http.rs
@@ -25,6 +25,7 @@ mod cache;
 mod util;
 
 pub use cache::*;
+use zng_var::impl_from_and_into_var;
 
 use std::convert::TryFrom;
 use std::error::Error as StdError;
@@ -33,6 +34,8 @@ use std::sync::Arc;
 use std::time::Duration;
 use std::{fmt, mem};
 
+use crate::Progress;
+
 use super::io::AsyncRead;
 
 use isahc::config::Configurable;
@@ -44,7 +47,7 @@ use futures_lite::io::{AsyncReadExt, BufReader};
 use isahc::{AsyncReadResponseExt, ResponseExt};
 use parking_lot::{const_mutex, Mutex};
 
-use zng_txt::Txt;
+use zng_txt::{formatx, Txt};
 use zng_unit::*;
 
 /// Marker trait for types that try-to-convert to [`Uri`].
@@ -1043,7 +1046,7 @@ impl fmt::Display for Metrics {
         if self.upload_progress.0 != self.upload_progress.1 {
             write!(
                 f,
-                "upload: {} of {}, {}/s",
+                "↑ {} - {}, {}/s",
                 self.upload_progress.0, self.upload_progress.1, self.upload_speed
             )?;
             ws = true;
@@ -1051,7 +1054,7 @@ impl fmt::Display for Metrics {
         if self.download_progress.0 != self.download_progress.1 {
             write!(
                 f,
-                "{}download: {} of {}, {}/s",
+                "{}↓ {} - {}, {}/s",
                 if ws { "\n" } else { "" },
                 self.download_progress.0,
                 self.download_progress.1,
@@ -1062,22 +1065,45 @@ impl fmt::Display for Metrics {
 
         if !ws {
             if self.upload_progress.1.bytes() > 0 {
-                write!(f, "uploaded: {}", self.upload_progress.1)?;
+                write!(f, "↑ {}", self.upload_progress.1)?;
                 ws = true;
             }
             if self.download_progress.1.bytes() > 0 {
-                write!(f, "{}downloaded: {}", if ws { "\n" } else { "" }, self.download_progress.1)?;
+                write!(f, "{}↓ {}", if ws { "\n" } else { "" }, self.download_progress.1)?;
                 ws = true;
             }
 
             if ws {
-                write!(f, "\ntotal time: {:?}", self.total_time)?;
+                write!(f, "\n{:?}", self.total_time)?;
             }
         }
 
         Ok(())
     }
 }
+impl_from_and_into_var! {
+    fn from(metrics: Metrics) -> Progress {
+        let mut status = Progress::indeterminate();
+        if metrics.download_progress.1 > 0.bytes() {
+            status = Progress::from_n_of(metrics.download_progress.0 .0, metrics.download_progress.1 .0);
+        }
+        if metrics.upload_progress.1 > 0.bytes() {
+            let u_status = Progress::from_n_of(metrics.upload_progress.0 .0, metrics.upload_progress.1 .0);
+            if status.is_indeterminate() {
+                status = u_status;
+            } else {
+                status = status.and_fct(u_status.fct());
+            }
+        }
+        status.with_msg(formatx!("{metrics}")).with_meta_mut(|mut m| {
+            m.set(*METRICS_ID, metrics);
+        })
+    }
+}
+zng_state_map::static_id! {
+    /// Metrics in a [`Progress::with_meta`] metadata.
+    pub static ref METRICS_ID: zng_state_map::StateId<Metrics>;
+}
 
 /// HTTP client.
 ///
diff --git a/crates/zng-task/src/io.rs b/crates/zng-task/src/io.rs
index efa5ab11b..da63a2b15 100644
--- a/crates/zng-task/src/io.rs
+++ b/crates/zng-task/src/io.rs
@@ -13,7 +13,7 @@ use std::{
     time::Duration,
 };
 
-use crate::McWaker;
+use crate::{McWaker, Progress};
 
 #[doc(no_inline)]
 pub use futures_lite::io::{
@@ -23,7 +23,9 @@ pub use futures_lite::io::{
 use parking_lot::Mutex;
 use std::io::{Error, Result};
 use zng_time::{DInstant, INSTANT};
+use zng_txt::formatx;
 use zng_unit::{ByteLength, ByteUnits};
+use zng_var::impl_from_and_into_var;
 
 /// Measure read/write of an async task.
 ///
@@ -174,37 +176,53 @@ impl Metrics {
 impl fmt::Display for Metrics {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         let mut w = false;
-        if self.read_progress.0 > 0.bytes() {
+        if self.read_progress.1 > 0.bytes() {
             w = true;
             if self.read_progress.0 != self.read_progress.1 {
-                write!(
-                    f,
-                    "read: {} of {}, {}/s",
-                    self.read_progress.0, self.read_progress.1, self.read_speed
-                )?;
+                write!(f, "↓ {}-{}, {}/s", self.read_progress.0, self.read_progress.1, self.read_speed)?;
                 w = true;
             } else {
-                write!(f, "read {} in {:?}", self.read_progress.0, self.total_time)?;
+                write!(f, "↓ {} . {:?}", self.read_progress.0, self.total_time)?;
             }
         }
-        if self.write_progress.0 > 0.bytes() {
+        if self.write_progress.1 > 0.bytes() {
             if w {
                 writeln!(f)?;
             }
             if self.write_progress.0 != self.write_progress.1 {
-                write!(
-                    f,
-                    "write: {} of {}, {}/s",
-                    self.write_progress.0, self.write_progress.1, self.write_speed
-                )?;
+                write!(f, "↑ {} - {}, {}/s", self.write_progress.0, self.write_progress.1, self.write_speed)?;
             } else {
-                write!(f, "written {} in {:?}", self.read_progress.0, self.total_time)?;
+                write!(f, "↑ {} . {:?}", self.write_progress.0, self.total_time)?;
             }
         }
 
         Ok(())
     }
 }
+impl_from_and_into_var! {
+    fn from(metrics: Metrics) -> Progress {
+        let mut status = Progress::indeterminate();
+        if metrics.read_progress.1 > 0.bytes() {
+            status = Progress::from_n_of(metrics.read_progress.0 .0, metrics.read_progress.1 .0);
+        }
+        if metrics.write_progress.1 > 0.bytes() {
+            let w_status = Progress::from_n_of(metrics.write_progress.0 .0, metrics.write_progress.1 .0);
+            if status.is_indeterminate() {
+                status = w_status;
+            } else {
+                status = status.and_fct(w_status.fct());
+            }
+        }
+        status.with_msg(formatx!("{metrics}")).with_meta_mut(|mut m| {
+            m.set(*METRICS_ID, metrics);
+        })
+    }
+}
+
+zng_state_map::static_id! {
+    /// Metrics in a [`Progress::with_meta`] metadata.
+    pub static ref METRICS_ID: zng_state_map::StateId<Metrics>;
+}
 
 /// Extension methods for [`std::io::Error`] to be used with errors returned by [`McBufReader`].
 pub trait McBufErrorExt {
diff --git a/crates/zng-task/src/lib.rs b/crates/zng-task/src/lib.rs
index b6f9afc83..f32426e68 100644
--- a/crates/zng-task/src/lib.rs
+++ b/crates/zng-task/src/lib.rs
@@ -33,6 +33,9 @@ use zng_app_context::{app_local, LocalContext};
 use zng_time::Deadline;
 use zng_var::{response_done_var, response_var, AnyVar, ResponseVar, VarValue};
 
+#[cfg(test)]
+pub mod tests;
+
 #[doc(no_inline)]
 pub use rayon;
 
@@ -58,6 +61,9 @@ pub use rayon_ctx::*;
 
 pub use ui::*;
 
+mod progress;
+pub use progress::*;
+
 /// Spawn a parallel async task, this function is not blocking and the `task` starts executing immediately.
 ///
 /// # Parallel
@@ -1974,117 +1980,3 @@ impl McWaker {
         self.0.cancel()
     }
 }
-
-#[cfg(test)]
-pub mod tests {
-    use rayon::prelude::*;
-
-    use super::*;
-    use zng_unit::TimeUnits;
-
-    #[track_caller]
-    fn async_test<F>(test: F) -> F::Output
-    where
-        F: Future,
-    {
-        block_on(with_deadline(test, 20.secs())).unwrap()
-    }
-
-    #[test]
-    pub fn any_one() {
-        let r = async_test(async { any!(async { true }).await });
-
-        assert!(r);
-    }
-
-    #[test]
-    pub fn any_nine() {
-        let one_s = 1.secs();
-        let r = async_test(async {
-            any!(
-                async {
-                    deadline(one_s).await;
-                    1
-                },
-                async {
-                    deadline(one_s).await;
-                    2
-                },
-                async {
-                    deadline(one_s).await;
-                    3
-                },
-                async {
-                    deadline(one_s).await;
-                    4
-                },
-                async {
-                    deadline(one_s).await;
-                    5
-                },
-                async {
-                    deadline(one_s).await;
-                    6
-                },
-                async {
-                    deadline(one_s).await;
-                    7
-                },
-                async {
-                    deadline(one_s).await;
-                    8
-                },
-                async { 9 },
-            )
-            .await
-        });
-
-        assert_eq!(9, r);
-    }
-
-    #[test]
-    pub fn run_wake_immediately() {
-        async_test(async {
-            run(async {
-                yield_now().await;
-            })
-            .await;
-        });
-    }
-
-    #[test]
-    pub fn run_panic_handling() {
-        async_test(async {
-            let r = run_catch(async {
-                run(async {
-                    deadline(1.ms()).await;
-                    panic!("test panic")
-                })
-                .await;
-            })
-            .await;
-
-            assert!(r.is_err());
-        })
-    }
-
-    #[test]
-    pub fn run_panic_handling_parallel() {
-        async_test(async {
-            let r = run_catch(async {
-                run(async {
-                    deadline(1.ms()).await;
-                    (0..100000).into_par_iter().for_each(|i| {
-                        if i == 50005 {
-                            panic!("test panic");
-                        }
-                    });
-                })
-                .await;
-            })
-            .await;
-
-            assert!(r.is_err());
-        })
-    }
-}
diff --git a/crates/zng-task/src/progress.rs b/crates/zng-task/src/progress.rs
new file mode 100644
index 000000000..e052ddeb5
--- /dev/null
+++ b/crates/zng-task/src/progress.rs
@@ -0,0 +1,289 @@
+use core::fmt;
+use std::sync::Arc;
+
+use parking_lot::RwLock;
+use zng_state_map::{OwnedStateMap, StateMapMut, StateMapRef};
+use zng_txt::Txt;
+use zng_unit::{Factor, FactorPercent, FactorUnits as _};
+use zng_var::impl_from_and_into_var;
+
+/// Status update about a task progress.
+#[derive(Clone)]
+pub struct Progress {
+    factor: Factor,
+    msg: Txt,
+    meta: Arc<RwLock<OwnedStateMap<Progress>>>,
+}
+impl Progress {
+    /// New indeterminate.
+    pub fn indeterminate() -> Self {
+        Self::new(-1.fct())
+    }
+
+    /// New completed.
+    pub fn complete() -> Self {
+        Self::new(1.fct())
+    }
+
+    /// New with a factor of completion.
+    ///
+    /// The `factor` must be in the `0..=1` range, with a rounding error of `0.001`, values outside this range
+    /// are converted to indeterminate.
+    pub fn from_fct(factor: impl Into<Factor>) -> Self {
+        Self::new(factor.into())
+    }
+
+    /// New with completed `n` of `total`.
+    pub fn from_n_of(n: usize, total: usize) -> Self {
+        Self::new(Self::normalize_n_of(n, total))
+    }
+
+    /// Set the display message about the task status update.
+    pub fn with_msg(mut self, msg: impl Into<Txt>) -> Self {
+        self.msg = msg.into();
+        self
+    }
+
+    /// Set custom status metadata for writing.
+    ///
+    /// Note that metadata is shared between all clones of `self`.
+    pub fn with_meta_mut(self, meta: impl FnOnce(StateMapMut<Progress>)) -> Self {
+        meta(self.meta.write().borrow_mut());
+        self
+    }
+
+    /// Combine the factor completed [`fct`] with another `factor`.
+    ///
+    /// [`fct`]: Self::fct
+    pub fn and_fct(mut self, factor: impl Into<Factor>) -> Self {
+        if self.is_indeterminate() {
+            return self;
+        }
+        let factor = Self::normalize_factor(factor.into());
+        if factor < 0.fct() {
+            // indeterminate
+            self.factor = -1.fct();
+        } else {
+            self.factor = (self.factor + factor) / 2.fct();
+        }
+        self
+    }
+
+    /// Combine the factor completed [`fct`] with another factor computed from `n` of `total`.
+    ///
+    /// [`fct`]: Self::fct
+    pub fn and_n_of(self, n: usize, total: usize) -> Self {
+        self.and_fct(Self::normalize_n_of(n, total))
+    }
+
+    /// Replace the [`fct`] value with a new `factor`.
+    ///
+    /// [`fct`]: Self::fct
+    pub fn with_fct(mut self, factor: impl Into<Factor>) -> Self {
+        self.factor = Self::normalize_factor(factor.into());
+        self
+    }
+
+    /// Replace the [`fct`] value with a new factor computed from `n` of `total`.
+    ///
+    /// [`fct`]: Self::fct
+    pub fn with_n_of(mut self, n: usize, total: usize) -> Self {
+        self.factor = Self::normalize_n_of(n, total);
+        self
+    }
+
+    /// Factor completed.
+    ///
+    /// Is `-1.fct()` for indeterminate, otherwise is a value in the `0..=1` range, `1.fct()` indicates task completion.
+    pub fn fct(&self) -> Factor {
+        self.factor
+    }
+
+    /// Factor of completion cannot be known.
+    pub fn is_indeterminate(&self) -> bool {
+        self.factor < 0.fct()
+    }
+
+    /// Task has completed.
+    pub fn is_complete(&self) -> bool {
+        self.fct() >= 1.fct()
+    }
+
+    /// Display text about the task status update.
+    pub fn msg(&self) -> Txt {
+        self.msg.clone()
+    }
+
+    /// Borrow the custom status metadata for reading.
+    pub fn with_meta<T>(&self, visitor: impl FnOnce(StateMapRef<Progress>) -> T) -> T {
+        visitor(self.meta.read().borrow())
+    }
+
+    fn normalize_factor(mut value: Factor) -> Factor {
+        if value.0 < 0.0 {
+            if value.0 > -0.001 {
+                value.0 = 0.0;
+            } else {
+                // too wrong, indeterminate
+                value.0 = -1.0;
+            }
+        } else if value.0 > 1.0 {
+            if value.0 < 1.001 {
+                value.0 = 1.0;
+            } else {
+                value.0 = -1.0;
+            }
+        } else if !value.0.is_finite() {
+            value.0 = -1.0;
+        }
+        value
+    }
+
+    fn normalize_n_of(n: usize, total: usize) -> Factor {
+        if n > total {
+            -1.fct() // invalid, indeterminate
+        } else if total == 0 {
+            1.fct() // 0 of 0, complete
+        } else {
+            Self::normalize_factor(Factor(n as f32 / total as f32))
+        }
+    }
+
+    fn new(value: Factor) -> Self {
+        Self {
+            factor: Self::normalize_factor(value),
+            msg: Txt::from_static(""),
+            meta: Arc::new(RwLock::new(OwnedStateMap::new())),
+        }
+    }
+}
+impl fmt::Debug for Progress {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("TaskStatus")
+            .field("factor", &self.factor)
+            .field("message", &self.msg)
+            .finish_non_exhaustive()
+    }
+}
+impl fmt::Display for Progress {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        if !self.msg.is_empty() {
+            write!(f, "{}", self.msg)?;
+            if !self.is_indeterminate() {
+                write!(f, " ({})", self.factor.pct())
+            } else {
+                Ok(())
+            }
+        } else if !self.is_indeterminate() {
+            write!(f, "{}", self.factor.pct())
+        } else {
+            Ok(())
+        }
+    }
+}
+impl PartialEq for Progress {
+    fn eq(&self, other: &Self) -> bool {
+        self.factor == other.factor && self.msg == other.msg && {
+            let a = self.meta.read();
+            let b = other.meta.read();
+            let a = a.borrow();
+            let b = b.borrow();
+            a.is_empty() == b.is_empty() && (a.is_empty() || Arc::ptr_eq(&self.meta, &other.meta))
+        }
+    }
+}
+impl Eq for Progress {}
+impl_from_and_into_var! {
+    fn from(completed: Factor) -> Progress {
+        Progress::from_fct(completed)
+    }
+    fn from(completed: FactorPercent) -> Progress {
+        Progress::from_fct(completed)
+    }
+    fn from(completed: f32) -> Progress {
+        Progress::from_fct(completed)
+    }
+    fn from(status: Progress) -> Factor {
+        status.fct()
+    }
+    fn from(status: Progress) -> FactorPercent {
+        status.fct().pct()
+    }
+    fn from(status: Progress) -> f32 {
+        status.fct().0
+    }
+    fn from(n_total: (usize, usize)) -> Progress {
+        Progress::from_n_of(n_total.0, n_total.1)
+    }
+    fn from(indeterminate_message: Txt) -> Progress {
+        Progress::indeterminate().with_msg(indeterminate_message)
+    }
+    fn from(indeterminate_message: &'static str) -> Progress {
+        Progress::indeterminate().with_msg(indeterminate_message)
+    }
+    fn from(indeterminate_or_completed: bool) -> Progress {
+        match indeterminate_or_completed {
+            false => Progress::indeterminate(),
+            true => Progress::from_fct(true),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn fct_n1() {
+        let p = Progress::from_fct(-1.fct());
+        assert_eq!(p, Progress::indeterminate());
+    }
+
+    #[test]
+    fn fct_2() {
+        let p = Progress::from_fct(2.fct());
+        assert_eq!(p, Progress::indeterminate());
+    }
+
+    #[test]
+    fn fct_05() {
+        let p = Progress::from_fct(0.5.fct());
+        assert_eq!(p, Progress::from(0.5.fct()));
+    }
+
+    #[test]
+    fn fct_0() {
+        let p = Progress::from_fct(0.fct());
+        assert_eq!(p, Progress::from(0.fct()));
+    }
+
+    #[test]
+    fn fct_1() {
+        let p = Progress::from_fct(1.fct());
+        assert_eq!(p, Progress::from(1.fct()));
+    }
+
+    #[test]
+    fn zero_of_zero() {
+        let p = Progress::from_n_of(0, 0);
+        assert_eq!(p, Progress::complete());
+    }
+
+    #[test]
+    fn ten_of_ten() {
+        let p = Progress::from_n_of(10, 10);
+        assert_eq!(p, Progress::complete());
+    }
+
+    #[test]
+    fn ten_of_one() {
+        let p = Progress::from_n_of(10, 1);
+        assert_eq!(p, Progress::indeterminate());
+    }
+
+    #[test]
+    fn five_of_ten() {
+        let p = Progress::from_n_of(5, 10);
+        assert_eq!(p, Progress::from(50.pct()));
+    }
+}
diff --git a/crates/zng-unit/src/factor.rs b/crates/zng-unit/src/factor.rs
index c0e9de5ce..19040b5b2 100644
--- a/crates/zng-unit/src/factor.rs
+++ b/crates/zng-unit/src/factor.rs
@@ -765,7 +765,8 @@ impl fmt::Debug for FactorPercent {
 }
 impl fmt::Display for FactorPercent {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "{}%", self.0)
+        // round by 2 decimal places, without printing `.00`
+        write!(f, "{}%", (self.0 * 100.0).round() / 100.0)
     }
 }
 
diff --git a/crates/zng-wgt-container/src/lib.rs b/crates/zng-wgt-container/src/lib.rs
index fc28e937b..4d651cded 100644
--- a/crates/zng-wgt-container/src/lib.rs
+++ b/crates/zng-wgt-container/src/lib.rs
@@ -35,13 +35,18 @@ impl Container {
     }
 }
 
-/// The content.
+/// The widget's child.
 ///
 /// Can be any type that implements [`UiNode`], any widget.
 ///
+/// In `Container!` derived widgets or similar this property is captured and used as the actual child, in other widgets
+/// this property is an alias for [`child_under`](fn@child_under).
+///
 /// [`UiNode`]: zng_app::widget::node::UiNode
-#[property(CHILD, capture, default(FillUiNode), widget_impl(Container))]
-pub fn child(child: impl UiNode) {}
+#[property(CHILD, default(FillUiNode), widget_impl(Container))]
+pub fn child(widget_child: impl UiNode, child: impl UiNode) -> impl UiNode {
+    child_under(widget_child, child)
+}
 
 /// Margin space around the content of a widget.
 ///
@@ -350,10 +355,8 @@ pub fn child_insert(
                 }
                 ChildInsert::Over | ChildInsert::Under => {
                     let child_size = children.with_node(0, |n| n.layout(wl));
-                    children.with_node(1, |n| {
-                        LAYOUT.with_constraints(PxConstraints2d::new_exact_size(child_size), || n.layout(wl));
-                    });
-                    child_size
+                    let insert_size = children.with_node(1, |n| n.layout(wl));
+                    child_size.max(insert_size)
                 }
                 ChildInsert::Start | ChildInsert::End => unreachable!(), // already resolved
             };
diff --git a/crates/zng-wgt-fill/src/node.rs b/crates/zng-wgt-fill/src/node.rs
index 6bda32f8a..0afdd9b53 100644
--- a/crates/zng-wgt-fill/src/node.rs
+++ b/crates/zng-wgt-fill/src/node.rs
@@ -1075,10 +1075,14 @@ pub fn flood(color: impl IntoVar<Rgba>) -> impl UiNode {
             }
         }
         UiNodeOp::Render { frame } => {
-            frame.push_color(PxRect::from_size(render_size), frame_key.bind_var(&color, |&c| c));
+            if !render_size.is_empty() {
+                frame.push_color(PxRect::from_size(render_size), frame_key.bind_var(&color, |&c| c));
+            }
         }
         UiNodeOp::RenderUpdate { update } => {
-            update.update_color_opt(frame_key.update_var(&color, |&c| c));
+            if !render_size.is_empty() {
+                update.update_color_opt(frame_key.update_var(&color, |&c| c));
+            }
         }
         _ => {}
     })
diff --git a/crates/zng-wgt-progress/Cargo.toml b/crates/zng-wgt-progress/Cargo.toml
new file mode 100644
index 000000000..f491e4d88
--- /dev/null
+++ b/crates/zng-wgt-progress/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "zng-wgt-progress"
+version = "0.1.0"
+authors = ["The Zng Project Developers"]
+edition = "2021"
+license = "Apache-2.0 OR MIT"
+readme = "README.md"
+description = "Part of the zng project."
+documentation = "https://zng-ui.github.io/doc/zng_wgt_progress"
+repository = "https://github.com/zng-ui/zng"
+categories = ["gui"]
+keywords = ["gui", "ui", "user-interface", "zng"]
+
+[dependencies]
+zng-wgt = { path = "../zng-wgt", version = "0.5.3" }
+zng-wgt-text = { path = "../zng-wgt-text", version = "0.4.20" }
+zng-ext-font = { path = "../zng-ext-font", version = "0.4.11" }
+zng-layout = { path = "../zng-layout", version = "0.2.29" }
+zng-app = { path = "../zng-app", version = "0.13.3" }
+zng-task = { path = "../zng-task", version = "0.4.8" }
+zng-wgt-style = { path = "../zng-wgt-style", version = "0.3.27" }
+zng-wgt-container = { path = "../zng-wgt-container", version = "0.3.17" }
+zng-wgt-fill = { path = "../zng-wgt-fill", version = "0.2.37" }
+zng-wgt-size-offset = { path = "../zng-wgt-size-offset", version = "0.2.37" }
+zng-var = { path = "../zng-var", version = "0.5.8" }
+
+tracing = "0.1"
diff --git a/crates/zng-wgt-progress/README.md b/crates/zng-wgt-progress/README.md
new file mode 100644
index 000000000..bf784cf50
--- /dev/null
+++ b/crates/zng-wgt-progress/README.md
@@ -0,0 +1,7 @@
+<!--do doc --readme header-->
+This crate is part of the [`zng`](https://github.com/zng-ui/zng?tab=readme-ov-file#crates) project.
+
+
+<!--do doc --readme features-->
+
+
diff --git a/crates/zng-wgt-progress/src/lib.rs b/crates/zng-wgt-progress/src/lib.rs
new file mode 100644
index 000000000..26976da78
--- /dev/null
+++ b/crates/zng-wgt-progress/src/lib.rs
@@ -0,0 +1,212 @@
+#![doc(html_favicon_url = "https://raw.githubusercontent.com/zng-ui/zng/main/examples/image/res/zng-logo-icon.png")]
+#![doc(html_logo_url = "https://raw.githubusercontent.com/zng-ui/zng/main/examples/image/res/zng-logo.png")]
+//!
+//! Progress indicator widget.
+//!
+//! # Crate
+//!
+#![doc = include_str!(concat!("../", std::env!("CARGO_PKG_README")))]
+#![warn(unused_extern_crates)]
+#![warn(missing_docs)]
+
+zng_wgt::enable_widget_macros!();
+
+use zng_app::handler::FilterWidgetHandler;
+use zng_wgt::{base_color, prelude::*, visibility};
+use zng_wgt_container::{child_out_bottom, Container};
+use zng_wgt_fill::background_color;
+use zng_wgt_size_offset::{height, width, x};
+use zng_wgt_style::{impl_style_fn, style_fn, Style, StyleMix};
+
+pub use zng_task::Progress;
+
+/// Progress indicator widget.
+#[widget($crate::ProgressView {
+    ($progress:expr) => {
+        progress = $progress;
+    };
+})]
+pub struct ProgressView(StyleMix<WidgetBase>);
+impl ProgressView {
+    fn widget_intrinsic(&mut self) {
+        self.style_intrinsic(STYLE_FN_VAR, property_id!(self::style_fn));
+        widget_set! {
+            self;
+            style_base_fn = style_fn!(|_| DefaultStyle!());
+        }
+    }
+}
+impl_style_fn!(ProgressView);
+
+context_var! {
+    /// The progress status value in a [`ProgressView`](struct@ProgressView)
+    pub static PROGRESS_VAR: Progress = Progress::indeterminate();
+}
+
+/// The progress status to be displayed.
+///
+/// This property sets the [`PROGRESS_VAR`].
+#[property(CONTEXT, default(PROGRESS_VAR), widget_impl(ProgressView))]
+pub fn progress(child: impl UiNode, progress: impl IntoVar<Progress>) -> impl UiNode {
+    with_context_var(child, PROGRESS_VAR, progress)
+}
+
+/// Collapse visibility when [`Progress::is_complete`].
+#[property(CONTEXT, default(false), widget_impl(ProgressView))]
+pub fn collapse_complete(child: impl UiNode, collapse: impl IntoVar<bool>) -> impl UiNode {
+    let collapse = collapse.into_var();
+    visibility(
+        child,
+        expr_var! {
+            if #{PROGRESS_VAR}.is_complete() && *#{collapse} {
+                Visibility::Collapsed
+            } else {
+                Visibility::Visible
+            }
+        },
+    )
+}
+
+/// Event raised for each progress update, and once after info init.
+///
+/// This event works in any context that sets [`PROGRESS_VAR`].
+#[property(EVENT, widget_impl(ProgressView))]
+pub fn on_progress(child: impl UiNode, mut handler: impl WidgetHandler<Progress>) -> impl UiNode {
+    // copied from `on_info_init`
+    enum State {
+        WaitInfo,
+        InfoInited,
+        Done,
+    }
+    let mut state = State::WaitInfo;
+
+    match_node(child, move |c, op| match op {
+        UiNodeOp::Init => {
+            WIDGET.sub_var(&PROGRESS_VAR);
+            state = State::WaitInfo;
+        }
+        UiNodeOp::Info { .. } => {
+            if let State::WaitInfo = &state {
+                state = State::InfoInited;
+                WIDGET.update();
+            }
+        }
+        UiNodeOp::Update { updates } => {
+            c.update(updates);
+
+            match state {
+                State::Done => {
+                    if PROGRESS_VAR.is_new() {
+                        PROGRESS_VAR.with(|u| handler.event(u));
+                    } else {
+                        handler.update();
+                    }
+                }
+                State::InfoInited => {
+                    PROGRESS_VAR.with(|u| handler.event(u));
+                    state = State::Done;
+                }
+                State::WaitInfo => {}
+            }
+        }
+        _ => {}
+    })
+}
+
+/// Event raised when progress updates to a complete state or inits completed.
+///
+/// This event works in any context that sets [`PROGRESS_VAR`].
+#[property(EVENT, widget_impl(ProgressView))]
+pub fn on_complete(child: impl UiNode, handler: impl WidgetHandler<Progress>) -> impl UiNode {
+    let mut is_complete = false;
+    on_progress(
+        child,
+        FilterWidgetHandler::new(handler, move |u| {
+            let complete = u.is_complete();
+            if complete != is_complete {
+                is_complete = complete;
+                return is_complete;
+            }
+            false
+        }),
+    )
+}
+
+/// Getter property that is `true` when progress is indeterminate.
+///
+/// This event works in any context that sets [`PROGRESS_VAR`].
+#[property(EVENT, widget_impl(ProgressView))]
+pub fn is_indeterminate(child: impl UiNode, state: impl IntoVar<bool>) -> impl UiNode {
+    bind_state(child, PROGRESS_VAR.map(|p| p.is_indeterminate()), state)
+}
+
+/// Progress view default style (progress bar with message text).
+#[widget($crate::DefaultStyle)]
+pub struct DefaultStyle(Style);
+impl DefaultStyle {
+    fn widget_intrinsic(&mut self) {
+        let indeterminate_x = var(Length::from(0));
+        let mut indeterminate_animation = None;
+        let indeterminate_width = 10.pct();
+        widget_set! {
+            self;
+            base_color = light_dark(rgb(0.82, 0.82, 0.82), rgb(0.18, 0.18, 0.18));
+
+            zng_wgt_container::child = Container! {
+                height = 5;
+                background_color = colors::BASE_COLOR_VAR.rgba();
+
+                clip_to_bounds = true;
+                child_align = Align::FILL_START;
+                child = zng_wgt::Wgt! {
+                    background_color = colors::ACCENT_COLOR_VAR.rgba();
+
+                    #[easing(200.ms())]
+                    width = PROGRESS_VAR.map(|p| Length::from(p.fct()));
+
+                    on_progress = hn!(indeterminate_x, |p: &Progress| {
+                        if p.is_indeterminate() {
+                            // only animates when actually indeterminate
+                            if indeterminate_animation.is_none() {
+                                let h = indeterminate_x.sequence(move |i| {
+                                    use zng_var::animation::easing;
+                                    i.set_ease(
+                                        -indeterminate_width,
+                                        100.pct(),
+                                        1.5.secs(),
+                                        |t| easing::ease_out(easing::quad, t),
+                                    )
+                                });
+                                indeterminate_animation = Some(h);
+                            }
+                        } else {
+                            indeterminate_animation = None;
+                        }
+                    });
+                    when *#{PROGRESS_VAR.map(|p| p.is_indeterminate())} {
+                        width = indeterminate_width;
+                        x = indeterminate_x;
+                    }
+                };
+            };
+
+            child_out_bottom = zng_wgt_text::Text! {
+                txt = PROGRESS_VAR.map(|p| p.msg());
+                zng_wgt::visibility = PROGRESS_VAR.map(|p| Visibility::from(!p.msg().is_empty()));
+                zng_wgt::align = Align::CENTER;
+            }, 6;
+        }
+    }
+}
+
+/// Progress view style that is only the progress bar, no message text.
+#[widget($crate::SimpleBarStyle)]
+pub struct SimpleBarStyle(DefaultStyle);
+impl SimpleBarStyle {
+    fn widget_intrinsic(&mut self) {
+        widget_set! {
+            self;
+            child_out_bottom = unset!;
+        }
+    }
+}
diff --git a/crates/zng-wgt-size-offset/src/lib.rs b/crates/zng-wgt-size-offset/src/lib.rs
index cfe82ff22..97873beec 100644
--- a/crates/zng-wgt-size-offset/src/lib.rs
+++ b/crates/zng-wgt-size-offset/src/lib.rs
@@ -13,7 +13,7 @@ use zng_wgt::prelude::*;
 
 /// Widget layout offset.
 ///
-/// Relative values are computed of the parent fill size or the widget's size, whichever is greater.
+/// Relative values are computed from the constraints maximum bounded size.
 ///
 /// # `x` and `y`
 ///
@@ -39,7 +39,7 @@ pub fn offset(child: impl UiNode, offset: impl IntoVar<Vector>) -> impl UiNode {
 
 /// Offset on the ***x*** axis.
 ///
-/// Relative values are computed of the parent fill width or the widget's width, whichever is greater.
+/// Relative values are computed from the constraints maximum bounded width.
 ///
 /// # `offset`
 ///
@@ -53,9 +53,8 @@ pub fn x(child: impl UiNode, x: impl IntoVar<Length>) -> impl UiNode {
         }
         UiNodeOp::Layout { wl, final_size } => {
             let size = child.layout(wl);
-            let x = LAYOUT.with_constraints(PxConstraints2d::new_exact_size(LAYOUT.constraints().fill_size().max(size)), || {
-                x.layout_x()
-            });
+
+            let x = with_fill_metrics(LAYOUT.constraints(), |_| x.layout_x());
             wl.translate(PxVector::new(x, Px(0)));
             *final_size = size;
         }
@@ -65,7 +64,7 @@ pub fn x(child: impl UiNode, x: impl IntoVar<Length>) -> impl UiNode {
 
 /// Offset on the ***y*** axis.
 ///
-/// Relative values are computed of the parent fill height or the widget's height, whichever is greater.
+/// Relative values are computed from the constraints maximum bounded height.
 ///
 /// # `offset`
 ///
diff --git a/crates/zng/Cargo.toml b/crates/zng/Cargo.toml
index a08d2aaa7..0ec9afa37 100644
--- a/crates/zng/Cargo.toml
+++ b/crates/zng/Cargo.toml
@@ -257,6 +257,7 @@ zng-wgt-markdown = { path = "../zng-wgt-markdown", version = "0.3.30" }
 zng-wgt-inspector = { path = "../zng-wgt-inspector", version = "0.2.41" }
 zng-wgt-settings = { path = "../zng-wgt-settings", version = "0.1.21" }
 zng-wgt-dialog = { path = "../zng-wgt-dialog", version = "0.1.16" }
+zng-wgt-progress = { path = "../zng-wgt-progress", version = "0.1.0" }
 
 zng-wgt-material-icons = { path = "../zng-wgt-material-icons", version = "0.3.20", default-features = false, optional = true }
 zng-ext-single-instance = { path = "../zng-ext-single-instance", version = "0.3.21", optional = true }
diff --git a/crates/zng/src/container.rs b/crates/zng/src/container.rs
index ae9e79273..d5625f31e 100644
--- a/crates/zng/src/container.rs
+++ b/crates/zng/src/container.rs
@@ -88,6 +88,6 @@
 //! See [`zng_wgt_container`] for the full widget API.
 
 pub use zng_wgt_container::{
-    child_bottom, child_end, child_insert, child_left, child_out_bottom, child_out_end, child_out_insert, child_out_left, child_out_right,
-    child_out_start, child_out_top, child_over, child_right, child_start, child_top, child_under, ChildInsert, Container,
+    child, child_bottom, child_end, child_insert, child_left, child_out_bottom, child_out_end, child_out_insert, child_out_left,
+    child_out_right, child_out_start, child_out_top, child_over, child_right, child_start, child_top, child_under, ChildInsert, Container,
 };
diff --git a/crates/zng/src/lib.rs b/crates/zng/src/lib.rs
index 620ce2017..99309b73c 100644
--- a/crates/zng/src/lib.rs
+++ b/crates/zng/src/lib.rs
@@ -514,6 +514,7 @@ pub mod mouse;
 pub mod panel;
 pub mod pointer_capture;
 pub mod popup;
+pub mod progress;
 pub mod render;
 pub mod rule_line;
 pub mod scroll;
diff --git a/crates/zng/src/progress.rs b/crates/zng/src/progress.rs
new file mode 100644
index 000000000..dbbfdb311
--- /dev/null
+++ b/crates/zng/src/progress.rs
@@ -0,0 +1,29 @@
+//! Progress indicator widget, styles and properties.
+//!
+//! This widget displays [`task::Progress`] values that track the status of a running task.
+//!
+//! ```
+//! use zng::prelude::*;
+//!
+//! let p = var(task::Progress::indeterminate());
+//!
+//! // on the view
+//! let view = zng::progress::ProgressView!(p.clone());
+//!
+//! // on the controller/view-model
+//! task::spawn(async move {
+//!     for n in 0..=10 {
+//!         task::deadline(500.ms()).await;
+//!         p.set(task::Progress::from_n_of(n, 10).with_msg(formatx!("sleeping {n} of 10")));
+//!     }
+//!     p.set(task::Progress::complete().with_msg("done sleeping"));
+//! });
+//! ```
+//!
+//! [`task::Progress`]: zng::task::Progress
+//!
+//! # Full API
+//!
+//! See [`zng_wgt_progress`] and [`zng_task::Progress`] for the full widget API.
+
+pub use zng_wgt_progress::{is_indeterminate, on_complete, on_progress, DefaultStyle, ProgressView, SimpleBarStyle, PROGRESS_VAR};
diff --git a/crates/zng/src/task.rs b/crates/zng/src/task.rs
index 94fcd431e..323d35f98 100644
--- a/crates/zng/src/task.rs
+++ b/crates/zng/src/task.rs
@@ -146,7 +146,7 @@
 pub use zng_task::{
     all, all_ok, all_some, any, any_ok, any_some, block_on, channel, deadline, fs, future_fn, io, join, join_context, poll_respond,
     poll_spawn, respond, run, run_catch, scope, spawn, spawn_wait, wait, wait_catch, wait_respond, with_deadline, yield_now, DeadlineError,
-    McWaker, ParallelIteratorExt, ParallelIteratorWithCtx, ScopeCtx, SignalOnce, UiTask,
+    McWaker, ParallelIteratorExt, ParallelIteratorWithCtx, Progress, ScopeCtx, SignalOnce, UiTask,
 };
 
 #[cfg(any(doc, feature = "test_util"))]


Test patch:
diff --git a/crates/zng-task/src/tests.rs b/crates/zng-task/src/tests.rs
new file mode 100644
index 000000000..b2a175be0
--- /dev/null
+++ b/crates/zng-task/src/tests.rs
@@ -0,0 +1,110 @@
+use rayon::prelude::*;
+
+use super::*;
+use zng_unit::TimeUnits;
+
+#[track_caller]
+fn async_test<F>(test: F) -> F::Output
+where
+    F: Future,
+{
+    block_on(with_deadline(test, 20.secs())).unwrap()
+}
+
+#[test]
+pub fn any_one() {
+    let r = async_test(async { any!(async { true }).await });
+
+    assert!(r);
+}
+
+#[test]
+pub fn any_nine() {
+    let one_s = 1.secs();
+    let r = async_test(async {
+        any!(
+            async {
+                deadline(one_s).await;
+                1
+            },
+            async {
+                deadline(one_s).await;
+                2
+            },
+            async {
+                deadline(one_s).await;
+                3
+            },
+            async {
+                deadline(one_s).await;
+                4
+            },
+            async {
+                deadline(one_s).await;
+                5
+            },
+            async {
+                deadline(one_s).await;
+                6
+            },
+            async {
+                deadline(one_s).await;
+                7
+            },
+            async {
+                deadline(one_s).await;
+                8
+            },
+            async { 9 },
+        )
+        .await
+    });
+
+    assert_eq!(9, r);
+}
+
+#[test]
+pub fn run_wake_immediately() {
+    async_test(async {
+        run(async {
+            yield_now().await;
+        })
+        .await;
+    });
+}
+
+#[test]
+pub fn run_panic_handling() {
+    async_test(async {
+        let r = run_catch(async {
+            run(async {
+                deadline(1.ms()).await;
+                panic!("test panic")
+            })
+            .await;
+        })
+        .await;
+
+        assert!(r.is_err());
+    })
+}
+
+#[test]
+pub fn run_panic_handling_parallel() {
+    async_test(async {
+        let r = run_catch(async {
+            run(async {
+                deadline(1.ms()).await;
+                (0..100000).into_par_iter().for_each(|i| {
+                    if i == 50005 {
+                        panic!("test panic");
+                    }
+                });
+            })
+            .await;
+        })
+        .await;
+
+        assert!(r.is_err());
+    })
+}