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
+3
-34
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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))))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:#}");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user