migrate ui components to gpui-kit

This commit is contained in:
2026-10-21 13:40:29 +07:00
parent 0d4acf5b65
commit d0ad1a6f57
173 changed files with 2775 additions and 10885 deletions
-445
View File
@@ -1,445 +0,0 @@
use gpui::{
AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement,
Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce,
SharedString, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px,
};
use theme::ActiveTheme;
use crate::{Selectable, Sizable, Size, StyledExt};
/// Number of rows and columns in the generated pixel grid.
const PIXEL_GRID: usize = 8;
/// Empty cells kept between the pattern and the avatar edge.
const MARGIN: usize = 1;
/// Probability that a cell in the left half of the pattern area is filled.
const FILL_PROBABILITY: f32 = 0.42;
/// Probability that a filled cell uses the accent shade instead of the main color.
const ACCENT_PROBABILITY: f32 = 0.25;
/// Minimum number of filled left-half cells, so a pattern never reads as empty.
const MIN_FILLED: usize = 5;
/// Fallback seed for an avatar that has neither a picture nor a seed of its own.
const FALLBACK_SEED: &str = "coop";
/// Number of segments used to approximate the avatar circle.
const CIRCLE_SEGMENTS: usize = 32;
/// Returns the size of the avatar based on the given [`Size`].
fn avatar_size(size: Size) -> AbsoluteLength {
match size {
Size::Large => px(64.).into(),
Size::Medium => px(32.).into(),
Size::Small => px(24.).into(),
Size::XSmall => px(18.).into(),
Size::Size(size) => size.into(),
}
}
/// A deterministic, offline pixel-art avatar derived from a seed.
#[derive(IntoElement)]
struct PixelAvatar {
seed: u64,
size: Size,
style: StyleRefinement,
}
impl PixelAvatar {
/// Creates a pixel avatar from `seed`.
fn new(seed: impl AsRef<str>) -> Self {
Self {
seed: fnv1a(seed.as_ref().as_bytes()),
size: Size::Medium,
style: StyleRefinement::default(),
}
}
}
impl Sizable for PixelAvatar {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl Styled for PixelAvatar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for PixelAvatar {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let side = avatar_size(self.size).to_pixels(window.rem_size());
let seed = self.seed;
canvas(
move |_bounds, _window, _cx| seed,
move |bounds, seed, window, cx| {
let theme = cx.theme();
let main = Hsla {
h: seed as f32 / u64::MAX as f32,
s: 0.6,
l: if theme.is_dark() { 0.6 } else { 0.45 },
a: 1.,
};
let shade = if theme.is_dark() {
Hsla {
l: (main.l * 1.6).min(0.95),
..main
}
} else {
Hsla {
l: (main.l * 0.45).max(0.18),
..main
}
};
let circle = circle_polygon(bounds.center(), bounds.size.width.as_f32() / 2.);
paint_polygons(window, std::iter::once(&circle), main.opacity(0.16));
let pattern = pixel_pattern(seed);
let mut cells = Vec::new();
for (value, color) in [(1u8, main), (2u8, shade)] {
cells.clear();
for row in 0..PIXEL_GRID {
for col in 0..PIXEL_GRID {
if pattern[row * PIXEL_GRID + col] != value {
continue;
}
let cell = clip_polygon(&cell_polygon(&bounds, row, col), &circle);
if cell.len() >= 3 {
cells.push(cell);
}
}
}
paint_polygons(window, cells.iter(), color);
}
},
)
.refine_style(&self.style)
.size(side)
.flex_shrink_0()
}
}
/// Builds the mirrored fill pattern for `seed`.
fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
let mut rng = PixelRng::new(seed);
let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID];
let mut filled = 0usize;
let art_rows = PIXEL_GRID - 2 * MARGIN;
let art_columns = PIXEL_GRID / 2 - MARGIN;
for row in MARGIN..PIXEL_GRID - MARGIN {
for col in MARGIN..PIXEL_GRID / 2 {
if rng.chance(FILL_PROBABILITY) {
let accent = rng.chance(ACCENT_PROBABILITY);
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
filled += 1;
}
}
}
if filled < MIN_FILLED {
let total = art_rows * art_columns;
let start = (rng.next() % total as u64) as usize;
for offset in 0..total {
if filled >= MIN_FILLED {
break;
}
let ix = (start + offset) % total;
let row = MARGIN + ix / art_columns;
let col = MARGIN + ix % art_columns;
if pattern[row * PIXEL_GRID + col] == 0 {
set_cell(&mut pattern, row, col, 1);
filled += 1;
}
}
}
pattern
}
/// Paints `polygons` as a single anti-aliased filled path in `color`.
fn paint_polygons<'a>(
window: &mut Window,
polygons: impl IntoIterator<Item = &'a Vec<Point<Pixels>>>,
color: Hsla,
) {
let mut builder = PathBuilder::fill();
let mut painted = false;
for polygon in polygons {
if polygon.len() >= 3 {
builder.add_polygon(polygon, true);
painted = true;
}
}
if painted && let Ok(path) = builder.build() {
window.paint_path(path, color);
}
}
/// Approximates the circle of `radius` around `center` as a convex polygon wound left-interior.
fn circle_polygon(center: Point<Pixels>, radius: f32) -> Vec<Point<Pixels>> {
let center_x = center.x.as_f32();
let center_y = center.y.as_f32();
(0..CIRCLE_SEGMENTS)
.map(|index| {
let angle = std::f32::consts::TAU * index as f32 / CIRCLE_SEGMENTS as f32;
point(
px(center_x + radius * angle.cos()),
px(center_y + radius * angle.sin()),
)
})
.collect()
}
/// The four corners of cell `(row, col)` of the grid laid out in `bounds`.
fn cell_polygon(bounds: &Bounds<Pixels>, row: usize, col: usize) -> [Point<Pixels>; 4] {
let cell = bounds.size.width.as_f32() / PIXEL_GRID as f32;
let left = bounds.origin.x.as_f32() + col as f32 * cell;
let top = bounds.origin.y.as_f32() + row as f32 * cell;
[
point(px(left), px(top)),
point(px(left + cell), px(top)),
point(px(left + cell), px(top + cell)),
point(px(left), px(top + cell)),
]
}
/// Clips `subject` to the convex `clip` polygon, keeping the part inside it.
fn clip_polygon(subject: &[Point<Pixels>], clip: &[Point<Pixels>]) -> Vec<Point<Pixels>> {
let mut current = subject.to_vec();
let mut next = Vec::with_capacity(subject.len() + 4);
for (&start, &end) in clip.iter().zip(clip.iter().cycle().skip(1)) {
if current.is_empty() {
break;
}
next.clear();
let mut previous = match current.last() {
Some(&vertex) => vertex,
None => break,
};
for &vertex in current.iter() {
let previous_inside = is_inside(start, end, previous);
let vertex_inside = is_inside(start, end, vertex);
if vertex_inside {
if !previous_inside
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
{
next.push(crossing);
}
next.push(vertex);
} else if previous_inside
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
{
next.push(crossing);
}
previous = vertex;
}
std::mem::swap(&mut current, &mut next);
}
current
}
/// Whether `vertex` lies on the interior side of the directed edge `start -> end`.
fn is_inside(start: Point<Pixels>, end: Point<Pixels>, vertex: Point<Pixels>) -> bool {
let start_x = start.x.as_f32();
let start_y = start.y.as_f32();
let edge_x = end.x.as_f32() - start_x;
let edge_y = end.y.as_f32() - start_y;
let to_vertex_x = vertex.x.as_f32() - start_x;
let to_vertex_y = vertex.y.as_f32() - start_y;
edge_x * to_vertex_y - edge_y * to_vertex_x >= 0.
}
/// The intersection of segment `from -> to` with the infinite line `start -> end`.
fn line_intersection(
start: Point<Pixels>,
end: Point<Pixels>,
from: Point<Pixels>,
to: Point<Pixels>,
) -> Option<Point<Pixels>> {
let start_x = start.x.as_f32();
let start_y = start.y.as_f32();
let edge_x = end.x.as_f32() - start_x;
let edge_y = end.y.as_f32() - start_y;
let from_x = from.x.as_f32();
let from_y = from.y.as_f32();
let segment_x = to.x.as_f32() - from_x;
let segment_y = to.y.as_f32() - from_y;
let denominator = edge_x * segment_y - edge_y * segment_x;
if denominator.abs() < f32::EPSILON {
return None;
}
let offset_x = from_x - start_x;
let offset_y = from_y - start_y;
let t = (edge_y * offset_x - edge_x * offset_y) / denominator;
Some(point(
px(from_x + segment_x * t),
px(from_y + segment_y * t),
))
}
/// Fills `cell (row, col)` and its horizontal mirror.
fn set_cell(pattern: &mut [u8; PIXEL_GRID * PIXEL_GRID], row: usize, col: usize, value: u8) {
pattern[row * PIXEL_GRID + col] = value;
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)] = value;
}
/// FNV-1a 64-bit hash, stable across platforms and runs.
fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325u64;
for &byte in bytes {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
/// Tiny xorshift64* PRNG for deriving the pattern from the seed.
struct PixelRng(u64);
impl PixelRng {
fn new(seed: u64) -> Self {
Self(seed.max(1))
}
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
fn chance(&mut self, probability: f32) -> bool {
self.next() as f32 / (u64::MAX as f32) < probability
}
}
/// Renders the generated pixel avatar shown in place of a missing picture.
fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement {
PixelAvatar::new(seed.unwrap_or(FALLBACK_SEED))
.with_size(size)
.into_any_element()
}
/// An element that renders a user avatar with customizable appearance options.
#[derive(IntoElement)]
pub struct Avatar {
base: Div,
picture: Option<ImageSource>,
seed: Option<String>,
style: StyleRefinement,
size: Size,
selected: bool,
}
impl Avatar {
/// Creates an avatar for an entity whose profile picture may be missing.
pub fn new(picture: Option<SharedString>) -> Self {
Self::from_source(picture)
}
/// Creates an avatar from an already-resolved source, which may be missing.
pub fn from_source(picture: Option<impl Into<ImageSource>>) -> Self {
Self::from_picture(picture.map(Into::into))
}
fn from_picture(picture: Option<ImageSource>) -> Self {
Avatar {
base: div(),
picture,
seed: None,
style: StyleRefinement::default(),
size: Size::Medium,
selected: false,
}
}
/// Sets the seed for the generated pixel avatar.
pub fn seed(mut self, seed: impl Into<String>) -> Self {
self.seed = Some(seed.into());
self
}
}
impl Sizable for Avatar {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl Styled for Avatar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl Selectable for Avatar {
fn is_selected(&self) -> bool {
self.selected
}
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl InteractiveElement for Avatar {
fn interactivity(&mut self) -> &mut Interactivity {
self.base.interactivity()
}
}
impl RenderOnce for Avatar {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let image_size = avatar_size(self.size).to_pixels(window.rem_size());
let content = match self.picture {
Some(picture) => {
let seed = self.seed;
img(picture)
.size(image_size)
.rounded_full()
.object_fit(ObjectFit::Cover)
.with_fallback(move || generated_avatar(seed.as_deref(), image_size))
.into_any_element()
}
None => generated_avatar(self.seed.as_deref(), image_size),
};
div()
.flex_shrink_0()
.size(image_size)
.rounded_full()
.overflow_hidden()
.child(content)
}
}
-581
View File
@@ -1,581 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window, div, px, relative,
};
use gpui_base::Button as BaseButton;
use theme::{
ActiveTheme, TextStyle, Typeset as _, bubble_radius, button_radius, plate_active, plate_hover,
wash,
};
use crate::indicator::Indicator;
use crate::tooltip::Tooltip;
use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, h_flex};
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct ButtonCustomVariant {
color: Hsla,
foreground: Hsla,
hover: Hsla,
active: Hsla,
}
impl ButtonCustomVariant {
pub fn new(_window: &Window, cx: &App) -> Self {
Self {
color: cx.theme().solid,
foreground: cx.theme().on_solid,
hover: plate_hover(cx.theme().solid),
active: plate_active(cx.theme().solid),
}
}
pub fn color(mut self, color: Hsla) -> Self {
self.color = color;
self
}
pub fn foreground(mut self, color: Hsla) -> Self {
self.foreground = color;
self
}
pub fn hover(mut self, color: Hsla) -> Self {
self.hover = color;
self
}
pub fn active(mut self, color: Hsla) -> Self {
self.active = color;
self
}
}
/// The variant of the Button.
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum ButtonVariant {
#[default]
Primary,
Secondary,
Danger,
Warning,
Ghost {
alt: bool,
},
Transparent,
Custom(ButtonCustomVariant),
}
pub trait ButtonVariants: Sized {
fn with_variant(self, variant: ButtonVariant) -> Self;
fn primary(self) -> Self {
self.with_variant(ButtonVariant::Primary)
}
fn secondary(self) -> Self {
self.with_variant(ButtonVariant::Secondary)
}
fn danger(self) -> Self {
self.with_variant(ButtonVariant::Danger)
}
fn warning(self) -> Self {
self.with_variant(ButtonVariant::Warning)
}
fn ghost(self) -> Self {
self.with_variant(ButtonVariant::Ghost { alt: false })
}
fn ghost_alt(self) -> Self {
self.with_variant(ButtonVariant::Ghost { alt: true })
}
fn transparent(self) -> Self {
self.with_variant(ButtonVariant::Transparent)
}
fn custom(self, style: ButtonCustomVariant) -> Self {
self.with_variant(ButtonVariant::Custom(style))
}
}
/// Which side of the label the icon sits on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IconPosition {
/// Left of the label.
#[default]
Start,
/// Right of the label.
End,
}
/// A Button element.
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub struct Button {
base: BaseButton,
icon: Option<Icon>,
label: Option<SharedString>,
tooltip: Option<SharedString>,
children: Vec<AnyElement>,
variant: ButtonVariant,
size: Size,
disabled: bool,
loading: bool,
rounded: bool,
compact: bool,
caret: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
tab_stop: bool,
selected: bool,
icon_position: IconPosition,
}
impl From<Button> for AnyElement {
fn from(button: Button) -> Self {
button.into_any_element()
}
}
impl Button {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: BaseButton::new(id),
icon: None,
label: None,
variant: ButtonVariant::default(),
disabled: false,
selected: false,
compact: false,
caret: false,
rounded: false,
size: Size::Medium,
tooltip: None,
on_click: None,
loading: false,
children: Vec::new(),
tab_stop: true,
icon_position: IconPosition::Start,
}
}
pub fn rounded(mut self) -> Self {
self.rounded = true;
self
}
/// Set label to the Button, if no label is set, the button will be in Icon Button mode.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
/// Set the icon of the button, if the Button has no label, the button will be in Icon Button mode.
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
/// Set which side of the label the icon sits on (default [`IconPosition::Start`]).
pub fn icon_position(mut self, position: IconPosition) -> Self {
self.icon_position = position;
self
}
pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
pub fn loading(mut self, loading: bool) -> Self {
self.loading = loading;
self
}
/// Set true to make the button compact (no padding).
pub fn compact(mut self) -> Self {
self.compact = true;
self
}
pub fn caret(mut self) -> Self {
self.caret = true;
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
/// Set whether the button can be focused with the tab key (default true).
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
self.tab_stop = tab_stop;
self
}
#[inline]
fn clickable(&self) -> bool {
!(self.disabled || self.loading) && self.on_click.is_some()
}
}
impl Disableable for Button {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Selectable for Button {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl Sizable for Button {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl ButtonVariants for Button {
fn with_variant(mut self, variant: ButtonVariant) -> Self {
self.variant = variant;
self
}
}
impl Styled for Button {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl ParentElement for Button {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.children.extend(elements)
}
}
impl InteractiveElement for Button {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.base.interactivity()
}
}
impl RenderOnce for Button {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let style = self.variant;
let clickable = self.clickable();
let normal_style = style.normal(cx);
let icon_size = match self.size {
Size::Size(v) => Size::Size(v * 0.75),
Size::Large => Size::Medium,
_ => self.size,
};
let icon_start = self.icon_position == IconPosition::Start;
self.base
.tab_index(0)
.tab_stop(self.tab_stop)
.disabled(self.disabled)
.when_some(self.on_click.clone(), |this, on_click| {
this.on_click(move |event, window, cx| {
// Stop clicks when disabled, so a parent (e.g. dropdown menu) doesn't handle them.
if !clickable {
cx.stop_propagation();
return;
}
on_click(event, window, cx);
})
})
.relative()
.flex_shrink_0()
.flex()
.items_center()
.justify_center()
.cursor_default()
.overflow_hidden()
.map(|this| match self.rounded {
false => this.rounded(px(button_radius())),
true => this.rounded(px(bubble_radius())),
})
.when_some(
match style {
ButtonVariant::Secondary => Some(cx.theme().border),
_ => None,
},
|this, border_color| this.border_1().border_color(border_color),
)
.when(!self.compact, |this| {
if self.label.is_none() && self.children.is_empty() {
// Icon Button
match self.size {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_5(),
Size::Small => this.size_6(),
Size::Medium => this.size_7(),
_ => this.size_9(),
}
} else {
// Normal Button
let this = match self.size {
Size::Size(size) => this.px(size * 0.2),
Size::XSmall => {
if self.icon.is_some() {
this.h_6().pl_2().pr_2p5()
} else {
this.h_6().px_2()
}
}
Size::Small => {
if self.icon.is_some() {
this.h_7().pl_2().pr_2p5()
} else {
this.h_7().px_2()
}
}
Size::Medium => {
if self.icon.is_some() {
this.h_8().pl_3().pr_3p5()
} else {
this.h_8().px_3()
}
}
Size::Large => {
if self.icon.is_some() {
this.h_10().px_3().pr_3p5()
} else {
this.h_10().px_3()
}
}
};
if !icon_start && self.icon.is_some() {
match self.size {
Size::XSmall | Size::Small => this.pl_2p5().pr_2(),
Size::Medium | Size::Large => this.pl_3p5().pr_3(),
Size::Size(_) => this,
}
} else {
this
}
}
})
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
// Stop clicks when disabled, so a parent (e.g. dropdown menu) doesn't handle them.
if self.disabled {
cx.stop_propagation();
return;
}
// Avoid focus on mouse down.
window.prevent_default();
})
.child({
h_flex()
.id("label")
.justify_center()
.map(|this| match self.size {
Size::XSmall => this.text_style(TextStyle::Caption).gap_1(),
Size::Small => this.text_style(TextStyle::Callout).gap_1p5(),
_ => this.text_style(TextStyle::Callout).gap(px(theme::SPACE)),
})
.when(!self.loading && icon_start, |this| {
this.when_some(self.icon.clone(), |this, icon| {
this.child(icon.with_size(icon_size))
})
})
.when(self.loading && icon_start, |this| {
this.child(Indicator::new())
})
.when_some(self.label, |this, label| {
this.child(div().flex_none().line_height(relative(1.)).child(label))
})
.children(self.children)
.when(!self.loading && !icon_start, |this| {
this.when_some(self.icon.clone(), |this, icon| {
this.child(icon.with_size(icon_size))
})
})
.when(self.loading && !icon_start, |this| {
this.child(Indicator::new())
})
.when(self.caret, |this| {
this.justify_between().gap_0p5().child(
Icon::new(IconName::ChevronDown)
.small()
.text_color(cx.theme().text_muted),
)
})
})
.text_color(normal_style.fg)
.when(!self.disabled && !self.selected, |this| {
this.bg(normal_style.bg)
.hover(|this| {
let hover_style = style.hovered(cx);
this.bg(hover_style.bg).text_color(hover_style.fg)
})
.active(|this| {
let active_style = style.active(cx);
this.bg(active_style.bg).text_color(active_style.fg)
})
})
.when(self.selected, |this| {
let selected_style = style.selected(cx);
this.bg(selected_style.bg).text_color(selected_style.fg)
})
.when(self.disabled, |this| {
let disabled_style = style.disabled(cx);
this.cursor_not_allowed()
.bg(disabled_style.bg)
.text_color(disabled_style.fg)
})
.when(self.loading && !self.disabled, |this| {
this.bg(normal_style.bg.opacity(0.8))
.text_color(normal_style.fg.opacity(0.8))
})
.when_some(self.tooltip.clone(), |this, tooltip| {
this.tooltip(move |window, cx| Tooltip::new(tooltip.clone(), window, cx).into())
})
}
}
struct ButtonVariantStyle {
bg: Hsla,
fg: Hsla,
}
impl ButtonVariant {
fn normal(&self, cx: &App) -> ButtonVariantStyle {
let bg = self.bg_color(cx);
let fg = self.text_color(cx);
ButtonVariantStyle { bg, fg }
}
fn bg_color(&self, cx: &App) -> Hsla {
match self {
ButtonVariant::Primary => cx.theme().solid,
ButtonVariant::Secondary => cx.theme().surface_raised,
ButtonVariant::Danger => cx.theme().danger_strong,
ButtonVariant::Warning => cx.theme().warning_muted,
ButtonVariant::Ghost { alt } => {
if *alt {
cx.theme().surface_raised
} else {
gpui::transparent_black()
}
}
ButtonVariant::Custom(colors) => colors.color,
_ => gpui::transparent_black(),
}
}
fn text_color(&self, cx: &App) -> Hsla {
match self {
ButtonVariant::Primary => cx.theme().on_solid,
ButtonVariant::Secondary => cx.theme().text,
// Bezel's destructive pairing: the red plate carries white ink in
// both appearances, not the appearance-flipping on_accent.
ButtonVariant::Danger => gpui::white(),
// warning_muted is a translucent amber wash, so the label is the
// amber token itself — dark amber in light, bright in dark.
ButtonVariant::Warning => cx.theme().warning,
ButtonVariant::Transparent => cx.theme().text_faint,
ButtonVariant::Ghost { alt } => {
if *alt {
cx.theme().text
} else {
cx.theme().text_muted
}
}
ButtonVariant::Custom(colors) => colors.foreground,
}
}
fn hovered(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => plate_hover(cx.theme().solid),
ButtonVariant::Secondary => cx.theme().surface_raised_hover,
ButtonVariant::Danger => plate_hover(cx.theme().danger_strong),
ButtonVariant::Warning => plate_hover(cx.theme().warning_muted),
ButtonVariant::Ghost { .. } => cx.theme().element_hover,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.hover,
};
let fg = match self {
ButtonVariant::Secondary => cx.theme().text,
ButtonVariant::Ghost { .. } => cx.theme().text,
ButtonVariant::Transparent => cx.theme().text_faint,
_ => self.text_color(cx),
};
ButtonVariantStyle { bg, fg }
}
fn active(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => plate_active(cx.theme().solid),
ButtonVariant::Secondary => plate_active(cx.theme().surface_raised),
ButtonVariant::Danger => plate_active(cx.theme().danger_strong),
ButtonVariant::Warning => plate_active(cx.theme().warning_muted),
ButtonVariant::Ghost { .. } => cx.theme().element_active,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.active,
};
let fg = match self {
ButtonVariant::Secondary => cx.theme().text,
ButtonVariant::Transparent => cx.theme().text_faint,
_ => self.text_color(cx),
};
ButtonVariantStyle { bg, fg }
}
fn selected(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().element_active,
ButtonVariant::Secondary => cx.theme().element_active,
ButtonVariant::Danger => plate_active(cx.theme().danger_strong),
ButtonVariant::Warning => plate_active(cx.theme().warning_muted),
ButtonVariant::Ghost { .. } => cx.theme().element_active,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.active,
};
let fg = match self {
ButtonVariant::Secondary => cx.theme().text,
ButtonVariant::Transparent => cx.theme().text_faint,
_ => self.text_color(cx),
};
ButtonVariantStyle { bg, fg }
}
fn disabled(&self, cx: &App) -> ButtonVariantStyle {
let bg = wash(0.08);
let fg = cx.theme().text_muted;
ButtonVariantStyle { bg, fg }
}
}
-394
View File
@@ -1,394 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnimationExt as _, AnyElement, App, ClickEvent, Div, FocusHandle, IntoElement, ParentElement,
Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window, div, px, transparent_black,
};
use gpui_base::{DialogBackdrop, DialogPopup, DialogTitle};
use theme::{ActiveTheme, SCRIM_ALPHA_DARK, scrim, surface_radius, surface_shadows};
use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _};
use crate::motion::DIALOG_IN;
use crate::scroll::ScrollableElement;
use crate::{IconName, Root, StyledExt, WindowExtension, h_flex, v_flex};
type OnOk = Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>>;
type OnCancel = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>;
type RenderButtonFn = Box<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
type FooterFn =
Box<dyn Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<AnyElement>>;
/// Dialog button props.
pub struct DialogButtonProps {
ok_text: Option<SharedString>,
ok_variant: ButtonVariant,
cancel_text: Option<SharedString>,
cancel_variant: ButtonVariant,
}
impl Default for DialogButtonProps {
fn default() -> Self {
Self {
ok_text: None,
ok_variant: ButtonVariant::Primary,
cancel_text: None,
cancel_variant: ButtonVariant::Ghost { alt: false },
}
}
}
impl DialogButtonProps {
/// Sets the text of the OK button. Default is `OK`.
pub fn ok_text(mut self, ok_text: impl Into<SharedString>) -> Self {
self.ok_text = Some(ok_text.into());
self
}
/// Sets the variant of the OK button. Default is `ButtonVariant::Primary`.
pub fn ok_variant(mut self, ok_variant: ButtonVariant) -> Self {
self.ok_variant = ok_variant;
self
}
/// Sets the text of the Cancel button. Default is `Cancel`.
pub fn cancel_text(mut self, cancel_text: impl Into<SharedString>) -> Self {
self.cancel_text = Some(cancel_text.into());
self
}
}
#[derive(IntoElement)]
pub struct Dialog {
style: StyleRefinement,
title: Option<AnyElement>,
footer: Option<FooterFn>,
content: Div,
width: Pixels,
max_width: Option<Pixels>,
on_ok: OnOk,
on_cancel: OnCancel,
overlay: bool,
overlay_closable: bool,
keyboard: bool,
show_close: bool,
button_props: DialogButtonProps,
/// The focus handle created when the dialog is opened, owned by the `Root`.
pub(crate) focus_handle: FocusHandle,
pub(crate) layer_ix: usize,
pub(crate) overlay_visible: bool,
}
impl Dialog {
pub fn new(_window: &mut Window, cx: &mut App) -> Self {
Self {
style: StyleRefinement::default(),
focus_handle: cx.focus_handle(),
title: None,
footer: None,
content: v_flex(),
width: px(380.),
max_width: None,
overlay: true,
keyboard: true,
layer_ix: 0,
overlay_visible: false,
on_ok: None,
on_cancel: Rc::new(|_, _, _| true),
button_props: DialogButtonProps::default(),
show_close: true,
overlay_closable: true,
}
}
/// Sets the title of the dialog.
pub fn title(mut self, title: impl IntoElement) -> Self {
self.title = Some(title.into_any_element());
self
}
/// Sets a custom footer, replacing the default footer buttons.
fn footer<E, F>(mut self, footer: F) -> Self
where
E: IntoElement,
F: Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<E> + 'static,
{
self.footer = Some(Box::new(move |ok, cancel, window, cx| {
footer(ok, cancel, window, cx)
.into_iter()
.map(|e| e.into_any_element())
.collect()
}));
self
}
/// Set to use confirm dialog, with OK and Cancel buttons.
pub fn confirm(self) -> Self {
self.footer(|ok, cancel, window, cx| vec![cancel(window, cx), ok(window, cx)])
.overlay_closable(false)
.show_close(false)
}
/// Set the button props of the dialog.
pub fn button_props(mut self, button_props: DialogButtonProps) -> Self {
self.button_props = button_props;
self
}
/// Sets the confirm callback, returning `false` keeps the dialog open.
pub fn on_ok(
mut self,
on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
) -> Self {
self.on_ok = Some(Rc::new(on_ok));
self
}
/// Sets the cancel callback, returning `false` keeps the dialog open.
pub fn on_cancel(
mut self,
on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
) -> Self {
self.on_cancel = Rc::new(on_cancel);
self
}
/// Sets the false to hide close icon, default: true
pub fn show_close(mut self, show_close: bool) -> Self {
self.show_close = show_close;
self
}
/// Sets the width of the dialog, defaults to 380px.
pub fn width(mut self, width: Pixels) -> Self {
self.width = width;
self
}
/// Sets whether clicking the overlay closes the dialog, defaults to `true`.
pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
self.overlay_closable = overlay_closable;
self
}
/// Set whether to support keyboard esc to close the dialog, defaults to `true`.
pub fn keyboard(mut self, keyboard: bool) -> Self {
self.keyboard = keyboard;
self
}
pub(crate) fn has_overlay(&self) -> bool {
self.overlay
}
}
impl ParentElement for Dialog {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.content.extend(elements);
}
}
impl Styled for Dialog {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Dialog {
fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement {
let layer_ix = self.layer_ix;
let is_topmost = layer_ix + 1 == Root::read(window, cx).active_dialogs.len();
let has_footer = self.footer.is_some();
let on_ok = self.on_ok.clone();
let on_cancel = self.on_cancel.clone();
let render_ok: RenderButtonFn = Box::new({
let on_ok = on_ok.clone();
let ok_variant = self.button_props.ok_variant;
let ok_text = self.button_props.ok_text.unwrap_or_else(|| "OK".into());
move |_, _| {
Button::new("ok")
.label(ok_text)
.with_variant(ok_variant)
.font_semibold()
.on_click({
let on_ok = on_ok.clone();
move |_, window, cx| {
if let Some(on_ok) = &on_ok
&& !on_ok(&ClickEvent::default(), window, cx)
{
return;
}
window.close_dialog(cx);
}
})
.into_any_element()
}
});
let render_cancel: RenderButtonFn = Box::new({
let on_cancel = on_cancel.clone();
let cancel_variant = self.button_props.cancel_variant;
let cancel_text = self
.button_props
.cancel_text
.unwrap_or_else(|| "Cancel".into());
move |_, _| {
Button::new("cancel")
.label(cancel_text)
.with_variant(cancel_variant)
.on_click({
let on_cancel = on_cancel.clone();
move |_, window, cx| {
if !on_cancel(&ClickEvent::default(), window, cx) {
return;
}
window.close_dialog(cx);
}
})
.into_any_element()
}
});
let mut padding_right = px(16.);
let mut padding_left = px(16.);
if let Some(pl) = self.style.padding.left {
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
}
if let Some(pr) = self.style.padding.right {
padding_right = pr.to_pixels(self.width.into(), window.rem_size());
}
let backdrop = DialogBackdrop::new()
.absolute()
.inset_0()
.when(self.overlay_visible, |this| {
this.bg(scrim(SCRIM_ALPHA_DARK))
})
.with_animation("fade-in", DIALOG_IN.animation(), move |this, delta| {
this.opacity(delta)
});
let card = DialogPopup::new()
.flex()
.flex_col()
.bg(cx.theme().surface_dialog)
.border_1()
.border_color(cx.theme().border)
.rounded(px(surface_radius()))
.when(cx.theme().shadow, |this| this.shadow(surface_shadows()))
.min_h_24()
.refine_style(&self.style)
.relative()
.w(self.width)
.when_some(self.max_width, |this, w| this.max_w(w))
.child(
div()
.px_4()
.h_8()
.w_full()
.flex()
.items_center()
.justify_center()
.relative()
.when_some(self.title, |this, title| {
this.h_10().child(
DialogTitle::new()
.font_semibold()
.text_center()
.child(title),
)
})
.when(self.show_close, |this| {
let on_cancel = on_cancel.clone();
this.child(
div()
.absolute()
.top_0()
.bottom_0()
.right_2()
.flex()
.items_center()
.child(
Button::new("close")
.icon(IconName::CloseCircleFill)
.custom(
ButtonCustomVariant::new(window, cx)
.foreground(cx.theme().text_faint)
.color(transparent_black())
.hover(cx.theme().element_hover)
.active(cx.theme().element_active),
)
.on_click(move |_, window, cx| {
on_cancel(&ClickEvent::default(), window, cx);
window.close_dialog(cx);
}),
),
)
}),
)
.child(
div()
.pt_px()
.w_full()
.h_auto()
.flex_1()
.overflow_hidden()
.child(
v_flex()
.pr(padding_right)
.pl(padding_left)
.size_full()
.overflow_y_scrollbar()
.child(self.content),
),
)
.when_none(&self.footer, |this| this.child(div().pt(padding_left)))
.when_some(self.footer, |this, footer| {
this.child(
h_flex()
.gap_1()
.pt(padding_left)
.pr(padding_right)
.pb(padding_left)
.pl(padding_right)
.justify_end()
.children(footer(render_ok, render_cancel, window, cx)),
)
})
.with_animation("slide-down", DIALOG_IN.animation(), move |this, delta| {
let y_offset = px(2.) * (delta - 1.);
let mut shadow = surface_shadows();
for shadow in &mut shadow {
shadow.color.a *= delta;
}
this.top(y_offset).shadow(shadow)
});
gpui_base::Dialog::new(cx)
.layer(layer_ix, is_topmost)
.backdrop(backdrop)
.popup(card)
.focus_handle(self.focus_handle.clone())
.close_on_escape(self.keyboard)
.close_on_backdrop_press(self.overlay_closable)
.on_ok(move |event, window, cx| match &on_ok {
Some(on_ok) => on_ok(event, window, cx),
None => has_footer,
})
.on_cancel(move |event, window, cx| on_cancel(event, window, cx))
.on_close(move |_, window, cx| window.close_dialog(cx))
}
}
-805
View File
@@ -1,805 +0,0 @@
use std::cell::{Cell, RefCell};
use std::ops::Deref as _;
use std::rc::Rc;
use std::sync::Arc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, Empty, Entity,
GlobalElementId, ImageSource, InspectorElementId, InteractiveElement as _, IntoElement,
LayoutId, MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render,
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, Style, StyleRefinement,
Styled as _, WeakEntity, Window, actions, div, px,
};
pub use gpui_base::dock::{DockArea, DockLayout, DockPlacement};
use gpui_base::dock::{
DockAreaRenderer, DockContext, DragPanel, DropIndicator, InsertTarget, NodeId, PaneNode,
PaneRef, PanelId, TabGroupContext, TabGroupRenderer,
};
use gpui_base::{HandleEdge, Placement, ResizeHandleContext};
use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _, button_radius, panel_radius};
use crate::button::{Button, ButtonVariants as _};
use crate::resizable::{resize_handle, resize_handle_appearance};
use crate::tab::Tab;
use crate::tab::tab_bar::TabBar;
use crate::title_bar::{TRAFFIC_LIGHT_PADDING, title_bar_drag_handlers, window_controls};
use crate::{IconName, Selectable, Sizable, StyledExt, background, h_flex, v_flex};
mod panel;
pub use panel::*;
actions!(dock, [ToggleZoom, ClosePanel]);
pub fn dock_area(
id: impl Into<SharedString>,
window: &mut Window,
cx: &mut App,
) -> Entity<DockArea> {
let shared = Rc::new(SkinShared {
area: RefCell::new(None),
resizing: Cell::new(None),
});
let area = cx.new(|cx| {
DockArea::new(id, None, window, cx).with_renderer(Rc::new(DockSkin {
shared: shared.clone(),
}))
});
*shared.area.borrow_mut() = Some(area.downgrade());
area
}
pub fn add_panel(
area: &mut DockArea,
panel: PanelHandle,
placement: DockPlacement,
window: &mut Window,
cx: &mut Context<DockArea>,
) {
let key = panel.panel().panel_id(cx);
if let Some((id, node, ix)) = find_panel(area, &key, cx) {
area.move_panel(
id,
InsertTarget::Tabs {
node,
ix: Some(ix),
activate: true,
},
window,
cx,
);
return;
}
area.add_panel_view(Arc::new(panel), placement, None, window, cx);
}
/// Add a panel to a dock reached through a weak handle.
pub fn add_panel_to(
dock: &WeakEntity<DockArea>,
panel: PanelHandle,
placement: DockPlacement,
window: &mut Window,
cx: &mut App,
) {
if let Err(error) = dock.update(cx, |area, cx| {
add_panel(area, panel, placement, window, cx);
}) {
log::warn!("Failed to add panel to dock: {error}");
}
}
/// The panel in any region of `area` whose logical id is `key`.
fn find_panel(area: &DockArea, key: &SharedString, cx: &App) -> Option<(PanelId, NodeId, usize)> {
let placements = [
DockPlacement::Center,
DockPlacement::Left,
DockPlacement::Right,
DockPlacement::Bottom,
];
for placement in placements {
let Some(tree) = area.layout(placement) else {
continue;
};
for id in tree.panels() {
let matches = area
.panel(id)
.and_then(PanelHandle::of)
.is_some_and(|handle| handle.panel().panel_id(cx) == *key);
if !matches {
continue;
}
let Some(node) = tree.find_panel_node(id) else {
continue;
};
let Some(PaneRef::Tabs { panels, .. }) = tree.find_node(node).map(PaneNode::kind)
else {
continue;
};
let Some(ix) = panels.iter().position(|candidate| *candidate == id) else {
continue;
};
return Some((id, node, ix));
}
}
None
}
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()),
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
}
}
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()),
PaneRef::Split { axis, children, .. } => {
let child = if axis == gpui::Axis::Vertical {
children.first()
} else {
children.last()
};
child.and_then(right_top_group)
}
}
}
#[derive(Default)]
struct SkinShared {
area: RefCell<Option<WeakEntity<DockArea>>>,
resizing: Cell<Option<DockPlacement>>,
}
impl SkinShared {
fn area(&self) -> Option<Entity<DockArea>> {
self.area.borrow().as_ref().and_then(|area| area.upgrade())
}
}
struct DockSkin {
shared: Rc<SkinShared>,
}
impl DockSkin {
fn resize_handle(&self, dock: &DockContext) -> impl IntoElement {
let placement = dock.placement();
let shared = self.shared.clone();
let id = match placement {
DockPlacement::Left => "dock-resize-handle-left",
DockPlacement::Right => "dock-resize-handle-right",
DockPlacement::Bottom => "dock-resize-handle-bottom",
DockPlacement::Center => "dock-resize-handle-center",
};
let edge = match placement {
DockPlacement::Left => Some(HandleEdge::Trailing),
DockPlacement::Right | DockPlacement::Bottom => Some(HandleEdge::Leading),
DockPlacement::Center => None,
};
resize_handle(id, placement.axis())
.when_some(edge, |this, edge| this.inside(edge))
.with_appearance(resize_handle_appearance())
.on_drag(DockResizeHandle, move |info, _, _, cx| {
cx.stop_propagation();
shared.resizing.set(Some(placement));
cx.new(|_| info.deref().clone())
})
}
}
impl DockAreaRenderer for DockSkin {
fn render_split_handle(
&self,
handle: &ResizeHandleContext,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
resize_handle_appearance()(handle, window, cx)
}
fn render_dock(
&self,
dock: &DockContext,
content: AnyElement,
_: &mut Window,
_: &mut App,
) -> AnyElement {
div()
.flex()
.size_full()
.relative()
.child(content)
.child(self.resize_handle(dock))
.child(DockResizeTracker {
dock: dock.clone(),
shared: self.shared.clone(),
})
.into_any_element()
}
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
Rc::new(TabGroupSkin::new(self.shared.clone()))
}
}
/// The payload a dock's resize handle drags; the handle is the affordance.
#[derive(Clone)]
struct DockResizeHandle;
impl Render for DockResizeHandle {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
/// Turns the window's mouse stream into dock resizing.
struct DockResizeTracker {
dock: DockContext,
shared: Rc<SkinShared>,
}
impl IntoElement for DockResizeTracker {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl gpui::Element for DockResizeTracker {
type PrepaintState = ();
type RequestLayoutState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
(window.request_layout(Style::default(), None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Window,
_: &mut App,
) {
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
_: &mut App,
) {
let placement = self.dock.placement();
window.on_mouse_event({
let dock = self.dock.clone();
let shared = self.shared.clone();
move |event: &MouseMoveEvent, phase, window, cx| {
if !phase.bubble() || shared.resizing.get() != Some(placement) {
return;
}
let open = shared
.area()
.is_some_and(|area| area.read(cx).is_dock_open(placement));
if !open {
dock.toggle(window, cx);
}
dock.resize_to(event.position, window, cx);
}
});
window.on_mouse_event({
let shared = self.shared.clone();
move |_: &MouseUpEvent, phase, _, _| {
if !phase.bubble() || shared.resizing.get() != Some(placement) {
return;
}
shared.resizing.set(None);
}
});
}
}
struct DragPreview {
panel: Arc<dyn gpui_base::dock::PanelView>,
}
impl Render for DragPreview {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.id("drag-panel")
.cursor_grab()
.p_2()
.min_w_24()
.justify_center()
.overflow_hidden()
.whitespace_nowrap()
.rounded(px(button_radius()))
.text_style(TextStyle::Callout)
.text_color(cx.theme().text)
.text_ellipsis()
.when(cx.theme().shadow, |this| this.shadow_xs())
.bg(cx.theme().bg)
.child(panel_title(&self.panel, cx))
}
}
struct TabGroupSkin {
shared: Rc<SkinShared>,
scroll_handle: ScrollHandle,
last_active_ix: Cell<Option<usize>>,
}
impl TabGroupSkin {
fn new(shared: Rc<SkinShared>) -> Self {
Self {
shared,
scroll_handle: ScrollHandle::new(),
last_active_ix: Cell::new(None),
}
}
fn is_title_bar_group(&self, group: &TabGroupContext, cx: &App) -> bool {
let Some(area) = self.shared.area() else {
return false;
};
area.read(cx)
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root()))
== Some(group.node())
}
/// Whether this group is the left dock's root with a single panel.
fn is_plain_left_group(&self, group: &TabGroupContext, cx: &App) -> bool {
let Some(area) = self.shared.area() else {
return false;
};
let area = area.read(cx);
area.layout(DockPlacement::Left)
.map(|tree| tree.root().id())
== Some(group.node())
&& group.panels().len() == 1
}
fn is_leftmost_top_group(&self, group: &TabGroupContext, cx: &App) -> bool {
let Some(area) = self.shared.area() else {
return false;
};
let area = area.read(cx);
let left_open =
area.is_dock_open(DockPlacement::Left) && !area.is_empty(DockPlacement::Left, cx);
let tree = if left_open {
area.layout(DockPlacement::Left)
} else {
area.layout(DockPlacement::Center)
};
tree.and_then(|tree| left_top_group(tree.root())) == Some(group.node())
}
fn render_tabs(
&self,
group: &TabGroupContext,
visible: &[usize],
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let has_leading = left_button.is_some() || bottom_button.is_some();
let collapsed = group.is_collapsed();
let droppable = group.is_droppable();
let tabs_count = group.panels().len();
let is_title_bar = self.is_title_bar_group(group, cx);
let wallpaper = group
.active_panel()
.and_then(|panel| panel_background(panel, cx))
.is_some();
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
let displayed_ix = displayed.and_then(|displayed| {
group
.panels()
.iter()
.position(|panel| panel.panel_id(cx) == displayed)
});
let needs_traffic_light_padding =
cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx);
let empty_space = div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow_1()
.min_w_16()
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().drop_target))
.on_drop({
let group = TabGroupContext::clone(group);
move |drag: &DragPanel, window, cx| {
let ix = (drag.source() == group.node()).then(|| tabs_count - 1);
group.drop_panel(drag.clone(), ix, false, window, cx);
}
})
});
let empty_space = if is_title_bar {
title_bar_drag_handlers(empty_space, window, cx).into_any_element()
} else {
empty_space.into_any_element()
};
let bar = TabBar::new("tab-bar")
.track_scroll(&self.scroll_handle)
.h(TABBAR_HEIGHT)
.when(!wallpaper, |this| this.bg(cx.theme().bg))
.when(needs_traffic_light_padding, |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING))
})
.when(is_title_bar || has_leading, |this| {
this.prefix(
h_flex()
.size(TABBAR_HEIGHT)
.flex_shrink_0()
.justify_center()
.top_0()
.right(-px(1.))
.pl_0p5()
.pr_1()
.children(left_button)
.children(bottom_button),
)
})
.children(visible.iter().map(|ix| {
let ix = *ix;
let panel = group.panels()[ix].clone();
let drag = tab_drag(group, ix, cx);
Tab::new()
.ix(ix)
.tab_bar_prefix(has_leading)
.child(panel_title(&panel, cx))
.selected(!collapsed && displayed_ix == Some(ix))
.disabled(collapsed)
.suffix(
Button::new("close")
.icon(IconName::Close)
.tooltip("Close panel")
.ghost()
.xsmall()
.on_click({
let group = TabGroupContext::clone(group);
let panel = panel.clone();
move |_, window, cx| {
group.close(panel.panel_id(cx), window, cx);
}
}),
)
.on_click({
let group = TabGroupContext::clone(group);
move |_, window, cx| group.select_tab(ix, window, cx)
})
.when(!collapsed, |this| {
this.on_mouse_down(MouseButton::Middle, {
let group = TabGroupContext::clone(group);
let panel = panel.clone();
move |_, window, cx| {
group.close(panel.panel_id(cx), window, cx);
}
})
.when_some(drag, |this, drag| {
this.on_drag(drag, {
let panel = panel.clone();
move |drag, offset, _, cx| {
cx.stop_propagation();
drag.set_drag_offset(offset);
cx.new(|_| DragPreview {
panel: panel.clone(),
})
}
})
})
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| {
this.rounded_l_none()
.border_l_2()
.border_r_0()
.border_color(cx.theme().drop_line)
})
.on_drop({
let group = TabGroupContext::clone(group);
move |drag: &DragPanel, window, cx| {
group.drop_panel(drag.clone(), Some(ix), true, window, cx);
}
})
})
})
}))
.last_empty_space(empty_space)
.when(!collapsed, |this| {
this.suffix(
h_flex()
.flex_shrink_0()
.items_center()
.top_0()
.right_0()
.h_full()
.px_0p5()
.gap_1()
.children(right_button),
)
});
if is_title_bar {
h_flex()
.h(TABBAR_HEIGHT)
.w_full()
.when(!wallpaper, |this| this.bg(cx.theme().bg))
.child(bar.flex_1())
.child(window_controls())
.into_any_element()
} else {
bar.into_any_element()
}
}
fn dock_toggle_button(
&self,
placement: DockPlacement,
group: &TabGroupContext,
cx: &mut App,
) -> Option<Button> {
if group.is_zoomed() {
return None;
}
let area = self.shared.area()?;
let is_open = {
let area = area.read(cx);
if !area.is_dock_collapsible(placement) {
return None;
}
let designated = match placement {
DockPlacement::Left => area
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root())),
DockPlacement::Right => area
.layout(DockPlacement::Center)
.and_then(|tree| right_top_group(tree.root())),
DockPlacement::Bottom => area
.layout(DockPlacement::Bottom)
.and_then(|tree| left_top_group(tree.root())),
DockPlacement::Center => None,
};
if designated != Some(group.node()) {
return None;
}
area.is_dock_open(placement)
};
let icon = match (placement, is_open) {
(DockPlacement::Left, true) => IconName::PanelLeft,
(DockPlacement::Left, false) => IconName::PanelLeftOpen,
(DockPlacement::Right, true) => IconName::PanelRight,
(DockPlacement::Right, false) => IconName::PanelRightOpen,
(DockPlacement::Bottom, true) => IconName::PanelBottom,
(DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
(DockPlacement::Center, _) => return None,
};
Some(
Button::new(SharedString::from(format!("toggle-dock:{:?}", placement)))
.icon(icon)
.small()
.ghost()
.tab_stop(false)
.map(|this| {
if is_open {
this.tooltip("Collapse")
} else {
this.tooltip("Expand")
}
})
.on_click(move |_, window, cx| {
area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
}),
)
}
}
impl TabGroupRenderer for TabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
let wallpaper = group
.active_panel()
.and_then(|panel| panel_background(panel, cx));
div()
.id("tab-panel")
.relative()
.when_some(wallpaper, |this, source| this.child(background(source, cx)))
.when(!group.is_collapsed(), |this| {
this.on_action({
let group = TabGroupContext::clone(group);
move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx)
})
.on_action({
let group = TabGroupContext::clone(group);
move |_: &ClosePanel, window, cx| {
let Some(panel) = group.active_panel() else {
return;
};
let panel = panel.panel_id(cx);
group.close(panel, window, cx);
}
})
})
}
fn render_tab_bar(
&self,
group: &TabGroupContext,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
// The plain left group draws bare so its content can own the window's top-left corner.
if self.is_plain_left_group(group, cx) {
return Empty.into_any_element();
}
let visible: Vec<usize> = group
.panels()
.iter()
.enumerate()
.filter(|(_, panel)| panel.visible(cx))
.map(|(ix, _)| ix)
.collect();
let active_ix = group.active_ix();
if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix)
&& let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix)
{
self.scroll_handle.scroll_to_item(visible_ix);
}
if visible.is_empty() {
return Empty.into_any_element();
}
self.render_tabs(group, visible.as_slice(), window, cx)
}
fn render_active_panel(
&self,
panel: AnyView,
group: &TabGroupContext,
_: &mut Window,
cx: &mut App,
) -> AnyElement {
if group.is_collapsed() {
return Empty.into_any_element();
}
let wallpaper = group
.active_panel()
.and_then(|active| panel_background(active, cx))
.is_some();
v_flex()
.id("tab-content")
.group("")
.overflow_hidden()
.flex_1()
.child(
div()
.size_full()
.when(!wallpaper, |this| this.bg(cx.theme().bg))
.overflow_hidden()
.child(panel.cached(StyleRefinement::default().v_flex().size_full())),
)
.into_any_element()
}
fn render_drop_indicator(
&self,
indicator: DropIndicator,
_: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
let size = indicator.bounds().size;
let fraction = 0.35;
let (left, top, width, height) = match indicator.placement() {
Some(Placement::Left) => (px(0.), px(0.), size.width * fraction, size.height),
Some(Placement::Right) => (
size.width * (1. - fraction),
px(0.),
size.width * fraction,
size.height,
),
Some(Placement::Top) => (px(0.), px(0.), size.width, size.height * fraction),
Some(Placement::Bottom) => (
px(0.),
size.height * (1. - fraction),
size.width,
size.height * fraction,
),
None => (px(0.), px(0.), size.width, size.height),
};
Some(
div()
.absolute()
.left(left)
.top(top)
.w(width)
.h(height)
.rounded(px(panel_radius()))
.border_1()
.border_color(cx.theme().drop_line)
.bg(cx.theme().drop_target)
.into_any_element(),
)
}
}
fn tab_drag(group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
group
.is_draggable()
.then(|| group.drag_panel(ix, cx))
.flatten()
}
fn panel_title(panel: &Arc<dyn gpui_base::dock::PanelView>, cx: &App) -> AnyElement {
match PanelHandle::of(panel) {
Some(handle) => handle.panel().title(cx),
None => SharedString::from(panel.panel_name(cx)).into_any_element(),
}
}
fn panel_background(panel: &Arc<dyn gpui_base::dock::PanelView>, cx: &App) -> Option<ImageSource> {
PanelHandle::of(panel).and_then(|handle| handle.panel().background(cx))
}
-205
View File
@@ -1,205 +0,0 @@
use std::any::Any;
use std::sync::Arc;
use gpui::{
AnyElement, AnyView, App, Context, Element, Entity, EventEmitter, FocusHandle, Focusable,
ImageSource, Render, SharedString, Window,
};
use gpui_base::dock::{PanelId, PanelState};
use crate::button::Button;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelEvent {}
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
/// The name of the panel used to serialize, deserialize and identify the panel.
fn panel_id(&self) -> SharedString;
/// The title of the panel
fn title(&self, _cx: &App) -> AnyElement {
SharedString::from("Unnamed").into_any()
}
/// Whether the panel can be closed, default is `true`.
fn closable(&self, _cx: &App) -> bool {
true
}
/// Whether the panel is zoomable, default is `true`.
fn zoomable(&self, _cx: &App) -> bool {
true
}
/// Return false to hide panel, true to show panel, default is `true`.
fn visible(&self, _cx: &App) -> bool {
true
}
/// Set active state of the panel.
fn set_active(&self, _active: bool, _window: &mut Window, _cx: &mut App) {}
/// Called when the panel leaves the dock.
fn on_removed(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
/// Set zoomed state of the panel.
fn set_zoomed(&self, _zoomed: bool, _cx: &mut App) {}
/// Toolbar buttons shown at the right of the title bar.
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
/// An optional image drawn behind the panel and its tab bar.
fn background(&self, _cx: &App) -> Option<ImageSource> {
None
}
}
pub(crate) trait PanelView: 'static + Send + Sync {
fn panel_id(&self, cx: &App) -> SharedString;
fn title(&self, cx: &App) -> AnyElement;
fn closable(&self, cx: &App) -> bool;
fn zoomable(&self, cx: &App) -> bool;
fn visible(&self, cx: &App) -> bool;
fn background(&self, cx: &App) -> Option<ImageSource>;
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App);
fn set_zoomed(&self, zoomed: bool, cx: &mut App);
fn on_removed(&self, window: &mut Window, cx: &mut App);
fn view(&self) -> AnyView;
fn focus_handle(&self, cx: &App) -> FocusHandle;
}
impl<T: Panel> PanelView for Entity<T> {
fn panel_id(&self, cx: &App) -> SharedString {
self.read(cx).panel_id()
}
fn title(&self, cx: &App) -> AnyElement {
self.read(cx).title(cx)
}
fn closable(&self, cx: &App) -> bool {
self.read(cx).closable(cx)
}
fn zoomable(&self, cx: &App) -> bool {
self.read(cx).zoomable(cx)
}
fn visible(&self, cx: &App) -> bool {
self.read(cx).visible(cx)
}
fn background(&self, cx: &App) -> Option<ImageSource> {
self.read(cx).background(cx)
}
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) {
self.update(cx, |this, cx| {
this.set_active(active, window, cx);
})
}
fn set_zoomed(&self, zoomed: bool, cx: &mut App) {
self.update(cx, |this, cx| {
this.set_zoomed(zoomed, cx);
})
}
fn on_removed(&self, window: &mut Window, cx: &mut App) {
self.update(cx, |this, cx| {
this.on_removed(window, cx);
})
}
fn view(&self) -> AnyView {
self.clone().into()
}
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.read(cx).focus_handle(cx)
}
}
#[derive(Clone)]
pub struct PanelHandle {
id: PanelId,
panel: Arc<dyn PanelView>,
}
impl PanelHandle {
pub fn new<P: Panel>(panel: Entity<P>) -> Self {
Self {
id: PanelId::from(panel.entity_id()),
panel: Arc::new(panel),
}
}
/// Downcast a base handle back to the coop handle.
pub(crate) fn of(panel: &Arc<dyn gpui_base::dock::PanelView>) -> Option<&Self> {
panel.as_any().downcast_ref::<Self>()
}
pub(crate) fn panel(&self) -> &Arc<dyn PanelView> {
&self.panel
}
}
impl gpui_base::dock::PanelView for PanelHandle {
fn panel_name(&self, _: &App) -> &'static str {
"CoopPanel"
}
fn panel_id(&self, _: &App) -> PanelId {
self.id
}
fn closable(&self, cx: &App) -> bool {
self.panel.closable(cx)
}
fn zoomable(&self, cx: &App) -> bool {
self.panel.zoomable(cx)
}
fn visible(&self, cx: &App) -> bool {
self.panel.visible(cx)
}
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) {
self.panel.set_active(active, window, cx);
}
fn set_zoomed(&self, zoomed: bool, _: &mut Window, cx: &mut App) {
self.panel.set_zoomed(zoomed, cx);
}
fn on_added_to(
&self,
_group: gpui::WeakEntity<gpui_base::dock::TabGroup>,
_: &mut Window,
_: &mut App,
) {
}
fn on_removed(&self, window: &mut Window, cx: &mut App) {
self.panel.on_removed(window, cx);
}
fn view(&self) -> AnyView {
self.panel.view()
}
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.panel.focus_handle(cx)
}
fn dump(&self, cx: &App) -> PanelState {
PanelState::new(self.panel_name(cx))
}
fn as_any(&self) -> &dyn Any {
self
}
}
-126
View File
@@ -1,126 +0,0 @@
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce,
StyleRefinement, Styled, Window, div, px,
};
use smallvec::SmallVec;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::{StyledExt as _, v_flex};
/// The variant of the GroupBox.
#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Hash)]
pub enum GroupBoxVariant {
#[default]
Normal,
Fill,
}
/// Trait to add GroupBox variant methods to elements.
pub trait GroupBoxVariants: Sized {
/// Set the variant of the [`GroupBox`].
#[must_use]
fn with_variant(self, variant: GroupBoxVariant) -> Self;
/// Set to use [`GroupBoxVariant::Fill`] to GroupBox.
#[must_use]
fn fill(self) -> Self {
self.with_variant(GroupBoxVariant::Fill)
}
}
/// A styled container with an optional title that groups related content together.
#[derive(IntoElement)]
pub struct GroupBox {
id: Option<ElementId>,
variant: GroupBoxVariant,
style: StyleRefinement,
title: Option<AnyElement>,
children: SmallVec<[AnyElement; 1]>,
}
impl GroupBox {
/// Create a new GroupBox.
pub fn new() -> Self {
Self {
id: None,
variant: GroupBoxVariant::default(),
style: StyleRefinement::default(),
title: None,
children: SmallVec::new(),
}
}
/// Set the id of the group box, default is None.
#[must_use]
pub fn id(mut self, id: impl Into<ElementId>) -> Self {
self.id = Some(id.into());
self
}
/// Set the title of the group box, default is None.
#[must_use]
pub fn title(mut self, title: impl IntoElement) -> Self {
self.title = Some(title.into_any_element());
self
}
}
impl Default for GroupBox {
fn default() -> Self {
Self::new()
}
}
impl ParentElement for GroupBox {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Styled for GroupBox {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl GroupBoxVariants for GroupBox {
fn with_variant(mut self, variant: GroupBoxVariant) -> Self {
self.variant = variant;
self
}
}
impl RenderOnce for GroupBox {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let (bg, has_paddings) = match self.variant {
GroupBoxVariant::Normal => (None, false),
GroupBoxVariant::Fill => (Some(cx.theme().surface), true),
};
v_flex()
.id(self.id.unwrap_or("group-box".into()))
.w_full()
.when(has_paddings, |this| this.gap_1p5())
.when(!has_paddings, |this| this.gap_2())
.refine_style(&self.style)
.when_some(self.title, |this, title| {
this.child(
div()
.text_color(cx.theme().text_muted)
.text_style(TextStyle::Subheadline)
.font_semibold()
.child(title),
)
})
.child(
v_flex()
.when_some(bg, |this, bg| this.bg(bg))
.text_color(cx.theme().text)
.when(has_paddings, |this| this.p_2())
.gap_4()
.rounded(px(theme::panel_radius()))
.children(self.children),
)
}
}
-274
View File
@@ -1,274 +0,0 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, Context, Hsla, IntoElement, Render, RenderOnce, SharedString, StyleRefinement,
Styled, Svg, Window, svg,
};
use theme::ActiveTheme;
use crate::{Sizable, Size};
impl From<IconName> for Icon {
fn from(value: IconName) -> Self {
Icon::build(value)
}
}
#[derive(IntoElement, Clone)]
pub enum IconName {
ArrowLeft,
ArrowRight,
ArrowDownCircle,
Book,
Ban,
ChevronDown,
CaretDown,
CaretRight,
Check,
CheckCircle,
Close,
CloseCircle,
CloseCircleFill,
Compass,
Copy,
Door,
Ellipsis,
Emoji,
Eye,
Input,
Info,
Invite,
Inbox,
Link,
Loader,
Lock,
Login,
Logout,
Message,
Moon,
Plus,
PlusChat,
PlusCircle,
Profile,
Relay,
Reply,
Search,
Settings,
Settings2,
Sun,
Group,
Upload,
Edit,
PanelLeft,
PanelLeftOpen,
PanelRight,
PanelRightOpen,
PanelBottom,
PanelBottomOpen,
PaperPlaneFill,
Warning,
WindowClose,
WindowMaximize,
WindowMinimize,
WindowRestore,
}
impl IconName {
fn path(self) -> SharedString {
match self {
Self::ArrowLeft => "icons/arrow-left.svg",
Self::ArrowRight => "icons/arrow-right.svg",
Self::ArrowDownCircle => "icons/arrow-down-circle.svg",
Self::Book => "icons/book.svg",
Self::Ban => "icons/ban.svg",
Self::ChevronDown => "icons/chevron-down.svg",
Self::CaretDown => "icons/caret-down.svg",
Self::CaretRight => "icons/caret-right.svg",
Self::Check => "icons/check.svg",
Self::CheckCircle => "icons/check-circle.svg",
Self::Close => "icons/close.svg",
Self::CloseCircle => "icons/close-circle.svg",
Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::Compass => "icons/compass.svg",
Self::Copy => "icons/copy.svg",
Self::Door => "icons/door.svg",
Self::Ellipsis => "icons/ellipsis.svg",
Self::Emoji => "icons/emoji.svg",
Self::Eye => "icons/eye.svg",
Self::Input => "icons/input.svg",
Self::Info => "icons/info.svg",
Self::Invite => "icons/invite.svg",
Self::Inbox => "icons/inbox.svg",
Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg",
Self::Lock => "icons/lock.svg",
Self::Login => "icons/login.svg",
Self::Logout => "icons/logout.svg",
Self::Message => "icons/message.svg",
Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg",
Self::PlusChat => "icons/plus-chat.svg",
Self::PlusCircle => "icons/plus-circle.svg",
Self::Profile => "icons/profile.svg",
Self::Relay => "icons/relay.svg",
Self::Reply => "icons/reply.svg",
Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg",
Self::Sun => "icons/sun.svg",
Self::Upload => "icons/upload.svg",
Self::Group => "icons/group.svg",
Self::PanelLeft => "icons/panel-left.svg",
Self::PanelLeftOpen => "icons/panel-left-open.svg",
Self::PanelRight => "icons/panel-right.svg",
Self::PanelRightOpen => "icons/panel-right-open.svg",
Self::PanelBottom => "icons/panel-bottom.svg",
Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
Self::Edit => "icons/edit.svg",
Self::Warning => "icons/warning.svg",
Self::WindowClose => "icons/window-close.svg",
Self::WindowMaximize => "icons/window-maximize.svg",
Self::WindowMinimize => "icons/window-minimize.svg",
Self::WindowRestore => "icons/window-restore.svg",
}
.into()
}
}
impl From<IconName> for AnyElement {
fn from(val: IconName) -> Self {
Icon::build(val).into_any_element()
}
}
impl RenderOnce for IconName {
fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement {
Icon::build(self)
}
}
#[derive(IntoElement)]
pub struct Icon {
base: Svg,
style: StyleRefinement,
path: SharedString,
text_color: Option<Hsla>,
size: Option<Size>,
}
impl Default for Icon {
fn default() -> Self {
Self {
base: svg().flex_none().size_4(),
style: StyleRefinement::default(),
path: "".into(),
text_color: None,
size: None,
}
}
}
impl Clone for Icon {
fn clone(&self) -> Self {
let mut this = Self::default().path(self.path.clone());
this.style = self.style.clone();
this.size = self.size;
this.text_color = self.text_color;
this
}
}
impl Icon {
pub fn new(icon: impl Into<Icon>) -> Self {
icon.into()
}
fn build(name: IconName) -> Self {
Self::default().path(name.path())
}
/// Sets the icon path, relative to the assets bundle (for example `icons/foo.svg`).
pub fn path(mut self, path: impl Into<SharedString>) -> Self {
self.path = path.into();
self
}
pub fn transform(mut self, transformation: gpui::Transformation) -> Self {
self.base = self.base.with_transformation(transformation);
self
}
pub fn empty() -> Self {
Self::default()
}
}
impl Styled for Icon {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
fn text_color(mut self, color: impl Into<Hsla>) -> Self {
self.text_color = Some(color.into());
self
}
}
impl Sizable for Icon {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = Some(size.into());
self
}
}
impl RenderOnce for Icon {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let text_color = self.text_color.unwrap_or_else(|| window.text_style().color);
let text_size = window.text_style().font_size.to_pixels(window.rem_size());
let has_base_size = self.style.size.width.is_some() || self.style.size.height.is_some();
let mut base = self.base;
*base.style() = self.style;
base.flex_shrink_0()
.text_color(text_color)
.when(!has_base_size, |this| this.size(text_size))
.when_some(self.size, |this, size| match size {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_3(),
Size::Small => this.size_4(),
Size::Medium => this.size_5(),
Size::Large => this.size_6(),
})
.path(self.path)
}
}
impl From<Icon> for AnyElement {
fn from(val: Icon) -> Self {
val.into_any_element()
}
}
impl Render for Icon {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let text_color = self.text_color.unwrap_or_else(|| cx.theme().text_muted);
let text_size = window.text_style().font_size.to_pixels(window.rem_size());
let has_base_size = self.style.size.width.is_some() || self.style.size.height.is_some();
let mut base = svg().flex_none();
*base.style() = self.style.clone();
base.flex_shrink_0()
.text_color(text_color)
.when(!has_base_size, |this| this.size(text_size))
.when_some(self.size, |this, size| match size {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_3(),
Size::Small => this.size_4(),
Size::Medium => this.size_5(),
Size::Large => this.size_6(),
})
.path(self.path.clone())
}
}
-59
View File
@@ -1,59 +0,0 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, Hsla, IntoElement, ParentElement, RenderOnce, Styled as _, Transformation, Window, div,
percentage,
};
use instant::Duration;
use crate::motion::{EASE_IN_OUT, MotionSpec, Painter, pulse_delta};
use crate::{Icon, IconName, Sizable, Size};
#[derive(IntoElement)]
pub struct Indicator {
size: Size,
icon: Icon,
speed: Duration,
color: Option<Hsla>,
}
impl Default for Indicator {
fn default() -> Self {
Self::new()
}
}
impl Indicator {
pub fn new() -> Self {
Self {
size: Size::Small,
speed: Duration::from_secs(1),
icon: Icon::new(IconName::Loader),
color: None,
}
}
pub fn color(mut self, color: Hsla) -> Self {
self.color = Some(color);
self
}
}
impl Sizable for Indicator {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for Indicator {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let spec = MotionSpec::new(self.speed.as_millis().max(1) as u64, EASE_IN_OUT);
let phase = pulse_delta(&spec, Painter::from(window.current_view()), cx);
div().child(
self.icon
.with_size(self.size)
.when_some(self.color, |this, color| this.text_color(color))
.transform(Transformation::rotate(percentage(phase))),
)
}
}
-15
View File
@@ -1,15 +0,0 @@
use gpui::{App, Styled};
use theme::ActiveTheme;
use crate::button::{Button, ButtonVariants as _};
use crate::{Icon, IconName, Sizable as _};
#[inline]
pub(crate) fn clear_button(cx: &App) -> Button {
Button::new("clean")
.icon(Icon::new(IconName::CloseCircle))
.ghost()
.xsmall()
.tab_stop(false)
.text_color(cx.theme().text_faint)
}
-277
View File
@@ -1,277 +0,0 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, DefiniteLength, Edges, Entity, Hsla, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Pixels, Rems, RenderOnce, StyleRefinement, Styled, TextAlign, Window, div,
px,
};
use gpui_base::InputBase;
use gpui_base::input::{InputBaseState, InputEditorStyle, InputMode, InputModeKind, TextareaMode};
use theme::{ActiveTheme, button_radius};
use crate::indicator::Indicator;
use crate::input::clear_button;
use crate::{Sizable, Size, StyleSized, StyledExt, h_flex, v_flex};
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn nearest_caret_offset(
value: &str,
position: gpui::Point<Pixels>,
mut caret_bounds: impl FnMut(usize) -> Option<gpui::Bounds<Pixels>>,
) -> Option<usize> {
value
.char_indices()
.map(|(offset, _)| offset)
.chain(std::iter::once(value.len()))
.filter_map(|offset| {
let bounds = caret_bounds(offset)?;
let vertical_distance = if position.y < bounds.top() {
bounds.top() - position.y
} else if position.y > bounds.bottom() {
position.y - bounds.bottom()
} else {
px(0.)
};
Some((
offset,
vertical_distance,
(position.x - bounds.left()).abs(),
))
})
.min_by(|left, right| left.1.cmp(&right.1).then_with(|| left.2.cmp(&right.2)))
.map(|(offset, _, _)| offset)
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn paste_from_primary<M: InputModeKind>(
state: &Entity<InputBaseState<M>>,
position: gpui::Point<Pixels>,
window: &mut Window,
cx: &mut App,
) {
if !state.read(cx).is_editable() {
return;
}
// Read before changing focus or selection: those can change PRIMARY.
let Some(text) = cx.read_from_primary().and_then(|item| item.text()) else {
return;
};
state.update(cx, |state, cx| {
let Some(offset) = nearest_caret_offset(&state.value(), position, |offset| {
state.range_to_bounds(&(offset..offset))
}) else {
return;
};
let text = if state.is_multi_line() {
text
} else {
text.replace(['\r', '\n'], "")
};
state.focus(window, cx);
state.set_selected_range(offset..offset, cx);
state.insert(text, window, cx);
});
cx.stop_propagation();
}
/// The background of an input frame, which reads muted while the input is disabled.
fn input_background(disabled: bool, cx: &App) -> Hsla {
if disabled {
cx.theme().surface
} else {
cx.theme().input_bg
}
}
/// The colors base paints input text with, read from the coop theme.
fn input_editor_style(cx: &App) -> InputEditorStyle {
let theme = cx.theme();
InputEditorStyle {
foreground: theme.text,
muted_foreground: theme.text_faint,
background: theme.input_bg,
border: theme.border_faint,
selection: theme.selection,
caret: theme.caret,
..InputEditorStyle::default()
}
}
/// The input's own padding, resolved to pixels.
fn input_paddings(size: Size, style: &StyleRefinement, window: &Window) -> Edges<Pixels> {
let mut probe = div().input_px(size).input_py(size).refine_style(style);
let padding = probe.style().padding.clone();
let base_size = window.text_style().font_size;
let rem_size = window.rem_size();
let resolve = |value: Option<DefiniteLength>| {
value
.map(|value| value.to_pixels(base_size, rem_size))
.unwrap_or(px(0.))
};
Edges {
left: resolve(padding.left),
right: resolve(padding.right),
top: resolve(padding.top),
bottom: resolve(padding.bottom),
}
}
/// A text input element bound to an [`InputState`] or a [`TextareaState`].
#[derive(IntoElement)]
pub struct Input<M: InputModeKind = InputMode> {
state: Entity<InputBaseState<M>>,
style: StyleRefinement,
size: Size,
appearance: bool,
cleanable: bool,
disabled: bool,
}
/// A styled multi-line text input.
pub type Textarea = Input<TextareaMode>;
impl<M: InputModeKind> Sizable for Input<M> {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl<M: InputModeKind> Input<M> {
/// Create a new [`Input`] element bind to the given state.
pub fn new(state: &Entity<InputBaseState<M>>) -> Self {
Self {
state: state.clone(),
size: Size::default(),
style: StyleRefinement::default(),
appearance: true,
cleanable: false,
disabled: false,
}
}
/// Set the appearance of the input field, if false the input field will no border, background.
pub fn appearance(mut self, appearance: bool) -> Self {
self.appearance = appearance;
self
}
/// Set whether to show the clear button when the input field is not empty, default is false.
pub fn cleanable(mut self, cleanable: bool) -> Self {
self.cleanable = cleanable;
self
}
/// Set to disable the input field.
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl<M: InputModeKind> Styled for Input<M> {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl<M: InputModeKind> RenderOnce for Input<M> {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
const LINE_HEIGHT: Rems = Rems(1.25);
let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left);
let multi_line = self.state.read(cx).is_multi_line();
let editor_paddings = if multi_line {
input_paddings(self.size, &self.style, window)
} else {
Edges::default()
};
self.state.update(cx, |state, cx| {
state.set_editor_style(input_editor_style(cx));
state.set_editor_paddings(editor_paddings);
state.set_disabled(self.disabled, cx);
if state.is_single_line() {
state.set_text_align(text_align, cx);
}
});
let state = self.state.read(cx);
let presentation = state.presentation();
let disabled = presentation.is_disabled();
let loading = presentation.is_loading();
let text_is_empty = state.text().len() == 0;
let gap_x = match self.size {
Size::Small => px(4.),
Size::Large => px(8.),
_ => px(6.),
};
let background = input_background(disabled, cx);
let show_clear_button =
self.cleanable && state.is_editable() && !loading && !text_is_empty && !multi_line;
let has_suffix = loading || show_clear_button;
let state_entity = self.state.clone();
let input = InputBase::new(("input", self.state.entity_id()))
.flex()
.size_full()
.line_height(LINE_HEIGHT)
.when(!multi_line, |this| {
this.input_px(self.size).input_py(self.size)
})
.input_h(self.size)
.when(!disabled, |this| this.cursor_text())
.on_mouse_down(MouseButton::Left, {
let state_entity = state_entity.clone();
move |_, window, cx| state_entity.update(cx, |state, cx| state.focus(window, cx))
});
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
let input = input.on_mouse_down(MouseButton::Middle, {
let state_entity = state_entity.clone();
move |event, window, cx| paste_from_primary(&state_entity, event.position, window, cx)
});
input
.items_center()
.when(multi_line, |this| this.h_auto())
.when(self.appearance, |this| {
this.bg(background)
.when(self.disabled, |this| this.opacity(0.5))
.rounded(px(button_radius()))
})
.tab_index(0)
.gap(gap_x)
.refine_style(&self.style)
.when(!multi_line, |this| this.child(state_entity.clone()))
.when(multi_line, |this| {
this.child(
v_flex()
.size_full()
.child(div().relative().flex_1().child(state_entity.clone())),
)
})
.when(has_suffix, |this| {
this.pr_2().child(
h_flex()
.id("suffix")
.gap(gap_x)
.items_center()
.when(loading, |this| this.child(Indicator::new()))
.when(show_clear_button, |this| {
this.child(clear_button(cx).on_click({
let state = state_entity.clone();
move |_, window, cx| {
state.update(cx, |state, cx| {
state.clean(window, cx);
state.focus(window, cx);
})
}
}))
}),
)
})
}
}
-7
View File
@@ -1,7 +0,0 @@
mod clear_button;
#[allow(clippy::module_inception)]
mod input;
pub(crate) use clear_button::*;
pub use gpui_base::input::{InputEvent, InputState, TextareaState};
pub use input::*;
-270
View File
@@ -1,270 +0,0 @@
use gpui::{
Action, AsKeystroke, IntoElement, KeyContext, Keystroke, ParentElement as _, RenderOnce,
StyleRefinement, Styled, Window, div,
};
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::StyledExt;
/// A key binding tag
#[derive(IntoElement, Clone, Debug)]
pub(crate) struct Kbd {
style: StyleRefinement,
stroke: Keystroke,
}
impl Kbd {
fn new(stroke: Keystroke) -> Self {
Self {
style: StyleRefinement::default(),
stroke,
}
}
/// Return the first keybinding for the given action and context.
pub(crate) fn binding_for_action(
action: &dyn Action,
context: Option<&str>,
window: &Window,
) -> Option<Self> {
let key_context = context.and_then(|context| KeyContext::parse(context).ok());
let binding = match key_context {
Some(context) => {
window.highest_precedence_binding_for_action_in_context(action, context)
}
None => window.highest_precedence_binding_for_action(action),
}?;
binding
.keystrokes()
.first()
.map(|key| Self::new(key.as_keystroke().clone()))
}
/// Return the platform specific keybinding string for a keystroke.
fn format(key: &Keystroke) -> String {
#[cfg(target_os = "macos")]
const DIVIDER: &str = "";
#[cfg(not(target_os = "macos"))]
const DIVIDER: &str = "+";
let mut parts = vec![];
if key.modifiers.control {
#[cfg(target_os = "macos")]
parts.push("⌃");
#[cfg(not(target_os = "macos"))]
parts.push("Ctrl");
}
if key.modifiers.alt {
#[cfg(target_os = "macos")]
parts.push("⌥");
#[cfg(not(target_os = "macos"))]
parts.push("Alt");
}
if key.modifiers.shift {
#[cfg(target_os = "macos")]
parts.push("⇧");
#[cfg(not(target_os = "macos"))]
parts.push("Shift");
}
if key.modifiers.platform {
#[cfg(target_os = "macos")]
parts.push("⌘");
#[cfg(not(target_os = "macos"))]
parts.push("Win");
}
let mut keys = String::new();
let key_str = key.key.as_str();
match key_str {
#[cfg(target_os = "macos")]
"ctrl" => keys.push('⌃'),
#[cfg(not(target_os = "macos"))]
"ctrl" => keys.push_str("Ctrl"),
#[cfg(target_os = "macos")]
"alt" => keys.push('⌥'),
#[cfg(not(target_os = "macos"))]
"alt" => keys.push_str("Alt"),
#[cfg(target_os = "macos")]
"shift" => keys.push('⇧'),
#[cfg(not(target_os = "macos"))]
"shift" => keys.push_str("Shift"),
#[cfg(target_os = "macos")]
"cmd" => keys.push('⌘'),
#[cfg(not(target_os = "macos"))]
"cmd" => keys.push_str("Win"),
#[cfg(target_os = "macos")]
"space" => keys.push_str("Space"),
#[cfg(target_os = "macos")]
"backspace" => keys.push('⌫'),
#[cfg(not(target_os = "macos"))]
"backspace" => keys.push_str("Backspace"),
#[cfg(target_os = "macos")]
"delete" => keys.push('⌫'),
#[cfg(not(target_os = "macos"))]
"delete" => keys.push_str("Delete"),
#[cfg(target_os = "macos")]
"escape" => keys.push('⎋'),
#[cfg(not(target_os = "macos"))]
"escape" => keys.push_str("Esc"),
#[cfg(target_os = "macos")]
"enter" => keys.push('⏎'),
#[cfg(not(target_os = "macos"))]
"enter" => keys.push_str("Enter"),
"pagedown" => keys.push_str("Page Down"),
"pageup" => keys.push_str("Page Up"),
#[cfg(target_os = "macos")]
"left" => keys.push('←'),
#[cfg(not(target_os = "macos"))]
"left" => keys.push_str("Left"),
#[cfg(target_os = "macos")]
"right" => keys.push('→'),
#[cfg(not(target_os = "macos"))]
"right" => keys.push_str("Right"),
#[cfg(target_os = "macos")]
"up" => keys.push('↑'),
#[cfg(not(target_os = "macos"))]
"up" => keys.push_str("Up"),
#[cfg(target_os = "macos")]
"down" => keys.push('↓'),
#[cfg(not(target_os = "macos"))]
"down" => keys.push_str("Down"),
_ => {
if key_str.len() == 1 {
keys.push_str(&key_str.to_uppercase());
} else {
let mut chars = key_str.chars();
if let Some(first_char) = chars.next() {
keys.push_str(&format!(
"{}{}",
first_char.to_uppercase(),
chars.collect::<String>()
));
} else {
keys.push_str(key_str);
}
}
}
}
parts.push(&keys);
parts.join(DIVIDER)
}
}
impl Styled for Kbd {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Kbd {
fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
div()
.border_1()
.border_color(cx.theme().border_faint)
.text_color(cx.theme().text_muted)
.bg(cx.theme().surface_raised)
.py_0p5()
.px_1()
.min_w_5()
.text_center()
.rounded_sm()
.text_style(TextStyle::Caption)
.whitespace_normal()
.flex_shrink_0()
.refine_style(&self.style)
.child(Self::format(&self.stroke))
.into_any_element()
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_format() {
use gpui::Keystroke;
use super::Kbd;
if cfg!(target_os = "macos") {
assert_eq!(Kbd::format(&Keystroke::parse("cmd-a").unwrap()), "⌘A");
assert_eq!(Kbd::format(&Keystroke::parse("cmd--").unwrap()), "⌘-");
assert_eq!(Kbd::format(&Keystroke::parse("cmd-+").unwrap()), "⌘+");
assert_eq!(Kbd::format(&Keystroke::parse("cmd-enter").unwrap()), "⌘⏎");
assert_eq!(
Kbd::format(&Keystroke::parse("secondary-f12").unwrap()),
"⌘F12"
);
assert_eq!(
Kbd::format(&Keystroke::parse("shift-pagedown").unwrap()),
"⇧Page Down"
);
assert_eq!(
Kbd::format(&Keystroke::parse("shift-pageup").unwrap()),
"⇧Page Up"
);
assert_eq!(
Kbd::format(&Keystroke::parse("shift-space").unwrap()),
"⇧Space"
);
assert_eq!(Kbd::format(&Keystroke::parse("cmd-ctrl-a").unwrap()), "⌃⌘A");
assert_eq!(
Kbd::format(&Keystroke::parse("cmd-alt-backspace").unwrap()),
"⌥⌘⌫"
);
assert_eq!(
Kbd::format(&Keystroke::parse("shift-delete").unwrap()),
"⇧⌫"
);
assert_eq!(
Kbd::format(&Keystroke::parse("cmd-ctrl-shift-a").unwrap()),
"⌃⇧⌘A"
);
assert_eq!(
Kbd::format(&Keystroke::parse("cmd-ctrl-shift-alt-a").unwrap()),
"⌃⌥⇧⌘A"
);
} else {
assert_eq!(Kbd::format(&Keystroke::parse("a").unwrap()), "A");
assert_eq!(Kbd::format(&Keystroke::parse("ctrl-a").unwrap()), "Ctrl+A");
assert_eq!(
Kbd::format(&Keystroke::parse("shift-space").unwrap()),
"Shift+Space"
);
assert_eq!(
Kbd::format(&Keystroke::parse("ctrl-alt-a").unwrap()),
"Ctrl+Alt+A"
);
assert_eq!(
Kbd::format(&Keystroke::parse("ctrl-alt-shift-a").unwrap()),
"Ctrl+Alt+Shift+A"
);
assert_eq!(
Kbd::format(&Keystroke::parse("ctrl-alt-shift-win-a").unwrap()),
"Ctrl+Alt+Shift+Win+A"
);
assert_eq!(
Kbd::format(&Keystroke::parse("ctrl-shift-backspace").unwrap()),
"Ctrl+Shift+Backspace"
);
assert_eq!(
Kbd::format(&Keystroke::parse("alt-delete").unwrap()),
"Alt+Delete"
);
assert_eq!(
Kbd::format(&Keystroke::parse("alt-tab").unwrap()),
"Alt+Tab"
);
}
}
}
+101
View File
@@ -0,0 +1,101 @@
//! Layout constants, ported from bezel. Numbers drive layout, colors are
//! paint: these live as plain numbers and never depend on which color is
//! painted.
use std::sync::atomic::{AtomicU32, Ordering};
use gpui::{Pixels, px};
/// The base radius behind every radius accessor, as raw `f32` bits.
static BASE: AtomicU32 = AtomicU32::new(BASE_RADIUS.to_bits());
/// The gap between siblings. Measured on macOS 26: `NSStackView().spacing`,
/// visual format's `-`, and `constraint(equalToSystemSpacingAfter:multiplier: 1)`
/// all report 8.
pub const SPACE: f32 = 8.0;
/// The margin from content to its container's edge. Same measurement,
/// visual format's `|-`.
pub const CONTENT_MARGIN: f32 = 20.0;
/// From a column's edge to the glyph of the control at the end of a row,
/// measured to the glyph rather than the button. Mirrored at the trailing end.
pub const EDGE: f32 = 12.0;
/// Button, text field and select-trigger height. Measured:
/// `NSButton`, `NSTextField` and `NSPopUpButton` all report 24 at
/// `.regular` — the body line box with 4 above and below.
pub const BUTTON_HEIGHT: f32 = 24.0;
/// The same controls at `.small`.
pub const CONTROL_HEIGHT_SMALL: f32 = 20.0;
/// Every radius below is a ratio of this, so a brand radius moves the whole
/// set together and keeps the concentric relationships intact.
pub const BASE_RADIUS: f32 = 8.0;
/// Message bubble corner radius (2x).
pub fn bubble_radius() -> f32 {
radius(2.0)
}
/// Floating-surface corner radius — popovers, menus, dialogs (1.5x).
pub fn surface_radius() -> f32 {
radius(1.5)
}
/// Panel / card corner radius (1.25x).
pub fn panel_radius() -> f32 {
radius(1.25)
}
/// Button, text field and select-trigger radius (1x).
pub fn button_radius() -> f32 {
radius(1.0)
}
/// Small control radius (chips, tags, steppers) — a size down from
/// [`button_radius`], for things that sit inside a control rather than being one
/// (0.75x).
pub fn control_radius() -> f32 {
radius(0.75)
}
/// A corner as a multiple of the branded base radius.
///
/// Read from a process-wide mirror rather than the theme global for the reason
/// [`crate::paint::current_appearance`] is: the element builders that round a
/// corner are free functions with no `cx` in scope, and a radius is one number
/// for the whole app.
fn radius(ratio: f32) -> f32 {
f32::from_bits(BASE.load(Ordering::Relaxed)) * ratio
}
/// The concentric child of a surface: a row inset by `inset` inside a
/// container of radius `outer` keeps its corners parallel to the container's,
/// rather than looking pasted onto it.
///
/// This is SwiftUI's `ContainerRelativeShape` rule done as arithmetic. gpui
/// has no container shape to inherit at paint time, so the relationship is
/// stated where the child is *defined* instead of resolved at runtime.
pub const fn inset_radius(outer: f32, inset: f32) -> f32 {
if outer > inset { outer - inset } else { 0.0 }
}
/// Window border radius for platforms with client-side decorations.
pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0);
/// Window shadow size for platforms with client-side decorations.
pub const CLIENT_SIDE_DECORATION_SHADOW: Pixels = px(10.0);
/// Window border size for platforms with client-side decorations.
pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
/// Space left of the sidebar header for the macOS traffic lights.
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
/// Workspace tabbar height.
pub const TABBAR_HEIGHT: Pixels = px(36.0);
/// Default sidebar width.
pub const SIDEBAR_WIDTH: Pixels = px(240.);
+13 -48
View File
@@ -1,50 +1,15 @@
pub(crate) use gpui_base::{ElementExt, InteractiveElementExt};
pub use icon::*;
pub use root::Root;
pub use styled::*;
pub use title_bar::{TRAFFIC_LIGHT_PADDING, title_bar_drag_handlers};
pub use window_ext::*;
pub use layout::{
BASE_RADIUS, BUTTON_HEIGHT, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING,
CLIENT_SIDE_DECORATION_SHADOW, CONTENT_MARGIN, CONTROL_HEIGHT_SMALL, EDGE, SIDEBAR_WIDTH,
SPACE, TABBAR_HEIGHT, TRAFFIC_LIGHT_PADDING, bubble_radius, button_radius, control_radius,
inset_radius, panel_radius, surface_radius,
};
pub use styled::{background, divider};
pub use typography::{DEFAULT_BASE_TEXT_SIZE, Metrics, TextStyle, Typeset, base_text_size};
pub use window_drag::title_bar_drag_handlers;
pub mod avatar;
pub mod button;
pub mod dialog;
pub mod dock;
pub mod group_box;
pub mod indicator;
pub mod input;
pub mod markdown;
pub mod menu;
pub mod message;
pub mod nav;
pub mod nav_item;
pub mod notification;
pub mod scroll;
pub mod switch;
pub mod tab;
pub mod tooltip;
mod icon;
mod kbd;
pub mod layout;
pub mod motion;
mod popover;
mod resizable;
mod root;
mod styled;
mod title_bar;
mod window_ext;
/// Initialize the UI module.
///
/// This must be called before using any of the UI components.
/// You can initialize the UI module at your application's entry point.
pub fn init(cx: &mut gpui::App) {
gpui_base::init(cx);
#[cfg(not(target_os = "macos"))]
cx.bind_keys([gpui::KeyBinding::new(
"shift-insert",
gpui_base::input::Paste,
Some("Input"),
)]);
theme::sync_base(cx);
menu::init(cx);
}
pub mod styled;
pub mod typography;
pub mod window_drag;
-146
View File
@@ -1,146 +0,0 @@
use std::ops::Range;
use gpui::{
AnyElement, App, ElementId, HighlightStyle, IntoElement, SharedString, SharedUri,
StyleRefinement, Styled as _, Window,
};
use gpui_base::{TextView, TextViewStyle};
use theme::ActiveTheme as _;
/// A span of the source content, by byte range, that renders as `text` instead.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineReplacement {
pub range: Range<usize>,
pub text: SharedString,
/// When set, the span renders as this image (NIP-30 custom emoji)
pub image: Option<SharedUri>,
}
impl InlineReplacement {
pub fn new(range: Range<usize>, text: impl Into<SharedString>) -> Self {
Self {
range,
text: text.into(),
image: None,
}
}
pub fn image(
range: Range<usize>,
alt: impl Into<SharedString>,
url: impl Into<SharedUri>,
) -> Self {
Self {
range,
text: alt.into(),
image: Some(url.into()),
}
}
}
/// Message content prepared for rendering as a GPUI Kit text view.
#[derive(Default)]
pub struct RenderedText {
text: SharedString,
}
impl RenderedText {
pub fn new(content: &str, replacements: &[InlineReplacement]) -> Self {
let mut text = String::with_capacity(content.len());
let mut last = 0;
for replacement in replacements {
let range = &replacement.range;
if range.start < last
|| range.end > content.len()
|| range.start >= range.end
|| !content.is_char_boundary(range.start)
|| !content.is_char_boundary(range.end)
{
continue;
}
text.push_str(&content[last..range.start]);
match &replacement.image {
Some(url) => text.push_str(&format!(
"![{}](<{url}>)",
escape_link_text(&replacement.text)
)),
None => text.push_str(&format!(
"[{}](<{}>)",
escape_link_text(&replacement.text),
&content[range.start..range.end]
)),
}
last = range.end;
}
text.push_str(&content[last..]);
Self {
text: SharedString::from(text),
}
}
/// Render the message as a GPUI Kit text view.
pub fn element(&self, id: ElementId, _window: &Window, cx: &App) -> AnyElement {
let theme = cx.theme();
let code_block = StyleRefinement::default().font_family(theme.font_mono.clone());
let muted_heading = theme.text_muted;
TextView::markdown(id, self.text.clone())
.style(
TextViewStyle::default()
.with_foreground(theme.text)
.with_muted_foreground(theme.text_muted)
// Deeply nested headings read as secondary text.
.with_heading(move |level| {
if level >= 4 {
StyleRefinement::default().text_color(muted_heading)
} else {
StyleRefinement::default()
}
})
.with_link(theme.accent)
.with_code_background(theme.code_wash)
.with_inline_code(HighlightStyle {
color: Some(theme.code_text),
..Default::default()
})
.with_border(theme.border)
.with_code_block(code_block)
.with_dark(theme.is_dark()),
)
.on_link_click(|url, _, _, cx| {
if is_web_url(url) {
cx.open_url(url);
} else if let Some(bech32) = nevent_bech32(url) {
cx.open_url(&format!("https://njump.me/{bech32}"));
}
})
.into_any_element()
}
}
/// Escapes the characters that would terminate a markdown link label.
fn escape_link_text(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for character in text.chars() {
if matches!(character, '[' | ']' | '\\') {
escaped.push('\\');
}
escaped.push(character);
}
escaped
}
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
fn is_web_url(url: &str) -> bool {
let url = url.to_ascii_lowercase();
url.starts_with("http://") || url.starts_with("https://")
}
/// Extracts the bech32 from a NIP-19 event reference (`nevent1...` or `nostr:nevent1...`).
fn nevent_bech32(url: &str) -> Option<&str> {
let bech32 = url.strip_prefix("nostr:").unwrap_or(url);
bech32.starts_with("nevent1").then_some(bech32)
}
-178
View File
@@ -1,178 +0,0 @@
use std::rc::Rc;
use gpui::{
Anchor, AnyElement, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement,
IntoElement, MouseButton, RenderOnce, SharedString, StyleRefinement, Styled, Window,
};
use crate::Selectable;
use crate::avatar::Avatar;
use crate::button::Button;
use crate::menu::PopupMenu;
use crate::nav::Nav;
use crate::nav_item::NavItem;
use crate::popover::{Popover, PopoverState};
/// Builds the items of a popup menu on each render.
type MenuBuilder = dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu;
/// A dropdown menu trait for buttons and other interactive elements
pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
/// Create a dropdown menu with the given items, anchored to the TopRight corner
fn dropdown_menu(
self,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> DropdownMenuPopover<Self> {
self.dropdown_menu_with_anchor(Anchor::TopRight, f)
}
/// Create a dropdown menu with the given items, anchored to the given corner
fn dropdown_menu_with_anchor(
mut self,
anchor: impl Into<Anchor>,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> DropdownMenuPopover<Self> {
let style = self.style().clone();
let id = self.interactivity().element_id.clone();
DropdownMenuPopover::new(id.unwrap_or(0.into()), anchor, self, f).trigger_style(style)
}
}
impl DropdownMenu for Button {}
impl DropdownMenu for Avatar {}
impl DropdownMenu for Nav {}
impl DropdownMenu for NavItem {}
#[derive(IntoElement)]
pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
id: ElementId,
style: StyleRefinement,
anchor: Anchor,
trigger: T,
builder: Rc<MenuBuilder>,
}
impl<T> DropdownMenuPopover<T>
where
T: Selectable + IntoElement + 'static,
{
fn new(
id: ElementId,
anchor: impl Into<Anchor>,
trigger: T,
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
Self {
id: SharedString::from(format!("dropdown-menu:{:?}", id)).into(),
style: StyleRefinement::default(),
anchor: anchor.into(),
trigger,
builder: Rc::new(builder),
}
}
/// Set the style refinement for the dropdown menu trigger.
fn trigger_style(mut self, style: StyleRefinement) -> Self {
self.style = style;
self
}
}
/// Opens a [`PopupMenu`] when its child is right-clicked, keeping the child's own click handler.
#[derive(IntoElement)]
pub struct ContextMenu {
id: ElementId,
child: AnyElement,
builder: Rc<MenuBuilder>,
}
impl ContextMenu {
pub fn new(
id: impl Into<ElementId>,
child: impl IntoElement,
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
Self {
id: id.into(),
child: child.into_any_element(),
builder: Rc::new(builder),
}
}
}
#[derive(Default)]
struct MenuState {
menu: Option<Entity<PopupMenu>>,
}
/// Builds the menu once and reuses it until dismissed, preserving focus and selection state.
fn cached_menu(
menu_state: &Entity<MenuState>,
builder: Rc<MenuBuilder>,
window: &mut Window,
cx: &mut Context<PopoverState>,
) -> Entity<PopupMenu> {
if let Some(menu) = menu_state.read(cx).menu.clone() {
return menu;
}
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
builder(menu, window, cx)
});
menu_state.update(cx, |state, _| {
state.menu = Some(menu.clone());
});
menu.focus_handle(cx).focus(window, cx);
let popover_state = cx.entity();
window
.subscribe(&menu, cx, {
let menu_state = menu_state.clone();
move |_, _: &DismissEvent, window, cx| {
popover_state.update(cx, |state, cx| state.dismiss(window, cx));
menu_state.update(cx, |state, _| {
state.menu = None;
});
}
})
.detach();
menu
}
impl<T> RenderOnce for DropdownMenuPopover<T>
where
T: Selectable + IntoElement + 'static,
{
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
let builder = self.builder.clone();
let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
Popover::new(SharedString::from(format!("popover:{}", self.id)))
.appearance(false)
.overlay_closable(false)
.trigger(self.trigger)
.trigger_style(self.style)
.anchor(self.anchor)
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
}
}
impl RenderOnce for ContextMenu {
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
let builder = self.builder.clone();
let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
Popover::new(SharedString::from(format!("context-menu:{}", self.id)))
.appearance(false)
.overlay_closable(false)
.anchor(Anchor::TopLeft)
.mouse_button(MouseButton::Right)
.trigger_with(move |_open, _window, _cx| self.child)
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
}
}
-121
View File
@@ -1,121 +0,0 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window,
};
use smallvec::SmallVec;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::{Disableable, StyledExt, h_flex};
#[derive(IntoElement)]
pub(crate) struct MenuItemElement {
id: ElementId,
group_name: SharedString,
style: StyleRefinement,
disabled: bool,
selected: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
#[allow(clippy::type_complexity)]
on_hover: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
children: SmallVec<[AnyElement; 2]>,
}
impl MenuItemElement {
/// Create a new MenuItem with the given ID and group name.
pub(crate) fn new(id: impl Into<ElementId>, group_name: impl Into<SharedString>) -> Self {
let id: ElementId = id.into();
Self {
id: id.clone(),
group_name: group_name.into(),
style: StyleRefinement::default(),
disabled: false,
selected: false,
on_click: None,
on_hover: None,
children: SmallVec::new(),
}
}
/// Set the selected style of the item.
pub(crate) fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
/// Set a handler for when the MenuItem is clicked.
pub(crate) fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
/// Set a handler for when the mouse enters the MenuItem.
pub(crate) fn on_hover(
mut self,
handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_hover = Some(Box::new(handler));
self
}
}
impl Disableable for MenuItemElement {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Styled for MenuItemElement {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl ParentElement for MenuItemElement {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for MenuItemElement {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
h_flex()
.id(self.id)
.group(&self.group_name)
.gap_x_1()
.p_1()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text)
.relative()
.items_center()
.justify_between()
.refine_style(&self.style)
.when_some(self.on_hover, |this, on_hover| {
this.on_hover(move |hovered, window, cx| (on_hover)(hovered, window, cx))
})
.when(!self.disabled, |this| {
this.group_hover(self.group_name, |this| {
this.bg(cx.theme().element_hover)
.text_color(cx.theme().text)
})
.when(self.selected, |this| {
this.bg(cx.theme().element_active)
.text_color(cx.theme().text)
})
.when_some(self.on_click, |this, on_click| {
this.on_mouse_down(MouseButton::Left, move |_, _, cx| {
cx.stop_propagation();
})
.on_click(on_click)
})
})
.when(self.disabled, |this| this.text_color(cx.theme().text_faint))
.children(self.children)
}
}
-12
View File
@@ -1,12 +0,0 @@
use gpui::App;
mod dropdown_menu;
mod menu_item;
mod popup_menu;
pub use dropdown_menu::{ContextMenu, DropdownMenu};
pub use popup_menu::{PopupMenu, PopupMenuItem};
pub(crate) fn init(cx: &mut App) {
popup_menu::init(cx);
}
-839
View File
@@ -1,839 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
Action, Anchor, App, AppContext, Axis, Bounds, ClickEvent, Context, DismissEvent, Edges,
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding,
MouseDownEvent, ParentElement, Pixels, Point, Render, SharedString, Styled, WeakEntity, Window,
anchored, div, px, rems,
};
use gpui_base::actions::{Cancel, Confirm, SelectDown, SelectLeft, SelectRight, SelectUp};
use theme::{ActiveTheme, Side, TextStyle, Typeset as _};
use crate::kbd::Kbd;
use crate::menu::menu_item::MenuItemElement;
use crate::{Disableable, ElementExt, Icon, IconName, Sizable as _, StyledExt, h_flex, v_flex};
const CONTEXT: &str = "PopupMenu";
const MAX_WIDTH: Pixels = px(500.);
pub(crate) fn init(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
KeyBinding::new("up", SelectUp, Some(CONTEXT)),
KeyBinding::new("down", SelectDown, Some(CONTEXT)),
KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
KeyBinding::new("right", SelectRight, Some(CONTEXT)),
]);
}
/// A menu item in a popup menu.
pub enum PopupMenuItem {
/// A menu separator item.
Separator,
/// A non-interactive label item.
Label(SharedString),
/// A standard menu item.
Item {
icon: Option<Icon>,
label: SharedString,
disabled: bool,
checked: bool,
action: Option<Box<dyn Action>>,
#[allow(clippy::type_complexity)]
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
},
/// A submenu item that opens another popup menu.
Submenu {
icon: Option<Icon>,
label: SharedString,
disabled: bool,
menu: Entity<PopupMenu>,
},
}
impl FluentBuilder for PopupMenuItem {}
impl PopupMenuItem {
/// Create a new menu item with the given label.
#[inline]
pub fn new(label: impl Into<SharedString>) -> Self {
PopupMenuItem::Item {
icon: None,
label: label.into(),
disabled: false,
checked: false,
action: None,
handler: None,
}
}
/// Create a new submenu item that opens another popup menu.
#[inline]
fn submenu(label: impl Into<SharedString>, menu: Entity<PopupMenu>) -> Self {
PopupMenuItem::Submenu {
icon: None,
label: label.into(),
disabled: false,
menu,
}
}
/// Create a separator menu item.
#[inline]
fn separator() -> Self {
PopupMenuItem::Separator
}
/// Create a label menu item.
#[inline]
fn label(label: impl Into<SharedString>) -> Self {
PopupMenuItem::Label(label.into())
}
/// Set the icon for the menu item.
fn icon(mut self, icon: impl Into<Icon>) -> Self {
match &mut self {
PopupMenuItem::Item { icon: i, .. } => {
*i = Some(icon.into());
}
PopupMenuItem::Submenu { icon: i, .. } => {
*i = Some(icon.into());
}
_ => {}
}
self
}
/// Set the action for the menu item.
fn action(mut self, action: Box<dyn Action>) -> Self {
if let PopupMenuItem::Item { action: a, .. } = &mut self {
*a = Some(action);
}
self
}
/// Set the disabled state for the menu item.
fn disabled(mut self, disabled: bool) -> Self {
match &mut self {
PopupMenuItem::Item { disabled: d, .. } => {
*d = disabled;
}
PopupMenuItem::Submenu { disabled: d, .. } => {
*d = disabled;
}
_ => {}
}
self
}
/// Set the checked state for the menu item.
fn checked(mut self, checked: bool) -> Self {
if let PopupMenuItem::Item { checked: c, .. } = &mut self {
*c = checked;
}
self
}
/// Add a click handler for the menu item.
pub fn on_click<F>(mut self, handler: F) -> Self
where
F: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
{
if let PopupMenuItem::Item { handler: h, .. } = &mut self {
*h = Some(Rc::new(handler));
}
self
}
#[inline]
fn is_clickable(&self) -> bool {
matches!(
self,
PopupMenuItem::Item {
disabled: false,
..
} | PopupMenuItem::Submenu {
disabled: false,
..
}
)
}
#[inline]
fn is_separator(&self) -> bool {
matches!(self, PopupMenuItem::Separator)
}
fn has_left_icon(&self) -> bool {
match self {
PopupMenuItem::Item { icon, checked, .. } => icon.is_some() || *checked,
PopupMenuItem::Submenu { icon, .. } => icon.is_some(),
_ => false,
}
}
#[inline]
fn is_checked(&self) -> bool {
matches!(self, PopupMenuItem::Item { checked: true, .. })
}
}
pub struct PopupMenu {
focus_handle: FocusHandle,
menu_items: Vec<PopupMenuItem>,
axis: Axis,
selected_index: Option<usize>,
bounds: Bounds<Pixels>,
/// The parent menu of this menu, if this is a submenu
parent_menu: Option<WeakEntity<Self>>,
/// This will update on render
submenu_anchor: (Anchor, Pixels),
}
impl PopupMenu {
pub(crate) fn new(cx: &mut App) -> Self {
Self {
focus_handle: cx.focus_handle(),
parent_menu: None,
menu_items: Vec::new(),
selected_index: None,
axis: Axis::Vertical,
bounds: Bounds::default(),
submenu_anchor: (Anchor::TopLeft, Pixels::ZERO),
}
}
pub(crate) fn build(
window: &mut Window,
cx: &mut App,
f: impl FnOnce(Self, &mut Window, &mut Context<PopupMenu>) -> Self,
) -> Entity<Self> {
cx.new(|cx| f(Self::new(cx), window, cx))
}
/// Set the axis of children to horizontal.
pub fn horizontal(mut self) -> Self {
self.axis = Axis::Horizontal;
self
}
/// Add Menu Item
pub fn menu(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
self.add_menu_item(label, None, action, false, false);
self
}
/// Add label
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.menu_items.push(PopupMenuItem::label(label.into()));
self
}
/// Add Menu Item with Icon.
pub fn menu_with_icon(
mut self,
label: impl Into<SharedString>,
icon: impl Into<Icon>,
action: Box<dyn Action>,
) -> Self {
self.add_menu_item(label, Some(icon.into()), action, false, false);
self
}
/// Add Menu Item with check icon
pub fn menu_with_check(
self,
label: impl Into<SharedString>,
checked: bool,
action: Box<dyn Action>,
) -> Self {
self.menu_with_check_and_disabled(label, checked, action, false)
}
/// Add Menu Item with check icon and disabled state
pub fn menu_with_check_and_disabled(
mut self,
label: impl Into<SharedString>,
checked: bool,
action: Box<dyn Action>,
disabled: bool,
) -> Self {
self.add_menu_item(label, None, action, disabled, checked);
self
}
/// Add a separator Menu Item
pub fn separator(mut self) -> Self {
if self.menu_items.is_empty() {
return self;
}
if let Some(PopupMenuItem::Separator) = self.menu_items.last() {
return self;
}
self.menu_items.push(PopupMenuItem::separator());
self
}
/// Add a Submenu
pub fn submenu(
mut self,
label: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
let submenu = PopupMenu::build(window, cx, f);
let parent_menu = cx.entity().downgrade();
submenu.update(cx, |view, _| {
view.parent_menu = Some(parent_menu);
});
self.menu_items.push(PopupMenuItem::submenu(label, submenu));
self
}
/// Add menu item.
pub fn item(mut self, item: impl Into<PopupMenuItem>) -> Self {
let item: PopupMenuItem = item.into();
self.menu_items.push(item);
self
}
fn add_menu_item(
&mut self,
label: impl Into<SharedString>,
icon: Option<Icon>,
action: Box<dyn Action>,
disabled: bool,
checked: bool,
) -> &mut Self {
self.menu_items.push(
PopupMenuItem::new(label)
.when_some(icon, |item, icon| item.icon(icon))
.disabled(disabled)
.checked(checked)
.action(action),
);
self
}
fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
if let Some(ix) = self.selected_index
&& let Some(item) = self.menu_items.get(ix)
{
return match item {
PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
_ => None,
};
}
None
}
fn clickable_menu_items(&self) -> impl Iterator<Item = (usize, &PopupMenuItem)> {
self.menu_items
.iter()
.enumerate()
.filter(|(_, item)| item.is_clickable())
}
fn on_click(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
cx.stop_propagation();
window.prevent_default();
self.selected_index = Some(ix);
self.confirm(&Confirm { secondary: false }, window, cx);
}
fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
if let Some(index) = self.selected_index
&& let Some(PopupMenuItem::Item {
handler, action, ..
}) = self.menu_items.get(index)
{
if let Some(handler) = handler {
handler(&ClickEvent::default(), window, cx);
} else if let Some(action) = action.as_ref() {
window.dispatch_action(action.boxed_clone(), cx);
}
self.dismiss(&Cancel, window, cx)
}
}
fn set_selected_index(&mut self, ix: usize, cx: &mut Context<Self>) {
if self.selected_index != Some(ix) {
self.selected_index = Some(ix);
cx.notify();
}
}
fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
cx.stop_propagation();
let ix = self.selected_index.unwrap_or(0);
if let Some((prev_ix, _)) = self
.menu_items
.iter()
.enumerate()
.rev()
.find(|(i, item)| *i < ix && item.is_clickable())
{
self.set_selected_index(prev_ix, cx);
return;
}
let last_clickable_ix = self.clickable_menu_items().last().map(|(ix, _)| ix);
self.set_selected_index(last_clickable_ix.unwrap_or(0), cx);
}
fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
cx.stop_propagation();
let Some(ix) = self.selected_index else {
self.set_selected_index(0, cx);
return;
};
if let Some((next_ix, _)) = self
.menu_items
.iter()
.enumerate()
.find(|(i, item)| *i > ix && item.is_clickable())
{
self.set_selected_index(next_ix, cx);
return;
}
self.set_selected_index(0, cx);
}
fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
self._unselect_submenu(window, cx)
} else {
self._select_submenu(window, cx)
};
if self.parent_side(cx).is_left() {
self._focus_parent_menu(window, cx);
}
if handled {
return;
}
// For parent AppMenuBar to handle.
if self.parent_menu.is_none() {
cx.propagate();
}
}
fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
self._select_submenu(window, cx)
} else {
self._unselect_submenu(window, cx)
};
if self.parent_side(cx).is_right() {
self._focus_parent_menu(window, cx);
}
if handled {
return;
}
// For parent AppMenuBar to handle.
if self.parent_menu.is_none() {
cx.propagate();
}
}
fn _select_submenu(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
if let Some(active_submenu) = self.active_submenu() {
// Focus the submenu, so that can be handle the action.
active_submenu.update(cx, |view, cx| {
view.set_selected_index(0, cx);
view.focus_handle.focus(window, cx);
});
cx.notify();
return true;
}
false
}
fn _unselect_submenu(&mut self, _: &mut Window, cx: &mut Context<Self>) -> bool {
if let Some(active_submenu) = self.active_submenu() {
active_submenu.update(cx, |view, cx| {
view.selected_index = None;
cx.notify();
});
return true;
}
false
}
fn _focus_parent_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(parent) = self.parent_menu.as_ref() else {
return;
};
let Some(parent) = parent.upgrade() else {
return;
};
self.selected_index = None;
parent.update(cx, |view, cx| {
view.focus_handle.focus(window, cx);
cx.notify();
});
}
fn parent_side(&self, cx: &App) -> Side {
let Some(parent) = self.parent_menu.as_ref() else {
return Side::Left;
};
let Some(parent) = parent.upgrade() else {
return Side::Left;
};
match parent.read(cx).submenu_anchor.0 {
Anchor::TopLeft | Anchor::BottomLeft => Side::Left,
Anchor::TopRight | Anchor::BottomRight => Side::Right,
_ => Side::Left,
}
}
#[expect(clippy::only_used_in_recursion)]
fn dismiss(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
if self.active_submenu().is_some() {
return;
}
cx.emit(DismissEvent);
let Some(parent_menu) = self.parent_menu.clone() else {
return;
};
// Dismiss parent menu, when this menu is dismissed
if let Err(error) = parent_menu.update(cx, |view, cx| {
view.selected_index = None;
view.dismiss(&Cancel, window, cx);
}) {
log::warn!("Failed to dismiss parent menu: {error}");
}
}
fn handle_dismiss(
&mut self,
position: &Point<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Do not dismiss, if click inside the parent menu
if let Some(parent) = self.parent_menu.as_ref()
&& let Some(parent) = parent.upgrade()
&& parent.read(cx).bounds.contains(position)
{
return;
}
self.dismiss(&Cancel, window, cx);
}
fn on_mouse_down_out(
&mut self,
e: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.handle_dismiss(&e.position, window, cx);
}
fn render_key_binding(
&self,
action: Option<Box<dyn Action>>,
window: &mut Window,
_: &mut Context<Self>,
) -> Option<Kbd> {
let action = action?;
Kbd::binding_for_action(action.as_ref(), None, window).map(|this| {
this.p_0()
.flex_nowrap()
.border_0()
.bg(gpui::transparent_white())
})
}
fn render_icon(
has_icon: bool,
checked: bool,
icon: Option<Icon>,
_: &mut Window,
cx: &mut Context<Self>,
) -> Option<impl IntoElement> {
if !has_icon {
return None;
}
let icon = if let Some(icon) = icon {
icon.clone()
} else if checked {
Icon::new(IconName::Check).text_color(cx.theme().accent)
} else {
Icon::empty()
};
Some(icon)
}
/// Calculate the anchor corner and left offset for child submenu
fn update_submenu_menu_anchor(&mut self, window: &Window) {
let bounds = self.bounds;
let (anchor, left) = if MAX_WIDTH + bounds.origin.x > window.bounds().size.width {
(Anchor::TopRight, -px(16.))
} else {
(Anchor::TopLeft, bounds.size.width - px(8.))
};
let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height;
self.submenu_anchor = if is_bottom_pos {
(anchor.other_side_along(gpui::Axis::Vertical), left)
} else {
(anchor, left)
};
}
fn render_item(
&self,
ix: usize,
item: &PopupMenuItem,
options: RenderOptions,
window: &mut Window,
cx: &mut Context<Self>,
) -> MenuItemElement {
let has_left_icon = options.has_left_icon;
let is_checked = item.is_checked();
let selected = self.selected_index == Some(ix);
const EDGE_PADDING: Pixels = px(4.);
const INNER_PADDING: Pixels = px(4.);
let is_submenu = matches!(item, PopupMenuItem::Submenu { .. });
let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix);
let item_height = px(26.);
let this = MenuItemElement::new(ix, &group_name)
.relative()
.text_style(TextStyle::Callout)
.py_0()
.px(INNER_PADDING)
.rounded(options.radius)
.items_center()
.selected(selected)
.on_hover(cx.listener(move |this, hovered, _, cx| {
if *hovered {
this.selected_index = Some(ix);
} else if !is_submenu && this.selected_index == Some(ix) {
// TODO: Better handle the submenu unselection when hover out
this.selected_index = None;
}
cx.notify();
}));
match item {
PopupMenuItem::Separator => this
.h_auto()
.p_0()
.my_0p5()
.mx_neg_1()
.border_b(px(2.))
.border_color(cx.theme().border_faint)
.disabled(true),
PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
h_flex()
.cursor_default()
.items_center()
.gap_x_1()
.h_6()
.child(
div()
.flex_1()
.text_style(TextStyle::Footnote)
.font_semibold()
.text_color(cx.theme().text_muted)
.child(label.clone()),
),
),
PopupMenuItem::Item {
icon,
label,
action,
disabled,
..
} => {
let action = action.as_ref().map(|action| action.boxed_clone());
let key = self.render_key_binding(action, window, cx);
this.when(!disabled, |this| {
this.on_click(
cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)),
)
})
.disabled(*disabled)
.h(item_height)
.gap_x(px(theme::SPACE))
.children(Self::render_icon(
has_left_icon,
is_checked,
icon.clone(),
window,
cx,
))
.child(
h_flex()
.w_full()
.gap_3()
.items_center()
.justify_between()
.child(label.clone())
.children(key),
)
}
PopupMenuItem::Submenu {
icon,
label,
menu,
disabled,
} => this
.selected(selected)
.disabled(*disabled)
.items_start()
.child(
h_flex()
.min_h(item_height)
.size_full()
.items_center()
.gap_x(px(theme::SPACE))
.children(Self::render_icon(
has_left_icon,
false,
icon.clone(),
window,
cx,
))
.child(
h_flex()
.flex_1()
.gap(px(theme::SPACE))
.items_center()
.justify_between()
.child(label.clone())
.child(
Icon::new(IconName::CaretRight)
.xsmall()
.text_color(cx.theme().text_muted),
),
),
)
.when(selected, |this| {
this.child({
let (anchor, left) = self.submenu_anchor;
let is_bottom_pos =
matches!(anchor, Anchor::BottomLeft | Anchor::BottomRight);
anchored()
.anchor(anchor)
.child(
div()
.id("submenu")
.occlude()
.when(is_bottom_pos, |this| this.bottom_0())
.when(!is_bottom_pos, |this| this.top_neg_1())
.left(left)
.child(menu.clone()),
)
.snap_to_window_with_margin(Edges::all(EDGE_PADDING))
})
}),
}
}
}
impl FluentBuilder for PopupMenu {}
impl EventEmitter<DismissEvent> for PopupMenu {}
impl Focusable for PopupMenu {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
#[derive(Clone, Copy)]
struct RenderOptions {
has_left_icon: bool,
radius: Pixels,
}
impl Render for PopupMenu {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.update_submenu_menu_anchor(window);
let view = cx.entity().clone();
let items_count = self.menu_items.len();
let has_left_icon = self.menu_items.iter().any(|item| item.has_left_icon());
let options = RenderOptions {
has_left_icon,
// Items sit p_1 (4px) inside the surface plate; keep corners concentric.
radius: px(theme::inset_radius(theme::surface_radius(), 4.0)),
};
v_flex()
.id("popup-menu")
.key_context(CONTEXT)
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::select_up))
.on_action(cx.listener(Self::select_down))
.on_action(cx.listener(Self::select_left))
.on_action(cx.listener(Self::select_right))
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::dismiss))
.on_mouse_down_out(cx.listener(Self::on_mouse_down_out))
.popover_style(cx)
.text_color(cx.theme().text)
.relative()
.occlude()
.child(
div()
.id("items")
.p_1()
.gap_y_0p5()
.min_w(rems(8.))
.max_w(MAX_WIDTH)
.map(|this| match self.axis {
Axis::Horizontal => this.flex().flex_row().items_center(),
Axis::Vertical => this.flex().flex_col(),
})
.children(
self.menu_items
.iter()
.enumerate()
// Ignore last separator
.filter(|(ix, item)| !(*ix + 1 == items_count && item.is_separator()))
.map(|(ix, item)| self.render_item(ix, item, options, window, cx)),
)
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)),
)
}
}
-553
View File
@@ -1,553 +0,0 @@
use std::collections::BTreeMap;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, MouseButton,
MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString, SharedUri,
StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div, img, px,
};
use smallvec::SmallVec;
use theme::{ActiveTheme, TextStyle, Typeset as _, bubble_radius, control_radius};
use crate::{InteractiveElementExt as _, StyledExt as _, h_flex, v_flex};
type MouseDownListener = Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>;
type ClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
/// Messages from the same author within this window are grouped into one run.
pub const RUN_WINDOW_SECS: u64 = 300;
/// Whether a message starts a new run, given the previous message's author and unix time.
pub fn opens_run<T: PartialEq>(previous: Option<(T, u64)>, current: (T, u64)) -> bool {
match previous {
None => true,
Some((previous_author, previous_at)) => {
previous_author != current.0 || current.1.saturating_sub(previous_at) > RUN_WINDOW_SECS
}
}
}
/// A single message row in a chat-like list.
#[derive(IntoElement)]
pub struct MessageRow {
id: ElementId,
style: StyleRefinement,
show_author: bool,
hide_avatar: bool,
avatar: Option<AnyElement>,
author: Option<SharedString>,
timestamp: Option<SharedString>,
header_extras: SmallVec<[AnyElement; 1]>,
body: SmallVec<[AnyElement; 4]>,
overlays: SmallVec<[AnyElement; 2]>,
on_mouse_down: Option<(MouseButton, MouseDownListener)>,
on_double_click: Option<ClickListener>,
}
impl MessageRow {
/// Create a message row with the given element id.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
show_author: false,
hide_avatar: false,
avatar: None,
author: None,
timestamp: None,
header_extras: SmallVec::new(),
body: SmallVec::new(),
overlays: SmallVec::new(),
on_mouse_down: None,
on_double_click: None,
}
}
/// Whether this row opens a run of messages from one author.
#[must_use]
pub fn show_author(mut self, show_author: bool) -> Self {
self.show_author = show_author;
self
}
/// Hide the avatar column entirely.
#[must_use]
pub fn hide_avatar(mut self, hide_avatar: bool) -> Self {
self.hide_avatar = hide_avatar;
self
}
/// The avatar shown when this row opens a run of messages.
#[must_use]
pub fn avatar(mut self, avatar: impl IntoElement) -> Self {
self.avatar = Some(avatar.into_any_element());
self
}
/// The author's display name, shown next to the timestamp.
#[must_use]
pub fn author(mut self, author: impl Into<SharedString>) -> Self {
self.author = Some(author.into());
self
}
/// The time the message was sent.
#[must_use]
pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
self.timestamp = Some(timestamp.into());
self
}
/// Append an element to the header row.
#[must_use]
pub fn header_extra(mut self, extra: impl IntoElement) -> Self {
self.header_extras.push(extra.into_any_element());
self
}
/// Append an element to the message body, in order.
#[must_use]
pub fn child(mut self, child: impl IntoElement) -> Self {
self.body.push(child.into_any_element());
self
}
/// Append several elements to the message body, in order.
#[must_use]
pub fn children(mut self, children: impl IntoIterator<Item = impl IntoElement>) -> Self {
self.body
.extend(children.into_iter().map(|child| child.into_any_element()));
self
}
/// Append an element positioned over the row.
#[must_use]
pub fn overlay(mut self, overlay: impl IntoElement) -> Self {
self.overlays.push(overlay.into_any_element());
self
}
/// Handle a mouse button press anywhere on the row.
#[must_use]
pub fn on_mouse_down(
mut self,
button: MouseButton,
listener: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_mouse_down = Some((button, Box::new(listener)));
self
}
/// Handle a double click anywhere on the row.
#[must_use]
pub fn on_double_click(
mut self,
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_double_click = Some(Box::new(listener));
self
}
}
impl Styled for MessageRow {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for MessageRow {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self {
id,
style,
show_author,
hide_avatar,
avatar,
author,
timestamp,
header_extras,
body,
overlays,
on_mouse_down,
on_double_click,
} = self;
div()
.id(id)
.group("")
.relative()
.w_full()
.py_1()
.when(show_author, |this| this.pt_2())
.px_3()
.refine_style(&style)
.child(
h_flex()
.items_start()
.gap_3()
.when(!hide_avatar, |this| {
if show_author {
match avatar {
Some(avatar) => this.child(avatar),
None => this.child(div().flex_shrink_0().w(px(32.))),
}
} else {
this.child(div().flex_shrink_0().w(px(32.)))
}
})
.child(
v_flex()
.flex_1()
.w_full()
.min_w_0()
.flex_initial()
.overflow_hidden()
.when(show_author, |this| {
this.child(
h_flex()
.gap(px(theme::SPACE))
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_dim)
.when_some(author, |this, author| {
this.child(div().font_semibold().child(author))
})
.when_some(timestamp, |this, timestamp| {
this.child(timestamp)
})
.children(header_extras),
)
})
.children(body),
),
)
.children(overlays)
.when_some(on_mouse_down, |this, (button, listener)| {
this.on_mouse_down(button, listener)
})
.when_some(on_double_click, |this, listener| {
this.on_double_click(listener)
})
.hover(|this| this.bg(cx.theme().element_hover))
.into_any_element()
}
}
/// A quoted preview of a message this message replies to.
#[derive(IntoElement)]
pub struct ReplyPreview {
id: ElementId,
style: StyleRefinement,
author: SharedString,
preview: SharedString,
on_click: Option<ClickListener>,
}
impl ReplyPreview {
/// Create a reply preview with the given element id, author and snippet.
pub fn new(
id: impl Into<ElementId>,
author: impl Into<SharedString>,
preview: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
author: author.into(),
preview: preview.into(),
on_click: None,
}
}
/// Handle a click anywhere on the preview.
#[must_use]
pub fn on_click(
mut self,
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(listener));
self
}
}
impl Styled for ReplyPreview {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for ReplyPreview {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self {
id,
style,
author,
preview,
on_click,
} = self;
div()
.id(id)
.w_full()
.px_2()
.border_l_2()
.border_color(cx.theme().element_active)
.text_style(TextStyle::Callout)
.refine_style(&style)
.child(div().font_semibold().child(author))
.child(div().w_full().text_ellipsis().line_clamp(1).child(preview))
.hover(|this| this.bg(cx.theme().surface_raised))
.when_some(on_click, |this, on_click| this.on_click(on_click))
.into_any_element()
}
}
/// The image media of a message: one large image or a wrapping row of thumbnails.
#[derive(IntoElement)]
pub struct MessageMedia {
style: StyleRefinement,
media: Vec<SharedUri>,
}
impl MessageMedia {
/// Create media from image URLs.
pub fn new(media: &[SharedUri]) -> Self {
Self {
style: StyleRefinement::default(),
media: media.to_vec(),
}
}
}
impl Styled for MessageMedia {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for MessageMedia {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self { style, media } = self;
if media.is_empty() {
return div().into_any_element();
}
if let [only] = media.as_slice() {
return div()
.refine_style(&style)
.child(
img(only.clone())
.border_1()
.border_color(cx.theme().border_faint)
.h(px(250.))
.object_fit(ObjectFit::Cover)
.rounded(px(bubble_radius())),
)
.into_any_element();
}
div()
.w_full()
.flex_1()
.flex()
.flex_row()
.flex_wrap()
.gap(px(theme::SPACE))
.refine_style(&style)
.children(media.into_iter().enumerate().map(|(ix, item)| {
div().id(format!("media-{ix}")).flex_shrink_0().child(
img(item)
.h_32()
.border_1()
.border_color(cx.theme().border_faint)
.rounded(px(bubble_radius())),
)
}))
.into_any_element()
}
}
/// Reactions of one emoji with the count of authors and whether the current user reacted.
pub struct ReactionGroup {
pub emoji: SharedString,
/// Image of a custom emoji (NIP-30) reaction
pub image: Option<SharedUri>,
pub count: usize,
pub mine: bool,
}
/// Group (emoji, image, author) triples by emoji, sorted by emoji.
pub fn group_reactions<A: PartialEq>(
reactions: impl IntoIterator<Item = (SharedString, Option<SharedUri>, A)>,
me: Option<A>,
) -> Vec<ReactionGroup> {
let mut groups: BTreeMap<SharedString, (usize, bool, Option<SharedUri>)> = BTreeMap::new();
for (emoji, image, author) in reactions {
let group = groups.entry(emoji).or_default();
group.0 += 1;
group.1 |= me.as_ref() == Some(&author);
if group.2.is_none() {
group.2 = image;
}
}
groups
.into_iter()
.map(|(emoji, (count, mine, image))| ReactionGroup {
emoji,
image,
count,
mine,
})
.collect()
}
/// Reaction chips shown under a message.
#[derive(IntoElement)]
pub struct ReactionBar {
style: StyleRefinement,
groups: SmallVec<[ReactionGroup; 4]>,
}
impl ReactionBar {
/// Create a reaction bar from grouped reactions.
pub fn new(groups: impl IntoIterator<Item = ReactionGroup>) -> Self {
Self {
style: StyleRefinement::default(),
groups: groups.into_iter().collect(),
}
}
}
impl Styled for ReactionBar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for ReactionBar {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self { style, groups } = self;
h_flex()
.mt_2()
.gap_1()
.refine_style(&style)
.children(groups.into_iter().map(|group| {
h_flex()
.gap(px(theme::SPACE))
.py_0p5()
.px_1()
.rounded(px(control_radius()))
.text_style(TextStyle::Caption)
.border_1()
.when(group.mine, |this| {
this.text_color(cx.theme().text)
.bg(cx.theme().surface_raised)
.border_color(cx.theme().element_active)
})
.when(!group.mine, |this| this.border_color(cx.theme().border))
.when(group.image.is_none(), |this| this.child(group.emoji))
.when_some(group.image, |this, image| {
this.child(img(image).size(px(16.)).flex_shrink_0())
})
.child(SharedString::from(group.count.to_string()))
}))
}
}
/// A welcome message shown at the top of a message list.
#[derive(IntoElement)]
pub struct WelcomeMessage {
id: ElementId,
style: StyleRefinement,
icon: Option<AnyElement>,
title: Option<SharedString>,
message: Option<SharedString>,
}
impl WelcomeMessage {
/// Create a welcome message with the given element id.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
icon: None,
title: None,
message: None,
}
}
/// The icon shown above the title.
#[must_use]
pub fn icon(mut self, icon: impl IntoElement) -> Self {
self.icon = Some(icon.into_any_element());
self
}
/// The welcome title.
#[must_use]
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
/// The welcome body text.
#[must_use]
pub fn message(mut self, message: impl Into<SharedString>) -> Self {
self.message = Some(message.into());
self
}
}
impl Styled for WelcomeMessage {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for WelcomeMessage {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self {
id,
style,
icon,
title,
message,
} = self;
v_flex()
.id(id)
.w_full()
.gap(px(theme::SPACE))
.p_3()
.items_center()
.justify_center()
.text_center()
.refine_style(&style)
.when_some(icon, |this, icon| this.child(icon))
.child(
v_flex()
.items_center()
.justify_center()
.text_center()
.when_some(title, |this, title| {
this.child(
div()
.text_style(TextStyle::Headline)
.text_color(cx.theme().text)
.child(title),
)
})
.when_some(message, |this, message| {
this.child(
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(message),
)
}),
)
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ use gpui::{
Animation, AnimationElement, AnimationExt as _, App, Context, ElementId, EntityId, Global,
Hsla, IntoElement, Rgba, SharedString, Styled, Window, px,
};
use instant::Instant;
use web_time::Instant;
/// Redraw rate for anything driven by [`pulse_delta`].
const PULSE_FPS: f32 = 30.0;
-143
View File
@@ -1,143 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window, div, px,
};
use theme::ActiveTheme;
use crate::{Selectable, StyledExt, h_flex, v_flex};
/// A single navigation row, such as an entry in a sidebar list.
#[derive(IntoElement)]
pub struct Nav {
id: ElementId,
interactivity: Interactivity,
style: StyleRefinement,
prefix: Option<AnyElement>,
label: SharedString,
suffix: Option<AnyElement>,
selected: bool,
clickable: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl Nav {
pub fn new(id: impl Into<ElementId>) -> Self {
let id = id.into();
let mut interactivity = Interactivity::default();
interactivity.element_id = Some(id.clone());
Self {
id,
interactivity,
style: StyleRefinement::default(),
prefix: None,
label: SharedString::default(),
suffix: None,
selected: false,
clickable: false,
on_click: None,
}
}
/// Marks the row as clickable even without an `on_click` handler, keeping the hover affordance.
pub fn clickable(mut self, clickable: bool) -> Self {
self.clickable = clickable;
self
}
/// Sets the element shown before the label, such as an avatar or icon.
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
/// Sets the row's label.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = label.into();
self
}
/// Sets the element shown after the label, such as a timestamp or badge.
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl Selectable for Nav {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl InteractiveElement for Nav {
fn interactivity(&mut self) -> &mut Interactivity {
&mut self.interactivity
}
}
impl Styled for Nav {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Nav {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let clickable = self.on_click.is_some() || self.clickable;
let text_color = if self.selected {
cx.theme().text
} else {
cx.theme().text_muted
};
v_flex().h_10().w_full().justify_center().child(
h_flex()
.id(self.id)
.h_9()
.w_full()
.px_1p5()
.gap_1p5()
.rounded(px(theme::button_radius()))
.text_color(text_color)
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
.gap_1()
.flex_1()
.min_w_0()
.child(div().flex_1().min_w_0().truncate().child(self.label))
.when_some(self.suffix, |this, suffix| {
this.child(div().flex_shrink_0().child(suffix))
}),
)
.when(clickable, |this| {
this.cursor_pointer()
.hover(|this| this.bg(cx.theme().element_hover))
.when(self.selected, |this| this.bg(cx.theme().element_active))
})
.when_some(self.on_click, |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx))
})
.refine_style(&self.style),
)
}
}
-125
View File
@@ -1,125 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window, div, px,
};
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::{Selectable, StyledExt, h_flex};
/// A single navigation entry in a sidebar.
#[allow(clippy::type_complexity)]
#[derive(IntoElement)]
pub struct NavItem {
id: ElementId,
interactivity: Interactivity,
style: StyleRefinement,
icon: AnyElement,
label: SharedString,
suffix: Option<AnyElement>,
selected: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl NavItem {
pub fn new(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
icon: impl IntoElement,
) -> Self {
let id = id.into();
let mut interactivity = Interactivity::default();
interactivity.element_id = Some(id.clone());
Self {
id,
interactivity,
style: StyleRefinement::default(),
icon: icon.into_any_element(),
label: label.into(),
suffix: None,
selected: false,
on_click: None,
}
}
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl Selectable for NavItem {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl InteractiveElement for NavItem {
fn interactivity(&mut self) -> &mut Interactivity {
&mut self.interactivity
}
}
impl Styled for NavItem {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for NavItem {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let clickable = self.on_click.is_some();
let text_color = if self.selected {
cx.theme().text
} else {
cx.theme().text_muted
};
h_flex()
.id(self.id)
.refine_style(&self.style)
.px_2()
.py_1()
.w_full()
.gap(px(theme::SPACE))
.rounded(px(theme::button_radius()))
.text_color(text_color)
.child(self.icon)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_style(TextStyle::Callout)
.child(self.label),
)
.when_some(self.suffix, |this, suffix| {
this.child(div().flex_shrink_0().child(suffix))
})
.when(clickable, |this| {
this.cursor_pointer()
.hover(|this| this.bg(cx.theme().element_hover))
.when(self.selected, |this| this.bg(cx.theme().element_active))
})
.when_some(self.on_click, |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx))
})
}
}
-594
View File
@@ -1,594 +0,0 @@
use std::any::TypeId;
use std::collections::HashMap;
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
Anchor, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context, DismissEvent,
ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _,
Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription,
Window, div, px, relative,
};
use gpui_base::{
Toast as BaseToast, ToastManager, ToastMotion, ToastOptions, ToastStack, ToastStackState,
ToastTransitionStatus,
};
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::button::{Button, ButtonVariants as _};
use crate::motion::NOTIFICATION_IN;
use crate::{Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
/// How often the notification lifecycle clock is sampled.
const ADVANCE_INTERVAL: Duration = Duration::from_millis(50);
/// How long a notification stays before it hides itself.
const AUTOHIDE_DURATION: Duration = Duration::from_secs(5);
/// Request by a notification to be dismissed; the list owns the transition.
struct DismissRequest;
#[derive(Debug, Clone, Copy, Default)]
pub enum NotificationKind {
#[default]
Info,
Success,
Warning,
Error,
}
impl NotificationKind {
fn icon(&self, cx: &App) -> Icon {
let (name, color) = match self {
Self::Info => (IconName::Info, cx.theme().text_muted),
Self::Success => (IconName::CheckCircle, cx.theme().success),
Self::Warning => (IconName::Warning, cx.theme().warning),
Self::Error => (IconName::CloseCircle, gpui::white()),
};
Icon::new(name).with_size(Size::Medium).text_color(color)
}
}
#[derive(Debug, PartialEq, Clone, Hash, Eq)]
pub(crate) enum NotificationId {
Id(TypeId),
IdAndElementId(TypeId, ElementId),
}
impl From<TypeId> for NotificationId {
fn from(type_id: TypeId) -> Self {
Self::Id(type_id)
}
}
impl From<(TypeId, ElementId)> for NotificationId {
fn from((type_id, id): (TypeId, ElementId)) -> Self {
Self::IdAndElementId(type_id, id)
}
}
#[allow(clippy::type_complexity)]
/// A notification element.
pub struct Notification {
/// Pushing a notification with the same id replaces the previous one.
id: NotificationId,
style: StyleRefinement,
kind: Option<NotificationKind>,
title: Option<SharedString>,
message: Option<SharedString>,
icon: Option<Icon>,
autohide: bool,
action_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button>>,
content_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement>>,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
transition_status: ToastTransitionStatus,
}
impl From<String> for Notification {
fn from(s: String) -> Self {
Self::new().message(s)
}
}
impl From<SharedString> for Notification {
fn from(s: SharedString) -> Self {
Self::new().message(s)
}
}
impl From<&'static str> for Notification {
fn from(s: &'static str) -> Self {
Self::new().message(s)
}
}
impl From<(NotificationKind, &'static str)> for Notification {
fn from((kind, content): (NotificationKind, &'static str)) -> Self {
Self::new().message(content).with_kind(kind)
}
}
impl From<(NotificationKind, SharedString)> for Notification {
fn from((kind, content): (NotificationKind, SharedString)) -> Self {
Self::new().message(content).with_kind(kind)
}
}
struct DefaultIdType;
impl Notification {
/// Creates a notification with a random UUID id.
pub fn new() -> Self {
let id: SharedString = uuid::Uuid::new_v4().to_string().into();
let id = (TypeId::of::<DefaultIdType>(), id.into());
Self {
id: id.into(),
style: StyleRefinement::default(),
title: None,
message: None,
kind: None,
icon: None,
autohide: true,
action_builder: None,
content_builder: None,
on_click: None,
transition_status: ToastTransitionStatus::Starting,
}
}
/// Set the message of the notification, default is None.
pub fn message(mut self, message: impl Into<SharedString>) -> Self {
self.message = Some(message.into());
self
}
/// Create a success notification with the given message.
pub fn success(message: impl Into<SharedString>) -> Self {
Self::new()
.message(message)
.with_kind(NotificationKind::Success)
}
/// Create a warning notification with the given message.
pub fn warning(message: impl Into<SharedString>) -> Self {
Self::new()
.message(message)
.with_kind(NotificationKind::Warning)
}
/// Create an error notification with the given message.
pub fn error(message: impl Into<SharedString>) -> Self {
Self::new()
.message(message)
.with_kind(NotificationKind::Error)
}
/// Sets the type used to uniquely identify the notification.
pub fn id<T: Sized + 'static>(mut self) -> Self {
self.id = TypeId::of::<T>().into();
self
}
/// Set the type and id of the notification, used to uniquely identify the notification.
pub fn type_id<T: Sized + 'static>(mut self, key: impl Into<ElementId>) -> Self {
self.id = (TypeId::of::<T>(), key.into()).into();
self
}
/// Sets the title of the notification, default is None.
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
/// Sets the kind of the notification, default is `NotificationKind::Info`.
pub fn with_kind(mut self, kind: NotificationKind) -> Self {
self.kind = Some(kind);
self
}
/// Set the auto hide of the notification, default is true.
pub fn autohide(mut self, autohide: bool) -> Self {
self.autohide = autohide;
self
}
/// Sets an action button; the notification will not autohide when set.
pub fn action<F>(mut self, action: F) -> Self
where
F: Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button + 'static,
{
self.action_builder = Some(Rc::new(action));
self.autohide = false;
self
}
/// Emits a request for the notification list to dismiss this notification.
pub(crate) fn dismiss(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissRequest);
}
/// Begin the exit transition, driven by the notification list.
pub(crate) fn begin_close(&mut self, cx: &mut Context<Self>) {
if self.transition_status != ToastTransitionStatus::Ending {
self.transition_status = ToastTransitionStatus::Ending;
cx.notify();
}
}
/// Mark the enter transition as finished, driven by the notification list.
pub(crate) fn complete_enter(&mut self, cx: &mut Context<Self>) {
if self.transition_status == ToastTransitionStatus::Starting {
self.transition_status = ToastTransitionStatus::Present;
cx.notify();
}
}
/// Finish the exit transition, driven by the notification list.
pub(crate) fn complete_close(&mut self, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
/// Set the content of the notification.
pub fn content(
mut self,
content: impl Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement + 'static,
) -> Self {
self.content_builder = Some(Rc::new(content));
self
}
}
impl Default for Notification {
fn default() -> Self {
Self::new()
}
}
impl EventEmitter<DismissEvent> for Notification {}
impl EventEmitter<DismissRequest> for Notification {}
impl FluentBuilder for Notification {}
impl Styled for Notification {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl Render for Notification {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let content = self
.content_builder
.clone()
.map(|builder| builder(self, window, cx));
let action = self.action_builder.clone().map(|builder| {
builder(self, window, cx)
.small()
.primary()
.px_4()
.font_medium()
});
let icon = match self.kind {
None => self.icon.clone(),
Some(kind) => Some(kind.icon(cx)),
};
let background = match self.kind {
Some(NotificationKind::Error) => cx.theme().danger_strong,
_ => cx.theme().surface_overlay,
};
let text_color = match self.kind {
// White on the danger plate in both appearances, per bezel's
// destructive pairing.
Some(NotificationKind::Error) => gpui::white(),
_ => cx.theme().text,
};
let transition_status = self.transition_status;
let closing = transition_status == ToastTransitionStatus::Ending;
let has_title = self.title.is_some();
let only_message = !has_title && content.is_none() && action.is_none();
let placement = cx.theme().notification.placement;
BaseToast::new("notification")
.transition_status(transition_status)
.h_flex()
.group("")
.occlude()
.relative()
.w_full()
.border_1()
.border_color(cx.theme().border)
.bg(background)
.text_color(text_color)
.rounded(px(theme::surface_radius()))
.when(cx.theme().shadow, |this| {
this.shadow(theme::surface_shadows())
})
.p_2()
.gap(px(theme::SPACE))
.justify_start()
.items_start()
.when(only_message, |this| this.items_center())
.refine_style(&self.style)
.when_some(icon, |this, icon| {
this.child(div().flex_shrink_0().size_5().child(icon))
})
.child(
v_flex()
.flex_1()
.gap_1()
.overflow_hidden()
.when_some(self.title.clone(), |this, title| {
this.child(h_flex().h_5().text_style(TextStyle::Headline).child(title))
})
.when_some(self.message.clone(), |this, message| {
this.child(
div()
.text_style(TextStyle::Callout)
.when(has_title, |this| this.text_color(cx.theme().text_muted))
.line_height(relative(1.3))
.child(message),
)
})
.when_some(content, |this, content| this.child(content))
.when_some(action, |this, action| {
this.gap(px(theme::SPACE)).child(
h_flex()
.mt_2()
.w_full()
.flex_1()
.justify_end()
.child(action),
)
}),
)
.child(
div()
.absolute()
.top(px(6.5))
.right(px(6.5))
.invisible()
.group_hover("", |this| this.visible())
.child(
Button::new("close")
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.dismiss(window, cx);
})),
),
)
.when_some(self.on_click.clone(), |this, on_click| {
this.on_click(cx.listener(move |view, event, window, cx| {
view.dismiss(window, cx);
on_click(event, window, cx);
}))
})
.on_aux_click(cx.listener(move |view, event: &ClickEvent, window, cx| {
if event.is_middle_click() {
view.dismiss(window, cx);
}
}))
.with_animation(
ElementId::NamedInteger("slide-down".into(), closing as u64),
NOTIFICATION_IN.animation(),
move |this, delta| {
if closing {
let opacity = 1. - delta;
let that = this.shadow_none().opacity(opacity);
match placement {
Anchor::TopRight | Anchor::BottomRight => {
let x_offset = px(0.) + delta * px(45.);
that.left(px(0.) + x_offset)
}
Anchor::TopLeft | Anchor::BottomLeft => {
let x_offset = px(0.) - delta * px(45.);
that.left(px(0.) + x_offset)
}
Anchor::TopCenter => {
let y_offset = px(0.) - delta * px(45.);
that.top(px(0.) + y_offset)
}
Anchor::BottomCenter => {
let y_offset = px(0.) + delta * px(45.);
that.top(px(0.) + y_offset)
}
_ => that,
}
} else {
let opacity = delta;
let y_offset = match placement {
Anchor::TopLeft | Anchor::TopRight | Anchor::TopCenter => {
px(-45.) + delta * px(45.)
}
Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter => {
px(45.) - delta * px(45.)
}
_ => px(0.),
};
this.top(px(0.) + y_offset)
.opacity(opacity)
.when(opacity < 0.85, |this| this.shadow_none())
}
},
)
}
}
/// A list of notifications.
pub(crate) struct NotificationList {
notifications: ToastManager<NotificationId, Entity<Notification>>,
/// Measured geometry and interaction state of the visible stack.
stack_state: ToastStackState,
/// Whether the lifecycle clock is running. The loop clears it as it exits.
is_advancing: bool,
_subscriptions: HashMap<NotificationId, Subscription>,
}
impl NotificationList {
pub(crate) fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
Self {
notifications: ToastManager::new(ToastMotion::default()),
stack_state: ToastStackState::default(),
is_advancing: false,
_subscriptions: HashMap::new(),
}
}
/// Ticks the toast lifecycle, including stack expansion which arrives through no event.
fn start_advancing(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_advancing {
return;
}
self.is_advancing = true;
cx.spawn_in(window, async move |view, cx| {
loop {
cx.background_executor().timer(ADVANCE_INTERVAL).await;
let running = view.update(cx, |view, cx| {
view.advance(cx);
view.is_advancing = !view.notifications.is_empty();
view.is_advancing
});
if !matches!(running, Ok(true)) {
break;
}
}
})
.detach();
}
fn advance(&mut self, cx: &mut Context<Self>) {
let changes = self.notifications.advance(
cx.background_executor().now(),
self.stack_state.is_expanded(),
);
for id in changes.presented {
if let Some(note) = self.notifications.get(&id) {
note.update(cx, |note, cx| note.complete_enter(cx));
}
}
for id in changes.ending {
if let Some(note) = self.notifications.get(&id) {
note.update(cx, |note, cx| note.begin_close(cx));
}
}
for (id, note) in changes.removed {
self._subscriptions.remove(&id);
note.update(cx, |note, cx| note.complete_close(cx));
}
if changes.changed {
cx.notify();
}
}
pub(crate) fn push(
&mut self,
notification: impl Into<Notification>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let notification = notification.into();
let id = notification.id.clone();
let autohide = notification.autohide;
let notification = cx.new(|_| notification);
let dismiss_id = id.clone();
self._subscriptions.insert(
id.clone(),
cx.subscribe(&notification, move |view, _, _: &DismissRequest, cx| {
if view
.notifications
.dismiss(&dismiss_id, cx.background_executor().now())
&& let Some(note) = view.notifications.get(&dismiss_id)
{
note.update(cx, |note, cx| note.begin_close(cx));
}
}),
);
self.notifications.push(
id,
notification,
ToastOptions {
timeout: autohide.then_some(AUTOHIDE_DURATION),
},
cx.background_executor().now(),
);
self.start_advancing(window, cx);
cx.notify();
}
pub(crate) fn close(
&mut self,
id: impl Into<NotificationId>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let id: NotificationId = id.into();
if self
.notifications
.dismiss(&id, cx.background_executor().now())
&& let Some(note) = self.notifications.get(&id)
{
note.update(cx, |note, cx| note.begin_close(cx));
}
cx.notify();
}
}
impl Render for NotificationList {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let size = window.viewport_size();
let settings = &cx.theme().notification;
let (placement, margins, max_items) = (
settings.placement,
settings.margins.clone(),
settings.max_items,
);
let items = self
.notifications
.visible(max_items)
.map(|(id, note, _)| (id.clone(), note.clone()))
.collect::<Vec<_>>();
let stack = items
.into_iter()
.fold(
ToastStack::new("notification-list", self.stack_state.clone()),
|stack, (id, note)| stack.item(format!("{id:?}"), note),
)
.placement(placement)
.v_flex()
.w_112()
.max_h(size.height)
.absolute()
.map(|this| match placement {
Anchor::TopLeft => this.top(margins.top).left(margins.left),
Anchor::TopRight => this.top(margins.top).right(margins.right),
Anchor::TopCenter => this.top(margins.top).left_0().right_0().mx_auto(),
Anchor::BottomLeft => this.bottom(margins.bottom).left(margins.left),
Anchor::BottomRight => this.bottom(margins.bottom).right(margins.right),
Anchor::BottomCenter => this.bottom(margins.bottom).left_0().right_0().mx_auto(),
Anchor::LeftCenter => this.left(margins.left).top_0().bottom_0().my_auto(),
Anchor::RightCenter => this.right(margins.right).top_0().bottom_0().my_auto(),
});
div().size_full().child(stack)
}
}
-170
View File
@@ -1,170 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Anchor, AnyElement, App, Context, Div, ElementId, InteractiveElement as _, IntoElement,
MouseButton, ParentElement, RenderOnce, Stateful, StyleRefinement, Styled, Window,
};
use gpui_base::Popover as BasePopover;
pub(crate) use gpui_base::PopoverState;
use crate::{Selectable, StyledExt as _, v_flex};
/// A popover element that can be triggered by a button or any other element.
#[derive(IntoElement)]
pub(crate) struct Popover {
id: ElementId,
style: StyleRefinement,
anchor: Anchor,
#[allow(clippy::type_complexity)]
trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
#[allow(clippy::type_complexity)]
content: Option<
Rc<
dyn Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> AnyElement
+ 'static,
>,
>,
children: Vec<AnyElement>,
/// Style refinement applied to the trigger element.
trigger_style: Option<StyleRefinement>,
mouse_button: MouseButton,
appearance: bool,
overlay_closable: bool,
}
impl Popover {
/// Creates a new Popover with the given id.
pub(crate) fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
anchor: Anchor::TopLeft,
trigger: None,
trigger_style: None,
content: None,
children: vec![],
mouse_button: MouseButton::Left,
appearance: true,
overlay_closable: true,
}
}
/// Sets the anchor corner of the popover, default is `Anchor::TopLeft`.
pub(crate) fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
/// Sets the mouse button that triggers the popover, default is `MouseButton::Left`.
pub(crate) fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
self.mouse_button = mouse_button;
self
}
/// Sets the trigger element, marked as selected while the popover is open.
pub(crate) fn trigger<T>(mut self, trigger: T) -> Self
where
T: Selectable + IntoElement + 'static,
{
self.trigger = Some(Box::new(|is_open, _, _| {
let selected = trigger.is_selected();
trigger.selected(selected || is_open).into_any_element()
}));
self
}
/// Sets the trigger from a builder, for elements that have no selected state.
pub(crate) fn trigger_with<F>(mut self, trigger: F) -> Self
where
F: FnOnce(bool, &Window, &App) -> AnyElement + 'static,
{
self.trigger = Some(Box::new(trigger));
self
}
/// Sets the style for the trigger element.
pub(crate) fn trigger_style(mut self, style: StyleRefinement) -> Self {
self.trigger_style = Some(style);
self
}
/// Sets whether clicking outside the popover dismisses it, default is `true`.
pub(crate) fn overlay_closable(mut self, closable: bool) -> Self {
self.overlay_closable = closable;
self
}
/// Sets the content builder, called on every render of the popover.
pub(crate) fn content<F, E>(mut self, content: F) -> Self
where
E: IntoElement,
F: Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
{
self.content = Some(Rc::new(move |state, window, cx| {
content(state, window, cx).into_any_element()
}));
self
}
/// Sets whether the popover renders with default styling, default is `true`.
pub(crate) fn appearance(mut self, appearance: bool) -> Self {
self.appearance = appearance;
self
}
fn render_popover_content(
anchor: Anchor,
appearance: bool,
_: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
v_flex()
.id("content")
.occlude()
.tab_group()
.when(appearance, |this| this.popover_style(cx).p_3())
.map(|this| match anchor {
Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(),
Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(),
Anchor::LeftCenter | Anchor::RightCenter => this.top_1(), // Fallback for centered
})
}
}
impl ParentElement for Popover {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Styled for Popover {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Popover {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let anchor = self.anchor;
let appearance = self.appearance;
let style = self.style;
let children = self.children;
let content = self.content;
BasePopover::new(self.id)
.anchor(anchor)
.mouse_button(self.mouse_button)
.overlay_closable(self.overlay_closable)
.content(move |state, window, cx| {
Self::render_popover_content(anchor, appearance, window, cx)
.when_some(content, |this, content| {
this.child((content)(state, window, cx))
})
.children(children)
.refine_style(&style)
})
.when_some(self.trigger, |this, trigger| this.trigger_with(trigger))
.into_any_element()
}
}
-31
View File
@@ -1,31 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{App, InteractiveElement as _, IntoElement, Pixels, Styled as _, Window, div, px};
pub(crate) use gpui_base::resize_handle;
use gpui_base::{ResizeHandleContext, ResizeHandleRenderer};
use theme::{ActiveTheme as _, AxisExt as _};
const HANDLE_SIZE: Pixels = px(1.);
pub(crate) fn resize_handle_appearance() -> ResizeHandleRenderer {
Rc::new(
|context: &ResizeHandleContext, _: &mut Window, cx: &mut App| {
let color = if context.is_active() {
cx.theme().border_strong
} else {
cx.theme().element_hover
};
let axis = context.axis();
Some(
div()
.bg(cx.theme().border_faint)
.group_hover("handle", move |this| this.bg(color))
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE))
.into_any_element(),
)
},
)
}
-430
View File
@@ -1,430 +0,0 @@
use std::any::TypeId;
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyView, App, AppContext, Bounds, Context, CursorStyle, Decorations, ElementId, Entity,
FocusHandle, HitboxBehavior, InteractiveElement, IntoElement, MouseButton, ParentElement as _,
Pixels, Point, Render, ResizeEdge, Size, Styled, Tiling, WeakFocusHandle, Window, canvas, div,
point, px, size,
};
use gpui_base::TextSelectionLayer;
use theme::{
ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING,
CLIENT_SIDE_DECORATION_SHADOW, base_text_size, surface_shadows,
};
use crate::dialog::Dialog;
use crate::notification::{Notification, NotificationList};
#[derive(Clone)]
#[allow(clippy::type_complexity)]
pub(crate) struct ActiveDialog {
focus_handle: FocusHandle,
/// The previous focused handle before opening the dialog.
previous_focused_handle: Option<WeakFocusHandle>,
builder: Rc<dyn Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static>,
}
impl ActiveDialog {
fn new(
focus_handle: FocusHandle,
previous_focused_handle: Option<WeakFocusHandle>,
builder: impl Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static,
) -> Self {
Self {
focus_handle,
previous_focused_handle,
builder: Rc::new(builder),
}
}
}
/// Root is a view for the App window for as the top level view (Must be the first view in the window).
///
/// It is used to manage the Dialog, and Notification.
pub struct Root {
/// All active dialogs
pub(crate) active_dialogs: Vec<ActiveDialog>,
/// Notification layer
pub(crate) notification: Entity<NotificationList>,
/// App view
view: AnyView,
}
impl Root {
pub fn new(view: AnyView, window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
active_dialogs: Vec::new(),
notification: cx.new(|cx| NotificationList::new(window, cx)),
view,
}
}
pub fn update<F>(window: &mut Window, cx: &mut App, f: F)
where
F: FnOnce(&mut Self, &mut Window, &mut Context<Self>) + 'static,
{
if let Some(Some(root)) = window.root::<Root>() {
root.update(cx, |root, cx| f(root, window, cx));
}
}
pub fn read<'a>(window: &'a mut Window, cx: &'a mut App) -> &'a Self {
window
.root::<Root>()
.expect("The window root view should be of type `ui::Root`.")
.unwrap()
.read(cx)
}
pub fn view(&self) -> &AnyView {
&self.view
}
/// Render the notification layer.
pub fn render_notification_layer(
window: &mut Window,
cx: &mut App,
) -> Option<impl IntoElement + use<>> {
let root = window.root::<Root>()??;
Some(
div()
.absolute()
.inset_0()
.child(root.read(cx).notification.clone()),
)
}
/// Render the dialog layer.
pub fn render_dialog_layer(
window: &mut Window,
cx: &mut App,
) -> Option<impl IntoElement + use<>> {
let root = window.root::<Root>()??;
let active_dialogs = root.read(cx).active_dialogs.clone();
if active_dialogs.is_empty() {
return None;
}
let mut show_overlay_ix = None;
let mut dialogs = active_dialogs
.iter()
.enumerate()
.map(|(i, active_dialog)| {
let mut dialog = Dialog::new(window, cx);
dialog = (active_dialog.builder)(dialog, window, cx);
// Give the dialog the focus handle, because `dialog` is a temporary value, is not possible to
// keep the focus handle in the dialog.
//
// So we keep the focus handle in the `active_dialog`, this is owned by the `Root`.
dialog.focus_handle = active_dialog.focus_handle.clone();
dialog.layer_ix = i;
// Find the dialog which one needs to show overlay.
if dialog.has_overlay() {
show_overlay_ix = Some(i);
}
dialog
})
.collect::<Vec<_>>();
if let Some(ix) = show_overlay_ix
&& let Some(dialog) = dialogs.get_mut(ix)
{
dialog.overlay_visible = true;
}
Some(div().children(dialogs))
}
/// Open a dialog.
pub fn open_dialog<F>(&mut self, builder: F, window: &mut Window, cx: &mut Context<'_, Self>)
where
F: Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static,
{
let previous_focused_handle = window.focused(cx).map(|h| h.downgrade());
let focus_handle = cx.focus_handle();
focus_handle.focus(window, cx);
self.active_dialogs.push(ActiveDialog::new(
focus_handle,
previous_focused_handle,
builder,
));
cx.notify();
}
/// Close the topmost dialog.
pub fn close_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(handle) = self
.active_dialogs
.pop()
.and_then(|d| d.previous_focused_handle)
.and_then(|h| h.upgrade())
{
window.focus(&handle, cx);
}
cx.notify();
}
/// Close all dialogs.
pub fn close_all_dialogs(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.active_dialogs.clear();
let previous_focused_handle = self
.active_dialogs
.first()
.and_then(|d| d.previous_focused_handle.clone());
if let Some(handle) = previous_focused_handle.and_then(|h| h.upgrade()) {
window.focus(&handle, cx);
}
cx.notify();
}
/// Push a notification to the notification layer.
pub fn push_notification<T>(&mut self, note: T, window: &mut Window, cx: &mut Context<'_, Root>)
where
T: Into<Notification>,
{
self.notification
.update(cx, |view, cx| view.push(note, window, cx));
cx.notify();
}
/// Clear a notification by its type.
pub fn clear_notification<T: Sized + 'static>(
&mut self,
window: &mut Window,
cx: &mut Context<'_, Root>,
) {
self.notification.update(cx, |view, cx| {
let id = TypeId::of::<T>();
view.close(id, window, cx);
});
cx.notify();
}
/// Clear a notification by its type.
pub fn clear_notification_by_id<T: Sized + 'static>(
&mut self,
key: impl Into<ElementId>,
window: &mut Window,
cx: &mut Context<'_, Root>,
) {
self.notification.update(cx, |view, cx| {
let id = (TypeId::of::<T>(), key.into());
view.close(id, window, cx);
});
cx.notify();
}
}
impl Render for Root {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let rem_size = px(base_text_size());
let font_family = cx.theme().font_sans.clone();
let decorations = window.window_decorations();
// Set the base font size
window.set_rem_size(rem_size);
// Set the client inset (linux only)
match decorations {
Decorations::Client { .. } => window.set_client_inset(CLIENT_SIDE_DECORATION_SHADOW),
Decorations::Server => window.set_client_inset(px(0.0)),
}
div()
.id("window")
.size_full()
.map(|div| match decorations {
Decorations::Server => div,
Decorations::Client { tiling } => div
.bg(gpui::transparent_black())
.child(
canvas(
|_bounds, window, _cx| {
window.insert_hitbox(
Bounds::new(
point(px(0.0), px(0.0)),
window.window_bounds().get_bounds().size,
),
HitboxBehavior::Normal,
)
},
move |_bounds, hitbox, window, _cx| {
let mouse = window.mouse_position();
let size = window.window_bounds().get_bounds().size;
let Some(edge) =
resize_edge(mouse, CLIENT_SIDE_DECORATION_SHADOW, size, tiling)
else {
return;
};
window.set_cursor_style(
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
}
},
&hitbox,
);
},
)
.size_full()
.absolute(),
)
.when(!(tiling.top || tiling.right), |div| {
div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.bottom || tiling.right), |div| {
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.bottom || tiling.left), |div| {
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!tiling.top, |div| div.pt(CLIENT_SIDE_DECORATION_SHADOW))
.when(!tiling.bottom, |div| div.pb(CLIENT_SIDE_DECORATION_SHADOW))
.when(!tiling.left, |div| div.pl(CLIENT_SIDE_DECORATION_SHADOW))
.when(!tiling.right, |div| div.pr(CLIENT_SIDE_DECORATION_SHADOW))
.on_mouse_down(MouseButton::Left, move |e, window, _cx| {
let size = window.window_bounds().get_bounds().size;
let pos = e.position;
if let Some(edge) =
resize_edge(pos, CLIENT_SIDE_DECORATION_SHADOW, size, tiling)
{
window.start_window_resize(edge)
};
}),
})
.child(
div()
.map(|div| match decorations {
Decorations::Server => div,
Decorations::Client { tiling } => div
.border_color(cx.theme().border)
.when(!(tiling.top || tiling.right), |div| {
div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.bottom || tiling.right), |div| {
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.bottom || tiling.left), |div| {
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!tiling.top, |div| {
div.border_t(CLIENT_SIDE_DECORATION_BORDER)
})
.when(!tiling.bottom, |div| {
div.border_b(CLIENT_SIDE_DECORATION_BORDER)
})
.when(!tiling.left, |div| {
div.border_l(CLIENT_SIDE_DECORATION_BORDER)
})
.when(!tiling.right, |div| {
div.border_r(CLIENT_SIDE_DECORATION_BORDER)
})
.when(!tiling.is_tiled() && cx.theme().shadow, |div| {
div.shadow(surface_shadows())
}),
})
.on_mouse_move(|_e, _, cx| {
cx.stop_propagation();
})
.size_full()
.font_family(font_family)
.bg(cx.theme().bg)
.text_color(cx.theme().text)
.child(TextSelectionLayer)
.child(self.view.clone()),
)
}
}
/// Get the window resize edge.
fn resize_edge(
pos: Point<Pixels>,
shadow_size: Pixels,
window_size: Size<Pixels>,
tiling: Tiling,
) -> Option<ResizeEdge> {
let bounds = Bounds::new(Point::default(), window_size).inset(shadow_size * 1.5);
if bounds.contains(&pos) {
return None;
}
let corner_size = size(shadow_size * 1.5, shadow_size * 1.5);
let top_left_bounds = Bounds::new(Point::new(px(0.), px(0.)), corner_size);
if !tiling.top && top_left_bounds.contains(&pos) {
return Some(ResizeEdge::TopLeft);
}
let top_right_bounds = Bounds::new(
Point::new(window_size.width - corner_size.width, px(0.)),
corner_size,
);
if !tiling.top && top_right_bounds.contains(&pos) {
return Some(ResizeEdge::TopRight);
}
let bottom_left_bounds = Bounds::new(
Point::new(px(0.), window_size.height - corner_size.height),
corner_size,
);
if !tiling.bottom && bottom_left_bounds.contains(&pos) {
return Some(ResizeEdge::BottomLeft);
}
let bottom_right_bounds = Bounds::new(
Point::new(
window_size.width - corner_size.width,
window_size.height - corner_size.height,
),
corner_size,
);
if !tiling.bottom && bottom_right_bounds.contains(&pos) {
return Some(ResizeEdge::BottomRight);
}
if !tiling.top && pos.y < shadow_size {
Some(ResizeEdge::Top)
} else if !tiling.bottom && pos.y > window_size.height - shadow_size {
Some(ResizeEdge::Bottom)
} else if !tiling.left && pos.x < shadow_size {
Some(ResizeEdge::Left)
} else if !tiling.right && pos.x > window_size.width - shadow_size {
Some(ResizeEdge::Right)
} else {
None
}
}
-4
View File
@@ -1,4 +0,0 @@
mod scrollable;
pub use gpui_base::Scrollbar;
pub use scrollable::*;
-93
View File
@@ -1,93 +0,0 @@
use std::panic::Location;
use gpui::prelude::FluentBuilder;
use gpui::{
App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
ScrollHandle, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
};
use gpui_base::{Scrollbar, ScrollbarAxis};
use crate::StyledExt as _;
/// A trait for elements that can be made scrollable with scrollbars.
pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
/// Almost equivalent to [`StatefulInteractiveElement::overflow_y_scroll`], but adds Vertical scrollbar.
#[track_caller]
fn overflow_y_scrollbar(self) -> Scrollable<Self> {
Scrollable::new(self, ScrollbarAxis::Vertical)
}
}
/// A scrollable element wrapper that adds scrollbars to an interactive element.
#[derive(IntoElement)]
pub struct Scrollable<E: InteractiveElement + Styled + ParentElement + Element> {
id: ElementId,
element: E,
axis: ScrollbarAxis,
}
impl<E> Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element,
{
#[track_caller]
fn new(element: E, axis: impl Into<ScrollbarAxis>) -> Self {
let caller = Location::caller();
Self {
id: ElementId::CodeLocation(*caller),
element,
axis: axis.into(),
}
}
}
impl<E> ParentElement for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element,
{
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.element.extend(elements)
}
}
impl<E> RenderOnce for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element + 'static,
{
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let scroll_handle = window
.use_keyed_state(self.id.clone(), cx, |_, _| ScrollHandle::default())
.read(cx)
.clone();
let style = StyleRefinement {
size: self.element.style().size.clone(),
..Default::default()
};
div()
.id(self.id)
.size_full()
.refine_style(&style)
.relative()
.child(
div()
.id("scroll-area")
.flex()
.size_full()
.track_scroll(&scroll_handle)
.map(|this| match self.axis {
ScrollbarAxis::Vertical => this.flex_col().overflow_y_scroll(),
ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(),
ScrollbarAxis::Both => this.overflow_scroll(),
})
.child(self.element.size_auto().flex_1()),
)
.child(
Scrollbar::new(&scroll_handle)
.axis(self.axis)
.id("scrollbar"),
)
}
}
impl ScrollableElement for Div {}
+10 -144
View File
@@ -1,23 +1,11 @@
use gpui::{
App, Div, ImageSource, ObjectFit, ParentElement, Pixels, Refineable, StyleRefinement, Styled,
StyledImage, div, img, px,
App, Div, ImageSource, ObjectFit, ParentElement as _, Styled as _, StyledImage as _, div, img,
};
pub use gpui_base::component_traits::{Disableable, Selectable};
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius, surface_shadows};
/// Returns a `Div` as horizontal flex layout.
pub fn h_flex() -> Div {
div().h_flex()
}
/// Returns a `Div` as vertical flex layout.
pub fn v_flex() -> Div {
div().v_flex()
}
use gpui_kit::component::ActiveTheme as _;
/// Returns a `Div` as divider.
pub fn divider(cx: &App) -> Div {
div().w_full().h_px().bg(cx.theme().border_faint)
div().w_full().h_px().bg(cx.theme().border)
}
/// Returns a full-bleed image with a wash for readability, to show behind panel content.
@@ -25,137 +13,15 @@ pub fn background<I>(image: I, cx: &App) -> Div
where
I: Into<ImageSource>,
{
let wash = if cx.theme().is_dark() { 0.5 } else { 0.2 };
let wash = if cx.theme().mode.is_dark() { 0.5 } else { 0.2 };
div()
.absolute()
.inset_0()
.child(img(image).size_full().object_fit(ObjectFit::Cover))
.child(div().absolute().inset_0().bg(cx.theme().bg.opacity(wash)))
}
macro_rules! font_weight {
($fn:ident, $const:ident) => {
/// [docs](https://tailwindcss.com/docs/font-weight)
fn $fn(self) -> Self {
self.font_weight(gpui::FontWeight::$const)
}
};
}
/// Extends [`gpui::Styled`] with specific styling methods.
pub trait StyledExt: Styled + Sized {
/// Refine the style of this element, applying the given style refinement.
fn refine_style(mut self, style: &StyleRefinement) -> Self {
self.style().refine(style);
self
}
/// Apply self into a horizontal flex layout.
#[inline]
fn h_flex(self) -> Self {
self.flex().flex_row().items_center()
}
/// Apply self into a vertical flex layout.
#[inline]
fn v_flex(self) -> Self {
self.flex().flex_col()
}
font_weight!(font_normal, NORMAL);
font_weight!(font_medium, MEDIUM);
font_weight!(font_semibold, SEMIBOLD);
/// Set as Popover style
#[inline]
fn popover_style(self, cx: &mut App) -> Self {
let shadows = cx.theme().shadow.then(surface_shadows).unwrap_or_default();
self.bg(cx.theme().surface_overlay)
.border_1()
.border_color(cx.theme().border)
.shadow(shadows)
.rounded(px(surface_radius()))
}
}
impl<E: Styled> StyledExt for E {}
/// A size for elements.
#[derive(Clone, Default, Copy, PartialEq, Eq, Debug)]
pub enum Size {
Size(Pixels),
XSmall,
Small,
#[default]
Medium,
Large,
}
impl From<Pixels> for Size {
fn from(size: Pixels) -> Self {
Size::Size(size)
}
}
/// A trait for setting the size of an element.
pub trait Sizable: Sized {
/// Set the element's [`Size`], or a custom pixel size (`px(30.)`).
fn with_size(self, size: impl Into<Size>) -> Self;
/// Set to Size::XSmall
fn xsmall(self) -> Self {
self.with_size(Size::XSmall)
}
/// Set to Size::Small
fn small(self) -> Self {
self.with_size(Size::Small)
}
/// Set to Size::Medium
fn medium(self) -> Self {
self.with_size(Size::Medium)
}
/// Set to Size::Large
fn large(self) -> Self {
self.with_size(Size::Large)
}
}
pub(crate) trait StyleSized<T: Styled> {
fn input_px(self, size: Size) -> Self;
fn input_py(self, size: Size) -> Self;
fn input_h(self, size: Size) -> Self;
}
impl<T: Styled> StyleSized<T> for T {
fn input_px(self, size: Size) -> Self {
match size {
Size::XSmall => self.px_1(),
Size::Medium => self.px_3(),
Size::Large => self.px_5(),
_ => self.px_2(),
}
}
fn input_py(self, size: Size) -> Self {
match size {
Size::XSmall => self.py_0p5(),
Size::Medium => self.py_2(),
Size::Large => self.py_1p5(),
_ => self.py_1(),
}
}
fn input_h(self, size: Size) -> Self {
match size {
Size::XSmall => self.h_6().text_style(TextStyle::Caption),
Size::Small => self.h_7().text_style(TextStyle::Callout),
Size::Medium => self.h_8().text_style(TextStyle::Body),
Size::Large => self.h_10().text_style(TextStyle::Title3),
Size::Size(size) => self.h(size).text_size(size * 0.5),
}
}
.child(
div()
.absolute()
.inset_0()
.bg(cx.theme().background.opacity(wash)),
)
}
-157
View File
@@ -1,157 +0,0 @@
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, ElementId, IntoElement, ParentElement as _, RenderOnce, SharedString, Styled as _, Window,
div, px, transparent_black,
};
use gpui_base::{Spring, Switch as BaseSwitch, SwitchThumb, SwitchTrack, spring};
use theme::{ActiveTheme, TextStyle, Typeset as _};
type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>;
#[derive(IntoElement)]
pub struct Switch {
id: ElementId,
checked: bool,
label: Option<SharedString>,
description: Option<SharedString>,
on_click: OnClick,
}
impl Switch {
pub fn new(id: impl Into<ElementId>) -> Self {
let id: ElementId = id.into();
Self {
id: id.clone(),
checked: false,
label: None,
description: None,
on_click: None,
}
}
pub fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn on_click<F>(mut self, handler: F) -> Self
where
F: Fn(&bool, &mut Window, &mut App) + 'static,
{
self.on_click = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Switch {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let checked = self.checked;
let on_click = self.on_click.clone();
let bg = if checked {
cx.theme().accent_strong
} else {
cx.theme().input_bg
};
let toggle_bg = cx.theme().on_solid;
// The border is structural (it insets the thumb); it only paints when off.
let track_border = if checked {
transparent_black()
} else {
cx.theme().border
};
let bg_width = px(36.);
let bg_height = px(20.);
let bar_width = px(16.);
let inset = px(2.);
let thumb_left = spring(
(self.id.clone(), "thumb"),
if checked {
bg_width - bar_width - inset * 2.
} else {
px(0.)
},
Spring::new(Duration::from_secs_f64(0.15)),
window,
cx,
);
let accessibility_label = self.label.clone();
let label = self.label;
div().child(
BaseSwitch::new(self.id.clone())
.checked(checked)
.when_some(accessibility_label, |this, label| {
this.accessibility_label(label)
})
.when_some(on_click, |this, on_click| {
this.on_change(move |next, _event, window, cx| on_click(&next, window, cx))
})
.flex_row_reverse()
.child(
div()
.w_full()
.flex()
.justify_between()
.items_center()
.gap_4()
.when_some(label, |this, label| {
this.child(
div()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text)
.child(label),
)
})
.child(
SwitchTrack::new((self.id.clone(), "track"))
.checked(checked)
.flex_shrink_0()
.w(bg_width)
.h(bg_height)
.rounded(bg_height / 2.)
.flex()
.items_center()
.border(inset)
.border_color(track_border)
.bg(bg)
.cursor_pointer()
.child(
SwitchThumb::new(checked)
.rounded_full()
.when(cx.theme().shadow, |this| this.shadow_sm())
.bg(toggle_bg)
.size(bar_width)
.left(thumb_left),
),
),
)
.when_some(self.description.clone(), |this, description| {
this.child(
div()
.pr_3()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(description),
)
}),
)
}
}
-224
View File
@@ -1,224 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
};
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::{Selectable, h_flex};
pub mod tab_bar;
#[allow(clippy::type_complexity)]
/// A Tab element for the [`super::TabBar`].
#[derive(IntoElement)]
pub struct Tab {
ix: usize,
base: Div,
label: Option<SharedString>,
children: Vec<AnyElement>,
tab_bar_prefix: Option<bool>,
suffix: Option<AnyElement>,
disabled: bool,
selected: bool,
segmented: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
impl Default for Tab {
fn default() -> Self {
Self {
ix: 0,
base: div(),
label: None,
tab_bar_prefix: None,
children: Vec::new(),
disabled: false,
selected: false,
segmented: false,
suffix: None,
on_click: None,
}
}
}
impl Tab {
/// Create a new tab with a label.
pub fn new() -> Self {
Self::default()
}
/// Set label for the tab.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
/// Set the right side of the tab
pub(crate) fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
/// Set disabled state to the tab, default false.
pub(crate) fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Set the click handler for the tab.
pub(crate) fn on_click(
mut self,
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(on_click));
self
}
/// Set index to the tab.
pub(crate) fn ix(mut self, ix: usize) -> Self {
self.ix = ix;
self
}
/// Set if the tab bar has a prefix.
pub(crate) fn tab_bar_prefix(mut self, tab_bar_prefix: bool) -> Self {
self.tab_bar_prefix = Some(tab_bar_prefix);
self
}
/// Render the tab as a segment inside a segmented control.
pub(crate) fn segmented(mut self, segmented: bool) -> Self {
self.segmented = segmented;
self
}
}
impl ParentElement for Tab {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Selectable for Tab {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl InteractiveElement for Tab {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.base.interactivity()
}
}
impl StatefulInteractiveElement for Tab {}
impl Styled for Tab {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl RenderOnce for Tab {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let Self {
ix,
base,
label,
suffix,
children,
disabled,
selected,
segmented,
on_click,
..
} = self;
let foreground = if disabled {
cx.theme().text_muted
} else if selected {
cx.theme().text
} else {
cx.theme().text_muted
};
let content = h_flex()
.flex_1()
.h_6()
.whitespace_nowrap()
.items_center()
.overflow_hidden()
.when(segmented, |this| this.justify_center().px_1())
.when(!segmented, |this| this.justify_start())
.map(|this| match label {
Some(label) => this.child(label),
None => this,
})
.children(children);
base.id(ix)
.group("tab")
.flex()
.items_center()
.text_color(foreground)
.when(segmented, |this| {
this.text_style(TextStyle::Caption)
.flex_1()
.h_6()
.rounded(px(theme::button_radius()))
.when(selected && !disabled, |this| {
this.bg(cx.theme().bg)
.when(cx.theme().shadow, |this| this.shadow_sm())
})
.when(!selected && !disabled, |this| {
this.hover(|this| this.bg(cx.theme().element_hover))
})
})
.when(!segmented, |this| {
this.flex_shrink_0()
.min_w_32()
.h_7()
.gap_1()
.px_1p5()
.text_style(TextStyle::Callout)
.rounded(px(theme::button_radius()))
.overflow_hidden()
.when(selected && !disabled, |this| {
this.bg(cx.theme().element_active)
})
.when(!selected && !disabled, |this| {
this.hover(|this| {
this.bg(cx.theme().element_hover)
.text_color(cx.theme().text)
})
})
})
.child(content)
.when_some(suffix, |this, suffix| {
this.child(
div()
.flex_shrink_0()
.when(!selected, |this| {
this.invisible().group_hover("tab", |this| this.visible())
})
.child(suffix),
)
})
.on_mouse_down(MouseButton::Left, |_ev, _window, cx| {
cx.stop_propagation();
})
.when(!disabled, |this| {
this.when_some(on_click, |this, on_click| {
this.on_click(move |event, window, cx| on_click(event, window, cx))
})
})
}
}
-161
View File
@@ -1,161 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, Div, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
ScrollHandle, Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
px,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use super::Tab;
use crate::{Selectable, StyledExt, h_flex};
/// A TabBar element that contains multiple [`Tab`] items.
#[derive(IntoElement)]
pub struct TabBar {
base: Stateful<Div>,
style: StyleRefinement,
scroll_handle: Option<ScrollHandle>,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
children: SmallVec<[Tab; 2]>,
last_empty_space: AnyElement,
selected_index: Option<usize>,
segmented: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
}
impl TabBar {
/// Create a new TabBar.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: div().id(id).px(px(-1.)),
style: StyleRefinement::default(),
children: SmallVec::new(),
scroll_handle: None,
prefix: None,
suffix: None,
last_empty_space: div().w_3().into_any_element(),
selected_index: None,
on_click: None,
segmented: false,
}
}
/// Render the tabs as a segmented control inside a pill-shaped track.
pub fn segmented(mut self, segmented: bool) -> Self {
self.segmented = segmented;
self
}
/// Track the scroll of the TabBar.
pub(crate) fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
self.scroll_handle = Some(scroll_handle.clone());
self
}
/// Set the prefix element of the TabBar
pub(crate) fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
/// Set the suffix element of the TabBar
pub(crate) fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
/// Add children of the TabBar.
pub(crate) fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
self.children.extend(children.into_iter().map(Into::into));
self
}
/// Add child of the TabBar.
pub fn child(mut self, child: impl Into<Tab>) -> Self {
self.children.push(child.into());
self
}
/// Set the selected index of the TabBar.
pub fn selected_index(mut self, index: usize) -> Self {
self.selected_index = Some(index);
self
}
/// Set the last empty space element of the TabBar.
pub(crate) fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
self.last_empty_space = last_empty_space.into_any_element();
self
}
/// Set the on_click callback of the TabBar; when set, the children's on_click is ignored.
pub fn on_click<F>(mut self, on_click: F) -> Self
where
F: Fn(&usize, &mut Window, &mut App) + 'static,
{
self.on_click = Some(Rc::new(on_click));
self
}
}
impl Styled for TabBar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for TabBar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let segmented = self.segmented;
let has_prefix = self.prefix.is_some();
self.base
.group("tab-bar")
.relative()
.flex()
.items_center()
.min_w_0()
.refine_style(&self.style)
.when(segmented, |this| {
this.bg(cx.theme().surface)
.p_0p5()
.rounded(px(theme::button_radius()))
})
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
.id("tabs")
.flex_1()
.when(!segmented, |this| {
this.overflow_x_scroll()
.when(!has_prefix, |this| this.pl_2())
})
.when_some(self.scroll_handle, |this, scroll_handle| {
this.track_scroll(&scroll_handle)
})
.gap_1()
.children(self.children.into_iter().enumerate().map(|(ix, child)| {
let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
child
.ix(ix)
.tab_bar_prefix(tab_bar_prefix)
.segmented(segmented)
.when_some(self.selected_index, |this, selected_ix| {
this.selected(selected_ix == ix)
})
.when_some(self.on_click.clone(), move |this, on_click| {
this.on_click(move |_, window, cx| on_click(&ix, window, cx))
})
}))
.when(self.suffix.is_some(), |this| {
this.child(self.last_empty_space)
}),
)
.when_some(self.suffix, |this, suffix| this.child(suffix))
}
}
-218
View File
@@ -1,218 +0,0 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, Div, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels,
RenderOnce, Stateful, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, div,
px,
};
use theme::{ActiveTheme, plate_active};
use crate::{Icon, IconName, Sizable as _, h_flex};
const TITLE_BAR_HEIGHT: Pixels = px(34.);
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
// Windows control buttons rely on the native window event, so no click handlers are needed.
#[derive(IntoElement, Clone)]
enum ControlIcon {
Minimize,
Restore,
Maximize,
Close,
}
impl ControlIcon {
fn minimize() -> Self {
Self::Minimize
}
fn restore() -> Self {
Self::Restore
}
fn maximize() -> Self {
Self::Maximize
}
fn close() -> Self {
Self::Close
}
fn id(&self) -> &'static str {
match self {
Self::Minimize => "minimize",
Self::Restore => "restore",
Self::Maximize => "maximize",
Self::Close => "close",
}
}
fn icon(&self) -> IconName {
match self {
Self::Minimize => IconName::WindowMinimize,
Self::Restore => IconName::WindowRestore,
Self::Maximize => IconName::WindowMaximize,
Self::Close => IconName::WindowClose,
}
}
fn window_control_area(&self) -> WindowControlArea {
match self {
Self::Minimize => WindowControlArea::Min,
Self::Restore | Self::Maximize => WindowControlArea::Max,
Self::Close => WindowControlArea::Close,
}
}
fn is_close(&self) -> bool {
matches!(self, Self::Close)
}
#[inline]
fn hover_fg(&self, cx: &App) -> Hsla {
if self.is_close() {
// White on the danger plate in both appearances, per bezel's
// destructive pairing.
gpui::white()
} else {
cx.theme().text
}
}
#[inline]
fn hover_bg(&self, cx: &App) -> Hsla {
if self.is_close() {
cx.theme().danger_strong
} else {
cx.theme().element_hover
}
}
#[inline]
fn active_bg(&self, cx: &mut App) -> Hsla {
if self.is_close() {
plate_active(cx.theme().danger_strong)
} else {
cx.theme().element_active
}
}
}
impl RenderOnce for ControlIcon {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let is_linux = cfg!(target_os = "linux");
let is_windows = cfg!(target_os = "windows");
let icon = self.clone();
let hover_fg = self.hover_fg(cx);
let hover_bg = self.hover_bg(cx);
let active_bg = self.active_bg(cx);
div()
.id(self.id())
.flex()
.w(TITLE_BAR_HEIGHT)
.h_full()
.flex_shrink_0()
.justify_center()
.content_center()
.items_center()
.text_color(cx.theme().text)
.hover(|style| style.bg(hover_bg).text_color(hover_fg))
.active(|style| style.bg(active_bg).text_color(hover_fg))
.when(is_windows, |this| {
this.window_control_area(self.window_control_area())
})
.when(is_linux, |this| {
this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.on_click(move |_, window, cx| {
cx.stop_propagation();
match icon {
Self::Minimize => window.minimize_window(),
Self::Restore | Self::Maximize => window.zoom_window(),
Self::Close => window.remove_window(),
}
})
})
.child(Icon::new(self.icon()).small())
}
}
#[derive(IntoElement)]
pub(crate) struct WindowControls;
pub(crate) fn window_controls() -> WindowControls {
WindowControls
}
pub fn title_bar_drag_handlers(
this: Stateful<Div>,
window: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
let state = window.use_state(cx, |_, _| TitleBarState { should_move: false });
let this = if cfg!(target_family = "wasm") {
this
} else {
this.window_control_area(WindowControlArea::Drag)
};
this.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}))
.on_mouse_down(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = true;
}),
)
.on_mouse_up(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}),
)
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
if state.should_move {
state.should_move = false;
window.start_window_move();
}
}))
.on_click(|event, window, _| {
if event.click_count() == 2 {
if cfg!(target_os = "macos") {
window.titlebar_double_click();
} else {
window.zoom_window();
}
}
})
}
impl RenderOnce for WindowControls {
fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
return div().id("window-controls");
}
h_flex()
.id("window-controls")
.items_center()
.flex_shrink_0()
.h_full()
.child(ControlIcon::minimize())
.child(if window.is_maximized() {
ControlIcon::restore()
} else {
ControlIcon::maximize()
})
.child(ControlIcon::close())
}
}
struct TitleBarState {
should_move: bool,
}
-34
View File
@@ -1,34 +0,0 @@
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Window, px,
};
use gpui_base::Tooltip as BaseTooltip;
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
pub struct Tooltip {
text: SharedString,
}
impl Tooltip {
pub fn new(text: impl Into<SharedString>, _window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|_| Self { text: text.into() })
}
}
impl Render for Tooltip {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
BaseTooltip::new("tooltip")
.font_family(cx.theme().font_sans.clone())
.m_3()
.p_1p5()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().surface_overlay)
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded(px(control_radius()))
.text_style(TextStyle::Caption)
.text_color(cx.theme().text)
.child(self.text.clone())
}
}
+131
View File
@@ -0,0 +1,131 @@
use std::sync::atomic::{AtomicU32, Ordering};
use gpui::{FontWeight, Styled, px};
/// The base the ladder starts at; bezel's measured body is 13, coop keeps 15.
pub const DEFAULT_BASE_TEXT_SIZE: f32 = 15.0;
/// The body size every painted role is scaled against, as raw `f32` bits.
static BASE: AtomicU32 = AtomicU32::new(DEFAULT_BASE_TEXT_SIZE.to_bits());
/// Point the ladder at a body size in points; the caller refreshes windows.
pub fn set_base_text_size(points: f32) {
BASE.store(points.max(1.0).to_bits(), Ordering::Relaxed);
}
/// The body size in points every painted role is scaled against.
pub fn base_text_size() -> f32 {
f32::from_bits(BASE.load(Ordering::Relaxed))
}
/// A role in the type ladder — SwiftUI's `Font.TextStyle`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TextStyle {
LargeTitle,
Title,
Title2,
Title3,
Headline,
Subheadline,
Body,
Callout,
Footnote,
Caption,
Caption2,
}
impl TextStyle {
/// The role's measured size in points.
pub const fn size(self) -> f32 {
match self {
Self::LargeTitle => 26.0,
Self::Title => 22.0,
Self::Title2 => 17.0,
Self::Title3 => 15.0,
Self::Headline | Self::Body => 13.0,
Self::Callout => 12.0,
Self::Subheadline => 11.0,
Self::Footnote | Self::Caption | Self::Caption2 => 10.0,
}
}
pub fn painted(self) -> f32 {
self.size() * base_text_size() / Self::Body.size()
}
pub const fn line_height(self) -> f32 {
match self {
Self::LargeTitle => 32.0,
Self::Title => 26.0,
Self::Title2 => 22.0,
Self::Title3 => 20.0,
Self::Headline | Self::Body => 16.0,
Self::Callout => 15.0,
Self::Subheadline => 14.0,
Self::Footnote | Self::Caption | Self::Caption2 => 13.0,
}
}
pub fn painted_line_height(self) -> f32 {
self.line_height() * base_text_size() / Self::Body.size()
}
pub const fn weight(self) -> FontWeight {
match self {
Self::Headline => FontWeight::BOLD,
Self::Caption2 => FontWeight::MEDIUM,
_ => FontWeight::NORMAL,
}
}
}
/// One role as it is set: a rung on the ladder, its leading, weight and scale.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Metrics {
pub role: TextStyle,
pub leading: f32,
pub weight: FontWeight,
pub scale: f32,
}
impl Metrics {
pub const fn new(role: TextStyle, leading: f32, weight: FontWeight) -> Self {
Self {
role,
leading,
weight,
scale: 1.0,
}
}
pub const fn scaled(self, scale: f32) -> Self {
Self { scale, ..self }
}
pub fn size(self) -> f32 {
self.role.painted() * self.scale
}
pub fn line_height(self) -> f32 {
self.size() * self.leading
}
}
impl From<TextStyle> for Metrics {
/// The ladder's own setting for a role: its measured leading and weight.
fn from(role: TextStyle) -> Self {
Self::new(role, role.line_height() / role.size(), role.weight())
}
}
/// The ladder, on anything styled.
pub trait Typeset: Styled + Sized {
/// Size, line height and weight together, from [`TextStyle`].
fn text_style(self, style: TextStyle) -> Self {
self.text_size(px(style.painted()))
.line_height(px(style.painted_line_height()))
.font_weight(style.weight())
}
}
impl<E: Styled> Typeset for E {}
+55
View File
@@ -0,0 +1,55 @@
use gpui::{
App, Div, InteractiveElement as _, MouseButton, Stateful, StatefulInteractiveElement as _,
Window, WindowControlArea,
};
/// Window-drag and double-click handlers for the app's integrated title bar
/// row. Attach to the header element that acts as the drag region.
pub fn title_bar_drag_handlers(
this: Stateful<Div>,
window: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
let state = window.use_state(cx, |_, _| DragState { should_move: false });
let this = if cfg!(target_family = "wasm") {
this
} else {
this.window_control_area(WindowControlArea::Drag)
};
this.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}))
.on_mouse_down(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = true;
}),
)
.on_mouse_up(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}),
)
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
if state.should_move {
state.should_move = false;
window.start_window_move();
}
}))
.on_click(|event, window, _| {
if event.click_count() == 2 {
if cfg!(target_os = "macos") {
window.titlebar_double_click();
} else {
window.zoom_window();
}
}
})
}
struct DragState {
should_move: bool,
}
-90
View File
@@ -1,90 +0,0 @@
use gpui::{App, ElementId, Window};
use crate::Root;
use crate::dialog::Dialog;
use crate::notification::Notification;
/// Extension trait for [`Window`] to add dialog, notification .. functionality.
pub trait WindowExtension: Sized {
/// Opens a Dialog.
fn open_dialog<F>(&mut self, cx: &mut App, builder: F)
where
F: Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static;
/// Closes the last active Dialog.
fn close_dialog(&mut self, cx: &mut App);
/// Closes all active Dialogs.
fn close_all_dialogs(&mut self, cx: &mut App);
/// Pushes a notification to the notification list.
fn push_notification<T>(&mut self, note: T, cx: &mut App)
where
T: Into<Notification>;
/// Clear the unique notification.
fn clear_notification<T: Sized + 'static>(&mut self, cx: &mut App);
/// Clear the unique notification with the given id.
fn clear_notification_by_id<T: Sized + 'static>(
&mut self,
key: impl Into<ElementId>,
cx: &mut App,
);
}
impl WindowExtension for Window {
#[inline]
fn open_dialog<F>(&mut self, cx: &mut App, builder: F)
where
F: Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static,
{
Root::update(self, cx, move |root, window, cx| {
root.open_dialog(builder, window, cx);
})
}
#[inline]
fn close_dialog(&mut self, cx: &mut App) {
Root::update(self, cx, move |root, window, cx| {
root.close_dialog(window, cx);
})
}
#[inline]
fn close_all_dialogs(&mut self, cx: &mut App) {
Root::update(self, cx, |root, window, cx| {
root.close_all_dialogs(window, cx);
})
}
#[inline]
fn push_notification<T>(&mut self, note: T, cx: &mut App)
where
T: Into<Notification>,
{
let note = note.into();
Root::update(self, cx, move |root, window, cx| {
root.push_notification(note, window, cx);
})
}
#[inline]
fn clear_notification<T: Sized + 'static>(&mut self, cx: &mut App) {
Root::update(self, cx, |root, window, cx| {
root.clear_notification::<T>(window, cx);
})
}
#[inline]
fn clear_notification_by_id<T: Sized + 'static>(
&mut self,
key: impl Into<ElementId>,
cx: &mut App,
) {
let key: ElementId = key.into();
Root::update(self, cx, |root, window, cx| {
root.clear_notification_by_id::<T>(key, window, cx);
})
}
}