239 lines
8.0 KiB
Rust
239 lines
8.0 KiB
Rust
use std::time::{Duration, Instant};
|
|
|
|
use gpui::{
|
|
Animation, AnimationExt, App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Task,
|
|
Window, div, prelude::*,
|
|
};
|
|
|
|
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<()>>,
|
|
}
|
|
|
|
/// 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, dot, label) = match state {
|
|
Connectivity::Offline => (
|
|
IconName::WifiOff,
|
|
colors.fg_subtle,
|
|
"Offline · changes stay on this device",
|
|
),
|
|
Connectivity::Reconnecting => (IconName::Wifi, colors.warning, "Reconnecting…"),
|
|
Connectivity::Online => (IconName::Wifi, colors.success, "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({
|
|
let light = div()
|
|
.size(theme.icon_size(IconSize::Xs) / 2.0)
|
|
.rounded_full()
|
|
.bg(dot);
|
|
if state == Connectivity::Reconnecting && !still {
|
|
light
|
|
.with_animation(
|
|
"reconnect-pulse",
|
|
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()
|
|
} else {
|
|
light.into_any_element()
|
|
}
|
|
})
|
|
.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),
|
|
}
|
|
}
|
|
|
|
/// Minus, percentage, plus. Scales the window's rem size, and all it holds.
|
|
#[derive(IntoElement)]
|
|
pub struct ZoomControl {
|
|
id: ElementId,
|
|
}
|
|
|
|
impl ZoomControl {
|
|
pub fn new(id: impl Into<ElementId>) -> Self {
|
|
Self { id: id.into() }
|
|
}
|
|
}
|
|
|
|
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();
|
|
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))
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|