migrate ui components to gpui-kit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
})
|
||||
}
|
||||
}))
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
"",
|
||||
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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(¬ification, 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
mod scrollable;
|
||||
|
||||
pub use gpui_base::Scrollbar;
|
||||
pub use scrollable::*;
|
||||
@@ -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
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user