tooltip: Add KeyBinding to tooltip and Button. (#783)

- Improve `Kbd::format` method.
- Add `tooltip_with_action` method to Button for display key binding in
tooltip.
- 
## Break change

- The `Tooltip::new` has been changed it argument and return type.
    
    ```diff
- pub fn new(text: impl Into<Text>, _: &mut Window, cx: &mut App) ->
AnyView
    + pub fn new(text: impl Into<Text>) -> Self
    ```
This commit is contained in:
Jason Lee 2025-04-14 16:40:40 +08:00 committed by GitHub
parent 95fb86ec08
commit d0a73011de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 362 additions and 107 deletions

View file

@ -0,0 +1,40 @@
use gpui::*;
use story::{Assets, TooltipStory};
pub struct Example {
root: Entity<TooltipStory>,
}
impl Example {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let root = TooltipStory::view(window, cx);
Self { root }
}
fn view(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::new(window, cx))
}
}
impl Render for Example {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.p_4()
.id("example")
.overflow_y_scroll()
.size_full()
.child(self.root.clone())
}
}
fn main() {
let app = Application::new().with_assets(Assets);
app.run(move |cx| {
story::init(cx);
cx.activate(true);
story::create_new_window("Tooltip Example", Example::view, cx);
});
}

View file

@ -36,8 +36,9 @@ pub use toggle_story::ToggleStory;
use gpui::{ use gpui::{
actions, div, impl_internal_actions, prelude::FluentBuilder as _, px, size, AnyElement, actions, div, impl_internal_actions, prelude::FluentBuilder as _, px, size, AnyElement,
AnyView, App, AppContext, Bounds, Context, Div, Entity, EventEmitter, Focusable, Global, Hsla, AnyView, App, AppContext, Bounds, Context, Div, Entity, EventEmitter, Focusable, Global, Hsla,
InteractiveElement, IntoElement, KeyBinding, ParentElement, Render, SharedString, InteractiveElement, IntoElement, KeyBinding, Menu, MenuItem, ParentElement, Render,
StatefulInteractiveElement, Styled as _, Window, WindowBounds, WindowKind, WindowOptions, SharedString, StatefulInteractiveElement, Styled as _, Window, WindowBounds, WindowKind,
WindowOptions,
}; };
pub use icon_story::IconStory; pub use icon_story::IconStory;
pub use image_story::ImageStory; pub use image_story::ImageStory;
@ -210,13 +211,21 @@ pub fn init(cx: &mut App) {
dropdown_story::init(cx); dropdown_story::init(cx);
popup_story::init(cx); popup_story::init(cx);
webview_story::init(cx); webview_story::init(cx);
tooltip_story::init(cx);
let http_client = std::sync::Arc::new( let http_client = std::sync::Arc::new(
reqwest_client::ReqwestClient::user_agent("gpui-component/story").unwrap(), reqwest_client::ReqwestClient::user_agent("gpui-component/story").unwrap(),
); );
cx.set_http_client(http_client); cx.set_http_client(http_client);
cx.bind_keys([KeyBinding::new("/", ToggleSearch, None)]); cx.bind_keys([
KeyBinding::new("/", ToggleSearch, None),
KeyBinding::new("cmd-q", Quit, None),
]);
cx.on_action(|_: &Quit, cx: &mut App| {
cx.quit();
});
register_panel(cx, PANEL_NAME, |_, _, info, window, cx| { register_panel(cx, PANEL_NAME, |_, _, info, window, cx| {
let story_state = match info { let story_state = match info {
@ -250,6 +259,30 @@ pub fn init(cx: &mut App) {
}); });
Box::new(view) Box::new(view)
}); });
use gpui_component::input::{Copy, Cut, Paste, Redo, Undo};
cx.set_menus(vec![
Menu {
name: "GPUI App".into(),
items: vec![MenuItem::action("Quit", Quit)],
},
Menu {
name: "Edit".into(),
items: vec![
MenuItem::os_action("Undo", Undo, gpui::OsAction::Undo),
MenuItem::os_action("Redo", Redo, gpui::OsAction::Redo),
MenuItem::separator(),
MenuItem::os_action("Cut", Cut, gpui::OsAction::Cut),
MenuItem::os_action("Copy", Copy, gpui::OsAction::Copy),
MenuItem::os_action("Paste", Paste, gpui::OsAction::Paste),
],
},
Menu {
name: "Window".into(),
items: vec![],
},
]);
cx.activate(true);
} }
actions!(story, [ShowPanelInfo]); actions!(story, [ShowPanelInfo]);

View file

@ -14,8 +14,8 @@ use std::{sync::Arc, time::Duration};
use story::{ use story::{
AccordionStory, AppState, AppTitleBar, Assets, ButtonStory, CalendarStory, DropdownStory, AccordionStory, AppState, AppTitleBar, Assets, ButtonStory, CalendarStory, DropdownStory,
FormStory, IconStory, ImageStory, InputStory, ListStory, ModalStory, Open, PopupStory, FormStory, IconStory, ImageStory, InputStory, ListStory, ModalStory, Open, PopupStory,
ProgressStory, Quit, ResizableStory, ScrollableStory, SidebarStory, StoryContainer, ProgressStory, ResizableStory, ScrollableStory, SidebarStory, StoryContainer, SwitchStory,
SwitchStory, TableStory, TextStory, TooltipStory, WebViewStory, TableStory, TextStory, TooltipStory, WebViewStory,
}; };
#[derive(Clone, PartialEq, Eq, Deserialize)] #[derive(Clone, PartialEq, Eq, Deserialize)]
@ -38,12 +38,12 @@ const STATE_FILE: &str = "target/layout.json";
const STATE_FILE: &str = "layout.json"; const STATE_FILE: &str = "layout.json";
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
cx.on_action(|_action: &Open, _cx: &mut App| {}); cx.on_action(|_action: &Open, _cx: &mut App| {});
gpui_component::init(cx); gpui_component::init(cx);
story::init(cx); story::init(cx);
cx.activate(true);
} }
pub struct StoryWorkspace { pub struct StoryWorkspace {
@ -534,44 +534,14 @@ impl Render for StoryWorkspace {
} }
fn main() { fn main() {
use gpui_component::input::{Copy, Cut, Paste, Redo, Undo};
let app = Application::new().with_assets(Assets); let app = Application::new().with_assets(Assets);
app.run(move |cx| { app.run(move |cx| {
init(cx); init(cx);
cx.on_action(quit);
cx.set_menus(vec![
Menu {
name: "GPUI App".into(),
items: vec![MenuItem::action("Quit", Quit)],
},
Menu {
name: "Edit".into(),
items: vec![
MenuItem::os_action("Undo", Undo, gpui::OsAction::Undo),
MenuItem::os_action("Redo", Redo, gpui::OsAction::Redo),
MenuItem::separator(),
MenuItem::os_action("Cut", Cut, gpui::OsAction::Cut),
MenuItem::os_action("Copy", Copy, gpui::OsAction::Copy),
MenuItem::os_action("Paste", Paste, gpui::OsAction::Paste),
],
},
Menu {
name: "Window".into(),
items: vec![],
},
]);
cx.activate(true);
open_new(cx, |_, _, _| { open_new(cx, |_, _, _| {
// do something // do something
}) })
.detach(); .detach();
}); });
} }
fn quit(_: &Quit, cx: &mut App) {
cx.quit();
}

View file

@ -1,6 +1,6 @@
use gpui::{ use gpui::{
div, App, AppContext, Context, Entity, Focusable, InteractiveElement, ParentElement, Render, actions, div, App, AppContext, Context, Entity, Focusable, InteractiveElement, KeyBinding,
StatefulInteractiveElement, Styled, Window, ParentElement, Render, StatefulInteractiveElement, Styled, Window,
}; };
use gpui_component::{ use gpui_component::{
@ -13,6 +13,12 @@ use gpui_component::{
v_flex, ActiveTheme, IconName, v_flex, ActiveTheme, IconName,
}; };
actions!(tooltip, [Info]);
pub fn init(cx: &mut App) {
cx.bind_keys([KeyBinding::new("ctrl-shift-delete", Info, Some("Tooltip"))]);
}
pub struct TooltipStory { pub struct TooltipStory {
focus_handle: gpui::FocusHandle, focus_handle: gpui::FocusHandle,
} }
@ -50,45 +56,51 @@ impl Focusable for TooltipStory {
impl Render for TooltipStory { impl Render for TooltipStory {
fn render( fn render(
&mut self, &mut self,
_window: &mut gpui::Window, window: &mut gpui::Window,
_cx: &mut gpui::Context<Self>, _cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement { ) -> impl gpui::IntoElement {
v_flex() v_flex()
.p_4() .p_4()
.gap_5() .gap_5()
.child( .child(
div() h_flex()
.gap_3()
.child( .child(
Button::new("button") Button::new("btn0")
.label("Hover me") .label("Search")
.with_variant(ButtonVariant::Primary), .with_variant(ButtonVariant::Primary)
.tooltip("This is a search Button."),
) )
.id("tooltip-1") .child(Button::new("btn1").label("Info").tooltip_with_action(
.tooltip(|window, cx| Tooltip::new("This is a Button", window, cx)), "This is a tooltip with Action for display keybinding.",
&Info,
Some("Tooltip"),
window,
)),
) )
.child( .child(
h_flex() h_flex()
.justify_center() .justify_center()
.child(Label::new("Hover me")) .child(Label::new("Hover me"))
.id("tooltip-2") .id("tooltip-2")
.tooltip(|window, cx| Tooltip::new("This is a Label", window, cx)), .tooltip(|window, cx| Tooltip::new("This is a Label").build(window, cx)),
) )
.child( .child(
div() div()
.child(Checkbox::new("check").label("Remember me").checked(true)) .child(Checkbox::new("check").label("Remember me").checked(true))
.id("tooltip-3") .id("tooltip-3")
.tooltip(|window, cx| Tooltip::new("Checked!", window, cx)), .tooltip(|window, cx| Tooltip::new("Checked!").build(window, cx)),
) )
.child( .child(
div() div()
.child( .child(
Button::new("button") Button::new("btn3")
.label("Hover me") .label("Hover me")
.with_variant(ButtonVariant::Primary), .with_variant(ButtonVariant::Primary),
) )
.id("tooltip-4") .id("tooltip-4")
.tooltip(|window, cx| { .tooltip(|window, cx| {
Tooltip::new_element(window, cx, |_, cx| { Tooltip::element(|_, cx| {
h_flex() h_flex()
.gap_x_1() .gap_x_1()
.child(IconName::Info) .child(IconName::Info)
@ -100,6 +112,7 @@ impl Render for TooltipStory {
.child(div().child("Danger").text_color(cx.theme().danger)) .child(div().child("Danger").text_color(cx.theme().danger))
.child(IconName::ArrowUp) .child(IconName::ArrowUp)
}) })
.build(window, cx)
}), }),
) )
} }

View file

@ -1,10 +1,10 @@
use crate::{ use crate::{
h_flex, indicator::Indicator, tooltip::Tooltip, ActiveTheme, Colorize as _, Disableable, Icon, h_flex, indicator::Indicator, tooltip::Tooltip, ActiveTheme, Colorize as _, Disableable, Icon,
Selectable, Sizable, Size, StyleSized, Kbd, Selectable, Sizable, Size, StyleSized,
}; };
use gpui::{ use gpui::{
div, prelude::FluentBuilder as _, relative, AnyElement, App, ClickEvent, Corners, Div, Edges, div, prelude::FluentBuilder as _, relative, Action, AnyElement, App, ClickEvent, Corners, Div,
ElementId, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Edges, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels,
RenderOnce, SharedString, StatefulInteractiveElement as _, Styled, Window, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled, Window,
}; };
@ -184,6 +184,7 @@ pub struct Button {
size: Size, size: Size,
compact: bool, compact: bool,
tooltip: Option<SharedString>, tooltip: Option<SharedString>,
key_binding: Option<Kbd>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>, on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
pub(crate) stop_propagation: bool, pub(crate) stop_propagation: bool,
loading: bool, loading: bool,
@ -211,6 +212,7 @@ impl Button {
border_edges: Edges::all(true), border_edges: Edges::all(true),
size: Size::Medium, size: Size::Medium,
tooltip: None, tooltip: None,
key_binding: None,
on_click: None, on_click: None,
stop_propagation: true, stop_propagation: true,
loading: false, loading: false,
@ -263,6 +265,18 @@ impl Button {
self self
} }
pub fn tooltip_with_action(
mut self,
tooltip: impl Into<SharedString>,
action: &dyn Action,
context: Option<&str>,
window: &Window,
) -> Self {
self.tooltip = Some(tooltip.into());
self.key_binding = Kbd::binding_for_action(action, context, window);
self
}
/// Set true to show the loading indicator. /// Set true to show the loading indicator.
pub fn loading(mut self, loading: bool) -> Self { pub fn loading(mut self, loading: bool) -> Self {
self.loading = loading; self.loading = loading;
@ -483,8 +497,12 @@ impl RenderOnce for Button {
.children(self.children) .children(self.children)
}) })
.when(self.loading, |this| this.bg(normal_style.bg.opacity(0.8))) .when(self.loading, |this| this.bg(normal_style.bg.opacity(0.8)))
.when_some(self.tooltip.clone(), |this, tooltip| { .when_some(self.tooltip, |this, tooltip| {
this.tooltip(move |window, cx| Tooltip::new(tooltip.clone(), window, cx)) this.tooltip(move |window, cx| {
Tooltip::new(tooltip.clone())
.key_binding(self.key_binding.clone())
.build(window, cx)
})
}) })
} }
} }

View file

@ -353,8 +353,8 @@ impl Render for ColorPicker {
.when(self.open, |this| this.border_2()) .when(self.open, |this| this.border_2())
}) })
.when(!display_title.is_empty(), |this| { .when(!display_title.is_empty(), |this| {
this.tooltip(move |window, cx| { this.tooltip(move |_, cx| {
Tooltip::new(display_title.clone(), window, cx) cx.new(|_| Tooltip::new(display_title.clone())).into()
}) })
}), }),
) )

View file

@ -1,58 +1,185 @@
use gpui::{div, relative, IntoElement, Keystroke, ParentElement as _, RenderOnce, Styled as _}; use gpui::{
div, relative, Action, IntoElement, KeyContext, Keystroke, ParentElement as _, RenderOnce,
Styled as _, Window,
};
use crate::ActiveTheme; use crate::ActiveTheme;
/// A key binding tag /// A key binding tag
#[derive(IntoElement)] #[derive(IntoElement, Clone, Debug)]
pub struct Kbd { pub struct Kbd {
stroke: gpui::Keystroke, stroke: gpui::Keystroke,
appearance: bool,
} }
impl From<Keystroke> for Kbd { impl From<Keystroke> for Kbd {
fn from(stroke: Keystroke) -> Self { fn from(stroke: Keystroke) -> Self {
Self { stroke } Self {
stroke,
appearance: true,
}
} }
} }
impl Kbd { impl Kbd {
pub fn new(stroke: Keystroke) -> Self { pub fn new(stroke: Keystroke) -> Self {
Self { stroke } Self {
stroke,
appearance: true,
}
}
/// Set the appearance of the keybinding.
pub fn appearance(mut self, appearance: bool) -> Self {
self.appearance = appearance;
self
}
/// Return the first keybinding for the given action and context.
pub 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 bindings = match key_context {
Some(context) => window.bindings_for_action_in_context(action, context),
None => window.bindings_for_action(action),
};
bindings.first().and_then(|binding| {
if let Some(key) = binding.keystrokes().first() {
Some(Self::new(key.clone()))
} else {
None
}
})
} }
/// Return the Platform specific keybinding string by KeyStroke /// Return the Platform specific keybinding string by KeyStroke
///
/// macOS: https://support.apple.com/en-us/HT201236
/// Windows: https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec
pub fn format(key: &Keystroke) -> String { pub fn format(key: &Keystroke) -> String {
if cfg!(target_os = "macos") { #[cfg(target_os = "macos")]
return format!("{}", key); const DIVIDER: &str = "";
} #[cfg(not(target_os = "macos"))]
const DIVIDER: &str = "+";
let mut parts = vec![]; let mut parts = vec![];
if key.modifiers.control {
parts.push("Ctrl");
}
if key.modifiers.alt {
parts.push("Alt");
}
if key.modifiers.platform { if key.modifiers.platform {
#[cfg(target_os = "macos")]
parts.push("");
#[cfg(not(target_os = "macos"))]
parts.push("Win"); parts.push("Win");
} }
if key.modifiers.shift { if key.modifiers.shift {
#[cfg(target_os = "macos")]
parts.push("");
#[cfg(not(target_os = "macos"))]
parts.push("Shift"); parts.push("Shift");
} }
if key.modifiers.control {
#[cfg(target_os = "macos")]
parts.push("");
// Capitalize the first letter #[cfg(not(target_os = "macos"))]
let key = if let Some(first_c) = key.key.chars().next() { parts.push("Ctrl");
format!("{}{}", first_c.to_uppercase(), &key.key[1..]) }
} else { if key.modifiers.alt {
key.key.to_string() #[cfg(target_os = "macos")]
}; parts.push("");
parts.push(&key); #[cfg(not(target_os = "macos"))]
parts.join("+") parts.push("Alt");
}
let mut keys = String::new();
for key in key.key.split("-") {
if parts.len() > 0 || keys.len() > 0 {
keys.push_str(DIVIDER);
}
match key {
#[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.len() == 1 {
keys.push_str(&key.to_uppercase());
} else {
if let Some(first_char) = key.chars().next() {
keys.push_str(&format!("{}{}", first_char.to_uppercase(), &key[1..]));
} else {
keys.push_str(&key);
}
}
}
}
}
parts.push(&keys);
parts.join(DIVIDER)
} }
} }
impl RenderOnce for Kbd { impl RenderOnce for Kbd {
fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement { fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
if !self.appearance {
return Self::format(&self.stroke).into_any_element();
}
div() div()
.border_1() .border_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
@ -66,6 +193,7 @@ impl RenderOnce for Kbd {
.line_height(relative(1.)) .line_height(relative(1.))
.text_xs() .text_xs()
.child(Self::format(&self.stroke)) .child(Self::format(&self.stroke))
.into_any_element()
} }
} }
@ -97,14 +225,39 @@ mod tests {
); );
} else { } else {
assert_eq!(Kbd::format(&Keystroke::parse("cmd-a").unwrap()), "⌘A"); assert_eq!(Kbd::format(&Keystroke::parse("cmd-a").unwrap()), "⌘A");
assert_eq!(Kbd::format(&Keystroke::parse("cmd-ctrl-a").unwrap()), "^⌘A"); 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!( assert_eq!(
Kbd::format(&Keystroke::parse("cmd-ctrl-shift-a").unwrap()), Kbd::format(&Keystroke::parse("cmd-ctrl-shift-a").unwrap()),
"^⌘⇧A" "⌘⇧A"
); );
assert_eq!( assert_eq!(
Kbd::format(&Keystroke::parse("cmd-ctrl-shift-alt-a").unwrap()), Kbd::format(&Keystroke::parse("cmd-ctrl-shift-alt-a").unwrap()),
"^⌥⌘⇧A" "⌘⇧⌃⌥A"
); );
} }
} }

View file

@ -623,15 +623,15 @@ impl PopupMenu {
}); });
} }
fn render_keybinding( fn render_key_binding(
action: Option<Box<dyn Action>>, action: Option<Box<dyn Action>>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Option<impl IntoElement> { ) -> Option<impl IntoElement> {
if let Some(action) = action { if let Some(action) = action {
if let Some(keybinding) = window.bindings_for_action(action.deref()).first() { if let Some(key_binding) = window.bindings_for_action(action.deref()).first() {
let el = div().text_color(cx.theme().muted_foreground).children( let el = div().text_color(cx.theme().muted_foreground).children(
keybinding key_binding
.keystrokes() .keystrokes()
.into_iter() .into_iter()
.map(|key| Kbd::format(key)), .map(|key| Kbd::format(key)),
@ -746,7 +746,7 @@ impl PopupMenu {
} => { } => {
let show_link_icon = *is_link && self.external_link_icon; let show_link_icon = *is_link && self.external_link_icon;
let action = action.as_ref().map(|action| action.boxed_clone()); let action = action.as_ref().map(|action| action.boxed_clone());
let key = Self::render_keybinding(action, window, cx); let key = Self::render_key_binding(action, window, cx);
this.when(!disabled, |this| { this.when(!disabled, |this| {
this.on_click( this.on_click(

View file

@ -203,7 +203,7 @@ impl Slider {
.border_color(cx.theme().slider_bar.opacity(0.9)) .border_color(cx.theme().slider_bar.opacity(0.9))
.when(cx.theme().shadow, |this| this.shadow_md()) .when(cx.theme().shadow, |this| this.shadow_md())
.bg(cx.theme().slider_thumb) .bg(cx.theme().slider_thumb)
.tooltip(move |window, cx| Tooltip::new(format!("{}", value), window, cx)) .tooltip(move |window, cx| Tooltip::new(format!("{}", value)).build(window, cx))
} }
fn on_mouse_down( fn on_mouse_down(

View file

@ -3,42 +3,60 @@ use gpui::{
ParentElement, Render, Styled, Window, ParentElement, Render, Styled, Window,
}; };
use crate::{text::Text, ActiveTheme}; use crate::{h_flex, text::Text, ActiveTheme, Kbd};
enum TooltipContext {
Text(Text),
Element(Box<dyn Fn(&mut Window, &mut App) -> AnyElement>),
}
pub struct Tooltip { pub struct Tooltip {
text: Text, content: TooltipContext,
element_builder: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement>>, key_binding: Option<Kbd>,
} }
impl Tooltip { impl Tooltip {
pub fn new(text: impl Into<Text>, _: &mut Window, cx: &mut App) -> AnyView { /// Create a Tooltip with a text content.
cx.new(|_| Self { pub fn new(text: impl Into<Text>) -> Self {
text: text.into(), Self {
element_builder: None, content: TooltipContext::Text(text.into()),
}) key_binding: None,
.into() }
} }
pub fn new_element<E, F>(_: &mut Window, cx: &mut App, builder: F) -> AnyView /// Create a Tooltip with a custom element.
pub fn element<E, F>(builder: F) -> Self
where where
E: IntoElement, E: IntoElement,
F: Fn(&mut Window, &mut App) -> E + 'static, F: Fn(&mut Window, &mut App) -> E + 'static,
{ {
cx.new(|_| Self { Self {
text: "".into(), key_binding: None,
element_builder: Some(Box::new(move |window, cx| { content: TooltipContext::Element(Box::new(move |window, cx| {
builder(window, cx).into_any_element() builder(window, cx).into_any_element()
})), })),
}) }
.into() }
/// Set KeyBinding information for the tooltip.
pub(crate) fn key_binding(mut self, kbd: Option<impl Into<Kbd>>) -> Self {
self.key_binding = kbd.map(Into::into);
self
}
/// Build the tooltip and return it as an `AnyView`.
pub fn build(self, _: &mut Window, cx: &mut App) -> AnyView {
cx.new(|_| self).into()
} }
} }
impl FluentBuilder for Tooltip {}
impl Render for Tooltip { impl Render for Tooltip {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().child( div().child(
// Wrap in a child, to ensure the left margin is applied to the tooltip // Wrap in a child, to ensure the left margin is applied to the tooltip
div() h_flex()
.font_family(".SystemUIFont") .font_family(".SystemUIFont")
.m_3() .m_3()
.bg(cx.theme().popover) .bg(cx.theme().popover)
@ -48,15 +66,25 @@ impl Render for Tooltip {
.border_color(cx.theme().border) .border_color(cx.theme().border)
.shadow_md() .shadow_md()
.rounded(px(6.)) .rounded(px(6.))
.justify_between()
.py_0p5() .py_0p5()
.px_2() .px_2()
.text_sm() .text_sm()
.gap_3()
.map(|this| { .map(|this| {
if let Some(builder) = &self.element_builder { this.child(div().map(|this| match self.content {
this.child(builder(window, cx)) TooltipContext::Text(ref text) => this.child(text.clone()),
} else { TooltipContext::Element(ref builder) => this.child(builder(window, cx)),
this.child(self.text.clone()) }))
} })
.when_some(self.key_binding.clone(), |this, kbd| {
this.child(
div()
.text_xs()
.flex_shrink_0()
.text_color(cx.theme().muted_foreground)
.child(kbd.appearance(false)),
)
}), }),
) )
} }