Files
Ely-GPUI-Components/src/shell/status.rs
T

313 lines
10 KiB
Rust

use std::{rc::Rc, time::Duration};
use gpui::{
Animation, AnimationExt, AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce,
Styled, Task, Window, div, prelude::*,
};
use web_time::Instant;
use crate::{
buttons::{ButtonVariant, IconButton},
motion,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
typography::AnimatedNumber,
};
/// Network state as the host sees it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Connectivity {
Online,
Offline,
Reconnecting,
}
/// How long "Back online" stays.
const HOLD: Duration = Duration::from_millis(2400);
struct Seen {
last: Connectivity,
back_at: Option<Instant>,
generation: u64,
_hide: Option<Task<()>>,
}
/// The state's dot, in its color; it breathes while reconnecting.
pub(crate) fn connectivity_dot(
id: impl Into<ElementId>,
state: Connectivity,
cx: &App,
) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let tone = match state {
Connectivity::Online => colors.success,
Connectivity::Reconnecting => colors.warning,
Connectivity::Offline => colors.fg_subtle,
};
let dot = div().size(theme.status_dot()).rounded_full().bg(tone);
if state != Connectivity::Reconnecting || theme.reduced_motion {
return dot.into_any_element();
}
dot.with_animation(
id,
Animation::new(motion::duration(motion::SLOW, cx) * 3)
.repeat()
.with_easing(motion::ease_in_out_cubic),
|dot, t| dot.opacity(0.35 + 0.65 * (1.0 - (2.0 * t - 1.0).abs())),
)
.into_any_element()
}
/// A quiet pill while offline or reconnecting, and briefly once back.
#[derive(IntoElement)]
pub struct OfflineIndicator {
id: ElementId,
state: Connectivity,
}
impl OfflineIndicator {
pub fn new(id: impl Into<ElementId>, state: Connectivity) -> Self {
Self {
id: id.into(),
state,
}
}
}
impl RenderOnce for OfflineIndicator {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = self.state;
let seen = window.use_keyed_state(self.id.clone(), cx, |_, _| Seen {
last: state,
back_at: None,
generation: 0,
_hide: None,
});
if seen.read(cx).last != state {
let returned = state == Connectivity::Online;
let hide = returned.then(|| {
window.spawn(cx, async move |cx| {
cx.background_executor().timer(HOLD).await;
if let Err(error) = cx.update(|window, _| window.refresh()) {
log::error!("offline indicator: window gone before hiding: {error:#}");
}
})
});
log::info!("connectivity: {:?} -> {state:?}", seen.read(cx).last);
seen.update(cx, |seen, _| {
seen.last = state;
seen.back_at = returned.then(Instant::now);
seen.generation += 1;
seen._hide = hide;
});
}
let (back_at, generation) = (seen.read(cx).back_at, seen.read(cx).generation);
let theme = cx.theme();
let colors = &theme.colors;
let (icon, label) = match state {
Connectivity::Offline => (IconName::WifiOff, "Offline · changes stay on this device"),
Connectivity::Reconnecting => (IconName::Wifi, "Reconnecting…"),
Connectivity::Online => (IconName::Wifi, "Back online"),
};
let showing =
state != Connectivity::Online || back_at.is_some_and(|at| at.elapsed() < HOLD);
if !showing {
return div().id(self.id).into_any_element();
}
let enter = motion::duration(motion::BASE, cx);
let still = theme.reduced_motion;
div()
.id(self.id)
.flex()
.items_center()
.gap_2()
.px_3()
.py_1()
.rounded_full()
.bg(colors.surface)
.border_1()
.border_color(colors.border)
.shadow(theme.elevation(crate::theme::Elevation::Raised))
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(connectivity_dot("dot", state, cx))
.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
.child(label)
.with_animation(
("connectivity", generation),
Animation::new(if state == Connectivity::Online && !still {
HOLD
} else {
enter
}),
move |pill, t| match state {
Connectivity::Online if !still => pill.opacity(if t < 0.85 {
(t / 0.1).min(1.0)
} else {
(1.0 - t) / 0.15
}),
_ => pill.opacity(t).mt(motion::NUDGE * (1.0 - t)),
},
)
.into_any_element()
}
}
/// Zoom steps, in percent.
const STEPS: [u32; 11] = [50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200];
/// The step after `current` in `direction`, or `None` at the end.
fn step(current: u32, direction: i8) -> Option<u32> {
match direction {
1 => STEPS.iter().copied().find(|step| *step > current),
_ => STEPS.iter().rev().copied().find(|step| *step < current),
}
}
/// A press on a `ZoomControl` its owner handles.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ZoomStep {
Out,
In,
Reset,
}
type OnZoom = Rc<dyn Fn(ZoomStep, &mut Window, &mut App)>;
/// Minus, percentage, plus. Scales the window's rem size, and all it holds, unless an owner zooms its own content.
#[derive(IntoElement)]
pub struct ZoomControl {
id: ElementId,
owned: Option<(u32, OnZoom)>,
}
impl ZoomControl {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
owned: None,
}
}
/// Shows `percent` and hands each press to `on_zoom`.
pub fn owned(
mut self,
percent: u32,
on_zoom: impl Fn(ZoomStep, &mut Window, &mut App) + 'static,
) -> Self {
self.owned = Some((percent, Rc::new(on_zoom)));
self
}
}
fn apply(percent: u32, window: &mut Window, cx: &mut App) {
let base = cx.theme().base_rem();
log::info!("zoom: {percent}%");
window.set_rem_size(base * (percent as f32 / 100.0));
window.refresh();
}
impl RenderOnce for ZoomControl {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
if let Some((percent, on_zoom)) = self.owned {
return owned(self.id, percent, on_zoom, cx).into_any_element();
}
let percent = (window.rem_size() / theme.base_rem() * 100.0).round() as u32;
let (down, up) = (step(percent, -1), step(percent, 1));
div()
.id(self.id)
.flex()
.items_center()
.gap_0p5()
.child(
IconButton::new("zoom-out", IconName::ZoomOut)
.size(ControlSize::Sm)
.disabled(down.is_none())
.when_some(down, |button, next| {
button.on_click(move |_, window, cx| apply(next, window, cx))
}),
)
.child(
div()
.id("zoom-reset")
.flex()
.items_center()
.justify_center()
.min_w(theme.control_height(ControlSize::Lg) * 1.5)
.h(theme.control_height(ControlSize::Sm))
.rounded_full()
.cursor_pointer()
.hover(|style| style.bg(theme.colors.hover))
.on_click(|_, window, cx| apply(100, window, cx))
.child(AnimatedNumber::new("zoom-value", percent as f64).size(TextSize::Sm))
.child(div().text_size(theme.text_size(TextSize::Sm)).child("%")),
)
.child(
IconButton::new("zoom-in", IconName::ZoomIn)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.disabled(up.is_none())
.when_some(up, |button, next| {
button.on_click(move |_, window, cx| apply(next, window, cx))
}),
)
.into_any_element()
}
}
/// The owner's zoom: its percent, its steps.
fn owned(id: ElementId, percent: u32, on_zoom: OnZoom, cx: &App) -> impl IntoElement {
let theme = cx.theme();
let press = |step: ZoomStep| {
let on_zoom = on_zoom.clone();
move |_: &gpui::ClickEvent, window: &mut Window, cx: &mut App| on_zoom(step, window, cx)
};
div()
.id(id)
.flex()
.flex_none()
.items_center()
.gap_0p5()
.child(
IconButton::new("zoom-out", IconName::ZoomOut)
.size(ControlSize::Sm)
.on_click(press(ZoomStep::Out)),
)
.child(
div()
.id("zoom-reset")
.flex()
.items_center()
.justify_center()
.min_w(theme.control_height(ControlSize::Lg) * 1.5)
.h(theme.control_height(ControlSize::Sm))
.rounded_full()
.cursor_pointer()
.hover(|style| style.bg(theme.colors.hover))
.on_click(press(ZoomStep::Reset))
.child(AnimatedNumber::new("zoom-value", percent as f64).size(TextSize::Sm))
.child(div().text_size(theme.text_size(TextSize::Sm)).child("%")),
)
.child(
IconButton::new("zoom-in", IconName::ZoomIn)
.size(ControlSize::Sm)
.on_click(press(ZoomStep::In)),
)
}
#[cfg(test)]
mod tests {
use super::step;
#[test]
fn steps_walk_the_scale_and_stop_at_its_ends() {
assert_eq!(step(100, 1), Some(110));
assert_eq!(step(100, -1), Some(90));
assert_eq!(step(103, -1), Some(100));
assert_eq!(step(200, 1), None);
assert_eq!(step(50, -1), None);
}
}