T04 window and shell: chrome, bars, rail, tabs, windows, dialogs, tray, badge, zoom
This commit is contained in:
37 files changed
+4516
-111
No files matched your search
@@ -0,0 +1,238 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user