chore: update gpui & components

This commit is contained in:
2025-10-27 08:20:37 +07:00
parent 15bbe82a87
commit 6017eebaed
24 changed files with 2112 additions and 234 deletions

View File

@@ -20,14 +20,4 @@ pub struct Confirm {
pub secondary: bool,
}
actions!(
list,
[
/// Close current list
Cancel,
/// Select the next item in lists
SelectPrev,
/// Select the previous item in list
SelectNext
]
);
actions!(ui, [Cancel, SelectUp, SelectDown, SelectLeft, SelectRight]);

View File

@@ -1,7 +1,9 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, relative, AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement,
IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, Stateful,
IntoElement, ParentElement, RenderOnce, SharedString, Stateful,
StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
};
use theme::ActiveTheme;
@@ -111,11 +113,11 @@ impl Default for ButtonVariant {
}
}
type OnClick = Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>;
/// A Button element.
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub struct Button {
id: ElementId,
base: Stateful<Div>,
style: StyleRefinement,
@@ -136,10 +138,13 @@ pub struct Button {
loading: bool,
loading_icon: Option<Icon>,
on_click: OnClick,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
tab_index: isize,
tab_stop: bool,
pub(crate) selected: bool,
pub(crate) stop_propagation: bool,
}
impl From<Button> for AnyElement {
@@ -150,8 +155,11 @@ impl From<Button> for AnyElement {
impl Button {
pub fn new(id: impl Into<ElementId>) -> Self {
let id = id.into();
Self {
base: div().id(id.into()).flex_shrink_0(),
id: id.clone(),
base: div().flex_shrink_0().id(id),
style: StyleRefinement::default(),
icon: None,
label: None,
@@ -162,13 +170,15 @@ impl Button {
size: Size::Medium,
tooltip: None,
on_click: None,
stop_propagation: true,
on_hover: None,
loading: false,
reverse: false,
bold: false,
cta: false,
children: Vec::new(),
loading_icon: None,
tab_index: 0,
tab_stop: true,
}
}
@@ -220,26 +230,52 @@ impl Button {
self
}
/// Set the stop propagation of the button.
pub fn stop_propagation(mut self, val: bool) -> Self {
self.stop_propagation = val;
self
}
/// Set the loading icon of the button.
pub fn loading_icon(mut self, icon: impl Into<Icon>) -> Self {
self.loading_icon = Some(icon.into());
self
}
/// Set the click handler of the button.
pub fn on_click<C>(mut self, handler: C) -> Self
where
C: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
{
self.on_click = Some(Box::new(handler));
/// Add click handler.
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
/// Add hover handler, the bool parameter indicates whether the mouse is hovering.
pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_hover = Some(Rc::new(handler));
self
}
/// Set the tab index of the button, it will be used to focus the button by tab key.
///
/// Default is 0.
pub fn tab_index(mut self, tab_index: isize) -> Self {
self.tab_index = tab_index;
self
}
/// Set the tab stop of the button, if true, the button will be focusable by tab key.
///
/// Default is true.
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
self.tab_stop = tab_stop;
self
}
#[inline]
fn clickable(&self) -> bool {
!(self.disabled || self.loading) && self.on_click.is_some()
}
#[inline]
fn hoverable(&self) -> bool {
!(self.disabled || self.loading) && self.on_hover.is_some()
}
}
impl Disableable for Button {
@@ -295,14 +331,28 @@ impl InteractiveElement for Button {
impl RenderOnce for Button {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let style: ButtonVariant = self.variant;
let normal_style = style.normal(window, cx);
let clickable = self.clickable();
let hoverable = self.hoverable();
let normal_style = style.normal(cx);
let icon_size = match self.size {
Size::Size(v) => Size::Size(v * 0.75),
Size::Large => Size::Medium,
_ => self.size,
};
let focus_handle = window
.use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
.read(cx)
.clone();
self.base
.when(!self.disabled, |this| {
this.track_focus(
&focus_handle
.tab_index(self.tab_index)
.tab_stop(self.tab_stop),
)
})
.flex_shrink_0()
.flex()
.items_center()
@@ -390,26 +440,39 @@ impl RenderOnce for Button {
.when(!self.disabled && !self.selected, |this| {
this.bg(normal_style.bg)
.hover(|this| {
let hover_style = style.hovered(window, cx);
let hover_style = style.hovered(cx);
this.bg(hover_style.bg).text_color(hover_style.fg)
})
.active(|this| {
let active_style = style.active(window, cx);
let active_style = style.active(cx);
this.bg(active_style.bg).text_color(active_style.fg)
})
})
.when(self.selected, |this| {
let selected_style = style.selected(window, cx);
let selected_style = style.selected(cx);
this.bg(selected_style.bg).text_color(selected_style.fg)
})
.when(self.disabled, |this| {
let disabled_style = style.disabled(window, cx);
let disabled_style = style.disabled(cx);
this.cursor_not_allowed()
.bg(disabled_style.bg)
.text_color(disabled_style.fg)
.shadow_none()
})
.refine_style(&self.style)
.on_mouse_down(gpui::MouseButton::Left, |_, window, _| {
// Avoid focus on mouse down.
window.prevent_default();
})
.when_some(self.on_click.filter(|_| clickable), |this, on_click| {
this.on_click(move |event, window, cx| {
(on_click)(event, window, cx);
})
})
.when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
this.on_hover(move |hovered, window, cx| {
(on_hover)(hovered, window, cx);
})
})
.child({
h_flex()
.id("label")
@@ -442,25 +505,13 @@ impl RenderOnce for Button {
})
.children(self.children)
})
.when(self.loading, |this| this.bg(normal_style.bg.opacity(0.8)))
.when(self.loading && !self.disabled, |this| {
this.bg(normal_style.bg.opacity(0.8))
.text_color(normal_style.fg.opacity(0.8))
})
.when_some(self.tooltip.clone(), |this, tooltip| {
this.tooltip(move |window, cx| Tooltip::new(tooltip.clone(), window, cx).into())
})
.when_some(
self.on_click.filter(|_| !self.disabled && !self.loading),
|this, on_click| {
let stop_propagation = self.stop_propagation;
this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
if stop_propagation {
cx.stop_propagation();
}
})
.on_click(move |event, window, cx| {
(on_click)(event, window, cx);
})
},
)
}
}
@@ -470,14 +521,14 @@ struct ButtonVariantStyle {
}
impl ButtonVariant {
fn normal(&self, window: &Window, cx: &App) -> ButtonVariantStyle {
let bg = self.bg_color(window, cx);
let fg = self.text_color(window, cx);
fn normal(&self, cx: &App) -> ButtonVariantStyle {
let bg = self.bg_color(cx);
let fg = self.text_color(cx);
ButtonVariantStyle { bg, fg }
}
fn bg_color(&self, _window: &Window, cx: &App) -> Hsla {
fn bg_color(&self, cx: &App) -> Hsla {
match self {
ButtonVariant::Primary => cx.theme().element_background,
ButtonVariant::Secondary => cx.theme().elevated_surface_background,
@@ -495,7 +546,7 @@ impl ButtonVariant {
}
}
fn text_color(&self, _window: &Window, cx: &App) -> Hsla {
fn text_color(&self, cx: &App) -> Hsla {
match self {
ButtonVariant::Primary => cx.theme().element_foreground,
ButtonVariant::Secondary => cx.theme().text_muted,
@@ -513,7 +564,7 @@ impl ButtonVariant {
}
}
fn hovered(&self, window: &Window, cx: &App) -> ButtonVariantStyle {
fn hovered(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().element_hover,
ButtonVariant::Secondary => cx.theme().secondary_hover,
@@ -528,13 +579,13 @@ impl ButtonVariant {
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Ghost { .. } => cx.theme().text,
ButtonVariant::Transparent => cx.theme().text_placeholder,
_ => self.text_color(window, cx),
_ => self.text_color(cx),
};
ButtonVariantStyle { bg, fg }
}
fn active(&self, window: &Window, cx: &App) -> ButtonVariantStyle {
fn active(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().element_active,
ButtonVariant::Secondary => cx.theme().secondary_active,
@@ -548,13 +599,13 @@ impl ButtonVariant {
let fg = match self {
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Transparent => cx.theme().text_placeholder,
_ => self.text_color(window, cx),
_ => self.text_color(cx),
};
ButtonVariantStyle { bg, fg }
}
fn selected(&self, window: &Window, cx: &App) -> ButtonVariantStyle {
fn selected(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().element_selected,
ButtonVariant::Secondary => cx.theme().secondary_selected,
@@ -568,13 +619,13 @@ impl ButtonVariant {
let fg = match self {
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Transparent => cx.theme().text_placeholder,
_ => self.text_color(window, cx),
_ => self.text_color(cx),
};
ButtonVariantStyle { bg, fg }
}
fn disabled(&self, _window: &Window, cx: &App) -> ButtonVariantStyle {
fn disabled(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Danger => cx.theme().danger_disabled,
ButtonVariant::Warning => cx.theme().warning_disabled,

View File

@@ -7,7 +7,7 @@ use gpui::{
};
use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm, SelectNext, SelectPrev};
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
use crate::input::clear_button::clear_button;
use crate::list::{List, ListDelegate, ListItem};
use crate::{h_flex, v_flex, Disableable as _, Icon, IconName, Sizable, Size, StyleSized};
@@ -26,8 +26,8 @@ pub enum ListEvent {
pub fn init(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("up", SelectPrev, Some(CONTEXT)),
KeyBinding::new("down", SelectNext, Some(CONTEXT)),
KeyBinding::new("up", SelectUp, Some(CONTEXT)),
KeyBinding::new("down", SelectDown, Some(CONTEXT)),
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
KeyBinding::new(
"secondary-enter",
@@ -440,7 +440,7 @@ where
cx.notify();
}
fn up(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context<Self>) {
fn up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context<Self>) {
if !self.open {
return;
}
@@ -449,7 +449,7 @@ where
cx.propagate();
}
fn down(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
fn down(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context<Self>) {
if !self.open {
self.open = true;
}

View File

@@ -3,8 +3,8 @@ use std::rc::Rc;
use gpui::{
fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler,
Entity, GlobalElementId, Half, Hitbox, IntoElement, LayoutId, MouseButton, MouseMoveEvent,
Path, Pixels, Point, ShapedLine, SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle,
Entity, GlobalElementId, Hitbox, IntoElement, LayoutId, MouseButton, MouseMoveEvent, Path,
Pixels, Point, ShapedLine, SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle,
Window,
};
use rope::Rope;
@@ -642,11 +642,8 @@ impl Element for TextElement {
}
let total_wrapped_lines = state.text_wrapper.len();
let empty_bottom_height = bounds
.size
.height
.half()
.max(BOTTOM_MARGIN_ROWS * line_height);
let empty_bottom_height = px(0.);
let scroll_size = size(
if longest_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width {
longest_line_width + line_number_width + RIGHT_MARGIN
@@ -872,9 +869,7 @@ impl Element for TextElement {
state.set_input_bounds(input_bounds, cx);
state.last_selected_range = Some(selected_range);
state.scroll_size = prepaint.scroll_size;
state
.scroll_handle
.set_offset(prepaint.cursor_scroll_offset);
state.update_scroll_offset(Some(prepaint.cursor_scroll_offset), cx);
state.deferred_scroll_offset = None;
cx.notify();

View File

@@ -1411,7 +1411,11 @@ impl InputState {
self.update_scroll_offset(Some(self.scroll_handle.offset() + delta), cx);
}
fn update_scroll_offset(&mut self, offset: Option<Point<Pixels>>, cx: &mut Context<Self>) {
pub(super) fn update_scroll_offset(
&mut self,
offset: Option<Point<Pixels>>,
cx: &mut Context<Self>,
) {
let mut offset = offset.unwrap_or(self.scroll_handle.offset());
let safe_y_range =
@@ -1472,13 +1476,12 @@ impl InputState {
// Check if row_offset_y is out of the viewport
// If row offset is not in the viewport, scroll to make it visible
let edge_height = 3 * line_height;
if row_offset_y - edge_height < -scroll_offset.y {
if row_offset_y - line_height < -scroll_offset.y {
// Scroll up
scroll_offset.y = -row_offset_y + edge_height;
} else if row_offset_y + edge_height > -scroll_offset.y + bounds.size.height {
scroll_offset.y = -row_offset_y + line_height;
} else if row_offset_y + line_height > -scroll_offset.y + bounds.size.height {
// Scroll down
scroll_offset.y = -(row_offset_y - bounds.size.height + edge_height);
scroll_offset.y = -(row_offset_y - bounds.size.height + line_height);
}
scroll_offset.x = scroll_offset.x.min(px(0.));

View File

@@ -150,6 +150,7 @@ impl RenderOnce for TextInput {
self.state.update(cx, |state, cx| {
state.text_wrapper.set_font(font, font_size, cx);
state.text_wrapper.prepare_if_need(&state.text, cx);
state.disabled = self.disabled;
});

View File

@@ -42,14 +42,19 @@ impl LineItem {
/// After use lines to calculate the scroll size of the Editor.
pub(super) struct TextWrapper {
text: Rope,
/// Total wrapped lines (Inlucde the first line), value is start and end index of the line.
soft_lines: usize,
font: Font,
font_size: Pixels,
/// If is none, it means the text is not wrapped
wrap_width: Option<Pixels>,
/// The lines by split \n
pub(super) lines: Vec<LineItem>,
_initialized: bool,
}
#[allow(unused)]
@@ -62,6 +67,7 @@ impl TextWrapper {
wrap_width,
soft_lines: 0,
lines: Vec::new(),
_initialized: false,
}
}
@@ -86,7 +92,6 @@ impl TextWrapper {
if wrap_width == self.wrap_width {
return;
}
self.wrap_width = wrap_width;
self.update_all(&self.text.clone(), true, cx);
}
@@ -95,12 +100,19 @@ impl TextWrapper {
if self.font.eq(&font) && self.font_size == font_size {
return;
}
self.font = font;
self.font_size = font_size;
self.update_all(&self.text.clone(), true, cx);
}
pub(super) fn prepare_if_need(&mut self, text: &Rope, cx: &mut App) {
if self._initialized {
return;
}
self._initialized = true;
self.update_all(text, true, cx);
}
/// Update the text wrapper and recalculate the wrapped lines.
///
/// If the `text` is the same as the current text, do nothing.

312
crates/ui/src/kbd.rs Normal file
View File

@@ -0,0 +1,312 @@
use gpui::{
div, relative, Action, AsKeystroke, FocusHandle, IntoElement, KeyContext, Keystroke,
ParentElement as _, RenderOnce, StyleRefinement, Styled, Window,
};
use theme::ActiveTheme;
use crate::StyledExt;
/// A key binding tag
#[derive(IntoElement, Clone, Debug)]
pub struct Kbd {
style: StyleRefinement,
stroke: Keystroke,
appearance: bool,
}
impl From<Keystroke> for Kbd {
fn from(stroke: Keystroke) -> Self {
Self {
style: StyleRefinement::default(),
stroke,
appearance: true,
}
}
}
impl Kbd {
pub fn new(stroke: Keystroke) -> Self {
Self {
style: StyleRefinement::default(),
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 binding = match key_context {
Some(context) => {
window.highest_precedence_binding_for_action_in_context(action, context)
}
None => window.highest_precedence_binding_for_action(action),
}?;
binding
.keystrokes()
.first()
.map(|key| Self::new(key.as_keystroke().clone()))
}
/// Return the first keybinding for the given action and focus handle.
pub fn binding_for_action_in(
action: &dyn Action,
focus_handle: &FocusHandle,
window: &Window,
) -> Option<Self> {
let binding = window.highest_precedence_binding_for_action_in(action, focus_handle)?;
binding
.keystrokes()
.first()
.map(|key| Self::new(key.as_keystroke().clone()))
}
/// 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 {
#[cfg(target_os = "macos")]
const DIVIDER: &str = "";
#[cfg(not(target_os = "macos"))]
const DIVIDER: &str = "+";
let mut parts = vec![];
// The key map order in macOS is: ⌃⌥⇧⌘
// And in Windows is: Ctrl+Alt+Shift+Win
if key.modifiers.control {
#[cfg(target_os = "macos")]
parts.push("");
#[cfg(not(target_os = "macos"))]
parts.push("Ctrl");
}
if key.modifiers.alt {
#[cfg(target_os = "macos")]
parts.push("");
#[cfg(not(target_os = "macos"))]
parts.push("Alt");
}
if key.modifiers.shift {
#[cfg(target_os = "macos")]
parts.push("");
#[cfg(not(target_os = "macos"))]
parts.push("Shift");
}
if key.modifiers.platform {
#[cfg(target_os = "macos")]
parts.push("");
#[cfg(not(target_os = "macos"))]
parts.push("Win");
}
let mut keys = String::new();
let key_str = key.key.as_str();
match key_str {
#[cfg(target_os = "macos")]
"ctrl" => keys.push('⌃'),
#[cfg(not(target_os = "macos"))]
"ctrl" => keys.push_str("Ctrl"),
#[cfg(target_os = "macos")]
"alt" => keys.push('⌥'),
#[cfg(not(target_os = "macos"))]
"alt" => keys.push_str("Alt"),
#[cfg(target_os = "macos")]
"shift" => keys.push('⇧'),
#[cfg(not(target_os = "macos"))]
"shift" => keys.push_str("Shift"),
#[cfg(target_os = "macos")]
"cmd" => keys.push('⌘'),
#[cfg(not(target_os = "macos"))]
"cmd" => keys.push_str("Win"),
#[cfg(target_os = "macos")]
"space" => keys.push_str("Space"),
#[cfg(target_os = "macos")]
"backspace" => keys.push('⌫'),
#[cfg(not(target_os = "macos"))]
"backspace" => keys.push_str("Backspace"),
#[cfg(target_os = "macos")]
"delete" => keys.push('⌫'),
#[cfg(not(target_os = "macos"))]
"delete" => keys.push_str("Delete"),
#[cfg(target_os = "macos")]
"escape" => keys.push('⎋'),
#[cfg(not(target_os = "macos"))]
"escape" => keys.push_str("Esc"),
#[cfg(target_os = "macos")]
"enter" => keys.push('⏎'),
#[cfg(not(target_os = "macos"))]
"enter" => keys.push_str("Enter"),
"pagedown" => keys.push_str("Page Down"),
"pageup" => keys.push_str("Page Up"),
#[cfg(target_os = "macos")]
"left" => keys.push('←'),
#[cfg(not(target_os = "macos"))]
"left" => keys.push_str("Left"),
#[cfg(target_os = "macos")]
"right" => keys.push('→'),
#[cfg(not(target_os = "macos"))]
"right" => keys.push_str("Right"),
#[cfg(target_os = "macos")]
"up" => keys.push('↑'),
#[cfg(not(target_os = "macos"))]
"up" => keys.push_str("Up"),
#[cfg(target_os = "macos")]
"down" => keys.push('↓'),
#[cfg(not(target_os = "macos"))]
"down" => keys.push_str("Down"),
_ => {
if key_str.len() == 1 {
keys.push_str(&key_str.to_uppercase());
} else {
let mut chars = key_str.chars();
if let Some(first_char) = chars.next() {
keys.push_str(&format!(
"{}{}",
first_char.to_uppercase(),
chars.collect::<String>()
));
} else {
keys.push_str(key_str);
}
}
}
}
parts.push(&keys);
parts.join(DIVIDER)
}
}
impl Styled for Kbd {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Kbd {
fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
if !self.appearance {
return Self::format(&self.stroke).into_any_element();
}
div()
.border_1()
.border_color(cx.theme().border)
.text_color(cx.theme().text_muted)
.bg(cx.theme().surface_background)
.py_0p5()
.px_1()
.min_w_5()
.text_center()
.rounded_sm()
.line_height(relative(1.))
.text_xs()
.whitespace_normal()
.flex_shrink_0()
.refine_style(&self.style)
.child(Self::format(&self.stroke))
.into_any_element()
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_format() {
use gpui::Keystroke;
use super::Kbd;
if cfg!(target_os = "macos") {
assert_eq!(Kbd::format(&Keystroke::parse("cmd-a").unwrap()), "⌘A");
assert_eq!(Kbd::format(&Keystroke::parse("cmd--").unwrap()), "⌘-");
assert_eq!(Kbd::format(&Keystroke::parse("cmd-+").unwrap()), "⌘+");
assert_eq!(Kbd::format(&Keystroke::parse("cmd-enter").unwrap()), "⌘⏎");
assert_eq!(
Kbd::format(&Keystroke::parse("secondary-f12").unwrap()),
"⌘F12"
);
assert_eq!(
Kbd::format(&Keystroke::parse("shift-pagedown").unwrap()),
"⇧Page Down"
);
assert_eq!(
Kbd::format(&Keystroke::parse("shift-pageup").unwrap()),
"⇧Page Up"
);
assert_eq!(
Kbd::format(&Keystroke::parse("shift-space").unwrap()),
"⇧Space"
);
assert_eq!(Kbd::format(&Keystroke::parse("cmd-ctrl-a").unwrap()), "⌃⌘A");
assert_eq!(
Kbd::format(&Keystroke::parse("cmd-alt-backspace").unwrap()),
"⌥⌘⌫"
);
assert_eq!(
Kbd::format(&Keystroke::parse("shift-delete").unwrap()),
"⇧⌫"
);
assert_eq!(
Kbd::format(&Keystroke::parse("cmd-ctrl-shift-a").unwrap()),
"⌃⇧⌘A"
);
assert_eq!(
Kbd::format(&Keystroke::parse("cmd-ctrl-shift-alt-a").unwrap()),
"⌃⌥⇧⌘A"
);
} else {
assert_eq!(Kbd::format(&Keystroke::parse("a").unwrap()), "A");
assert_eq!(Kbd::format(&Keystroke::parse("ctrl-a").unwrap()), "Ctrl+A");
assert_eq!(
Kbd::format(&Keystroke::parse("shift-space").unwrap()),
"Shift+Space"
);
assert_eq!(
Kbd::format(&Keystroke::parse("ctrl-alt-a").unwrap()),
"Ctrl+Alt+A"
);
assert_eq!(
Kbd::format(&Keystroke::parse("ctrl-alt-shift-a").unwrap()),
"Ctrl+Alt+Shift+A"
);
assert_eq!(
Kbd::format(&Keystroke::parse("ctrl-alt-shift-win-a").unwrap()),
"Ctrl+Alt+Shift+Win+A"
);
assert_eq!(
Kbd::format(&Keystroke::parse("ctrl-shift-backspace").unwrap()),
"Ctrl+Shift+Backspace"
);
assert_eq!(
Kbd::format(&Keystroke::parse("alt-delete").unwrap()),
"Alt+Delete"
);
assert_eq!(
Kbd::format(&Keystroke::parse("alt-tab").unwrap()),
"Alt+Tab"
);
}
}
}

View File

@@ -1,6 +1,8 @@
pub use event::InteractiveElementExt;
pub use focusable::FocusableCycle;
pub use icon::*;
pub use kbd::*;
pub use menu::{context_menu, popup_menu};
pub use root::{ContextModal, Root};
pub use styled::*;
pub use window_border::{window_border, WindowBorder};
@@ -12,7 +14,6 @@ pub mod animation;
pub mod avatar;
pub mod button;
pub mod checkbox;
pub mod context_menu;
pub mod divider;
pub mod dock_area;
pub mod dropdown;
@@ -21,10 +22,10 @@ pub mod history;
pub mod indicator;
pub mod input;
pub mod list;
pub mod menu;
pub mod modal;
pub mod notification;
pub mod popover;
pub mod popup_menu;
pub mod resizable;
pub mod scroll;
pub mod skeleton;
@@ -36,6 +37,7 @@ pub mod tooltip;
mod event;
mod focusable;
mod icon;
mod kbd;
mod root;
mod styled;
mod window_border;
@@ -53,5 +55,5 @@ pub fn init(cx: &mut gpui::App) {
list::init(cx);
modal::init(cx);
popover::init(cx);
popup_menu::init(cx);
menu::init(cx);
}

View File

@@ -12,7 +12,7 @@ use smol::Timer;
use theme::ActiveTheme;
use super::loading::Loading;
use crate::actions::{Cancel, Confirm, SelectNext, SelectPrev};
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
use crate::input::{InputEvent, InputState, TextInput};
use crate::scroll::{Scrollbar, ScrollbarState};
use crate::{v_flex, Icon, IconName, Sizable as _, Size};
@@ -23,8 +23,8 @@ pub fn init(cx: &mut App) {
KeyBinding::new("escape", Cancel, context),
KeyBinding::new("enter", Confirm { secondary: false }, context),
KeyBinding::new("secondary-enter", Confirm { secondary: true }, context),
KeyBinding::new("up", SelectPrev, context),
KeyBinding::new("down", SelectNext, context),
KeyBinding::new("up", SelectUp, context),
KeyBinding::new("down", SelectDown, context),
]);
}
@@ -428,12 +428,7 @@ where
cx.notify();
}
fn on_action_select_prev(
&mut self,
_: &SelectPrev,
window: &mut Window,
cx: &mut Context<Self>,
) {
fn on_select_prev(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context<Self>) {
let items_count = self.delegate.items_count(cx);
if items_count == 0 {
return;
@@ -448,12 +443,7 @@ where
self.select_item(selected_index, window, cx);
}
fn on_action_select_next(
&mut self,
_: &SelectNext,
window: &mut Window,
cx: &mut Context<Self>,
) {
fn on_select_next(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context<Self>) {
let items_count = self.delegate.items_count(cx);
if items_count == 0 {
return;
@@ -598,8 +588,8 @@ where
.when(!loading, |this| {
this.on_action(cx.listener(Self::on_action_cancel))
.on_action(cx.listener(Self::on_action_confirm))
.on_action(cx.listener(Self::on_action_select_next))
.on_action(cx.listener(Self::on_action_select_prev))
.on_action(cx.listener(Self::on_select_next))
.on_action(cx.listener(Self::on_select_prev))
.map(|this| {
if let Some(view) = initial_view {
this.child(view)

View File

@@ -0,0 +1,244 @@
use gpui::prelude::FluentBuilder;
use gpui::{
anchored, deferred, div, px, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity,
Focusable, InteractiveElement as _, IntoElement, KeyBinding, OwnedMenu, ParentElement, Render,
SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
};
use crate::actions::{Cancel, SelectLeft, SelectRight};
use crate::button::{Button, ButtonVariants};
use crate::popup_menu::PopupMenu;
use crate::{h_flex, Selectable, Sizable};
const CONTEXT: &str = "AppMenuBar";
pub fn init(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
KeyBinding::new("right", SelectRight, Some(CONTEXT)),
]);
}
/// The application menu bar, for Windows and Linux.
pub struct AppMenuBar {
menus: Vec<Entity<AppMenu>>,
selected_ix: Option<usize>,
}
impl AppMenuBar {
/// Create a new app menu bar.
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| {
let menu_bar = cx.entity();
let menus = cx
.get_menus()
.unwrap_or_default()
.iter()
.enumerate()
.map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), window, cx))
.collect();
Self {
selected_ix: None,
menus,
}
})
}
fn move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
let Some(selected_ix) = self.selected_ix else {
return;
};
let new_ix = if selected_ix == 0 {
self.menus.len().saturating_sub(1)
} else {
selected_ix.saturating_sub(1)
};
self.set_selected_ix(Some(new_ix), window, cx);
}
fn move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
let Some(selected_ix) = self.selected_ix else {
return;
};
let new_ix = if selected_ix + 1 >= self.menus.len() {
0
} else {
selected_ix + 1
};
self.set_selected_ix(Some(new_ix), window, cx);
}
fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
self.set_selected_ix(None, window, cx);
}
fn set_selected_ix(&mut self, ix: Option<usize>, _: &mut Window, cx: &mut Context<Self>) {
self.selected_ix = ix;
cx.notify();
}
#[inline]
fn has_activated_menu(&self) -> bool {
self.selected_ix.is_some()
}
}
impl Render for AppMenuBar {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.id("app-menu-bar")
.key_context(CONTEXT)
.on_action(cx.listener(Self::move_left))
.on_action(cx.listener(Self::move_right))
.on_action(cx.listener(Self::cancel))
.size_full()
.gap_x_1()
.overflow_x_scroll()
.children(self.menus.clone())
}
}
/// A menu in the menu bar.
pub(super) struct AppMenu {
menu_bar: Entity<AppMenuBar>,
ix: usize,
name: SharedString,
menu: OwnedMenu,
popup_menu: Option<Entity<PopupMenu>>,
_subscription: Option<Subscription>,
}
impl AppMenu {
pub(super) fn new(
ix: usize,
menu: &OwnedMenu,
menu_bar: Entity<AppMenuBar>,
_: &mut Window,
cx: &mut App,
) -> Entity<Self> {
let name = menu.name.clone();
cx.new(|_| Self {
ix,
menu_bar,
name,
menu: menu.clone(),
popup_menu: None,
_subscription: None,
})
}
fn build_popup_menu(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<PopupMenu> {
let popup_menu = match self.popup_menu.as_ref() {
None => {
let items = self.menu.items.clone();
let popup_menu = PopupMenu::build(window, cx, |menu, window, cx| {
menu.when_some(window.focused(cx), |this, handle| {
this.action_context(handle)
})
.with_menu_items(items, window, cx)
});
popup_menu.read(cx).focus_handle(cx).focus(window);
self._subscription =
Some(cx.subscribe_in(&popup_menu, window, Self::handle_dismiss));
self.popup_menu = Some(popup_menu.clone());
popup_menu
}
Some(menu) => menu.clone(),
};
let focus_handle = popup_menu.read(cx).focus_handle(cx);
if !focus_handle.contains_focused(window, cx) {
focus_handle.focus(window);
}
popup_menu
}
fn handle_dismiss(
&mut self,
_: &Entity<PopupMenu>,
_: &DismissEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
self._subscription.take();
self.popup_menu.take();
self.menu_bar.update(cx, |state, cx| {
state.cancel(&Cancel, window, cx);
});
}
fn handle_trigger_click(
&mut self,
_: &ClickEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let is_selected = self.menu_bar.read(cx).selected_ix == Some(self.ix);
self.menu_bar.update(cx, |state, cx| {
let new_ix = if is_selected { None } else { Some(self.ix) };
state.set_selected_ix(new_ix, window, cx);
});
}
fn handle_hover(&mut self, hovered: &bool, window: &mut Window, cx: &mut Context<Self>) {
if !*hovered {
return;
}
let has_activated_menu = self.menu_bar.read(cx).has_activated_menu();
if !has_activated_menu {
return;
}
self.menu_bar.update(cx, |state, cx| {
state.set_selected_ix(Some(self.ix), window, cx);
});
}
}
impl Render for AppMenu {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let menu_bar = self.menu_bar.read(cx);
let is_selected = menu_bar.selected_ix == Some(self.ix);
div()
.id(self.ix)
.relative()
.child(
Button::new("menu")
.small()
.py_0p5()
.xsmall()
.ghost()
.label(self.name.clone())
.selected(is_selected)
.on_click(cx.listener(Self::handle_trigger_click)),
)
.on_hover(cx.listener(Self::handle_hover))
.when(is_selected, |this| {
this.child(deferred(
anchored()
.anchor(gpui::Corner::TopLeft)
.snap_to_window_with_margin(px(8.))
.child(
div()
.size_full()
.occlude()
.top_1()
.child(self.build_popup_menu(window, cx)),
),
))
})
}
}

View File

@@ -3,10 +3,10 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
anchored, deferred, div, px, relative, AnyElement, App, Context, Corner, DismissEvent,
DispatchPhase, Element, ElementId, Entity, Focusable, GlobalElementId, InteractiveElement,
IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Position, Size,
Stateful, Style, Window,
anchored, deferred, div, px, relative, AnyElement, App, Context, Corner, DismissEvent, Element,
ElementId, Entity, Focusable, GlobalElementId, InspectorElementId, InteractiveElement,
IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Position, Stateful,
Style, Subscription, Window,
};
use crate::popup_menu::PopupMenu;
@@ -22,13 +22,12 @@ pub trait ContextMenuExt: ParentElement + Sized {
impl<E> ContextMenuExt for Stateful<E> where E: ParentElement {}
type Menu =
Option<Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>>;
/// A context menu that can be shown on right-click.
#[allow(clippy::type_complexity)]
pub struct ContextMenu {
id: ElementId,
menu: Menu,
menu:
Option<Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>>,
anchor: Corner,
}
@@ -76,20 +75,28 @@ impl IntoElement for ContextMenu {
}
}
struct ContextMenuSharedState {
menu_view: Option<Entity<PopupMenu>>,
open: bool,
position: Point<Pixels>,
_subscription: Option<Subscription>,
}
pub struct ContextMenuState {
menu_view: Rc<RefCell<Option<Entity<PopupMenu>>>>,
menu_element: Option<AnyElement>,
open: Rc<RefCell<bool>>,
position: Rc<RefCell<Point<Pixels>>>,
shared_state: Rc<RefCell<ContextMenuSharedState>>,
}
impl Default for ContextMenuState {
fn default() -> Self {
Self {
menu_view: Rc::new(RefCell::new(None)),
menu_element: None,
open: Rc::new(RefCell::new(false)),
position: Default::default(),
shared_state: Rc::new(RefCell::new(ContextMenuSharedState {
menu_view: None,
open: false,
position: Default::default(),
_subscription: None,
})),
}
}
}
@@ -106,6 +113,7 @@ impl Element for ContextMenu {
None
}
#[allow(clippy::field_reassign_with_default)]
fn request_layout(
&mut self,
id: Option<&gpui::GlobalElementId>,
@@ -113,29 +121,27 @@ impl Element for ContextMenu {
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
// Set the layout style relative to the table view to get same size.
style.position = Position::Absolute;
style.flex_grow = 1.0;
style.flex_shrink = 1.0;
style.size.width = relative(1.).into();
style.size.height = relative(1.).into();
let anchor = self.anchor;
let style = Style {
position: Position::Absolute,
flex_grow: 1.0,
flex_shrink: 1.0,
size: Size {
width: relative(1.).into(),
height: relative(1.).into(),
},
..Default::default()
};
self.with_element_state(
id.unwrap(),
window,
cx,
|_, state: &mut ContextMenuState, window, cx| {
let position = state.position.clone();
let position = position.borrow();
let open = state.open.clone();
let menu_view = state.menu_view.borrow().clone();
let (menu_element, menu_layout_id) = if *open.borrow() {
let (position, open) = {
let shared_state = state.shared_state.borrow();
(shared_state.position, shared_state.open)
};
let menu_view = state.shared_state.borrow().menu_view.clone();
let (menu_element, menu_layout_id) = if open {
let has_menu_item = menu_view
.as_ref()
.map(|menu| !menu.read(cx).is_empty())
@@ -144,12 +150,14 @@ impl Element for ContextMenu {
if has_menu_item {
let mut menu_element = deferred(
anchored()
.position(*position)
.position(position)
.snap_to_window_with_margin(px(8.))
.anchor(anchor)
.when_some(menu_view, |this, menu| {
// Focus the menu, so that can be handle the action.
menu.focus_handle(cx).focus(window);
if !menu.focus_handle(cx).contains_focused(window, cx) {
menu.focus_handle(cx).focus(window);
}
this.child(div().occlude().child(menu.clone()))
}),
@@ -188,7 +196,7 @@ impl Element for ContextMenu {
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Option<&InspectorElementId>,
_: gpui::Bounds<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
@@ -202,7 +210,7 @@ impl Element for ContextMenu {
fn paint(
&mut self,
id: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Option<&InspectorElementId>,
bounds: gpui::Bounds<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
@@ -222,34 +230,35 @@ impl Element for ContextMenu {
window,
cx,
|_view, state: &mut ContextMenuState, window, _| {
let position = state.position.clone();
let open = state.open.clone();
let menu_view = state.menu_view.clone();
let shared_state = state.shared_state.clone();
// When right mouse click, to build content menu, and show it at the mouse position.
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
if phase == DispatchPhase::Bubble
if phase.bubble()
&& event.button == MouseButton::Right
&& bounds.contains(&event.position)
{
*position.borrow_mut() = event.position;
*open.borrow_mut() = true;
{
let mut shared_state = shared_state.borrow_mut();
shared_state.position = event.position;
shared_state.open = true;
}
let menu = PopupMenu::build(window, cx, |menu, window, cx| {
(builder)(menu, window, cx)
})
.into_element();
let open = open.clone();
window
.subscribe(&menu, cx, move |_, _: &DismissEvent, window, _| {
*open.borrow_mut() = false;
let _subscription = window.subscribe(&menu, cx, {
let shared_state = shared_state.clone();
move |_, _: &DismissEvent, window, _| {
shared_state.borrow_mut().open = false;
window.refresh();
})
.detach();
*menu_view.borrow_mut() = Some(menu);
}
});
shared_state.borrow_mut().menu_view = Some(menu.clone());
shared_state.borrow_mut()._subscription = Some(_subscription);
window.refresh();
}
});

View File

@@ -0,0 +1,122 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use crate::{h_flex, Disableable, StyledExt};
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub(crate) struct MenuItemElement {
id: ElementId,
group_name: SharedString,
style: StyleRefinement,
disabled: bool,
selected: bool,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_hover: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
children: SmallVec<[AnyElement; 2]>,
}
impl MenuItemElement {
pub fn new(id: impl Into<ElementId>, group_name: impl Into<SharedString>) -> Self {
let id: ElementId = id.into();
Self {
id: id.clone(),
group_name: group_name.into(),
style: StyleRefinement::default(),
disabled: false,
selected: false,
on_click: None,
on_hover: None,
children: SmallVec::new(),
}
}
/// Set ListItem as the selected item style.
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
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
}
/// Set a handler for when the mouse enters the MenuItem.
#[allow(unused)]
pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_hover = Some(Box::new(handler));
self
}
}
impl Disableable for MenuItemElement {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Styled for MenuItemElement {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl ParentElement for MenuItemElement {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for MenuItemElement {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
h_flex()
.id(self.id)
.group(&self.group_name)
.gap_x_1()
.py_1()
.px_2()
.text_base()
.text_color(cx.theme().text)
.relative()
.items_center()
.justify_between()
.refine_style(&self.style)
.when_some(self.on_hover, |this, on_hover| {
this.on_hover(move |hovered, window, cx| (on_hover)(hovered, window, cx))
})
.when(!self.disabled, |this| {
this.group_hover(self.group_name, |this| {
this.bg(cx.theme().elevated_surface_background)
.text_color(cx.theme().text)
})
.when(self.selected, |this| {
this.bg(cx.theme().elevated_surface_background)
.text_color(cx.theme().text)
})
.when_some(self.on_click, |this, on_click| {
this.on_mouse_down(MouseButton::Left, move |_, _, cx| {
cx.stop_propagation();
})
.on_click(on_click)
})
})
.when(self.disabled, |this| this.text_color(cx.theme().text_muted))
.children(self.children)
}
}

14
crates/ui/src/menu/mod.rs Normal file
View File

@@ -0,0 +1,14 @@
use gpui::App;
mod app_menu_bar;
mod menu_item;
pub mod context_menu;
pub mod popup_menu;
pub use app_menu_bar::AppMenuBar;
pub(crate) fn init(cx: &mut App) {
app_menu_bar::init(cx);
popup_menu::init(cx);
}

File diff suppressed because it is too large Load Diff

View File

@@ -317,6 +317,10 @@ impl Side {
pub(crate) fn is_left(&self) -> bool {
matches!(self, Self::Left)
}
pub(crate) fn is_right(&self) -> bool {
matches!(self, Self::Right)
}
}
/// A trait for defining element that can be collapsed.