guise-ui 1.5.3

A component library for gpui, Zed's GPU-accelerated UI framework: a themed palette, sizing tokens, 130+ composable components, a reactive state layer, and an in-app Safari-style inspector.
Documentation
//! The finished app from the motion tutorial:
//! `cargo run -p guise-ui --example checklist`
//!
//! A release checklist that runs itself. Every animation idea in
//! `docs/motiontutorial.md` is in here and nothing else is — the panel slides
//! in, its rows stagger, one playhead drives the whole run, the working row
//! breathes, and the badge at the end animates *out* as well as in.

use gpui::prelude::*;
use gpui::{
  div, px, size, AnimationElement, App, Bounds, Context, Div, Entity, Hsla, IntoElement,
  SharedString, Window, WindowBounds, WindowOptions,
};
use guise::prelude::*;
use guise::theme::{theme, ColorName, Theme};

const STEPS: [&str; 5] = [
  "Run the test suite",
  "Build the universal binary",
  "Notarize and staple",
  "Attach the DMG",
  "Publish the release",
];

/// Milliseconds per step. The whole run is this times `STEPS.len()`.
const PER_STEP: f32 = 900.0;

/// How wide the progress track is, so the bar has something to fill.
const TRACK: f32 = 280.0;

struct Checklist {
  /// One playhead for the whole run. Every row reads it; none of them owns
  /// a clock of its own.
  run: Entity<Animator>,
  /// The "shipped" badge, latched so it can animate out again.
  shipped: Entity<Presence>,
  /// Bumped to replay the one-shot entrances.
  epoch: usize,
}

impl Checklist {
  fn new(cx: &mut Context<Self>) -> Self {
    // Two tracks on one clock: a step counter nothing styles, and the bar
    // that fills. `Custom` is the escape hatch — the frame carries the
    // number and the rows decide what it means.
    let run = cx.new(|cx| {
      Animator::new(
        motion! {
            duration: PER_STEP * STEPS.len() as f32;
            ease: linear;
            custom("step"): 0 => STEPS.len() as i32;
            w: 0 => TRACK;
        },
        cx,
      )
    });

    let shipped = cx.new(|cx| {
      Presence::new(cx)
        .kind(TransitionKind::SlideLeft)
        .duration_ms(220)
        .content(|_window, cx| {
          let color = theme(cx).color(ColorName::Teal, 5).hsla();
          div()
            .px(px(9.0))
            .py(px(3.0))
            .rounded(px(999.0))
            .bg(color)
            .text_size(px(11.0))
            .child("shipped")
            .into_any_element()
        })
    });

    // The badge follows the playhead rather than a timer, so scrubbing
    // backwards takes it away again.
    cx.subscribe(&run, |this: &mut Self, _run, event: &AnimatorEvent, cx| {
      let done = matches!(event, AnimatorEvent::Complete);
      this
        .shipped
        .update(cx, |badge, cx| badge.set_open(done, cx));
    })
    .detach();

    Checklist {
      run,
      shipped,
      epoch: 0,
    }
  }

  /// One row: waiting, working, or done, decided by the shared playhead.
  fn row(
    &self,
    index: usize,
    reached: f32,
    colors: (Hsla, Hsla, Hsla),
    delay: f32,
  ) -> AnimationElement<Div> {
    let (surface, accent, dimmed) = colors;
    let done = reached >= index as f32 + 1.0;
    let working = !done && reached > index as f32;

    let dot = div()
      .w(px(8.0))
      .h(px(8.0))
      .rounded(px(999.0))
      .bg(if done || working { accent } else { dimmed })
      // Only the working row breathes, and only while it is working —
      // an endless clip asks for a frame forever, so it should never
      // outlive the thing it is describing.
      .animate_when(
        working,
        ("pulse", index),
        motion! {
            duration: 700;
            ease: in_out sine;
            repeat: forever;
            alternate;
            opacity: 1 => 0.3;
        },
      );

    div()
      .flex()
      .items_center()
      .gap(px(9.0))
      .px(px(10.0))
      .py(px(7.0))
      .rounded(px(7.0))
      .bg(surface)
      .child(dot)
      .child(SharedString::from(STEPS[index]))
      .when(!done && !working, |el| el.text_color(dimmed))
      // The stagger: same clip, one delay per index.
      .animate(
        ("row", self.epoch * STEPS.len() + index),
        motion! {
            enter: slide_left 18;
            duration: 380;
            ease: out back;
            delay: delay;
        },
      )
  }
}

impl Render for Checklist {
  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
    let t = theme(cx);
    let body = t.body().hsla();
    let surface = t.surface().hsla();
    let raised = t.surface_hover().hsla();
    let accent = t.color(ColorName::Teal, 5).hsla();
    let text = t.text().hsla();
    let dimmed = t.dimmed().hsla();

    // One sample, read by everything below. Asking for it here is also
    // what keeps the window repainting while the run is moving.
    let frame = self.run.read(cx).frame(window);
    let reached = frame.number_or(Prop::Custom("step"), 0.0);
    let filled = frame.number_or(Prop::Width, 0.0);
    let playing = self.run.read(cx).is_playing();

    let rise = Stagger::new(70.0).start(120.0);
    let rows = (0..STEPS.len())
      .map(|index| {
        self.row(
          index,
          reached,
          (raised, accent, dimmed),
          rise.at(index, STEPS.len()),
        )
      })
      .collect::<Vec<_>>();

    let bar = div()
      .w(px(TRACK))
      .h(px(6.0))
      .rounded(px(999.0))
      .bg(raised)
      .child(div().h(px(6.0)).rounded(px(999.0)).bg(accent).w(px(filled)));

    let controls = div()
      .flex()
      .gap(px(8.0))
      .items_center()
      .child(
        Button::new("run", if playing { "Pause" } else { "Run" })
          .variant(Variant::Filled)
          .color(ColorName::Teal)
          .on_click(cx.listener(|this, _ev, _window, cx| {
            this.run.update(cx, |run, cx| run.toggle(cx));
          })),
      )
      .child(
        Button::new("rewind", "Rewind")
          .variant(Variant::Light)
          .on_click(cx.listener(|this, _ev, _window, cx| {
            this.run.update(cx, |run, cx| run.stop(cx));
            this
              .shipped
              .update(cx, |badge, cx| badge.set_open(false, cx));
          })),
      )
      .child(
        Button::new("replay", "Replay entrance")
          .variant(Variant::Subtle)
          .on_click(cx.listener(|this, _ev, _window, cx| {
            this.epoch += 1;
            cx.notify();
          })),
      )
      .child(self.shipped.clone());

    // The panel itself: one entrance, on its own box rather than in a
    // wrapper, so turning it on moved nothing.
    let panel = div()
      .w(px(360.0))
      .flex()
      .flex_col()
      .gap(px(10.0))
      .p(px(18.0))
      .rounded(px(12.0))
      .bg(surface)
      .text_color(text)
      .child(Title::new("Release checklist").order(4))
      .children(rows)
      .child(bar)
      .child(controls)
      .animate(
        ("panel", self.epoch),
        motion! {
            enter: slide_up 20;
            duration: 420;
            ease: out back;
        },
      );

    div()
      .size_full()
      .bg(body)
      .flex()
      .items_center()
      .justify_center()
      .child(panel)
  }
}

fn main() {
  gpui::Application::new().run(|cx: &mut App| {
    Theme::dark().init(cx);
    let bounds = Bounds::centered(None, size(px(640.0), px(560.0)), cx);
    cx.open_window(
      WindowOptions {
        window_bounds: Some(WindowBounds::Windowed(bounds)),
        ..Default::default()
      },
      |_window, cx| cx.new(Checklist::new),
    )
    .unwrap();
    cx.activate(true);
  });
}