T05 buttons and actions: groups, toggles, segmented, confirm, copy, share, bars, sheet, quick actions
This commit is contained in:
29 files changed
+2659
-151
No files matched your search
@@ -0,0 +1,162 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, App, Div, ElementId, IntoElement, ParentElement,
|
||||
RenderOnce, StyleRefinement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::IconButton;
|
||||
use crate::{
|
||||
motion,
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, Elevation, TextSize},
|
||||
typography::AnimatedNumber,
|
||||
};
|
||||
|
||||
/// Actions in a row at a region's foot: docked, or floating as a pill.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ActionBar {
|
||||
base: Div,
|
||||
floating: bool,
|
||||
children: SmallVec<[AnyElement; 4]>,
|
||||
}
|
||||
|
||||
impl ActionBar {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base: div(),
|
||||
floating: false,
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A raised pill instead of a full-width strip.
|
||||
pub fn floating(mut self) -> Self {
|
||||
self.floating = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ActionBar {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for ActionBar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for ActionBar {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ActionBar {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let bar = self.base.flex().items_center().gap_2();
|
||||
let bar = if self.floating {
|
||||
bar.p_1p5()
|
||||
.rounded_full()
|
||||
.bg(colors.overlay)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
} else {
|
||||
bar.w_full()
|
||||
.justify_end()
|
||||
.px_4()
|
||||
.py_3()
|
||||
.bg(colors.surface)
|
||||
.border_t_1()
|
||||
.border_color(colors.border)
|
||||
};
|
||||
bar.children(self.children).with_animation(
|
||||
"action-bar-in",
|
||||
Animation::new(motion::duration(motion::BASE, cx)).with_easing(motion::ease_out_cubic),
|
||||
|bar, t| bar.opacity(t).mt(motion::NUDGE * 2.0 * (1.0 - t)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type OnClear = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// Rises when items are selected: the count, bulk actions, and a clear button.
|
||||
#[derive(IntoElement)]
|
||||
pub struct BulkActionBar {
|
||||
id: ElementId,
|
||||
count: usize,
|
||||
actions: SmallVec<[AnyElement; 4]>,
|
||||
on_clear: Option<OnClear>,
|
||||
}
|
||||
|
||||
impl BulkActionBar {
|
||||
pub fn new(id: impl Into<ElementId>, count: usize) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
count,
|
||||
actions: SmallVec::new(),
|
||||
on_clear: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn action(mut self, action: impl IntoElement) -> Self {
|
||||
self.actions.push(action.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_clear(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_clear = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for BulkActionBar {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
if self.count == 0 {
|
||||
return div().into_any_element();
|
||||
}
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
ActionBar::new()
|
||||
.floating()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.pl_2()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg)
|
||||
.child(
|
||||
AnimatedNumber::new(self.id.clone(), self.count as f64).size(TextSize::Sm),
|
||||
)
|
||||
.child("selected"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.h(theme.control_height(ControlSize::Sm))
|
||||
.border_l_1()
|
||||
.border_color(colors.border),
|
||||
)
|
||||
.children(self.actions)
|
||||
.when_some(self.on_clear, |bar, clear| {
|
||||
bar.child(
|
||||
IconButton::new("bulk-clear", IconName::X)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Clear selection")
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("bulk action bar: cleared");
|
||||
clear(window, cx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
+48
-4
@@ -1,11 +1,12 @@
|
||||
use gpui::{
|
||||
App, ClickEvent, ElementId, FontWeight, Hsla, IntoElement, MouseButton, RenderOnce,
|
||||
App, ClickEvent, ElementId, FontWeight, Hsla, IntoElement, Keystroke, MouseButton, RenderOnce,
|
||||
SharedString, Window, div, prelude::*, transparent_black,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
primitives::{FocusRing, Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Mix, Palette, Radius, TextSize},
|
||||
typography::keys::{Platform, keystroke_labels},
|
||||
};
|
||||
|
||||
pub(crate) type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
|
||||
@@ -93,16 +94,33 @@ pub(crate) fn label_size(size: ControlSize) -> (TextSize, IconSize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a button sits inside a `ButtonGroup`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Slot {
|
||||
First,
|
||||
Middle,
|
||||
Last,
|
||||
}
|
||||
|
||||
/// A keystroke as the platform spells it: `⌘S`, or `Ctrl+S`.
|
||||
pub(crate) fn shortcut_text(stroke: &Keystroke) -> String {
|
||||
let platform = Platform::current();
|
||||
let glue = if platform == Platform::Mac { "" } else { "+" };
|
||||
keystroke_labels(stroke, platform).join(glue)
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Button {
|
||||
id: ElementId,
|
||||
label: SharedString,
|
||||
icon: Option<IconName>,
|
||||
trailing_icon: Option<IconName>,
|
||||
shortcut: Option<Keystroke>,
|
||||
variant: ButtonVariant,
|
||||
size: ControlSize,
|
||||
disabled: bool,
|
||||
full_width: bool,
|
||||
pub(crate) slot: Option<Slot>,
|
||||
on_click: Option<ClickHandler>,
|
||||
}
|
||||
|
||||
@@ -113,10 +131,12 @@ impl Button {
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
trailing_icon: None,
|
||||
shortcut: None,
|
||||
variant: ButtonVariant::default(),
|
||||
size: ControlSize::default(),
|
||||
disabled: false,
|
||||
full_width: false,
|
||||
slot: None,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
@@ -145,6 +165,14 @@ impl Button {
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows the keystroke that also runs it, e.g. `"cmd-s"`. Panics on a bad keystroke.
|
||||
pub fn shortcut(mut self, keystroke: &str) -> Self {
|
||||
let parsed = Keystroke::parse(keystroke)
|
||||
.unwrap_or_else(|error| panic!("button shortcut {keystroke:?}: {error}"));
|
||||
self.shortcut = Some(parsed);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
@@ -171,6 +199,8 @@ impl RenderOnce for Button {
|
||||
let (text, icon_size) = label_size(self.size);
|
||||
let link = self.variant == ButtonVariant::Link;
|
||||
let icon = |name| Icon::new(name).size(icon_size).color(tone.fg);
|
||||
let radius = theme.radius(Radius::Md);
|
||||
let hint = self.shortcut.as_ref().map(shortcut_text);
|
||||
|
||||
div()
|
||||
.id(self.id)
|
||||
@@ -181,17 +211,31 @@ impl RenderOnce for Button {
|
||||
.gap_1p5()
|
||||
.h(theme.control_height(self.size))
|
||||
.when(!link, |el| el.px(theme.control_padding(self.size)))
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(tone.border)
|
||||
.map(|el| match self.slot {
|
||||
None => el.rounded(radius),
|
||||
Some(Slot::First) => el.rounded_l(radius),
|
||||
Some(Slot::Middle) => el.border_l_0(),
|
||||
Some(Slot::Last) => el.rounded_r(radius).border_l_0(),
|
||||
})
|
||||
.text_size(theme.text_size(text))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(tone.fg)
|
||||
.bg(tone.bg)
|
||||
.border_1()
|
||||
.border_color(tone.border)
|
||||
.when(self.full_width, |el| el.w_full())
|
||||
.when_some(self.icon, |el, name| el.child(icon(name)))
|
||||
.child(self.label)
|
||||
.when_some(self.trailing_icon, |el, name| el.child(icon(name)))
|
||||
.when_some(hint, |el, hint| {
|
||||
el.child(
|
||||
div()
|
||||
.pl_1()
|
||||
.font_weight(FontWeight::NORMAL)
|
||||
.text_color(tone.fg.opacity(0.55))
|
||||
.child(hint),
|
||||
)
|
||||
})
|
||||
.map(|el| {
|
||||
if self.disabled {
|
||||
return el.opacity(0.45).cursor_not_allowed();
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, ElementId, FontWeight, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Task,
|
||||
Window, div, prelude::*, relative,
|
||||
};
|
||||
|
||||
use super::button::{ButtonVariant, label_size, tone};
|
||||
use crate::{
|
||||
motion,
|
||||
primitives::{FocusRing, Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, Radius},
|
||||
};
|
||||
|
||||
/// How long a hold takes to confirm.
|
||||
const HOLD: Duration = Duration::from_millis(1200);
|
||||
/// How long a first click stays armed.
|
||||
const ARMED: Duration = Duration::from_millis(3000);
|
||||
/// How long the done state shows.
|
||||
const DONE: Duration = Duration::from_millis(1400);
|
||||
|
||||
/// Hold the button down, or click it twice. Keys always press twice.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ConfirmMode {
|
||||
Hold,
|
||||
Twice,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Confirm {
|
||||
holding: bool,
|
||||
armed: bool,
|
||||
done: bool,
|
||||
generation: u64,
|
||||
_timer: Option<Task<()>>,
|
||||
}
|
||||
|
||||
type OnConfirm = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A destructive button that asks twice: by holding, or by a second click.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ConfirmButton {
|
||||
id: ElementId,
|
||||
label: SharedString,
|
||||
armed_label: SharedString,
|
||||
mode: ConfirmMode,
|
||||
size: ControlSize,
|
||||
on_confirm: Option<OnConfirm>,
|
||||
}
|
||||
|
||||
impl ConfirmButton {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
label: impl Into<SharedString>,
|
||||
mode: ConfirmMode,
|
||||
) -> Self {
|
||||
let label = label.into();
|
||||
Self {
|
||||
id: id.into(),
|
||||
armed_label: match mode {
|
||||
ConfirmMode::Hold => "Press again to confirm".into(),
|
||||
ConfirmMode::Twice => "Click again to confirm".into(),
|
||||
},
|
||||
label,
|
||||
mode,
|
||||
size: ControlSize::default(),
|
||||
on_confirm: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The label while waiting for the second click.
|
||||
pub fn armed_label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.armed_label = label.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: ControlSize) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_confirm(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_confirm = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears `field` after `after`, unless a newer change came first.
|
||||
fn expire(
|
||||
state: &gpui::Entity<Confirm>,
|
||||
after: Duration,
|
||||
clear: fn(&mut Confirm),
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Task<()> {
|
||||
let (weak, generation) = (state.downgrade(), state.read(cx).generation);
|
||||
window.spawn(cx, async move |cx| {
|
||||
cx.background_executor().timer(after).await;
|
||||
let cleared = weak.update(cx, |confirm, cx| {
|
||||
if confirm.generation == generation {
|
||||
clear(confirm);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
if let Err(error) = cleared {
|
||||
log::error!("confirm button: state gone before it expired: {error:#}");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn confirm_now(
|
||||
state: &gpui::Entity<Confirm>,
|
||||
handler: &Option<OnConfirm>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
log::info!("confirm button: confirmed");
|
||||
state.update(cx, |confirm, _| {
|
||||
confirm.holding = false;
|
||||
confirm.armed = false;
|
||||
confirm.done = true;
|
||||
confirm.generation += 1;
|
||||
});
|
||||
let timer = expire(state, DONE, |confirm| confirm.done = false, window, cx);
|
||||
state.update(cx, |confirm, cx| {
|
||||
confirm._timer = Some(timer);
|
||||
cx.notify();
|
||||
});
|
||||
if let Some(handler) = handler {
|
||||
handler(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ConfirmButton {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Confirm::default());
|
||||
let (holding, armed, done, generation) = {
|
||||
let confirm = state.read(cx);
|
||||
(
|
||||
confirm.holding,
|
||||
confirm.armed,
|
||||
confirm.done,
|
||||
confirm.generation,
|
||||
)
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let variant = if armed {
|
||||
ButtonVariant::Danger
|
||||
} else {
|
||||
ButtonVariant::Outline
|
||||
};
|
||||
let tone = tone(variant, colors);
|
||||
let (text, icon_size) = label_size(self.size);
|
||||
let label = if armed {
|
||||
self.armed_label.clone()
|
||||
} else {
|
||||
self.label.clone()
|
||||
};
|
||||
let fill = colors.danger.opacity(0.16);
|
||||
let handler = self.on_confirm.clone();
|
||||
let (press, release, out, click) =
|
||||
(state.clone(), state.clone(), state.clone(), state.clone());
|
||||
let hold = self.mode == ConfirmMode::Hold;
|
||||
let (hold_handler, click_handler) = (handler.clone(), handler);
|
||||
div()
|
||||
.id(self.id)
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_1p5()
|
||||
.h(theme.control_height(self.size))
|
||||
.px(theme.control_padding(self.size))
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(if holding { colors.danger } else { tone.border })
|
||||
.bg(tone.bg)
|
||||
.text_size(theme.text_size(text))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(if holding { colors.danger } else { tone.fg })
|
||||
.cursor_pointer()
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.hover(|style| style.bg(tone.hover))
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
if !hold || press.read(cx).done {
|
||||
return;
|
||||
}
|
||||
press.update(cx, |confirm, _| {
|
||||
confirm.holding = true;
|
||||
confirm.generation += 1;
|
||||
});
|
||||
let (weak, handler) = (press.downgrade(), hold_handler.clone());
|
||||
let generation = press.read(cx).generation;
|
||||
let timer = window.spawn(cx, async move |cx| {
|
||||
cx.background_executor().timer(HOLD).await;
|
||||
let held = cx.update(|window, cx| {
|
||||
let Some(state) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if state.read(cx).holding && state.read(cx).generation == generation {
|
||||
confirm_now(&state, &handler, window, cx);
|
||||
}
|
||||
});
|
||||
if let Err(error) = held {
|
||||
log::error!("confirm button: window gone during a hold: {error:#}");
|
||||
}
|
||||
});
|
||||
press.update(cx, |confirm, cx| {
|
||||
confirm._timer = Some(timer);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.on_mouse_up(MouseButton::Left, move |_, _, cx| let_go(&release, cx))
|
||||
.on_mouse_up_out(MouseButton::Left, move |_, _, cx| let_go(&out, cx))
|
||||
.on_click(move |event, window, cx| {
|
||||
if (hold && !event.is_keyboard()) || click.read(cx).done {
|
||||
return;
|
||||
}
|
||||
if click.read(cx).armed {
|
||||
confirm_now(&click, &click_handler, window, cx);
|
||||
return;
|
||||
}
|
||||
log::info!("confirm button: armed");
|
||||
click.update(cx, |confirm, _| {
|
||||
confirm.armed = true;
|
||||
confirm.generation += 1;
|
||||
});
|
||||
let timer = expire(&click, ARMED, |confirm| confirm.armed = false, window, cx);
|
||||
click.update(cx, |confirm, cx| {
|
||||
confirm._timer = Some(timer);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.when(holding, |button| {
|
||||
button.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.left_0()
|
||||
.bg(fill)
|
||||
.with_animation(
|
||||
("confirm-hold", generation),
|
||||
Animation::new(HOLD),
|
||||
|bar, t| bar.w(relative(t)),
|
||||
),
|
||||
)
|
||||
})
|
||||
.map(|button| {
|
||||
if done {
|
||||
button.child(
|
||||
Icon::new(IconName::Check)
|
||||
.size(icon_size)
|
||||
.color(colors.success)
|
||||
.with_animation(
|
||||
("confirm-done", generation),
|
||||
Animation::new(motion::duration(motion::FAST, cx))
|
||||
.with_easing(motion::ease_out_cubic),
|
||||
|icon, t| icon.rotate(gpui::radians((1.0 - t) * -0.6)),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
button
|
||||
}
|
||||
})
|
||||
.child(label)
|
||||
}
|
||||
}
|
||||
|
||||
fn let_go(state: &gpui::Entity<Confirm>, cx: &mut App) {
|
||||
if !state.read(cx).holding {
|
||||
return;
|
||||
}
|
||||
log::info!("confirm button: released early");
|
||||
state.update(cx, |confirm, cx| {
|
||||
confirm.holding = false;
|
||||
confirm.generation += 1;
|
||||
confirm._timer = None;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, ClipboardItem, ElementId, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Task, Window, div,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use super::IconButton;
|
||||
use crate::{
|
||||
motion,
|
||||
primitives::{Icon, IconName, Tooltip},
|
||||
theme::{ActiveTheme, ControlSize, IconSize},
|
||||
};
|
||||
|
||||
const CONFIRM: Duration = Duration::from_millis(1400);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Copied {
|
||||
count: u32,
|
||||
_reset: Option<Task<()>>,
|
||||
}
|
||||
|
||||
/// Copies text, then shows a check for a moment.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CopyButton {
|
||||
id: ElementId,
|
||||
text: SharedString,
|
||||
}
|
||||
|
||||
impl CopyButton {
|
||||
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for CopyButton {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Copied::default());
|
||||
let count = state.read(cx).count;
|
||||
let copied = state.read(cx)._reset.is_some();
|
||||
let theme = cx.theme();
|
||||
let success = theme.colors.success;
|
||||
let text = self.text;
|
||||
let button = IconButton::new("copy", IconName::Copy)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, window, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text.to_string()));
|
||||
log::info!("copy button: {} characters copied", text.chars().count());
|
||||
let weak = state.downgrade();
|
||||
let reset = window.spawn(cx, async move |cx| {
|
||||
cx.background_executor().timer(CONFIRM).await;
|
||||
if let Err(error) = weak.update(cx, |copied, cx| {
|
||||
copied._reset = None;
|
||||
cx.notify();
|
||||
}) {
|
||||
log::error!("copy button: reset lost its state: {error:#}");
|
||||
}
|
||||
});
|
||||
state.update(cx, |copied, cx| {
|
||||
copied.count += 1;
|
||||
copied._reset = Some(reset);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
let check = Icon::new(IconName::Check)
|
||||
.size(IconSize::Sm)
|
||||
.color(success)
|
||||
.with_animation(
|
||||
("copied", count),
|
||||
Animation::new(motion::duration(motion::FAST, cx))
|
||||
.with_easing(motion::ease_out_cubic),
|
||||
|icon, t| icon.rotate(gpui::radians((1.0 - t) * -0.6)),
|
||||
);
|
||||
div()
|
||||
.id(self.id)
|
||||
.flex_none()
|
||||
.size(theme.control_height(ControlSize::Sm))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.tooltip(Tooltip::text(if copied { "Copied" } else { "Copy" }))
|
||||
.map(|slot| {
|
||||
if copied {
|
||||
slot.child(check)
|
||||
} else {
|
||||
slot.child(button)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, ClickEvent, ElementId, FontWeight, InteractiveElement,
|
||||
IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement,
|
||||
Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::button::ClickHandler;
|
||||
use crate::{
|
||||
motion,
|
||||
primitives::{FocusRing, Icon, IconName, Tooltip},
|
||||
theme::{ActiveTheme, Elevation, IconSize, TextSize},
|
||||
};
|
||||
|
||||
/// A raised round button for a screen's main action; a label makes it a pill.
|
||||
#[derive(IntoElement)]
|
||||
pub struct FloatingActionButton {
|
||||
id: ElementId,
|
||||
icon: IconName,
|
||||
label: Option<SharedString>,
|
||||
tooltip: Option<SharedString>,
|
||||
on_click: Option<ClickHandler>,
|
||||
}
|
||||
|
||||
impl FloatingActionButton {
|
||||
pub fn new(id: impl Into<ElementId>, icon: IconName) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
icon,
|
||||
label: None,
|
||||
tooltip: None,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tooltip(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.tooltip = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FloatingActionButton {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let side = theme.fab_size();
|
||||
let (hover, lifted) = (colors.accent_hover, theme.elevation(Elevation::Modal));
|
||||
div()
|
||||
.id(self.id)
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.h(side)
|
||||
.map(|fab| match &self.label {
|
||||
Some(_) => fab.pl_5().pr_6(),
|
||||
None => fab.w(side),
|
||||
})
|
||||
.rounded_full()
|
||||
.bg(colors.accent)
|
||||
.text_color(colors.on_accent)
|
||||
.text_size(theme.text_size(TextSize::Md))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.border_1()
|
||||
.border_color(gpui::transparent_black())
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.cursor_pointer()
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.hover(move |style| style.bg(hover).shadow(lifted))
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.when_some(self.on_click, |fab, handler| fab.on_click(handler))
|
||||
.when_some(self.tooltip, |fab, text| fab.tooltip(Tooltip::text(text)))
|
||||
.child(
|
||||
Icon::new(self.icon)
|
||||
.size(IconSize::Lg)
|
||||
.color(colors.on_accent),
|
||||
)
|
||||
.when_some(self.label, |fab, label| fab.child(label))
|
||||
.with_animation(
|
||||
"fab-in",
|
||||
Animation::new(motion::duration(motion::SLOW, cx))
|
||||
.with_easing(motion::ease_out_cubic),
|
||||
|fab, t| fab.opacity(t).mt(motion::NUDGE * 2.0 * (1.0 - t)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
Button,
|
||||
button::{Slot, label_size},
|
||||
};
|
||||
use crate::{
|
||||
primitives::{FocusRing, Icon, IconName, Tooltip},
|
||||
theme::{ActiveTheme, ControlSize, Radius},
|
||||
};
|
||||
|
||||
/// Buttons joined edge to edge.
|
||||
#[derive(IntoElement, Default)]
|
||||
pub struct ButtonGroup {
|
||||
buttons: Vec<Button>,
|
||||
}
|
||||
|
||||
impl ButtonGroup {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn button(mut self, button: Button) -> Self {
|
||||
self.buttons.push(button);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ButtonGroup {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let count = self.buttons.len();
|
||||
assert!(count >= 2, "a button group joins two buttons or more");
|
||||
div().flex().children(
|
||||
self.buttons
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(ix, mut button)| {
|
||||
button.slot = Some(match ix {
|
||||
0 => Slot::First,
|
||||
ix if ix + 1 == count => Slot::Last,
|
||||
_ => Slot::Middle,
|
||||
});
|
||||
button
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One choice in a toggle: a label, an icon, or both.
|
||||
#[derive(Clone)]
|
||||
pub struct ToggleItem {
|
||||
value: SharedString,
|
||||
label: Option<SharedString>,
|
||||
icon: Option<IconName>,
|
||||
tooltip: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl ToggleItem {
|
||||
pub fn new(value: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
value: value.into(),
|
||||
label: None,
|
||||
icon: None,
|
||||
tooltip: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: IconName) -> Self {
|
||||
self.icon = Some(icon);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tooltip(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.tooltip = Some(text.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
type OnPress = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
fn toggle_face(
|
||||
id: ElementId,
|
||||
item: &ToggleItem,
|
||||
on: bool,
|
||||
size: ControlSize,
|
||||
press: OnPress,
|
||||
cx: &App,
|
||||
) -> impl IntoElement + use<> {
|
||||
assert!(
|
||||
item.label.is_some() || item.icon.is_some(),
|
||||
"toggle {} needs a label or an icon",
|
||||
item.value
|
||||
);
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let (text, icon_size) = label_size(size);
|
||||
let fg = if on { colors.fg } else { colors.fg_muted };
|
||||
let hover = colors.hover;
|
||||
div()
|
||||
.id(id)
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_1p5()
|
||||
.h(theme.control_height(size))
|
||||
.min_w(theme.control_height(size))
|
||||
.when(item.label.is_some(), |face| {
|
||||
face.px(theme.control_padding(size))
|
||||
})
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(gpui::transparent_black())
|
||||
.text_size(theme.text_size(text))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(fg)
|
||||
.map(|face| {
|
||||
if on {
|
||||
face.bg(colors.active)
|
||||
} else {
|
||||
face.hover(|style| style.bg(hover))
|
||||
}
|
||||
})
|
||||
.cursor_pointer()
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(move |_, window, cx| press(window, cx))
|
||||
.when_some(item.icon, |face, icon| {
|
||||
face.child(Icon::new(icon).size(icon_size).color(fg))
|
||||
})
|
||||
.when_some(item.label.clone(), |face, label| face.child(label))
|
||||
.when_some(item.tooltip.clone(), |face, text| {
|
||||
face.tooltip(Tooltip::text(text))
|
||||
})
|
||||
}
|
||||
|
||||
type OnToggle = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// A button that stays pressed until pressed again.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ToggleButton {
|
||||
id: ElementId,
|
||||
item: ToggleItem,
|
||||
on: bool,
|
||||
size: ControlSize,
|
||||
on_toggle: Option<OnToggle>,
|
||||
}
|
||||
|
||||
impl ToggleButton {
|
||||
pub fn new(id: impl Into<ElementId>, item: ToggleItem, on: bool) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
item,
|
||||
on,
|
||||
size: ControlSize::default(),
|
||||
on_toggle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: ControlSize) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_toggle = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ToggleButton {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let (on, toggle, value) = (self.on, self.on_toggle, self.item.value.clone());
|
||||
let press: OnPress = Rc::new(move |window, cx| {
|
||||
log::info!("toggle {value}: {}", !on);
|
||||
if let Some(toggle) = &toggle {
|
||||
toggle(!on, window, cx);
|
||||
}
|
||||
});
|
||||
toggle_face(self.id, &self.item, on, self.size, press, cx)
|
||||
}
|
||||
}
|
||||
|
||||
type OnChange = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
|
||||
|
||||
/// Toggles side by side. One is chosen, or several with `multiple`.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ToggleGroup {
|
||||
id: ElementId,
|
||||
items: Vec<ToggleItem>,
|
||||
selected: Vec<SharedString>,
|
||||
multiple: bool,
|
||||
size: ControlSize,
|
||||
on_change: Option<OnChange>,
|
||||
}
|
||||
|
||||
impl ToggleGroup {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
items: Vec::new(),
|
||||
selected: Vec::new(),
|
||||
multiple: false,
|
||||
size: ControlSize::default(),
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item(mut self, item: ToggleItem) -> Self {
|
||||
self.items.push(item);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn selected(mut self, values: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
|
||||
self.selected = values.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn multiple(mut self) -> Self {
|
||||
self.multiple = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: ControlSize) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
|
||||
/// Reports the whole selection after each press.
|
||||
pub fn on_change(
|
||||
mut self,
|
||||
handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The selection after pressing `value`.
|
||||
fn pressed(selected: &[SharedString], value: &SharedString, multiple: bool) -> Vec<SharedString> {
|
||||
let on = selected.contains(value);
|
||||
match (multiple, on) {
|
||||
(_, true) => selected.iter().filter(|v| *v != value).cloned().collect(),
|
||||
(true, false) => selected.iter().chain([value]).cloned().collect(),
|
||||
(false, false) => vec![value.clone()],
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ToggleGroup {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let faces: Vec<_> = self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, item)| {
|
||||
let on = self.selected.contains(&item.value);
|
||||
let next = pressed(&self.selected, &item.value, self.multiple);
|
||||
let change = self.on_change.clone();
|
||||
let press: OnPress = Rc::new(move |window, cx| {
|
||||
log::info!("toggle group: {next:?}");
|
||||
if let Some(change) = &change {
|
||||
change(&next, window, cx);
|
||||
}
|
||||
});
|
||||
toggle_face(("toggle", ix).into(), item, on, self.size, press, cx)
|
||||
})
|
||||
.collect();
|
||||
div()
|
||||
.id(self.id)
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_0p5()
|
||||
.p_0p5()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.children(faces)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::SharedString;
|
||||
|
||||
use super::pressed;
|
||||
|
||||
fn values(list: &[&'static str]) -> Vec<SharedString> {
|
||||
list.iter().map(|v| SharedString::from(*v)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_replaces_multiple_adds_and_pressing_again_clears() {
|
||||
let b = SharedString::from("b");
|
||||
assert_eq!(pressed(&values(&["a"]), &b, false), values(&["b"]));
|
||||
assert_eq!(pressed(&values(&["a"]), &b, true), values(&["a", "b"]));
|
||||
assert_eq!(pressed(&values(&["a", "b"]), &b, true), values(&["a"]));
|
||||
assert_eq!(pressed(&values(&["b"]), &b, false), values(&[]));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
use gpui::{
|
||||
App, ClickEvent, ElementId, IntoElement, MouseButton, RenderOnce, Window, div, prelude::*,
|
||||
App, ClickEvent, ElementId, IntoElement, MouseButton, RenderOnce, SharedString, Window, div,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use super::button::{ButtonVariant, ClickHandler, label_size, tone};
|
||||
use crate::{
|
||||
primitives::{FocusRing, Icon, IconName},
|
||||
primitives::{FocusRing, Icon, IconName, Tooltip},
|
||||
theme::{ActiveTheme, ControlSize, Radius},
|
||||
};
|
||||
|
||||
@@ -16,6 +17,7 @@ pub struct IconButton {
|
||||
variant: ButtonVariant,
|
||||
size: ControlSize,
|
||||
disabled: bool,
|
||||
tooltip: Option<SharedString>,
|
||||
on_click: Option<ClickHandler>,
|
||||
}
|
||||
|
||||
@@ -27,6 +29,7 @@ impl IconButton {
|
||||
variant: ButtonVariant::Ghost,
|
||||
size: ControlSize::default(),
|
||||
disabled: false,
|
||||
tooltip: None,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
@@ -46,6 +49,12 @@ impl IconButton {
|
||||
self
|
||||
}
|
||||
|
||||
/// Names what the icon does, on hover.
|
||||
pub fn tooltip(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.tooltip = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
@@ -74,6 +83,7 @@ impl RenderOnce for IconButton {
|
||||
.border_1()
|
||||
.border_color(tone.border)
|
||||
.child(Icon::new(self.icon).size(icon_size).color(tone.fg))
|
||||
.when_some(self.tooltip, |el, text| el.tooltip(Tooltip::text(text)))
|
||||
.map(|el| {
|
||||
if self.disabled {
|
||||
return el.opacity(0.45).cursor_not_allowed();
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
mod bars;
|
||||
mod button;
|
||||
mod confirm;
|
||||
mod copy;
|
||||
mod fab;
|
||||
mod group;
|
||||
mod icon_button;
|
||||
mod presets;
|
||||
mod quick;
|
||||
mod segmented;
|
||||
mod share;
|
||||
mod sheet;
|
||||
|
||||
pub use bars::{ActionBar, BulkActionBar};
|
||||
pub use button::{Button, ButtonVariant};
|
||||
pub use confirm::{ConfirmButton, ConfirmMode};
|
||||
pub use copy::CopyButton;
|
||||
pub use fab::FloatingActionButton;
|
||||
pub use group::{ButtonGroup, ToggleButton, ToggleGroup, ToggleItem};
|
||||
pub use icon_button::IconButton;
|
||||
pub use presets::{back_button, close_button, more_button};
|
||||
pub use quick::{QuickAction, QuickActions};
|
||||
pub use segmented::SegmentedControl;
|
||||
pub use share::ShareButton;
|
||||
pub use sheet::ActionSheet;
|
||||
@@ -0,0 +1,19 @@
|
||||
use gpui::ElementId;
|
||||
|
||||
use super::IconButton;
|
||||
use crate::primitives::IconName;
|
||||
|
||||
/// Closes a panel, dialog or tab.
|
||||
pub fn close_button(id: impl Into<ElementId>) -> IconButton {
|
||||
IconButton::new(id, IconName::X).tooltip("Close")
|
||||
}
|
||||
|
||||
/// Returns to the previous view.
|
||||
pub fn back_button(id: impl Into<ElementId>) -> IconButton {
|
||||
IconButton::new(id, IconName::ArrowLeft).tooltip("Back")
|
||||
}
|
||||
|
||||
/// Opens more actions.
|
||||
pub fn more_button(id: impl Into<ElementId>) -> IconButton {
|
||||
IconButton::new(id, IconName::Ellipsis).tooltip("More")
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, Keystroke, ParentElement,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::button::shortcut_text;
|
||||
use crate::{
|
||||
primitives::{FocusRing, Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
type Run = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// One quick action: what it is, and its shortcut.
|
||||
#[derive(Clone)]
|
||||
pub struct QuickAction {
|
||||
icon: IconName,
|
||||
label: SharedString,
|
||||
note: Option<SharedString>,
|
||||
shortcut: Option<Keystroke>,
|
||||
run: Run,
|
||||
}
|
||||
|
||||
impl QuickAction {
|
||||
pub fn new(
|
||||
icon: IconName,
|
||||
label: impl Into<SharedString>,
|
||||
run: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
icon,
|
||||
label: label.into(),
|
||||
note: None,
|
||||
shortcut: None,
|
||||
run: Rc::new(run),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn note(mut self, note: impl Into<SharedString>) -> Self {
|
||||
self.note = Some(note.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Panics on a bad keystroke.
|
||||
pub fn shortcut(mut self, keystroke: &str) -> Self {
|
||||
let parsed = Keystroke::parse(keystroke)
|
||||
.unwrap_or_else(|error| panic!("quick action shortcut {keystroke:?}: {error}"));
|
||||
self.shortcut = Some(parsed);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of actions. The pointer or the arrows choose; Enter or a click runs.
|
||||
#[derive(IntoElement)]
|
||||
pub struct QuickActions {
|
||||
id: ElementId,
|
||||
actions: Vec<QuickAction>,
|
||||
}
|
||||
|
||||
impl QuickActions {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
actions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn action(mut self, action: QuickAction) -> Self {
|
||||
self.actions.push(action);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct Pick {
|
||||
selected: usize,
|
||||
focus: gpui::FocusHandle,
|
||||
}
|
||||
|
||||
fn run(action: &QuickAction, window: &mut Window, cx: &mut App) {
|
||||
log::info!("quick actions: {}", action.label);
|
||||
(action.run)(window, cx);
|
||||
}
|
||||
|
||||
fn select(state: &Entity<Pick>, ix: usize, cx: &mut App) {
|
||||
if state.read(cx).selected != ix {
|
||||
state.update(cx, |pick, cx| {
|
||||
pick.selected = ix;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for QuickActions {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let count = self.actions.len();
|
||||
assert!(count > 0, "quick actions {:?} hold no action", self.id);
|
||||
let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| Pick {
|
||||
selected: 0,
|
||||
focus: cx.focus_handle(),
|
||||
});
|
||||
let (selected, focus) = {
|
||||
let pick = state.read(cx);
|
||||
(pick.selected.min(count - 1), pick.focus.clone())
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let rows: Vec<_> = self
|
||||
.actions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, action)| {
|
||||
let (hover, action_for_click) = (state.clone(), action.clone());
|
||||
div()
|
||||
.id(("quick", ix))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.when(ix == selected, |row| row.bg(colors.hover))
|
||||
.cursor_pointer()
|
||||
.on_hover(move |hovered, _, cx| {
|
||||
if *hovered {
|
||||
select(&hover, ix, cx);
|
||||
}
|
||||
})
|
||||
.on_click(move |_, window, cx| run(&action_for_click, window, cx))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(theme.control_height(ControlSize::Md))
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(colors.sunken)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
Icon::new(action.icon)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_muted),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Base))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg)
|
||||
.child(action.label.clone()),
|
||||
)
|
||||
.when_some(action.note.clone(), |text, note| {
|
||||
text.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(note),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when_some(action.shortcut.as_ref(), |row, stroke| {
|
||||
row.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(shortcut_text(stroke)),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let (keys, actions) = (state, self.actions);
|
||||
div()
|
||||
.id(self.id)
|
||||
.track_focus(&focus)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.p_1()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.surface)
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.on_key_down(move |event, window, cx| {
|
||||
let current = keys.read(cx).selected.min(count - 1);
|
||||
match event.keystroke.key.as_str() {
|
||||
"down" => select(&keys, (current + 1) % count, cx),
|
||||
"up" => select(&keys, (current + count - 1) % count, cx),
|
||||
"enter" => run(&actions[current], window, cx),
|
||||
_ => return,
|
||||
}
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, Bounds, ElementId, FontWeight, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement,
|
||||
Styled, Window, canvas, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::button::label_size;
|
||||
use crate::{
|
||||
motion,
|
||||
primitives::{FocusRing, Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, Elevation, Radius},
|
||||
};
|
||||
|
||||
/// Left edge and width of a segment, inside the control.
|
||||
type Span = (Pixels, Pixels);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Slide {
|
||||
origin: Pixels,
|
||||
spans: Vec<Option<Span>>,
|
||||
from: Option<Span>,
|
||||
last: Option<SharedString>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
type OnChange = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
|
||||
/// Mutually exclusive segments. A thumb slides to the chosen one.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SegmentedControl {
|
||||
id: ElementId,
|
||||
segments: Vec<(SharedString, SharedString, Option<IconName>)>,
|
||||
selected: SharedString,
|
||||
size: ControlSize,
|
||||
on_change: Option<OnChange>,
|
||||
}
|
||||
|
||||
impl SegmentedControl {
|
||||
pub fn new(id: impl Into<ElementId>, selected: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
segments: Vec::new(),
|
||||
selected: selected.into(),
|
||||
size: ControlSize::default(),
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn segment(
|
||||
mut self,
|
||||
value: impl Into<SharedString>,
|
||||
label: impl Into<SharedString>,
|
||||
icon: Option<IconName>,
|
||||
) -> Self {
|
||||
self.segments.push((value.into(), label.into(), icon));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: ControlSize) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_change(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Position along a slide from `from` to `to`, overshooting a little.
|
||||
fn glide(from: Span, to: Span, t: f32) -> Span {
|
||||
let s = motion::spring(t);
|
||||
let lerp = |a: Pixels, b: Pixels| a + (b - a) * s;
|
||||
(lerp(from.0, to.0), lerp(from.1, to.1))
|
||||
}
|
||||
|
||||
impl RenderOnce for SegmentedControl {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let chosen = self
|
||||
.segments
|
||||
.iter()
|
||||
.position(|(value, ..)| *value == self.selected)
|
||||
.unwrap_or_else(|| panic!("segmented control has no segment {}", self.selected));
|
||||
let count = self.segments.len();
|
||||
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Slide::default());
|
||||
if state.read(cx).spans.len() != count {
|
||||
state.update(cx, |slide, _| slide.spans = vec![None; count]);
|
||||
}
|
||||
if state.read(cx).last.as_ref() != Some(&self.selected) {
|
||||
state.update(cx, |slide, _| {
|
||||
let previous = slide
|
||||
.last
|
||||
.as_ref()
|
||||
.and_then(|last| self.segments.iter().position(|(value, ..)| value == last));
|
||||
slide.from = previous.and_then(|ix| slide.spans[ix]);
|
||||
slide.last = Some(self.selected.clone());
|
||||
slide.generation += 1;
|
||||
});
|
||||
}
|
||||
let (to, from, generation) = {
|
||||
let slide = state.read(cx);
|
||||
(slide.spans[chosen], slide.from, slide.generation)
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let (text, icon_size) = label_size(self.size);
|
||||
let duration = motion::duration(motion::SLOW, cx);
|
||||
let thumb = to.map(|to| {
|
||||
let from = from.unwrap_or(to);
|
||||
div()
|
||||
.absolute()
|
||||
.top_0p5()
|
||||
.bottom_0p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(colors.surface)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.shadow(theme.elevation(Elevation::Raised))
|
||||
.with_animation(
|
||||
("segment-thumb", generation),
|
||||
Animation::new(duration),
|
||||
move |thumb, t| {
|
||||
let (left, width) = glide(from, to, t);
|
||||
thumb.left(left).w(width)
|
||||
},
|
||||
)
|
||||
});
|
||||
let origin_state = state.clone();
|
||||
let segments = self
|
||||
.segments
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(ix, (value, label, icon))| {
|
||||
let on = ix == chosen;
|
||||
let fg = if on { colors.fg } else { colors.fg_muted };
|
||||
let (change, measure) = (self.on_change.clone(), state.clone());
|
||||
div()
|
||||
.id(("segment", ix))
|
||||
.relative()
|
||||
.flex()
|
||||
.flex_1()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_1p5()
|
||||
.h(theme.control_height(self.size))
|
||||
.px(theme.control_padding(self.size))
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(gpui::transparent_black())
|
||||
.text_size(theme.text_size(text))
|
||||
.font_weight(if on {
|
||||
FontWeight::SEMIBOLD
|
||||
} else {
|
||||
FontWeight::MEDIUM
|
||||
})
|
||||
.text_color(fg)
|
||||
.cursor_pointer()
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(move |_, window, cx| {
|
||||
if on {
|
||||
return;
|
||||
}
|
||||
log::info!("segmented control: {value}");
|
||||
if let Some(change) = &change {
|
||||
change(&value, window, cx);
|
||||
}
|
||||
})
|
||||
.when_some(icon, |segment, icon| {
|
||||
segment.child(Icon::new(icon).size(icon_size).color(fg))
|
||||
})
|
||||
.child(label)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds: Bounds<Pixels>, _, cx| {
|
||||
let span = Some((
|
||||
bounds.left() - measure.read(cx).origin,
|
||||
bounds.size.width,
|
||||
));
|
||||
if measure.read(cx).spans.get(ix) != Some(&span) {
|
||||
measure.update(cx, |slide, cx| {
|
||||
slide.spans[ix] = span;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
});
|
||||
div()
|
||||
.id(self.id)
|
||||
.relative()
|
||||
.flex()
|
||||
.items_center()
|
||||
.p_0p5()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.bg(colors.sunken)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds: Bounds<Pixels>, _, cx| {
|
||||
if origin_state.read(cx).origin != bounds.left() {
|
||||
origin_state.update(cx, |slide, cx| {
|
||||
slide.origin = bounds.left();
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
.children(thumb)
|
||||
.children(segments)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::px;
|
||||
|
||||
use super::glide;
|
||||
|
||||
#[test]
|
||||
fn glide_starts_at_from_and_settles_on_to() {
|
||||
let (from, to) = ((px(0.0), px(40.0)), (px(100.0), px(60.0)));
|
||||
assert_eq!(glide(from, to, 0.0), from);
|
||||
let (left, width) = glide(from, to, 1.0);
|
||||
assert!((f32::from(left) - 100.0).abs() < 0.5 && (f32::from(width) - 60.0).abs() < 0.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use gpui::{
|
||||
App, Bounds, ElementId, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, Styled,
|
||||
Window, canvas, div,
|
||||
};
|
||||
|
||||
use super::IconButton;
|
||||
use crate::primitives::IconName;
|
||||
|
||||
/// What to share: text, a link, or both.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Items {
|
||||
text: Option<SharedString>,
|
||||
url: Option<SharedString>,
|
||||
}
|
||||
|
||||
struct Anchor {
|
||||
bounds: Bounds<Pixels>,
|
||||
#[cfg(target_os = "macos")]
|
||||
picker: Option<mac::Picker>,
|
||||
}
|
||||
|
||||
/// Opens the system share picker beside itself. macOS only.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ShareButton {
|
||||
id: ElementId,
|
||||
items: Items,
|
||||
}
|
||||
|
||||
impl ShareButton {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
items: Items::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.items.text = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn url(mut self, url: impl Into<SharedString>) -> Self {
|
||||
self.items.url = Some(url.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ShareButton {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
assert!(
|
||||
self.items.text.is_some() || self.items.url.is_some(),
|
||||
"share button {:?} has nothing to share",
|
||||
self.id
|
||||
);
|
||||
let anchor = window.use_keyed_state(self.id.clone(), cx, |_, _| Anchor {
|
||||
bounds: Bounds::default(),
|
||||
#[cfg(target_os = "macos")]
|
||||
picker: None,
|
||||
});
|
||||
let (measure, open) = (anchor.clone(), anchor);
|
||||
let items = self.items;
|
||||
let button = IconButton::new(self.id, IconName::Share2).tooltip("Share");
|
||||
#[cfg(target_os = "macos")]
|
||||
let button = button.on_click(move |_, window, cx| {
|
||||
let bounds = open.read(cx).bounds;
|
||||
match mac::Picker::show(&items, bounds, window) {
|
||||
Ok(picker) => open.update(cx, |anchor, _| anchor.picker = Some(picker)),
|
||||
Err(error) => log::error!("share button: {error:#}"),
|
||||
}
|
||||
});
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let button = {
|
||||
let _ = (open, items);
|
||||
button
|
||||
.disabled(true)
|
||||
.tooltip("Sharing needs macOS in this build")
|
||||
};
|
||||
div().relative().child(button).child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
if measure.read(cx).bounds != bounds {
|
||||
measure.update(cx, |anchor, _| anchor.bounds = bounds);
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac {
|
||||
use anyhow::{Context as _, bail};
|
||||
use cocoa::{
|
||||
base::{id, nil},
|
||||
foundation::{NSArray, NSAutoreleasePool, NSPoint, NSRect, NSSize, NSString},
|
||||
};
|
||||
use gpui::{Bounds, Pixels, Window};
|
||||
use objc::{class, msg_send, sel, sel_impl};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
|
||||
use super::Items;
|
||||
|
||||
/// A shown picker; kept alive until the next share.
|
||||
pub struct Picker(id);
|
||||
|
||||
impl Drop for Picker {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
let _: () = msg_send![self.0, release];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn string(text: &str) -> id {
|
||||
unsafe { NSString::alloc(nil).init_str(text).autorelease() }
|
||||
}
|
||||
|
||||
impl Picker {
|
||||
pub fn show(
|
||||
items: &Items,
|
||||
bounds: Bounds<Pixels>,
|
||||
window: &Window,
|
||||
) -> anyhow::Result<Self> {
|
||||
let handle = HasWindowHandle::window_handle(window)
|
||||
.map_err(|error| anyhow::anyhow!("window has no native handle: {error:?}"))?;
|
||||
let RawWindowHandle::AppKit(appkit) = handle.as_raw() else {
|
||||
bail!("share needs an AppKit window");
|
||||
};
|
||||
let height = f64::from(window.viewport_size().height);
|
||||
unsafe {
|
||||
let view = appkit.ns_view.as_ptr() as id;
|
||||
let mut list: Vec<id> = Vec::new();
|
||||
if let Some(text) = &items.text {
|
||||
list.push(string(text));
|
||||
}
|
||||
if let Some(url) = &items.url {
|
||||
let link: id = msg_send![class!(NSURL), URLWithString: string(url)];
|
||||
anyhow::ensure!(link != nil, "share url {url} does not parse");
|
||||
list.push(link);
|
||||
}
|
||||
let array = NSArray::arrayWithObjects(nil, &list);
|
||||
let picker: id = msg_send![class!(NSSharingServicePicker), alloc];
|
||||
let picker: id = msg_send![picker, initWithItems: array];
|
||||
let picker = (picker != nil)
|
||||
.then_some(picker)
|
||||
.context("AppKit refused a share picker")?;
|
||||
let rect = NSRect::new(
|
||||
NSPoint::new(
|
||||
f64::from(bounds.left()),
|
||||
height - f64::from(bounds.bottom()),
|
||||
),
|
||||
NSSize::new(f64::from(bounds.size.width), f64::from(bounds.size.height)),
|
||||
);
|
||||
let _: () =
|
||||
msg_send![picker, showRelativeToRect: rect ofView: view preferredEdge: 1u64];
|
||||
log::info!("share button: picker shown for {} items", list.len());
|
||||
Ok(Self(picker))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, ElementId, FontWeight, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
motion,
|
||||
primitives::{Backdrop, FocusScope, Place, give_back, take_focus},
|
||||
theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize},
|
||||
};
|
||||
|
||||
type Run = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// A short list of choices rising from the bottom, with its own cancel.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ActionSheet {
|
||||
id: ElementId,
|
||||
title: Option<SharedString>,
|
||||
message: Option<SharedString>,
|
||||
actions: Vec<(SharedString, bool, Run)>,
|
||||
on_close: Run,
|
||||
}
|
||||
|
||||
impl ActionSheet {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
on_close: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
title: None,
|
||||
message: None,
|
||||
actions: Vec::new(),
|
||||
on_close: Rc::new(on_close),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn message(mut self, message: impl Into<SharedString>) -> Self {
|
||||
self.message = Some(message.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Runs `handler`, then closes.
|
||||
pub fn action(
|
||||
mut self,
|
||||
label: impl Into<SharedString>,
|
||||
handler: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.actions.push((label.into(), false, Rc::new(handler)));
|
||||
self
|
||||
}
|
||||
|
||||
/// An action that destroys something, set in the danger tone.
|
||||
pub fn destructive(
|
||||
mut self,
|
||||
label: impl Into<SharedString>,
|
||||
handler: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.actions.push((label.into(), true, Rc::new(handler)));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ActionSheet {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let takeover = take_focus(self.id.clone(), window, cx);
|
||||
let close: Run = {
|
||||
let (takeover, on_close) = (takeover.clone(), self.on_close);
|
||||
Rc::new(move |window, cx| {
|
||||
give_back(&takeover, window, cx);
|
||||
on_close(window, cx)
|
||||
})
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let card = || {
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.overflow_hidden()
|
||||
.rounded(theme.radius(Radius::Xl))
|
||||
.bg(colors.overlay)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.shadow(theme.elevation(Elevation::Modal))
|
||||
};
|
||||
let row = |id: ElementId, label: SharedString, fg, strong: bool| {
|
||||
div()
|
||||
.id(id)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h(theme.control_height(ControlSize::Lg)
|
||||
+ theme.control_height(ControlSize::Sm) / 2.0)
|
||||
.border_t_1()
|
||||
.border_color(colors.border)
|
||||
.text_size(theme.text_size(TextSize::Md))
|
||||
.font_weight(if strong {
|
||||
FontWeight::SEMIBOLD
|
||||
} else {
|
||||
FontWeight::NORMAL
|
||||
})
|
||||
.text_color(fg)
|
||||
.cursor_pointer()
|
||||
.tab_index(0)
|
||||
.focus(|style| style.bg(colors.active))
|
||||
.hover(|style| style.bg(colors.hover))
|
||||
.child(label)
|
||||
};
|
||||
let heading = (self.title.is_some() || self.message.is_some()).then(|| {
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.px_6()
|
||||
.py_4()
|
||||
.text_center()
|
||||
.when_some(self.title, |head, title| {
|
||||
head.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(title),
|
||||
)
|
||||
})
|
||||
.when_some(self.message, |head, message| {
|
||||
head.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(message),
|
||||
)
|
||||
})
|
||||
});
|
||||
let rows: Vec<_> = self
|
||||
.actions
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(ix, (label, danger, run))| {
|
||||
let close = close.clone();
|
||||
let fg = if danger { colors.danger } else { colors.fg };
|
||||
row(("sheet-action", ix).into(), label.clone(), fg, false).on_click(
|
||||
move |_, window, cx| {
|
||||
log::info!("action sheet: {label}");
|
||||
run(window, cx);
|
||||
close(window, cx);
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let (cancel, escape, dismiss) = (close.clone(), close.clone(), close);
|
||||
let focus = takeover.read(cx).focus.clone();
|
||||
let panel = div()
|
||||
.id("action-sheet")
|
||||
.w(theme.sheet_size())
|
||||
.pb_4()
|
||||
.on_key_down(move |event, window, cx| {
|
||||
if event.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
escape(window, cx);
|
||||
}
|
||||
})
|
||||
.child(
|
||||
FocusScope::new(&focus)
|
||||
.trap()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(card().map(|card| match heading {
|
||||
Some(heading) => card.child(heading).children(rows),
|
||||
None => card.children(rows),
|
||||
}))
|
||||
.child(
|
||||
card().child(
|
||||
row("sheet-cancel".into(), "Cancel".into(), colors.fg, true)
|
||||
.border_t_0()
|
||||
.on_click(move |_, window, cx| cancel(window, cx)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.with_animation(
|
||||
"action-sheet-in",
|
||||
Animation::new(motion::duration(motion::BASE, cx))
|
||||
.with_easing(motion::ease_out_cubic),
|
||||
|panel, t| panel.opacity(t).mb(-(motion::NUDGE * 6.0 * (1.0 - t))),
|
||||
);
|
||||
Backdrop::new(self.id)
|
||||
.place(Place::Bottom)
|
||||
.on_dismiss(move |window, cx| dismiss(window, cx))
|
||||
.child(div().w_full().flex().justify_center().child(panel))
|
||||
}
|
||||
}
|
||||
+42
-2
@@ -1,6 +1,6 @@
|
||||
use gpui::{
|
||||
AnyElement, App, Div, FocusHandle, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
StyleRefinement, Styled, Window, actions, div,
|
||||
AnyElement, App, Div, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement,
|
||||
RenderOnce, StyleRefinement, Styled, Window, actions, div,
|
||||
};
|
||||
|
||||
use crate::theme::ActiveTheme;
|
||||
@@ -94,3 +94,43 @@ fn step(scope: &FocusHandle, trap: bool, forward: bool, window: &mut Window, cx:
|
||||
window.focus(&origin);
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus an overlay holds while open, and what it held before.
|
||||
pub(crate) struct Takeover {
|
||||
pub focus: FocusHandle,
|
||||
previous: Option<FocusHandle>,
|
||||
taken: bool,
|
||||
}
|
||||
|
||||
/// Keyed focus for an overlay; focused on its first render.
|
||||
pub(crate) fn take_focus(
|
||||
key: impl Into<gpui::ElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Takeover> {
|
||||
let state = window.use_keyed_state(key, cx, |_, cx| Takeover {
|
||||
focus: cx.focus_handle(),
|
||||
previous: None,
|
||||
taken: false,
|
||||
});
|
||||
if !state.read(cx).taken {
|
||||
let previous = window.focused(cx);
|
||||
window.focus(&state.read(cx).focus.clone());
|
||||
state.update(cx, |takeover, _| {
|
||||
takeover.previous = previous;
|
||||
takeover.taken = true;
|
||||
});
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
/// Returns focus to whatever held it before the overlay opened.
|
||||
pub(crate) fn give_back(state: &Entity<Takeover>, window: &mut Window, cx: &App) {
|
||||
match state.read(cx).previous.clone() {
|
||||
Some(previous) => {
|
||||
window.focus(&previous);
|
||||
log::info!("focus: handed back after an overlay");
|
||||
}
|
||||
None => log::info!("focus: overlay closed, nothing was focused before"),
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod tooltip;
|
||||
pub use backdrop::{Backdrop, Place};
|
||||
pub use divider::Divider;
|
||||
pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope};
|
||||
pub(crate) use focus::{give_back, take_focus};
|
||||
pub use ghost::DragGhost;
|
||||
pub use icon::{Icon, IconName};
|
||||
pub use image::Image;
|
||||
|
||||
+10
-30
@@ -1,24 +1,22 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyWindowHandle, App, ElementId, Entity, FocusHandle,
|
||||
InteractiveElement, IntoElement, ParentElement, RenderOnce, StatefulInteractiveElement, Styled,
|
||||
Window, div, prelude::*,
|
||||
Animation, AnimationExt, AnyWindowHandle, App, ElementId, Entity, InteractiveElement,
|
||||
IntoElement, ParentElement, RenderOnce, StatefulInteractiveElement, Styled, Window, div,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use super::WindowManager;
|
||||
use crate::{
|
||||
motion,
|
||||
primitives::{Backdrop, FocusNext, FocusPrev, Icon, IconName, Place},
|
||||
primitives::{Backdrop, FocusNext, FocusPrev, Icon, IconName, Place, give_back, take_focus},
|
||||
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
|
||||
typography::Caption,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Switch {
|
||||
selected: usize,
|
||||
focus: FocusHandle,
|
||||
opened: bool,
|
||||
previous: Option<FocusHandle>,
|
||||
}
|
||||
|
||||
type Close = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
@@ -56,34 +54,16 @@ impl RenderOnce for WindowSwitcher {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let windows = WindowManager::windows(cx);
|
||||
let count = windows.len();
|
||||
let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| Switch {
|
||||
selected: 0,
|
||||
focus: cx.focus_handle(),
|
||||
opened: false,
|
||||
previous: None,
|
||||
});
|
||||
if !state.read(cx).opened {
|
||||
let previous = window.focused(cx);
|
||||
window.focus(&state.read(cx).focus.clone());
|
||||
state.update(cx, |switch, _| {
|
||||
switch.opened = true;
|
||||
switch.previous = previous;
|
||||
});
|
||||
}
|
||||
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Switch::default());
|
||||
let takeover = take_focus(self.id.clone(), window, cx);
|
||||
if count > 0 && state.read(cx).selected >= count {
|
||||
state.update(cx, |switch, _| switch.selected = count - 1);
|
||||
}
|
||||
let selected = state.read(cx).selected;
|
||||
let close: Close = {
|
||||
let (state, on_close) = (state.clone(), self.on_close);
|
||||
let (takeover, on_close) = (takeover.clone(), self.on_close);
|
||||
Rc::new(move |window, cx| {
|
||||
match state.read(cx).previous.clone() {
|
||||
Some(previous) => {
|
||||
window.focus(&previous);
|
||||
log::info!("window switcher: closed, focus handed back");
|
||||
}
|
||||
None => log::info!("window switcher: closed, nothing was focused before"),
|
||||
}
|
||||
give_back(&takeover, window, cx);
|
||||
on_close(window, cx)
|
||||
})
|
||||
};
|
||||
@@ -117,7 +97,7 @@ impl RenderOnce for WindowSwitcher {
|
||||
let handles: Vec<AnyWindowHandle> = windows.iter().map(|(handle, _)| *handle).collect();
|
||||
let (next, prev, keys) = (state.clone(), state.clone(), state.clone());
|
||||
let (close_keys, close_scrim) = (close.clone(), close);
|
||||
let focus = state.read(cx).focus.clone();
|
||||
let focus = takeover.read(cx).focus.clone();
|
||||
let panel = div()
|
||||
.id("window-switcher")
|
||||
.track_focus(&focus)
|
||||
|
||||
@@ -153,6 +153,11 @@ impl Theme {
|
||||
point(px(14.0), px(13.0))
|
||||
}
|
||||
|
||||
/// Diameter of a floating action button.
|
||||
pub fn fab_size(&self) -> Rems {
|
||||
px_to_rems(56.0)
|
||||
}
|
||||
|
||||
pub fn progress_thickness(&self) -> Rems {
|
||||
px_to_rems(2.0)
|
||||
}
|
||||
|
||||
+7
-73
@@ -1,25 +1,9 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, ClipboardItem, ElementId, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Task, Window, div,
|
||||
prelude::*,
|
||||
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::IconButton,
|
||||
motion,
|
||||
primitives::{Icon, IconName, Tooltip},
|
||||
theme::{ActiveTheme, ControlSize, IconSize},
|
||||
};
|
||||
|
||||
const CONFIRM: Duration = Duration::from_millis(1400);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Copied {
|
||||
count: u32,
|
||||
_reset: Option<Task<()>>,
|
||||
}
|
||||
use crate::{buttons::CopyButton, theme::ActiveTheme};
|
||||
|
||||
/// Text with a copy button that confirms with a check.
|
||||
#[derive(IntoElement)]
|
||||
@@ -45,65 +29,15 @@ impl CopyableText {
|
||||
}
|
||||
|
||||
impl RenderOnce for CopyableText {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Copied::default());
|
||||
let count = state.read(cx).count;
|
||||
let copied = state.read(cx)._reset.is_some();
|
||||
let theme = cx.theme();
|
||||
let (success, mono) = (theme.colors.success, theme.mono_family.clone());
|
||||
let text = self.text.clone();
|
||||
let button = IconButton::new("copy", IconName::Copy)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, window, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text.to_string()));
|
||||
let weak = state.downgrade();
|
||||
let reset = window.spawn(cx, async move |cx| {
|
||||
cx.background_executor().timer(CONFIRM).await;
|
||||
if let Err(error) = weak.update(cx, |copied, cx| {
|
||||
copied._reset = None;
|
||||
cx.notify();
|
||||
}) {
|
||||
log::error!("copyable text: reset lost its state: {error:#}");
|
||||
}
|
||||
});
|
||||
state.update(cx, |copied, cx| {
|
||||
copied.count += 1;
|
||||
copied._reset = Some(reset);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
let check = Icon::new(IconName::Check)
|
||||
.size(IconSize::Sm)
|
||||
.color(success)
|
||||
.with_animation(
|
||||
("copied", count),
|
||||
Animation::new(motion::duration(motion::FAST, cx))
|
||||
.with_easing(motion::ease_out_cubic),
|
||||
|icon, t| icon.rotate(gpui::radians((1.0 - t) * -0.6)),
|
||||
);
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let mono = cx.theme().mono_family.clone();
|
||||
div()
|
||||
.id(self.id)
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.when(self.mono, |row| row.font_family(mono))
|
||||
.child(self.text)
|
||||
.child(
|
||||
div()
|
||||
.id("copy-slot")
|
||||
.flex_none()
|
||||
.size(theme.control_height(ControlSize::Sm))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.tooltip(Tooltip::text(if copied { "Copied" } else { "Copy" }))
|
||||
.map(|slot| {
|
||||
if copied {
|
||||
slot.child(check)
|
||||
} else {
|
||||
slot.child(button)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(self.text.clone())
|
||||
.child(CopyButton::new("copy-slot", self.text))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user