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

+3 -34
View File
@@ -7,8 +7,8 @@ use gpui::{
use super::{Dock, DockSide, Spot};
use crate::{
layout::{FloatingPanel, resize_handle},
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
primitives::{DragGhost, Icon},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
struct DraggedPanel {
@@ -16,36 +16,6 @@ struct DraggedPanel {
id: SharedString,
}
struct PanelGhost {
title: SharedString,
icon: IconName,
}
impl Render for PanelGhost {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
div()
.flex()
.items_center()
.gap_1p5()
.px_2()
.py_1()
.rounded(theme.radius(Radius::Md))
.bg(theme.colors.overlay)
.border_1()
.border_color(theme.colors.border)
.shadow(theme.elevation(Elevation::Floating))
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg)
.child(
Icon::new(self.icon)
.size(IconSize::Sm)
.color(theme.colors.fg_muted),
)
.child(self.title.clone())
}
}
struct DockResize {
dock: EntityId,
side: DockSide,
@@ -98,8 +68,7 @@ impl Dock {
.child(title.clone())
.on_click(cx.listener(move |dock, _, _, cx| dock.activate(&id, cx)))
.on_drag(drag, move |_, _, _, cx| {
let (title, icon) = (title.clone(), icon);
cx.new(|_| PanelGhost { title, icon })
DragGhost::new(title.clone(), Some(icon), cx)
})
}))
.into_any_element()
+1
View File
@@ -4,6 +4,7 @@ pub mod buttons;
pub mod layout;
pub mod motion;
pub mod primitives;
pub mod shell;
pub mod theme;
pub mod typography;
+46
View File
@@ -0,0 +1,46 @@
use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Window, div, prelude::*,
};
use super::{Icon, IconName};
use crate::theme::{ActiveTheme, Elevation, IconSize, Radius, TextSize};
/// What follows the pointer while a tab or panel drags.
pub struct DragGhost {
title: SharedString,
icon: Option<IconName>,
}
impl DragGhost {
pub fn new(title: SharedString, icon: Option<IconName>, cx: &mut App) -> Entity<Self> {
cx.new(|_| Self { title, icon })
}
}
impl Render for DragGhost {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
div()
.flex()
.items_center()
.gap_1p5()
.px_2()
.py_1()
.rounded(theme.radius(Radius::Md))
.bg(theme.colors.overlay)
.border_1()
.border_color(theme.colors.border)
.shadow(theme.elevation(Elevation::Floating))
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg)
.when_some(self.icon, |ghost, icon| {
ghost.child(
Icon::new(icon)
.size(IconSize::Sm)
.color(theme.colors.fg_muted),
)
})
.child(self.title.clone())
}
}
+21 -1
View File
@@ -1,4 +1,7 @@
use gpui::{App, Hsla, IntoElement, Radians, RenderOnce, Transformation, Window, prelude::*, svg};
use gpui::{
App, Hsla, IntoElement, Radians, RenderOnce, SharedString, Transformation, Window, prelude::*,
svg,
};
use crate::theme::{ActiveTheme, IconSize};
@@ -36,6 +39,7 @@ icons! {
AlignCenter => "align-center",
AlignLeft => "align-left",
AlignRight => "align-right",
AppWindow => "app-window",
Archive => "archive",
ArrowDownLeft => "arrow-down-left",
ArrowDownWideNarrow => "arrow-down-wide-narrow",
@@ -182,6 +186,7 @@ icons! {
Pencil => "pencil",
PhoneOff => "phone-off",
Phone => "phone",
PictureInPicture2 => "picture-in-picture-2",
PinOff => "pin-off",
Pin => "pin",
Play => "play",
@@ -254,6 +259,7 @@ pub struct Icon {
name: IconName,
size: IconSize,
color: Option<Hsla>,
hover: Option<(SharedString, Hsla)>,
rotation: Option<Radians>,
}
@@ -263,6 +269,7 @@ impl Icon {
name,
size: IconSize::Md,
color: None,
hover: None,
rotation: None,
}
}
@@ -278,6 +285,16 @@ impl Icon {
self
}
/// Color while the pointer is over `group`.
pub fn group_hover_color(
mut self,
group: impl Into<SharedString>,
color: impl Into<Hsla>,
) -> Self {
self.hover = Some((group.into(), color.into()));
self
}
pub fn rotate(mut self, angle: impl Into<Radians>) -> Self {
self.rotation = Some(angle.into());
self
@@ -299,6 +316,9 @@ impl RenderOnce for Icon {
.size(theme.icon_size(self.size))
.flex_none()
.text_color(color)
.when_some(self.hover, |svg, (group, hover)| {
svg.group_hover(group, |style| style.text_color(hover))
})
.when_some(self.rotation, |svg, angle| {
svg.with_transformation(Transformation::rotate(angle))
})
+2
View File
@@ -1,6 +1,7 @@
mod backdrop;
mod divider;
mod focus;
mod ghost;
mod icon;
mod image;
mod measure;
@@ -10,6 +11,7 @@ mod tooltip;
pub use backdrop::{Backdrop, Place};
pub use divider::Divider;
pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope};
pub use ghost::DragGhost;
pub use icon::{Icon, IconName};
pub use image::Image;
pub use measure::{IntersectionObserver, Measure};
+184
View File
@@ -0,0 +1,184 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, App, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use crate::{
motion,
primitives::{Icon, IconName, Tooltip},
theme::{ActiveTheme, IconSize, TextSize},
};
/// One destination on an activity bar.
#[derive(Clone)]
pub struct ActivityItem {
id: SharedString,
icon: IconName,
label: SharedString,
badge: Option<u32>,
}
impl ActivityItem {
pub fn new(
id: impl Into<SharedString>,
icon: IconName,
label: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
icon,
label: label.into(),
badge: None,
}
}
/// A count; zero hides it.
pub fn badge(mut self, count: u32) -> Self {
self.badge = (count > 0).then_some(count);
self
}
}
type OnSelect = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// Icon rail at a window's edge. Main items top, footer items bottom.
#[derive(IntoElement)]
pub struct ActivityBar {
id: ElementId,
items: Vec<ActivityItem>,
footer: Vec<ActivityItem>,
selected: Option<SharedString>,
on_select: Option<OnSelect>,
}
impl ActivityBar {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
items: Vec::new(),
footer: Vec::new(),
selected: None,
on_select: None,
}
}
pub fn item(mut self, item: ActivityItem) -> Self {
self.items.push(item);
self
}
pub fn footer(mut self, item: ActivityItem) -> Self {
self.footer.push(item);
self
}
pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
self.selected = Some(id.into());
self
}
pub fn on_select(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ActivityBar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let side = theme.rail_width();
let mark = theme.icon_size(IconSize::Lg);
let grow = motion::duration(motion::BASE, cx);
let entry = |item: ActivityItem, cx: &App| {
let theme = cx.theme();
let colors = &theme.colors;
let selected = self.selected.as_ref() == Some(&item.id);
let group = SharedString::from(format!("activity-{}", item.id));
let on_select = self.on_select.clone();
let id = item.id.clone();
div()
.id(item.id.clone())
.group(group.clone())
.relative()
.flex()
.items_center()
.justify_center()
.size(side)
.cursor_pointer()
.tooltip(Tooltip::text(item.label.clone()))
.on_click(move |_, window, cx| {
log::info!("activity bar: {id}");
if let Some(select) = &on_select {
select(&id, window, cx);
}
})
.child(
Icon::new(item.icon)
.size(IconSize::Lg)
.color(if selected {
colors.fg
} else {
colors.fg_subtle
})
.group_hover_color(group, colors.fg),
)
.when(selected, |entry| {
entry.child(
div()
.absolute()
.left_0()
.top_0()
.bottom_0()
.flex()
.items_center()
.child(
div()
.h(mark)
.border_l_2()
.border_color(colors.fg)
.with_animation(
SharedString::from(format!("activity-mark-{}", item.id)),
Animation::new(grow).with_easing(motion::ease_out_cubic),
move |mark_line, t| mark_line.h(mark * t),
),
),
)
})
.when_some(item.badge, |entry, count| {
entry.child(
div()
.absolute()
.top_1()
.right_1()
.px_1()
.rounded_full()
.bg(colors.accent)
.text_color(colors.on_accent)
.text_size(theme.text_size(TextSize::Xs))
.line_height(theme.text_size(TextSize::Md))
.child(SharedString::from(count.to_string())),
)
})
};
let column = || div().flex().flex_col();
div()
.id(self.id)
.flex()
.flex_col()
.flex_none()
.justify_between()
.w(side)
.h_full()
.py_1()
.bg(theme.colors.surface)
.border_r_1()
.border_color(theme.colors.border)
.child(column().children(self.items.clone().into_iter().map(|item| entry(item, cx))))
.child(column().children(self.footer.clone().into_iter().map(|item| entry(item, cx))))
}
}
+238
View File
@@ -0,0 +1,238 @@
use gpui::{
AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
prelude::*,
};
use smallvec::SmallVec;
use crate::{
primitives::{Icon, IconName, Tooltip},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// A row of tools, grouped and divided.
#[derive(IntoElement)]
pub struct Toolbar {
base: Div,
children: SmallVec<[AnyElement; 4]>,
}
impl Toolbar {
pub fn new() -> Self {
Self {
base: div(),
children: SmallVec::new(),
}
}
}
impl Default for Toolbar {
fn default() -> Self {
Self::new()
}
}
impl Styled for Toolbar {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl ParentElement for Toolbar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for Toolbar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
self.base
.flex()
.flex_none()
.items_center()
.gap_1()
.h(cx.theme().control_height(ControlSize::Lg))
.px_1()
.children(self.children)
}
}
/// Tools that belong together.
#[derive(IntoElement, Default)]
pub struct ToolbarGroup {
children: SmallVec<[AnyElement; 4]>,
}
impl ToolbarGroup {
pub fn new() -> Self {
Self::default()
}
}
impl ParentElement for ToolbarGroup {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for ToolbarGroup {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
div()
.flex()
.items_center()
.gap_0p5()
.children(self.children)
}
}
/// Hairline between toolbar groups.
#[derive(IntoElement)]
pub struct ToolbarSeparator;
impl RenderOnce for ToolbarSeparator {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
div()
.mx_1()
.h(theme.icon_size(IconSize::Md))
.border_l_1()
.border_color(theme.colors.border)
}
}
/// Thin bar along a window's foot: items left and right.
#[derive(IntoElement)]
pub struct StatusBar {
base: Div,
left: SmallVec<[AnyElement; 4]>,
right: SmallVec<[AnyElement; 4]>,
}
impl StatusBar {
pub fn new() -> Self {
Self {
base: div(),
left: SmallVec::new(),
right: SmallVec::new(),
}
}
pub fn left(mut self, item: impl IntoElement) -> Self {
self.left.push(item.into_any_element());
self
}
pub fn right(mut self, item: impl IntoElement) -> Self {
self.right.push(item.into_any_element());
self
}
}
impl Default for StatusBar {
fn default() -> Self {
Self::new()
}
}
impl Styled for StatusBar {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for StatusBar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let side = || div().flex().items_center().gap_0p5().h_full();
self.base
.flex()
.flex_none()
.items_center()
.justify_between()
.h(theme.control_height(ControlSize::Sm))
.px_1()
.bg(theme.colors.bg)
.border_t_1()
.border_color(theme.colors.border)
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.child(side().children(self.left))
.child(side().children(self.right))
}
}
type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
/// One status: an icon, a label, or both. Clickable when given a handler.
#[derive(IntoElement)]
pub struct StatusBarItem {
id: ElementId,
icon: Option<IconName>,
label: Option<SharedString>,
tooltip: Option<SharedString>,
on_click: Option<ClickHandler>,
}
impl StatusBarItem {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
icon: None,
label: None,
tooltip: None,
on_click: None,
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
}
impl RenderOnce for StatusBarItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let hover = theme.colors.hover;
div()
.id(self.id)
.flex()
.items_center()
.gap_1()
.h_full()
.px_1p5()
.rounded(theme.radius(Radius::Sm))
.when_some(self.icon, |item, icon| {
item.child(
Icon::new(icon)
.size(IconSize::Xs)
.color(theme.colors.fg_muted),
)
})
.when_some(self.label, |item, label| item.child(label))
.when_some(self.tooltip, |item, text| item.tooltip(Tooltip::text(text)))
.when_some(self.on_click, |item, handler| {
item.cursor_pointer()
.hover(|style| style.bg(hover))
.on_click(handler)
})
}
}
+422
View File
@@ -0,0 +1,422 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Div, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement, Styled, Window,
WindowControlArea, div, prelude::*,
};
use smallvec::SmallVec;
use crate::{
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
};
/// Which system's window buttons to draw.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ControlsStyle {
Mac,
Windows,
Linux,
}
impl ControlsStyle {
/// The style of the system this build targets.
pub fn current() -> Self {
if cfg!(target_os = "macos") {
Self::Mac
} else if cfg!(target_os = "windows") {
Self::Windows
} else {
Self::Linux
}
}
}
/// Drags the window, and zooms it on double-click.
pub fn drag_region(id: impl Into<ElementId>, window: &mut Window, cx: &mut App) -> Stateful<Div> {
let id = id.into();
let pressed = window.use_keyed_state(id.clone(), cx, |_, _| false);
let (down, moved, up, out) = (pressed.clone(), pressed.clone(), pressed.clone(), pressed);
div()
.id(id)
.window_control_area(WindowControlArea::Drag)
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
down.update(cx, |pressed, _| *pressed = true)
})
.on_mouse_move(move |_, window, cx| {
if *moved.read(cx) {
moved.update(cx, |pressed, _| *pressed = false);
window.start_window_move();
}
})
.on_mouse_up(MouseButton::Left, move |_, _, cx| {
up.update(cx, |pressed, _| *pressed = false)
})
.on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
out.update(cx, |pressed, _| *pressed = false)
})
.on_click(|event, window, _| {
if !event.standard_click() || event.click_count() != 2 {
return;
}
if cfg!(target_os = "macos") {
window.titlebar_double_click();
} else if cfg!(target_os = "linux") {
window.zoom_window();
}
})
}
#[derive(Clone, Copy, Debug)]
enum Control {
Minimize,
Maximize,
Close,
}
type OnClose = Rc<dyn Fn(&mut Window, &mut App)>;
fn run(control: Control, on_close: Option<&OnClose>, window: &mut Window, cx: &mut App) {
log::info!("window controls: {control:?}");
match (control, on_close) {
(Control::Minimize, _) => window.minimize_window(),
(Control::Maximize, _) => window.zoom_window(),
(Control::Close, Some(close)) => close(window, cx),
(Control::Close, None) => window.remove_window(),
}
}
/// Minimize, maximize and close, drawn in one system's manner.
#[derive(IntoElement)]
pub struct WindowControls {
id: ElementId,
style: ControlsStyle,
on_close: Option<OnClose>,
}
impl WindowControls {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: ControlsStyle::current(),
on_close: None,
}
}
pub fn style(mut self, style: ControlsStyle) -> Self {
self.style = style;
self
}
/// Replaces closing the window, e.g. to confirm first.
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_close = Some(Rc::new(handler));
self
}
}
impl RenderOnce for WindowControls {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
match self.style {
ControlsStyle::Mac => traffic_lights(self.id, self.on_close, window, cx),
ControlsStyle::Windows => caption_buttons(self.id, self.on_close, window, cx),
ControlsStyle::Linux => round_buttons(self.id, self.on_close, window, cx),
}
}
}
fn traffic_lights(
id: ElementId,
on_close: Option<OnClose>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let active = window.is_window_active();
let lights = [
(Control::Close, colors.danger, "×"),
(Control::Minimize, colors.warning, "−"),
(Control::Maximize, colors.success, "+"),
];
let group = SharedString::from(format!("traffic-{id}"));
div()
.id(id)
.group(group.clone())
.flex()
.items_center()
.gap_2()
.children(lights.map(|(control, tint, glyph)| {
let on_close = on_close.clone();
div()
.id(SharedString::from(format!("{control:?}")))
.size(theme.traffic_light())
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(if active { tint } else { colors.border_strong })
.border_1()
.border_color(colors.shadow.opacity(0.12))
.text_size(theme.text_size(TextSize::Xs))
.line_height(theme.traffic_light())
.font_weight(FontWeight::BOLD)
.text_color(gpui::transparent_black())
.group_hover(group.clone(), |style| {
style.text_color(colors.shadow.opacity(0.6))
})
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx))
.child(glyph)
}))
.into_any_element()
}
fn caption_buttons(
id: ElementId,
on_close: Option<OnClose>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let maximize = if window.is_maximized() {
IconName::Copy
} else {
IconName::Square
};
let buttons = [
(Control::Minimize, IconName::Minus, WindowControlArea::Min),
(Control::Maximize, maximize, WindowControlArea::Max),
(Control::Close, IconName::X, WindowControlArea::Close),
];
div()
.id(id)
.flex()
.h_full()
.children(buttons.map(|(control, icon, area)| {
let on_close = on_close.clone();
let group = SharedString::from(format!("caption-{control:?}"));
let (hover, glyph) = match control {
Control::Close => (colors.danger, colors.on_accent),
_ => (colors.hover, colors.fg),
};
div()
.id(group.clone())
.group(group.clone())
.flex()
.items_center()
.justify_center()
.w(theme.caption_button_width())
.h_full()
.window_control_area(area)
.hover(|style| style.bg(hover))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx))
.child(
Icon::new(icon)
.size(IconSize::Sm)
.color(colors.fg_muted)
.group_hover_color(group, glyph),
)
}))
.into_any_element()
}
fn round_buttons(
id: ElementId,
on_close: Option<OnClose>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let offered = window.window_controls();
let buttons = [
(Control::Minimize, IconName::Minus, offered.minimize),
(Control::Maximize, IconName::Square, offered.maximize),
(Control::Close, IconName::X, true),
];
div()
.id(id)
.flex()
.items_center()
.gap_2()
.children(
buttons
.into_iter()
.filter(|(_, _, shown)| *shown)
.map(|(control, icon, _)| {
let on_close = on_close.clone();
div()
.id(SharedString::from(format!("{control:?}")))
.flex()
.items_center()
.justify_center()
.size(theme.control_height(ControlSize::Sm))
.rounded_full()
.bg(colors.hover)
.hover(|style| style.bg(colors.active))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx))
.child(Icon::new(icon).size(IconSize::Xs).color(colors.fg))
}),
)
.into_any_element()
}
/// The live close handler, read by the window's close request.
struct CloseRoute {
handler: Option<OnClose>,
registered: bool,
}
/// Sends system closes to `handler` while the bar lives and holds one.
fn route_close(id: &ElementId, handler: Option<OnClose>, window: &mut Window, cx: &mut App) {
let route = window.use_keyed_state(id.clone(), cx, |_, _| CloseRoute {
handler: None,
registered: false,
});
let register = handler.is_some() && !route.read(cx).registered;
route.update(cx, |route, _| {
route.handler = handler;
route.registered |= register;
});
if register {
let live = route.downgrade();
window.on_window_should_close(cx, move |window, cx| {
let handler = live
.upgrade()
.and_then(|route| route.read(cx).handler.clone());
let Some(close) = handler else {
return true;
};
log::info!("title bar: system close routed to on_close");
close(window, cx);
false
});
}
}
/// Window title bar. Its empty space drags the window.
#[derive(IntoElement)]
pub struct TitleBar {
id: ElementId,
title: Option<SharedString>,
style: Option<ControlsStyle>,
on_close: Option<OnClose>,
leading: SmallVec<[AnyElement; 2]>,
actions: SmallVec<[AnyElement; 2]>,
}
impl TitleBar {
/// On macOS the system draws the traffic lights; the bar leaves them room.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
title: None,
style: None,
on_close: None,
leading: SmallVec::new(),
actions: SmallVec::new(),
}
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
/// Draws this style's controls instead of the system's.
pub fn style(mut self, style: ControlsStyle) -> Self {
self.style = Some(style);
self
}
/// Runs instead of closing; without a forced style, system closes run it too.
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_close = Some(Rc::new(handler));
self
}
/// Right-aligned content before the window controls.
pub fn action(mut self, action: impl IntoElement) -> Self {
self.actions.push(action.into_any_element());
self
}
}
impl ParentElement for TitleBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.leading.extend(elements);
}
}
impl RenderOnce for TitleBar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
if self.style.is_none() {
route_close(&self.id, self.on_close.clone(), window, cx);
}
let bar = drag_region(self.id, window, cx);
let theme = cx.theme();
let system = self.style.is_none() && cfg!(target_os = "macos");
let style = self.style.unwrap_or_else(ControlsStyle::current);
let mut trailing = (!system).then_some(WindowControls {
id: "window-controls".into(),
style,
on_close: self.on_close,
});
let leading = if style == ControlsStyle::Mac {
trailing.take()
} else {
None
};
let title = self.title.map(|title| {
div()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::MEDIUM)
.text_color(theme.colors.fg_muted)
.child(title)
});
let centered = style != ControlsStyle::Windows;
let inset = theme.traffic_light_inset();
let fullscreen = window.is_fullscreen();
let bar = bar
.relative()
.flex()
.flex_none()
.items_center()
.gap_2()
.w_full()
.h(theme.titlebar_height())
.bg(theme.colors.bg)
.border_b_1()
.border_color(theme.colors.border);
let bar = match (system, style) {
(true, _) if fullscreen => bar.pl_3().pr_2(),
(true, _) => bar.pl(inset).pr_2(),
(false, ControlsStyle::Mac) => bar.pl_3().pr_2().children(leading),
(false, ControlsStyle::Windows) => bar.pl_3(),
(false, ControlsStyle::Linux) => bar.pl_3().pr_2(),
};
let bar = bar.children(self.leading);
let bar = match (centered, title) {
(false, Some(title)) => bar.child(title),
(_, title) => bar.when_some(title, |bar, title| {
bar.child(
div()
.absolute()
.inset_0()
.flex()
.items_center()
.justify_center()
.child(title),
)
}),
};
bar.child(div().flex_1())
.children(self.actions)
.children(trailing)
}
}
+163
View File
@@ -0,0 +1,163 @@
use std::rc::Rc;
use gpui::{
App, ClipboardItem, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window,
WindowHandle, div,
};
use super::{Hosted, open_hosted};
use crate::{
buttons::{Button, ButtonVariant},
layout::{Collapsible, ScrollArea},
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize, Radius, TextSize},
typography::{Paragraph, Title},
};
type Action = Rc<dyn Fn(&mut Window, &mut App)>;
/// After a crash: what happened, the report, and a choice to send it.
#[derive(IntoElement, Clone)]
pub struct CrashReporter {
name: SharedString,
summary: SharedString,
report: SharedString,
details: bool,
on_send: Option<Action>,
on_dismiss: Option<Action>,
}
impl CrashReporter {
pub fn new(
name: impl Into<SharedString>,
summary: impl Into<SharedString>,
report: impl Into<SharedString>,
) -> Self {
Self {
name: name.into(),
summary: summary.into(),
report: report.into(),
details: false,
on_send: None,
on_dismiss: None,
}
}
/// Opens the report at first.
pub fn details(mut self, open: bool) -> Self {
self.details = open;
self
}
pub fn on_send(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_send = Some(Rc::new(handler));
self
}
pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_dismiss = Some(Rc::new(handler));
self
}
pub fn open(self, cx: &mut App) -> anyhow::Result<WindowHandle<Hosted<CrashReporter>>> {
let size = cx.theme().dialog_window();
open_hosted(format!("{} crashed", self.name), size, self, cx)
}
}
impl RenderOnce for CrashReporter {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let initial = self.details;
let shown = window.use_keyed_state("crash-details", cx, |_, _| initial);
let open = *shown.read(cx);
let theme = cx.theme();
let colors = &theme.colors;
let action = |button: Button, action: Option<Action>| {
action.map(|action| button.on_click(move |_, window, cx| action(window, cx)))
};
div()
.flex_1()
.min_h_0()
.flex()
.flex_col()
.gap_4()
.px_8()
.pb_6()
.child(
Icon::new(IconName::TriangleAlert)
.size(IconSize::Xl)
.color(colors.fg_muted),
)
.child(Title::new(format!("{} quit unexpectedly", self.name)))
.child(Paragraph::new(self.summary))
.child(
div()
.flex()
.items_center()
.justify_between()
.child(
Button::new(
"crash-details",
if open { "Hide report" } else { "Show report" },
)
.variant(ButtonVariant::Link)
.trailing_icon(if open {
IconName::ChevronUp
} else {
IconName::ChevronDown
})
.on_click(move |_, _, cx| {
shown.update(cx, |open, cx| {
*open = !*open;
cx.notify();
})
}),
)
.child({
let report = self.report.clone();
Button::new("crash-copy", "Copy report")
.variant(ButtonVariant::Ghost)
.icon(IconName::Copy)
.on_click(move |_, _, cx| {
log::info!("crash reporter: report copied");
cx.write_to_clipboard(ClipboardItem::new_string(
report.to_string(),
));
})
}),
)
.child(
Collapsible::new("crash-report", open).child(
div()
.h(theme.pane_min())
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.bg(colors.sunken)
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(
ScrollArea::new("crash-report-scroll")
.size_full()
.child(div().p_3().child(self.report)),
),
),
)
.child(div().flex_1())
.child(
div()
.flex()
.justify_end()
.gap_2()
.children(action(
Button::new("crash-dismiss", "Don't send").variant(ButtonVariant::Ghost),
self.on_dismiss,
))
.children(action(
Button::new("crash-send", "Send report").primary(),
self.on_send,
)),
)
}
}
+287
View File
@@ -0,0 +1,287 @@
use gpui::{
Animation, AnimationExt, AnyElement, App, AppContext, Bounds, IntoElement, ParentElement,
RenderOnce, SharedString, Styled, Window, WindowBounds, WindowHandle, WindowOptions, div,
prelude::*, relative,
};
use super::{Hosted, open_hosted};
use crate::{
motion,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::{Caption, CopyableText, ExternalLink, Paragraph, Title},
};
fn mark(name: &SharedString, icon: Option<IconName>, cx: &App) -> impl IntoElement + use<> {
let theme = cx.theme();
let initial: SharedString = name.chars().take(1).collect::<String>().into();
div()
.flex()
.items_center()
.justify_center()
.size(theme.control_height(ControlSize::Lg) * 2.0)
.rounded(theme.radius(Radius::Xl))
.bg(theme.colors.sunken)
.border_1()
.border_color(theme.colors.border)
.text_size(theme.text_size(TextSize::Xxl))
.text_color(theme.colors.fg)
.map(|tile| match icon {
Some(icon) => tile.child(Icon::new(icon).size(IconSize::Xxl)),
None => tile.child(initial),
})
}
/// A thin track: `Some` fills to the value, `None` sweeps.
pub(super) fn progress_track(value: Option<f32>, cx: &App) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let fill = div()
.absolute()
.top_0()
.h_full()
.rounded_full()
.bg(colors.fg);
let track = div()
.relative()
.w_full()
.h(theme.progress_thickness())
.rounded_full()
.overflow_hidden()
.bg(colors.border);
match value {
Some(value) => {
assert!(
(0.0..=1.0).contains(&value),
"progress {value} is not 0..=1"
);
track
.child(fill.left_0().w(relative(value)))
.into_any_element()
}
None if theme.reduced_motion => track
.child(fill.left(relative(0.35)).w(relative(0.3)))
.into_any_element(),
None => track
.child(
fill.w(relative(0.3)).with_animation(
"progress-sweep",
Animation::new(motion::duration(motion::SLOW, cx) * 4)
.repeat()
.with_easing(motion::ease_in_out_cubic),
|bar, t| bar.left(relative(-0.3 + 1.3 * t)),
),
)
.into_any_element(),
}
}
/// Name, version, credits: the About window's content.
#[derive(IntoElement, Clone)]
pub struct AboutDialog {
name: SharedString,
version: SharedString,
icon: Option<IconName>,
description: Option<SharedString>,
links: Vec<(SharedString, SharedString)>,
copyright: Option<SharedString>,
}
impl AboutDialog {
pub fn new(name: impl Into<SharedString>, version: impl Into<SharedString>) -> Self {
Self {
name: name.into(),
version: version.into(),
icon: None,
description: None,
links: Vec::new(),
copyright: None,
}
}
/// App glyph; the name's initial stands in without one.
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
pub fn description(mut self, text: impl Into<SharedString>) -> Self {
self.description = Some(text.into());
self
}
pub fn link(mut self, label: impl Into<SharedString>, url: impl Into<SharedString>) -> Self {
self.links.push((label.into(), url.into()));
self
}
pub fn copyright(mut self, text: impl Into<SharedString>) -> Self {
self.copyright = Some(text.into());
self
}
/// Shows it in its own window; Escape closes it.
pub fn open(self, cx: &mut App) -> anyhow::Result<WindowHandle<Hosted<AboutDialog>>> {
let size = cx.theme().about_window();
open_hosted(format!("About {}", self.name), size, self, cx)
}
}
impl RenderOnce for AboutDialog {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
div()
.flex()
.flex_col()
.items_center()
.gap_2()
.px_8()
.pt_4()
.pb_8()
.w_full()
.child(mark(&self.name, self.icon, cx))
.child(div().pt_3().child(Title::new(self.name.clone())))
.child(CopyableText::new(
"about-version",
format!("Version {}", self.version),
))
.when_some(self.description, |about, text| {
about.child(div().pt_2().text_center().child(Paragraph::new(text)))
})
.when(!self.links.is_empty(), |about| {
about.child(
div()
.flex()
.gap_4()
.pt_2()
.children(
self.links
.into_iter()
.enumerate()
.map(|(ix, (label, url))| {
ExternalLink::new(("about-link", ix), label, url)
}),
),
)
})
.when_some(self.copyright, |about, text| {
about.child(div().pt_4().child(Caption::new(text)))
})
}
}
/// First screen while an app loads: mark, name, progress.
#[derive(IntoElement, Clone)]
pub struct SplashScreen {
name: SharedString,
icon: Option<IconName>,
tagline: Option<SharedString>,
status: Option<SharedString>,
progress: Option<f32>,
}
impl SplashScreen {
pub fn new(name: impl Into<SharedString>) -> Self {
Self {
name: name.into(),
icon: None,
tagline: None,
status: None,
progress: None,
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
pub fn tagline(mut self, text: impl Into<SharedString>) -> Self {
self.tagline = Some(text.into());
self
}
pub fn status(mut self, text: impl Into<SharedString>) -> Self {
self.status = Some(text.into());
self
}
/// 0 to 1; `None` shows a moving bar.
pub fn progress(mut self, progress: Option<f32>) -> Self {
if let Some(value) = progress {
assert!(
(0.0..=1.0).contains(&value),
"splash progress {value} is not 0..=1"
);
}
self.progress = progress;
self
}
/// Shows it borderless and centered. Close it with `window.remove_window()`.
pub fn open(self, cx: &mut App) -> anyhow::Result<WindowHandle<Hosted<SplashScreen>>> {
let size = cx.theme().splash_window();
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(None, size, cx))),
titlebar: None,
is_movable: false,
is_resizable: false,
is_minimizable: false,
..Default::default()
};
let handle = cx.open_window(options, |window, cx| {
let focus = cx.focus_handle();
window.focus(&focus);
cx.new(|_| Hosted::new(self, focus))
})?;
log::info!("splash: shown");
Ok(handle)
}
}
impl RenderOnce for SplashScreen {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let enter = motion::duration(motion::SLOW, cx);
let track = progress_track(self.progress, cx);
div()
.size_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.px_10()
.child(
div()
.flex()
.flex_col()
.items_center()
.gap_2()
.child(mark(&self.name, self.icon, cx))
.child(div().pt_2().child(Title::new(self.name.clone())))
.when_some(self.tagline, |block, text| block.child(Caption::new(text)))
.with_animation(
"splash-in",
Animation::new(enter).with_easing(motion::ease_out_cubic),
|block, t| block.opacity(t).mt(motion::NUDGE * 2.0 * (1.0 - t)),
),
)
.child(
div()
.w(relative(0.6))
.pt_8()
.flex()
.flex_col()
.items_center()
.gap_2()
.child(track)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(self.status.unwrap_or_default()),
),
)
}
}
+137
View File
@@ -0,0 +1,137 @@
use gpui::{
AnyElement, App, Bounds, CursorStyle, Decorations, HitboxBehavior, IntoElement, MouseButton,
ParentElement, Pixels, Point, RenderOnce, ResizeEdge, Size, Styled, Window, canvas, div, point,
prelude::*,
};
use smallvec::SmallVec;
use crate::theme::{ActiveTheme, Elevation, Radius};
/// Which edge or corner `pos` grabs, within `inset` of the window's rim.
fn resize_edge(pos: Point<Pixels>, inset: Pixels, size: Size<Pixels>) -> Option<ResizeEdge> {
let (top, bottom) = (pos.y < inset, pos.y > size.height - inset);
let (left, right) = (pos.x < inset, pos.x > size.width - inset);
Some(match (top, bottom, left, right) {
(true, _, true, _) => ResizeEdge::TopLeft,
(true, _, _, true) => ResizeEdge::TopRight,
(true, ..) => ResizeEdge::Top,
(_, true, true, _) => ResizeEdge::BottomLeft,
(_, true, _, true) => ResizeEdge::BottomRight,
(_, true, ..) => ResizeEdge::Bottom,
(.., true, _) => ResizeEdge::Left,
(.., true) => ResizeEdge::Right,
_ => return None,
})
}
fn cursor(edge: ResizeEdge) -> CursorStyle {
match edge {
ResizeEdge::Top | ResizeEdge::Bottom => CursorStyle::ResizeUpDown,
ResizeEdge::Left | ResizeEdge::Right => CursorStyle::ResizeLeftRight,
ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorStyle::ResizeUpLeftDownRight,
ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorStyle::ResizeUpRightDownLeft,
}
}
/// Resize margin, hairline and shadow for a client-drawn window; else a plain box.
#[derive(IntoElement, Default)]
pub struct ResizeBorder {
children: SmallVec<[AnyElement; 2]>,
}
impl ResizeBorder {
pub fn new() -> Self {
Self::default()
}
}
impl ParentElement for ResizeBorder {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for ResizeBorder {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let Decorations::Client { tiling } = window.window_decorations() else {
return div().size_full().children(self.children).into_any_element();
};
let theme = cx.theme();
let inset = theme.window_inset().to_pixels(window.rem_size());
window.set_client_inset(inset);
let radius = theme.radius(Radius::Lg);
let floating = !tiling.is_tiled();
div()
.id("resize-border")
.size_full()
.child(
canvas(
|_, window, _| {
let size = window.window_bounds().get_bounds().size;
window.insert_hitbox(
Bounds::new(point(Pixels::ZERO, Pixels::ZERO), size),
HitboxBehavior::Normal,
)
},
move |_, hitbox, window, _| {
let size = window.window_bounds().get_bounds().size;
if let Some(edge) = resize_edge(window.mouse_position(), inset, size) {
window.set_cursor_style(cursor(edge), &hitbox);
}
},
)
.absolute()
.size_full(),
)
.when(!tiling.top, |edge| edge.pt(inset))
.when(!tiling.bottom, |edge| edge.pb(inset))
.when(!tiling.left, |edge| edge.pl(inset))
.when(!tiling.right, |edge| edge.pr(inset))
.on_mouse_move(|_, window, _| window.refresh())
.on_mouse_down(MouseButton::Left, move |event, window, _| {
let size = window.window_bounds().get_bounds().size;
if let Some(edge) = resize_edge(event.position, inset, size) {
log::info!("window: resize from {edge:?}");
window.start_window_resize(edge);
}
})
.child(
div()
.size_full()
.overflow_hidden()
.cursor(CursorStyle::Arrow)
.bg(theme.colors.bg)
.when(floating, |pane| {
pane.rounded(radius)
.border_1()
.border_color(theme.colors.border_strong)
.shadow(theme.elevation(Elevation::Modal))
})
.on_mouse_move(|_, _, cx| cx.stop_propagation())
.children(self.children),
)
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use gpui::{ResizeEdge, point, px, size};
use super::resize_edge;
#[test]
fn corners_win_over_edges_and_the_middle_is_free() {
let window = size(px(400.0), px(300.0));
let at = |x: f32, y: f32| resize_edge(point(px(x), px(y)), px(10.0), window);
assert_eq!(at(2.0, 2.0), Some(ResizeEdge::TopLeft));
assert_eq!(at(398.0, 5.0), Some(ResizeEdge::TopRight));
assert_eq!(at(200.0, 3.0), Some(ResizeEdge::Top));
assert_eq!(at(1.0, 299.0), Some(ResizeEdge::BottomLeft));
assert_eq!(at(395.0, 295.0), Some(ResizeEdge::BottomRight));
assert_eq!(at(200.0, 295.0), Some(ResizeEdge::Bottom));
assert_eq!(at(4.0, 150.0), Some(ResizeEdge::Left));
assert_eq!(at(396.0, 150.0), Some(ResizeEdge::Right));
assert_eq!(at(200.0, 150.0), None);
}
}
+25
View File
@@ -0,0 +1,25 @@
mod activity;
mod bars;
mod chrome;
mod crash;
mod dialogs;
mod frame;
mod native;
mod status;
mod switcher;
mod tabs;
mod update;
mod windows;
pub use activity::{ActivityBar, ActivityItem};
pub use bars::{StatusBar, StatusBarItem, Toolbar, ToolbarGroup, ToolbarSeparator};
pub use chrome::{ControlsStyle, TitleBar, WindowControls, drag_region};
pub use crash::CrashReporter;
pub use dialogs::{AboutDialog, SplashScreen};
pub use frame::ResizeBorder;
pub use native::{TrayIcon, TrayItem, dock_badge, set_dock_badge};
pub use status::{Connectivity, OfflineIndicator, ZoomControl};
pub use switcher::WindowSwitcher;
pub use tabs::{TabBar, WindowTab};
pub use update::{UpdateBanner, UpdateDialog, UpdateState};
pub use windows::{Corner, Hosted, MiniWindow, WindowManager, open_hosted};
+245
View File
@@ -0,0 +1,245 @@
use std::rc::Rc;
use gpui::{App, SharedString};
/// One entry in a tray menu.
#[derive(Clone, Debug)]
pub enum TrayItem {
Action(SharedString),
Separator,
}
type OnPick = Rc<dyn Fn(usize, &mut App)>;
/// An icon in the system menu bar with a menu. Dropping it removes it.
pub struct TrayIcon {
#[cfg(target_os = "macos")]
inner: mac::Tray,
}
impl TrayIcon {
/// `symbol` names an SF Symbol. `on_pick` gets the index of the chosen item.
pub fn new(
symbol: &str,
items: Vec<TrayItem>,
on_pick: impl Fn(usize, &mut App) + 'static,
cx: &mut App,
) -> anyhow::Result<Self> {
let on_pick: OnPick = Rc::new(on_pick);
#[cfg(target_os = "macos")]
return Ok(Self {
inner: mac::Tray::new(symbol, &items, on_pick, cx)?,
});
#[cfg(not(target_os = "macos"))]
{
let _ = (symbol, items, on_pick, cx);
anyhow::bail!("tray icons need macOS in this build")
}
}
/// Chooses item `index` as a click would.
pub fn pick(&self, index: usize) {
#[cfg(target_os = "macos")]
self.inner.pick(index);
#[cfg(not(target_os = "macos"))]
let _ = index;
}
}
/// Sets or clears the badge on the app's Dock icon.
pub fn set_dock_badge(label: Option<&str>) -> anyhow::Result<()> {
#[cfg(target_os = "macos")]
{
mac::set_badge(label);
log::info!("dock badge: {label:?}");
Ok(())
}
#[cfg(not(target_os = "macos"))]
{
let _ = label;
anyhow::bail!("dock badges need macOS in this build")
}
}
/// The badge the Dock shows now.
pub fn dock_badge() -> anyhow::Result<Option<String>> {
#[cfg(target_os = "macos")]
return Ok(mac::badge());
#[cfg(not(target_os = "macos"))]
anyhow::bail!("dock badges need macOS in this build")
}
#[cfg(target_os = "macos")]
mod mac {
use std::{ffi::CStr, sync::Once};
use cocoa::{
appkit::{
NSApp, NSMenu, NSMenuItem, NSStatusBar, NSStatusItem, NSVariableStatusItemLength,
},
base::{id, nil},
foundation::{NSAutoreleasePool, NSString},
};
use gpui::{App, AsyncApp};
use objc::{
class,
declare::ClassDecl,
msg_send,
runtime::{Class, Object, Sel},
sel, sel_impl,
};
use super::{OnPick, TrayItem};
const TARGET: &str = "ElyTrayTarget";
const ROUTE: &str = "elyRoute";
struct Route {
app: AsyncApp,
on_pick: OnPick,
}
impl Route {
fn pick(&self, index: usize) {
log::info!("tray: picked item {index}");
let on_pick = self.on_pick.clone();
self.app
.spawn(async move |cx| {
if let Err(error) = cx.update(|cx| on_pick(index, cx)) {
log::error!("tray: app gone before item {index} ran: {error:#}");
}
})
.detach();
}
}
extern "C" fn picked(this: &Object, _: Sel, item: id) {
unsafe {
let route = *this.get_ivar::<*const std::ffi::c_void>(ROUTE) as *const Route;
let tag: isize = msg_send![item, tag];
(*route).pick(tag as usize);
}
}
fn target_class() -> &'static Class {
static REGISTER: Once = Once::new();
REGISTER.call_once(|| {
let mut decl = ClassDecl::new(TARGET, class!(NSObject))
.unwrap_or_else(|| panic!("objc class {TARGET} already exists"));
decl.add_ivar::<*const std::ffi::c_void>(ROUTE);
unsafe {
decl.add_method(sel!(elyPicked:), picked as extern "C" fn(&Object, Sel, id));
}
decl.register();
});
Class::get(TARGET).expect("registered above")
}
fn string(text: &str) -> id {
unsafe { NSString::alloc(nil).init_str(text).autorelease() }
}
pub struct Tray {
item: id,
menu: id,
target: id,
_route: Box<Route>,
}
impl Tray {
pub fn new(
symbol: &str,
items: &[TrayItem],
on_pick: OnPick,
cx: &mut App,
) -> anyhow::Result<Self> {
let route = Box::new(Route {
app: cx.to_async(),
on_pick,
});
unsafe {
let image: id = msg_send![class!(NSImage),
imageWithSystemSymbolName: string(symbol)
accessibilityDescription: nil];
anyhow::ensure!(image != nil, "no SF Symbol named {symbol}");
let target: id = msg_send![target_class(), new];
(*target).set_ivar(ROUTE, &*route as *const Route as *const std::ffi::c_void);
let menu = NSMenu::new(nil);
for (index, entry) in items.iter().enumerate() {
let entry = match entry {
TrayItem::Separator => NSMenuItem::separatorItem(nil),
TrayItem::Action(label) => {
let entry = NSMenuItem::alloc(nil)
.initWithTitle_action_keyEquivalent_(
string(label),
sel!(elyPicked:),
string(""),
)
.autorelease();
entry.setTarget_(target);
entry
}
};
let _: () = msg_send![entry, setTag: index as isize];
menu.addItem_(entry);
}
let item = NSStatusBar::systemStatusBar(nil)
.statusItemWithLength_(NSVariableStatusItemLength);
let item: id = msg_send![item, retain];
let button = item.button();
anyhow::ensure!(button != nil, "status item has no button");
let _: () = msg_send![button, setImage: image];
let _: () = msg_send![item, setMenu: menu];
log::info!("tray: shown with {} items", items.len());
Ok(Self {
item,
menu,
target,
_route: route,
})
}
}
pub fn pick(&self, index: usize) {
unsafe {
let count: isize = msg_send![self.menu, numberOfItems];
assert!((index as isize) < count, "tray has no item {index}");
let _: () = msg_send![self.menu, performActionForItemAtIndex: index as isize];
}
}
}
impl Drop for Tray {
fn drop(&mut self) {
unsafe {
NSStatusBar::systemStatusBar(nil).removeStatusItem_(self.item);
let _: () = msg_send![self.item, release];
let _: () = msg_send![self.menu, release];
let _: () = msg_send![self.target, release];
}
log::info!("tray: removed");
}
}
fn tile() -> id {
unsafe { msg_send![NSApp(), dockTile] }
}
pub fn set_badge(label: Option<&str>) {
unsafe {
let text = label.map_or(nil, string);
let _: () = msg_send![tile(), setBadgeLabel: text];
}
}
pub fn badge() -> Option<String> {
unsafe {
let label: id = msg_send![tile(), badgeLabel];
(label != nil).then(|| {
CStr::from_ptr(label.UTF8String())
.to_string_lossy()
.into_owned()
})
}
}
}
+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);
}
}
+180
View File
@@ -0,0 +1,180 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, AnyWindowHandle, App, ElementId, Entity, FocusHandle,
InteractiveElement, IntoElement, ParentElement, RenderOnce, StatefulInteractiveElement, Styled,
Window, div, prelude::*,
};
use super::WindowManager;
use crate::{
motion,
primitives::{Backdrop, FocusNext, FocusPrev, Icon, IconName, Place},
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
typography::Caption,
};
struct Switch {
selected: usize,
focus: FocusHandle,
opened: bool,
previous: Option<FocusHandle>,
}
type Close = Rc<dyn Fn(&mut Window, &mut App)>;
/// Lists managed windows over a scrim. Arrows or Tab move, Enter switches.
#[derive(IntoElement)]
pub struct WindowSwitcher {
id: ElementId,
on_close: Close,
}
impl WindowSwitcher {
pub fn new(
id: impl Into<ElementId>,
on_close: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
on_close: Rc::new(on_close),
}
}
}
fn step(state: &Entity<Switch>, by: isize, count: usize, cx: &mut App) {
if count == 0 {
return;
}
state.update(cx, |switch, cx| {
switch.selected = (switch.selected as isize + by).rem_euclid(count as isize) as usize;
cx.notify();
});
}
impl RenderOnce for WindowSwitcher {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let windows = WindowManager::windows(cx);
let count = windows.len();
let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| Switch {
selected: 0,
focus: cx.focus_handle(),
opened: false,
previous: None,
});
if !state.read(cx).opened {
let previous = window.focused(cx);
window.focus(&state.read(cx).focus.clone());
state.update(cx, |switch, _| {
switch.opened = true;
switch.previous = previous;
});
}
if count > 0 && state.read(cx).selected >= count {
state.update(cx, |switch, _| switch.selected = count - 1);
}
let selected = state.read(cx).selected;
let close: Close = {
let (state, on_close) = (state.clone(), self.on_close);
Rc::new(move |window, cx| {
match state.read(cx).previous.clone() {
Some(previous) => {
window.focus(&previous);
log::info!("window switcher: closed, focus handed back");
}
None => log::info!("window switcher: closed, nothing was focused before"),
}
on_close(window, cx)
})
};
let theme = cx.theme();
let colors = &theme.colors;
let rows: Vec<_> = windows
.iter()
.enumerate()
.map(|(ix, (handle, title))| {
let (handle, close) = (*handle, close.clone());
div()
.id(("switch", ix))
.flex()
.items_center()
.gap_3()
.h(theme.control_height(ControlSize::Lg))
.px_3()
.rounded(theme.radius(Radius::Md))
.text_size(theme.text_size(TextSize::Base))
.when(ix == selected, |row| row.bg(colors.hover))
.cursor_pointer()
.on_click(move |_, window, cx| switch_to(handle, &close, window, cx))
.child(
Icon::new(IconName::AppWindow)
.size(IconSize::Sm)
.color(colors.fg_muted),
)
.child(title.clone())
})
.collect();
let handles: Vec<AnyWindowHandle> = windows.iter().map(|(handle, _)| *handle).collect();
let (next, prev, keys) = (state.clone(), state.clone(), state.clone());
let (close_keys, close_scrim) = (close.clone(), close);
let focus = state.read(cx).focus.clone();
let panel = div()
.id("window-switcher")
.track_focus(&focus)
.w(theme.sheet_size())
.p_2()
.flex()
.flex_col()
.gap_0p5()
.rounded(theme.radius(Radius::Xl))
.bg(colors.overlay)
.border_1()
.border_color(colors.border)
.shadow(theme.elevation(Elevation::Modal))
.on_action(move |_: &FocusNext, _, cx| step(&next, 1, count, cx))
.on_action(move |_: &FocusPrev, _, cx| step(&prev, -1, count, cx))
.on_key_down(move |event, window, cx| {
match event.keystroke.key.as_str() {
"down" => step(&keys, 1, count, cx),
"up" => step(&keys, -1, count, cx),
"enter" => {
if let Some(handle) = handles.get(keys.read(cx).selected) {
switch_to(*handle, &close_keys, window, cx);
}
}
"escape" => close_keys(window, cx),
_ => return,
}
cx.stop_propagation();
})
.children(rows)
.when(count == 0, |panel| {
panel.child(
div()
.p_3()
.child(Caption::new("No managed windows are open.")),
)
})
.with_animation(
"switcher-in",
Animation::new(motion::duration(motion::BASE, cx))
.with_easing(motion::ease_out_cubic),
|panel, t| panel.opacity(t).mt(motion::NUDGE * (1.0 - t)),
);
Backdrop::new(self.id)
.place(Place::Center)
.on_dismiss(move |window, cx| close_scrim(window, cx))
.child(panel)
}
}
fn switch_to(handle: AnyWindowHandle, close: &Close, window: &mut Window, cx: &mut App) {
close(window, cx);
if handle == window.window_handle() {
window.activate_window();
return;
}
if let Err(error) = WindowManager::focus(handle, cx) {
log::error!("window switcher: {error:#}");
}
}
+249
View File
@@ -0,0 +1,249 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, App, ElementId, EntityId, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
Window, div, prelude::*,
};
use crate::{
buttons::IconButton,
motion,
primitives::{DragGhost, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// One window-level tab.
#[derive(Clone)]
pub struct WindowTab {
id: SharedString,
title: SharedString,
icon: Option<IconName>,
}
impl WindowTab {
pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
title: title.into(),
icon: None,
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
}
struct TabDrag {
owner: EntityId,
from: usize,
}
type OnTab = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
type OnAdd = Rc<dyn Fn(&mut Window, &mut App)>;
type OnReorder = Rc<dyn Fn(usize, usize, &mut Window, &mut App)>;
/// Browser-style tabs across the top of a window.
#[derive(IntoElement)]
pub struct TabBar {
id: ElementId,
tabs: Vec<WindowTab>,
selected: Option<SharedString>,
on_select: Option<OnTab>,
on_close: Option<OnTab>,
on_add: Option<OnAdd>,
on_reorder: Option<OnReorder>,
}
impl TabBar {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
tabs: Vec::new(),
selected: None,
on_select: None,
on_close: None,
on_add: None,
on_reorder: None,
}
}
pub fn tab(mut self, tab: WindowTab) -> Self {
self.tabs.push(tab);
self
}
pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
self.selected = Some(id.into());
self
}
pub fn on_select(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
/// Shows a close button on the selected and hovered tab.
pub fn on_close(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_close = Some(Rc::new(handler));
self
}
/// Shows a new-tab button after the tabs.
pub fn on_add(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_add = Some(Rc::new(handler));
self
}
/// Lets tabs drag; reports `(from, to)` indexes on drop.
pub fn on_reorder(
mut self,
handler: impl Fn(usize, usize, &mut Window, &mut App) + 'static,
) -> Self {
self.on_reorder = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TabBar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let owner = window
.use_keyed_state(self.id.clone(), cx, |_, _| ())
.entity_id();
let theme = cx.theme();
let (narrow, wide) = theme.tab_width();
let enter = motion::duration(motion::BASE, cx);
let nudge = motion::NUDGE;
let tabs = self.tabs.into_iter().enumerate().map(|(ix, tab)| {
let theme = cx.theme();
let colors = &theme.colors;
let selected = self.selected.as_ref() == Some(&tab.id);
let group = SharedString::from(format!("window-tab-{}", tab.id));
let (select, close, reorder) = (
self.on_select.clone(),
self.on_close.clone(),
self.on_reorder.clone(),
);
let (id, close_id) = (tab.id.clone(), tab.id.clone());
let (title, icon) = (tab.title.clone(), tab.icon);
let (lit, edge) = (colors.hover, colors.focus);
div()
.id(tab.id.clone())
.group(group.clone())
.flex()
.flex_1()
.items_center()
.gap_2()
.min_w(narrow)
.max_w(wide)
.h(theme.control_height(ControlSize::Md))
.pl_3()
.pr_1()
.rounded(theme.radius(Radius::Md))
.border_1()
.text_size(theme.text_size(TextSize::Sm))
.map(|el| {
if selected {
el.bg(colors.surface)
.border_color(colors.border)
.text_color(colors.fg)
.font_weight(FontWeight::MEDIUM)
} else {
el.border_color(gpui::transparent_black())
.text_color(colors.fg_muted)
.hover(|style| style.bg(lit))
}
})
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
log::info!("tab bar: select {id}");
if let Some(select) = &select {
select(&id, window, cx);
}
})
.when_some(reorder, |el, reorder| {
el.on_drag(TabDrag { owner, from: ix }, move |_, _, _, cx| {
DragGhost::new(title.clone(), icon, cx)
})
.drag_over::<TabDrag>(move |style, drag, _, _| {
if drag.owner == owner {
style.border_color(edge)
} else {
style
}
})
.on_drop(move |drag: &TabDrag, window, cx| {
if drag.owner == owner && drag.from != ix {
log::info!("tab bar: move {} -> {ix}", drag.from);
reorder(drag.from, ix, window, cx);
}
})
})
.when_some(tab.icon, |el, icon| {
el.child(Icon::new(icon).size(IconSize::Sm).color(if selected {
colors.fg
} else {
colors.fg_subtle
}))
})
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.child(tab.title.clone()),
)
.when_some(close, |el, close| {
el.child(
div()
.when(!selected, |slot| {
slot.invisible().group_hover(group, |style| style.visible())
})
.child(
IconButton::new(("close", ix), IconName::X)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
cx.stop_propagation();
log::info!("tab bar: close {close_id}");
close(&close_id, window, cx);
}),
),
)
})
.with_animation(
SharedString::from(format!("tab-in-{}", tab.id)),
Animation::new(enter).with_easing(motion::ease_out_cubic),
move |el, t| el.opacity(t).mt(nudge * (1.0 - t)),
)
});
div()
.id(self.id)
.flex()
.items_center()
.gap_1()
.w_full()
.min_w_0()
.children(tabs)
.when_some(self.on_add, |bar, add| {
bar.child(
IconButton::new("new-tab", IconName::Plus)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("tab bar: new tab");
add(window, cx)
}),
)
})
}
}
+270
View File
@@ -0,0 +1,270 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, WindowHandle, div, prelude::*,
};
use super::{Hosted, dialogs::progress_track, open_hosted};
use crate::{
buttons::{Button, ButtonVariant, IconButton},
layout::ScrollArea,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::{Caption, Label, Title},
};
/// Where an update stands.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum UpdateState {
Available,
/// 0 to 1.
Downloading(f32),
Ready,
}
type Action = Rc<dyn Fn(&mut Window, &mut App)>;
/// Handlers for the update flow. Unset ones hide their button.
#[derive(Clone, Default)]
struct Actions {
download: Option<Action>,
restart: Option<Action>,
later: Option<Action>,
}
fn progress_line(state: UpdateState, cx: &App) -> Option<AnyElement> {
match state {
UpdateState::Downloading(value) => Some(progress_track(Some(value), cx)),
_ => None,
}
}
fn buttons(state: UpdateState, actions: &Actions, size: ControlSize) -> Vec<Button> {
let wire = |button: Button, action: &Option<Action>| {
action.clone().map(|action| {
button
.size(size)
.on_click(move |_, window, cx| action(window, cx))
})
};
let later = wire(
Button::new("update-later", "Later").variant(ButtonVariant::Ghost),
&actions.later,
);
let main = match state {
UpdateState::Available => wire(
Button::new("update-download", "Download").primary(),
&actions.download,
),
UpdateState::Downloading(_) => None,
UpdateState::Ready => wire(
Button::new("update-restart", "Restart to update").primary(),
&actions.restart,
),
};
later.into_iter().chain(main).collect()
}
macro_rules! update_actions {
() => {
/// Starts the download while `Available`.
pub fn on_download(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.actions.download = Some(Rc::new(handler));
self
}
/// Relaunches once `Ready`.
pub fn on_restart(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.actions.restart = Some(Rc::new(handler));
self
}
pub fn on_later(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.actions.later = Some(Rc::new(handler));
self
}
};
}
/// A slim bar that offers a new version, shows the download, then asks to restart.
#[derive(IntoElement, Clone)]
pub struct UpdateBanner {
id: ElementId,
version: SharedString,
state: UpdateState,
actions: Actions,
}
impl UpdateBanner {
pub fn new(
id: impl Into<ElementId>,
version: impl Into<SharedString>,
state: UpdateState,
) -> Self {
Self {
id: id.into(),
version: version.into(),
state,
actions: Actions::default(),
}
}
update_actions!();
}
impl RenderOnce for UpdateBanner {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let message = match self.state {
UpdateState::Available => format!("Version {} is available.", self.version),
UpdateState::Downloading(value) => {
format!("Downloading {}… {}%", self.version, (value * 100.0).round())
}
UpdateState::Ready => format!("Version {} is ready.", self.version),
};
let dismiss = self.actions.later.clone();
div()
.id(self.id)
.relative()
.flex()
.items_center()
.gap_3()
.w_full()
.py_2()
.pl_4()
.pr_2()
.bg(theme.colors.surface)
.border_b_1()
.border_color(theme.colors.border)
.text_size(theme.text_size(TextSize::Sm))
.child(
Icon::new(IconName::Download)
.size(IconSize::Sm)
.color(theme.colors.fg_muted),
)
.child(div().flex_1().child(message))
.children(buttons(
self.state,
&Actions {
later: None,
..self.actions.clone()
},
ControlSize::Sm,
))
.when_some(dismiss, |banner, dismiss| {
banner.child(
IconButton::new("update-dismiss", IconName::X)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| dismiss(window, cx)),
)
})
.when_some(progress_line(self.state, cx), |banner, line| {
banner.child(div().absolute().left_0().right_0().bottom_0().child(line))
})
}
}
/// The update window: what changed, and what to do.
#[derive(IntoElement, Clone)]
pub struct UpdateDialog {
name: SharedString,
current: SharedString,
version: SharedString,
notes: Vec<SharedString>,
state: UpdateState,
actions: Actions,
}
impl UpdateDialog {
pub fn new(
name: impl Into<SharedString>,
current: impl Into<SharedString>,
version: impl Into<SharedString>,
state: UpdateState,
) -> Self {
Self {
name: name.into(),
current: current.into(),
version: version.into(),
notes: Vec::new(),
state,
actions: Actions::default(),
}
}
/// One line of release notes.
pub fn note(mut self, line: impl Into<SharedString>) -> Self {
self.notes.push(line.into());
self
}
pub fn state(mut self, state: UpdateState) -> Self {
self.state = state;
self
}
update_actions!();
pub fn open(self, cx: &mut App) -> anyhow::Result<WindowHandle<Hosted<UpdateDialog>>> {
let size = cx.theme().dialog_window();
open_hosted("Software Update", size, self, cx)
}
}
impl RenderOnce for UpdateDialog {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let headline = match self.state {
UpdateState::Ready => format!("{} {} is ready", self.name, self.version),
_ => format!("{} {} is available", self.name, self.version),
};
div()
.flex_1()
.min_h_0()
.flex()
.flex_col()
.gap_4()
.px_8()
.pb_6()
.child(
div()
.flex()
.flex_col()
.gap_1()
.child(Title::new(headline))
.child(Caption::new(format!("You have {}.", self.current))),
)
.child(
div()
.flex_1()
.min_h_0()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.sunken)
.child(
ScrollArea::new("update-notes").size_full().child(
div().flex().flex_col().gap_2().p_4().children(
self.notes.into_iter().map(|line| {
div()
.flex()
.gap_2()
.child(Label::new("·"))
.child(Label::new(line))
}),
),
),
),
)
.when_some(progress_line(self.state, cx), |dialog, line| {
dialog.child(line)
})
.child(div().flex().justify_end().gap_2().children(buttons(
self.state,
&self.actions,
ControlSize::Md,
)))
}
}
+267
View File
@@ -0,0 +1,267 @@
use anyhow::Context as _;
use gpui::{
AnyView, AnyWindowHandle, App, AppContext, Bounds, Context, Entity, FocusHandle, Global,
InteractiveElement, IntoElement, ParentElement, Pixels, Point, Render, SharedString, Size,
Styled, TitlebarOptions, Window, WindowBounds, WindowHandle, WindowKind, WindowOptions, div,
point, prelude::*,
};
use super::drag_region;
use crate::{
buttons::IconButton,
primitives::{FocusScope, IconName},
theme::{ActiveTheme, ControlSize, TextSize},
};
fn titlebar(title: SharedString, cx: &App) -> TitlebarOptions {
TitlebarOptions {
title: Some(title),
appears_transparent: true,
traffic_light_position: Some(cx.theme().traffic_light_origin()),
}
}
/// A window whose content is one component. Escape closes it.
pub struct Hosted<T> {
pub content: T,
focus: FocusHandle,
}
impl<T> Hosted<T> {
pub(crate) fn new(content: T, focus: FocusHandle) -> Self {
Self { content, focus }
}
}
impl<T: IntoElement + Clone + 'static> Render for Hosted<T> {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let bar = drag_region("hosted-bar", window, cx);
let theme = cx.theme();
div()
.id("hosted")
.size_full()
.on_key_down(|event, window, _| {
if event.keystroke.key == "escape" {
window.remove_window();
}
})
.child(
FocusScope::new(&self.focus)
.size_full()
.flex()
.flex_col()
.bg(theme.colors.bg)
.text_color(theme.colors.fg)
.font_family(theme.font_family.clone())
.child(bar.flex_none().h(theme.titlebar_height()))
.child(self.content.clone()),
)
}
}
/// Opens `content` alone in a fixed-size window, centered.
pub fn open_hosted<T: IntoElement + Clone + 'static>(
title: impl Into<SharedString>,
size: Size<Pixels>,
content: T,
cx: &mut App,
) -> anyhow::Result<WindowHandle<Hosted<T>>> {
let title = title.into();
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(None, size, cx))),
titlebar: Some(titlebar(title.clone(), cx)),
is_resizable: false,
is_minimizable: false,
..Default::default()
};
let handle = cx.open_window(options, |window, cx| {
let focus = cx.focus_handle();
window.focus(&focus);
cx.new(|_| Hosted::new(content, focus))
})?;
log::info!("window: opened {title}");
Ok(handle)
}
/// Windows opened through it, oldest first, with where each opened.
#[derive(Default)]
pub struct WindowManager {
windows: Vec<(AnyWindowHandle, SharedString, Point<Pixels>)>,
}
impl Global for WindowManager {}
impl WindowManager {
/// Opens a window one title bar below and right of the newest.
pub fn open<V: Render + 'static>(
title: impl Into<SharedString>,
size: Size<Pixels>,
cx: &mut App,
build: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
) -> anyhow::Result<WindowHandle<V>> {
let title = title.into();
Self::windows(cx);
let theme = cx.theme();
let step = theme.titlebar_height().to_pixels(theme.base_rem());
let newest = cx
.default_global::<WindowManager>()
.windows
.last()
.map(|(_, _, at)| *at);
let bounds = match newest {
Some(at) => Bounds::new(at + point(step, step), size),
None => Bounds::centered(None, size, cx),
};
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(titlebar(title.clone(), cx)),
..Default::default()
};
let handle = cx.open_window(options, build)?;
cx.default_global::<WindowManager>().windows.push((
handle.into(),
title.clone(),
bounds.origin,
));
log::info!("window manager: opened {title}");
Ok(handle)
}
/// Managed windows still open, oldest first.
pub fn windows(cx: &mut App) -> Vec<(AnyWindowHandle, SharedString)> {
let open = cx.windows();
let manager = cx.default_global::<WindowManager>();
manager.windows.retain(|(handle, ..)| open.contains(handle));
manager
.windows
.iter()
.map(|(handle, title, _)| (*handle, title.clone()))
.collect()
}
pub fn focus(handle: AnyWindowHandle, cx: &mut App) -> anyhow::Result<()> {
log::info!("window manager: focus {:?}", handle.window_id());
handle.update(cx, |_, window, _| window.activate_window())
}
}
/// Screen corner for a mini window.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Corner {
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
/// A small window above all others, around a host view.
pub struct MiniWindow {
title: SharedString,
view: AnyView,
back: Option<AnyWindowHandle>,
}
impl MiniWindow {
/// Pinned to a display corner or centered; expanding returns to the opener.
pub fn open(
title: impl Into<SharedString>,
size: Size<Pixels>,
corner: Option<Corner>,
view: AnyView,
cx: &mut App,
) -> anyhow::Result<WindowHandle<MiniWindow>> {
let title = title.into();
let back = cx.active_window();
let area = cx
.primary_display()
.context("a mini window needs a display")?
.bounds();
let margin = cx.theme().window_margin();
let (left, top) = (area.left() + margin, area.top() + margin * 2.0);
let (right, bottom) = (
area.right() - margin - size.width,
area.bottom() - margin - size.height,
);
let bounds = match corner {
Some(Corner::TopLeft) => Bounds::new(point(left, top), size),
Some(Corner::TopRight) => Bounds::new(point(right, top), size),
Some(Corner::BottomLeft) => Bounds::new(point(left, bottom), size),
Some(Corner::BottomRight) => Bounds::new(point(right, bottom), size),
None => Bounds::centered(None, size, cx),
};
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: None,
kind: WindowKind::PopUp,
is_resizable: false,
is_minimizable: false,
..Default::default()
};
let handle = cx.open_window(options, |_, cx| {
cx.new(|_| MiniWindow {
title: title.clone(),
view,
back,
})
})?;
log::info!("mini window: opened {title} at {corner:?}");
Ok(handle)
}
}
impl Render for MiniWindow {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let bar = drag_region("mini-bar", window, cx);
let theme = cx.theme();
let back = self.back;
div()
.id("mini-window")
.group("mini-window")
.relative()
.size_full()
.bg(theme.colors.bg)
.text_color(theme.colors.fg)
.font_family(theme.font_family.clone())
.child(self.view.clone())
.child(
bar.absolute()
.top_0()
.left_0()
.right_0()
.flex()
.items_center()
.gap_1()
.h(theme.control_height(ControlSize::Lg))
.px_2()
.bg(theme.colors.overlay.opacity(0.92))
.border_b_1()
.border_color(theme.colors.border)
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.invisible()
.group_hover("mini-window", |style| style.visible())
.child(div().flex_1().child(self.title.clone()))
.when_some(back, |bar, back| {
bar.child(
IconButton::new("mini-expand", IconName::Maximize2)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
window.remove_window();
let returned =
back.update(cx, |_, window, _| window.activate_window());
if let Err(error) = returned {
log::warn!(
"mini window: no window to return to: {error:#}"
);
}
}),
)
})
.child(
IconButton::new("mini-close", IconName::X)
.size(ControlSize::Sm)
.on_click(|_, window, _| window.remove_window()),
),
)
}
}
+66 -1
View File
@@ -1,4 +1,4 @@
use gpui::{BoxShadow, Hsla, Rems, Size, point, px, rems, size};
use gpui::{BoxShadow, Hsla, Pixels, Point, Rems, Size, point, px, rems, size};
use super::{Mode, Theme};
@@ -139,6 +139,71 @@ impl Theme {
px_to_rems(160.0)
}
pub fn titlebar_height(&self) -> Rems {
px_to_rems(38.0)
}
/// Room kept for the macOS traffic lights.
pub fn traffic_light_inset(&self) -> Rems {
px_to_rems(78.0)
}
/// Where the system traffic lights sit in a window this crate opens.
pub fn traffic_light_origin(&self) -> Point<Pixels> {
point(px(14.0), px(13.0))
}
pub fn progress_thickness(&self) -> Rems {
px_to_rems(2.0)
}
pub fn about_window(&self) -> Size<Pixels> {
size(px(360.0), px(400.0))
}
/// Update and crash windows.
pub fn dialog_window(&self) -> Size<Pixels> {
size(px(400.0), px(480.0))
}
pub fn splash_window(&self) -> Size<Pixels> {
size(px(480.0), px(300.0))
}
/// A window's rem size at 100% zoom; gpui's own default.
pub fn base_rem(&self) -> Pixels {
px(16.0)
}
/// Gap between a mini window and the screen edge.
pub fn window_margin(&self) -> Pixels {
px(24.0)
}
/// One drawn traffic light.
pub fn traffic_light(&self) -> Rems {
px_to_rems(12.0)
}
/// A Windows caption button.
pub fn caption_button_width(&self) -> Rems {
px_to_rems(46.0)
}
/// Resize margin of a client-drawn window.
pub fn window_inset(&self) -> Rems {
px_to_rems(10.0)
}
pub fn rail_width(&self) -> Rems {
px_to_rems(48.0)
}
/// Narrowest and widest window tab.
pub fn tab_width(&self) -> (Rems, Rems) {
(px_to_rems(96.0), px_to_rems(200.0))
}
/// Grab bar on a drawer.
pub fn grip(&self) -> Size<Rems> {
size(px_to_rems(36.0), px_to_rems(4.0))