T04 window and shell: chrome, bars, rail, tabs, windows, dialogs, tray, badge, zoom

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 03:21:03 -04:00
1 parent 2bdf902bf4
commit 77bcd5be07
37 files changed
+4516 -111

No files matched your search

+238
View File
@@ -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);
}
}