chore: audit all components (#2)
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -126,7 +126,13 @@ impl RenderOnce for Alert {
|
||||
let muted_foreground = theme.colors.muted_foreground;
|
||||
let muted = theme.colors.muted;
|
||||
let surface = theme.colors.surface;
|
||||
let border = theme.colors.border;
|
||||
|
||||
let border = if theme.borders.overlays {
|
||||
theme.colors.border
|
||||
} else {
|
||||
surface
|
||||
};
|
||||
|
||||
let shadows = theme.elevations.popover.shadows();
|
||||
let metrics = theme.sizes.resolve(Size::Medium);
|
||||
let icon_size = metrics.icon_size;
|
||||
@@ -190,7 +196,7 @@ impl RenderOnce for Alert {
|
||||
.id((self.id, "alert-close"))
|
||||
.flex_none()
|
||||
.cursor_pointer()
|
||||
.p_0p5()
|
||||
.p_1()
|
||||
.rounded(theme.radius.sm)
|
||||
.hover(|style| style.bg(muted))
|
||||
.child(
|
||||
|
||||
@@ -315,10 +315,4 @@ mod tests {
|
||||
assert!(filled >= MIN_FILLED * 2, "seed {seed} filled {filled}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fnv1a_is_stable_across_calls() {
|
||||
assert_eq!(fnv1a(b"reya"), fnv1a(b"reya"));
|
||||
assert_ne!(fnv1a(b"reya"), fnv1a(b"signed"));
|
||||
}
|
||||
}
|
||||
|
||||
+58
-19
@@ -3,14 +3,16 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyView, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, relative,
|
||||
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas,
|
||||
hsla, relative,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
use gpui_native_utils::focus_ring;
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
||||
use gpui_native_utils::{focus_ring, record_anchor_bounds};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::common::{Sizable, h_flex};
|
||||
use crate::spinner::Spinner;
|
||||
|
||||
/// The single activation handler, shared by pointer, Enter, and Space.
|
||||
type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
|
||||
@@ -20,10 +22,10 @@ type TooltipBuilder = Rc<dyn Fn(&mut Window, &mut App) -> AnyView>;
|
||||
/// Which visual treatment a button uses.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ButtonVariant {
|
||||
/// Accent-filled; the platform's default action.
|
||||
/// The platform's default action.
|
||||
#[default]
|
||||
Primary,
|
||||
/// Neutral and bordered; the everyday companion to `Primary`.
|
||||
/// The everyday companion to `Primary`.
|
||||
Secondary,
|
||||
/// Unpainted until hovered, for toolbars and dense UI.
|
||||
Ghost,
|
||||
@@ -40,6 +42,7 @@ pub struct Button {
|
||||
variant: ButtonVariant,
|
||||
size: Size,
|
||||
disabled: bool,
|
||||
loading: bool,
|
||||
tab_stop: bool,
|
||||
debug_selector: Option<Rc<dyn Fn() -> String>>,
|
||||
a11y_label: Option<SharedString>,
|
||||
@@ -60,6 +63,7 @@ impl Button {
|
||||
variant: ButtonVariant::default(),
|
||||
size: Size::default(),
|
||||
disabled: false,
|
||||
loading: false,
|
||||
tab_stop: true,
|
||||
debug_selector: None,
|
||||
a11y_label: None,
|
||||
@@ -119,6 +123,12 @@ impl Button {
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows the spinner in place of the icon and suppresses activation.
|
||||
pub fn loading(mut self, loading: bool) -> Self {
|
||||
self.loading = loading;
|
||||
self
|
||||
}
|
||||
|
||||
/// Overrides the accessibility label, which defaults to the visible label.
|
||||
pub fn accessibility_label<L>(mut self, label: L) -> Self
|
||||
where
|
||||
@@ -149,11 +159,12 @@ impl RenderOnce for Button {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors;
|
||||
let ramps = theme.ramps;
|
||||
let borders = theme.borders;
|
||||
let metrics = theme.sizes.resolve(self.size);
|
||||
|
||||
let show_border = borders.buttons && self.variant != ButtonVariant::Ghost;
|
||||
let on_click = self.on_click;
|
||||
let a11y_label = self.a11y_label.or_else(|| self.label.clone());
|
||||
let icon_only = self.label.is_none() && self.icon.is_some();
|
||||
|
||||
let (background, foreground, hover, pressed) = match self.variant {
|
||||
ButtonVariant::Primary => (
|
||||
@@ -182,42 +193,70 @@ impl RenderOnce for Button {
|
||||
),
|
||||
};
|
||||
|
||||
let (border, hover_border, pressed_border) = if show_border {
|
||||
(colors.border, colors.border, colors.border)
|
||||
} else {
|
||||
match background {
|
||||
Some(fill) => (fill, hover, pressed),
|
||||
None => (
|
||||
hsla(0.0, 0.0, 0.0, 0.0),
|
||||
hsla(0.0, 0.0, 0.0, 0.0),
|
||||
hsla(0.0, 0.0, 0.0, 0.0),
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.id(self.id.clone())
|
||||
.when_some(self.debug_selector, |button, selector| {
|
||||
button.debug_selector(move || selector())
|
||||
})
|
||||
.role(Role::Button)
|
||||
.justify_center()
|
||||
.when(icon_only, |button| button.size(metrics.control_height))
|
||||
.when(!icon_only, |button| {
|
||||
button.h(metrics.control_height).px(metrics.padding_x)
|
||||
.h(metrics.control_height + BORDER_WIDTH * 2.0)
|
||||
.when(self.icon.is_some(), |button| {
|
||||
button.px(metrics.dense_padding_x)
|
||||
})
|
||||
.when(self.icon.is_none(), |button| button.px(metrics.padding_x))
|
||||
.line_height(relative(1.0))
|
||||
.gap_1p5()
|
||||
.gap_2()
|
||||
.rounded(theme.radius.control)
|
||||
.text_size(metrics.text_size)
|
||||
.when(self.variant == ButtonVariant::Secondary, |button| {
|
||||
button.border_1().border_color(colors.border)
|
||||
})
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
.when_some(background, |button, background| button.bg(background))
|
||||
.text_color(foreground)
|
||||
.when_some(a11y_label, |button, label| button.aria_label(label))
|
||||
.when_some(self.icon, |button, role| {
|
||||
button.child(icon(role).size(metrics.icon_size).color(foreground))
|
||||
.when(self.loading, |button| {
|
||||
button.child(
|
||||
Spinner::new((self.id.clone(), "spinner"))
|
||||
.with_size(self.size)
|
||||
.variant(crate::spinner::SpinnerVariant::Icon)
|
||||
.color(foreground),
|
||||
)
|
||||
})
|
||||
.when(!self.loading, |button| {
|
||||
button.when_some(self.icon, |button, role| {
|
||||
button.child(icon(role).size(metrics.icon_size).color(foreground))
|
||||
})
|
||||
})
|
||||
.when_some(self.label, |button, label| button.child(label))
|
||||
.child(
|
||||
canvas(record_anchor_bounds(self.id.clone()), |_, _, _, _| {})
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
)
|
||||
.when(self.disabled, |button| button.opacity(0.5))
|
||||
.when_some(self.tooltip, |button, tooltip| {
|
||||
button.tooltip(move |window, cx| tooltip(window, cx))
|
||||
})
|
||||
.when(!self.disabled, |button| {
|
||||
.when(!self.disabled && !self.loading, |button| {
|
||||
button
|
||||
.cursor_pointer()
|
||||
.focusable()
|
||||
.tab_stop(self.tab_stop)
|
||||
.hover(move |style| style.bg(hover))
|
||||
.active(move |style| style.bg(pressed))
|
||||
.hover(move |style| style.bg(hover).border_color(hover_border))
|
||||
.active(move |style| style.bg(pressed).border_color(pressed_border))
|
||||
.focus_visible(|style| style.shadow(focus_ring(colors.ring)))
|
||||
.when_some(on_click, |button, on_click| {
|
||||
button.on_click(move |event, window, cx| on_click(event, window, cx))
|
||||
|
||||
+61
-12
@@ -12,13 +12,13 @@ use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, Bounds, Context, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
|
||||
KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role,
|
||||
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task,
|
||||
Window, actions, canvas, deferred, div, px,
|
||||
ScrollHandle, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
|
||||
Subscription, Task, Window, actions, anchored, canvas, deferred, div, point, px,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
||||
|
||||
use crate::common::{Sizable, h_flex, v_flex};
|
||||
use crate::common::{Sizable, StyledExt as _, h_flex, v_flex};
|
||||
use crate::input::{Input, InputState, InputStateEvent};
|
||||
use crate::scroll_area::ScrollArea;
|
||||
|
||||
@@ -81,6 +81,7 @@ where
|
||||
filter: Option<ComboboxFilter<T>>,
|
||||
unmatched: UnmatchedText,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl<T> Combobox<T>
|
||||
@@ -103,6 +104,7 @@ where
|
||||
filter: None,
|
||||
unmatched: UnmatchedText::default(),
|
||||
size: Size::default(),
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +167,16 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Style refinements set the combobox's width.
|
||||
impl<T> Styled for Combobox<T>
|
||||
where
|
||||
T: Clone + PartialEq + 'static,
|
||||
{
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
/// Emitted when the combobox's value changes.
|
||||
pub struct ComboboxChanged;
|
||||
|
||||
@@ -189,6 +201,8 @@ where
|
||||
filter: ComboboxFilter<T>,
|
||||
unmatched: UnmatchedText,
|
||||
size: Size,
|
||||
/// Width refinements from the builder, applied to the wrapper each render.
|
||||
style: StyleRefinement,
|
||||
/// Handler reports in flight; held so they die with the state.
|
||||
tasks: Vec<Task<()>>,
|
||||
/// Keeps the query-event subscription tied to the state's lifetime.
|
||||
@@ -225,6 +239,7 @@ where
|
||||
|
||||
let subscription =
|
||||
cx.subscribe_in(&query, window, |state, _, event, window, cx| match event {
|
||||
InputStateEvent::Focused => state.query_focused(window, cx),
|
||||
InputStateEvent::TextChanged => state.text_changed(window, cx),
|
||||
InputStateEvent::Blurred => state.blurred(window, cx),
|
||||
_ => {}
|
||||
@@ -248,6 +263,7 @@ where
|
||||
}),
|
||||
unmatched: combobox.unmatched,
|
||||
size: combobox.size,
|
||||
style: combobox.style,
|
||||
tasks: Vec::new(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
@@ -364,6 +380,16 @@ where
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Gaining focus.
|
||||
fn query_focused(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selected.is_some() {
|
||||
return;
|
||||
}
|
||||
self.refilter("");
|
||||
self.highlight = (!self.visible.is_empty()).then_some(0usize);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Losing focus. [`UnmatchedText`] decides what the text becomes.
|
||||
fn blurred(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.highlight = None;
|
||||
@@ -447,11 +473,25 @@ where
|
||||
T: Clone + PartialEq + 'static,
|
||||
{
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (metrics, colors, radius) = {
|
||||
let (metrics, colors, radius, panel_border, spacing_unit, shadows) = {
|
||||
let theme = cx.theme();
|
||||
let metrics = theme.sizes.resolve(self.size);
|
||||
(metrics, theme.colors, theme.radius)
|
||||
|
||||
let panel_border = if theme.borders.overlays {
|
||||
theme.colors.border
|
||||
} else {
|
||||
theme.colors.surface
|
||||
};
|
||||
(
|
||||
metrics,
|
||||
theme.colors,
|
||||
theme.radius,
|
||||
panel_border,
|
||||
theme.spacing.unit,
|
||||
theme.elevations.popover.shadows(),
|
||||
)
|
||||
};
|
||||
|
||||
let icon_size = metrics.dense_icon_size;
|
||||
let id = self.id.clone();
|
||||
let state = cx.entity();
|
||||
@@ -490,7 +530,9 @@ where
|
||||
.id(id.clone())
|
||||
.key_context(COMBOBOX_CONTEXT)
|
||||
.relative()
|
||||
.w_full()
|
||||
.w(window.rem_size() * 12.0)
|
||||
.min_w(window.rem_size() * 6.0)
|
||||
.refine_style(&self.style)
|
||||
.role(Role::ComboBox)
|
||||
.aria_expanded(open)
|
||||
.aria_label(self.name.clone())
|
||||
@@ -532,13 +574,14 @@ where
|
||||
.child(
|
||||
Input::new((id.clone(), "query"), self.query.clone())
|
||||
.placeholder(self.placeholder.clone())
|
||||
.placeholder_when_focused(true)
|
||||
.with_size(self.size),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.absolute()
|
||||
.right(metrics.dense_padding_x)
|
||||
.top((metrics.input_height - icon_size) / 2.)
|
||||
.top((metrics.input_height + BORDER_WIDTH * 2.0 - icon_size) / 2.)
|
||||
.child(chevron),
|
||||
);
|
||||
|
||||
@@ -626,6 +669,10 @@ where
|
||||
};
|
||||
|
||||
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
|
||||
let popup_width = wrapper_bounds
|
||||
.borrow()
|
||||
.map(|bounds| bounds.size.width)
|
||||
.unwrap_or_else(|| window.rem_size() * 12.0);
|
||||
|
||||
let panel = v_flex()
|
||||
.id((id.clone(), "panel"))
|
||||
@@ -633,14 +680,14 @@ where
|
||||
let name = format!("{id}-panel");
|
||||
move || name
|
||||
})
|
||||
.mt_1()
|
||||
.w_full()
|
||||
.w(popup_width)
|
||||
.p_1()
|
||||
.bg(colors.surface)
|
||||
.text_color(colors.foreground)
|
||||
.rounded(radius.overlay)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.border_color(panel_border)
|
||||
.shadow(shadows)
|
||||
.overflow_hidden()
|
||||
.child(rows)
|
||||
.child(
|
||||
@@ -684,7 +731,9 @@ where
|
||||
.inset_0(),
|
||||
);
|
||||
|
||||
wrapper = wrapper.child(deferred(panel));
|
||||
wrapper = wrapper.child(deferred(
|
||||
anchored().offset(point(px(0.), spacing_unit)).child(panel),
|
||||
));
|
||||
}
|
||||
|
||||
wrapper
|
||||
|
||||
@@ -10,6 +10,7 @@ use gpui::{
|
||||
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window,
|
||||
actions, canvas, div, px, relative,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
|
||||
use crate::common::{h_flex, v_flex};
|
||||
@@ -48,18 +49,27 @@ pub struct Command {
|
||||
pub label: SharedString,
|
||||
/// Gray text on the row's right edge, e.g. the keybinding.
|
||||
pub hint: Option<SharedString>,
|
||||
/// Leading glyph shown before the label.
|
||||
pub icon: Option<IconRole>,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Creates a command without a hint.
|
||||
/// Creates a command without a hint or icon.
|
||||
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
hint: None,
|
||||
icon: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows an icon before the label.
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
self.icon = Some(role);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the row's right-edge hint.
|
||||
pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
@@ -295,15 +305,21 @@ impl Render for CommandPaletteState {
|
||||
return div().into_any_element();
|
||||
}
|
||||
|
||||
let (colors, radius, metrics, shadows) = {
|
||||
let (colors, radius, metrics, panel_border, shadows) = {
|
||||
let theme = cx.theme();
|
||||
(
|
||||
theme.colors,
|
||||
theme.radius,
|
||||
theme.sizes.resolve(Size::default()),
|
||||
if theme.borders.overlays {
|
||||
theme.colors.border
|
||||
} else {
|
||||
theme.colors.surface
|
||||
},
|
||||
theme.elevations.dialog.shadows(),
|
||||
)
|
||||
};
|
||||
|
||||
let id = self.id.clone();
|
||||
let state = cx.entity();
|
||||
|
||||
@@ -345,6 +361,9 @@ impl Render for CommandPaletteState {
|
||||
state.update(cx, |state, cx| state.choose_row(row_ix, window, cx));
|
||||
}
|
||||
})
|
||||
.when_some(command.icon, |row, role| {
|
||||
row.child(icon(role).size(metrics.icon_size).color(colors.foreground))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
@@ -407,7 +426,7 @@ impl Render for CommandPaletteState {
|
||||
.text_color(colors.foreground)
|
||||
.rounded(radius.overlay)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.border_color(panel_border)
|
||||
.shadow(shadows)
|
||||
.overflow_hidden()
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
|
||||
@@ -130,7 +130,11 @@ impl RenderOnce for Dialog {
|
||||
let theme = cx.theme();
|
||||
(
|
||||
theme.colors.overlay,
|
||||
theme.colors.border,
|
||||
if theme.borders.overlays {
|
||||
theme.colors.border
|
||||
} else {
|
||||
theme.colors.surface
|
||||
},
|
||||
theme.colors.foreground,
|
||||
theme.colors.muted_foreground,
|
||||
theme.colors.surface,
|
||||
|
||||
+243
-220
@@ -7,15 +7,17 @@ use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, AppContext as _, Bounds, ClipboardItem, ContentMask, Context, CursorStyle, DefiniteLength,
|
||||
DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter, Font, HitboxBehavior,
|
||||
InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta,
|
||||
ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task,
|
||||
TextAlign, TextRun, UTF16Selection, Window, WrappedLine, actions, canvas, div, fill, point, px,
|
||||
Animation, AnimationExt as _, AnyElement, App, AppContext as _, Bounds, ClipboardItem,
|
||||
ContentMask, Context, CursorStyle, DefiniteLength, DispatchPhase, ElementId, Entity,
|
||||
EntityInputHandler, EventEmitter, Font, HitboxBehavior, InteractiveElement as _, IntoElement,
|
||||
KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
|
||||
ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta, ScrollEvent, SharedString,
|
||||
StatefulInteractiveElement as _, Styled as _, TextAlign, TextRun, UTF16Selection, Window,
|
||||
WrappedLine, actions, canvas, div, fill, point, px,
|
||||
};
|
||||
use gpui_native_icons::IconRole;
|
||||
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, ColorTokens, Size};
|
||||
use gpui_native_utils::focus_ring;
|
||||
|
||||
use crate::button::{Button, ButtonVariant};
|
||||
use crate::common::{Sizable, h_flex};
|
||||
@@ -476,97 +478,6 @@ impl HistoryEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Caret blink controller.
|
||||
struct CaretBlink {
|
||||
interval: Duration,
|
||||
generation: usize,
|
||||
visible: bool,
|
||||
active: bool,
|
||||
paused: bool,
|
||||
/// Pending blink timers; held so they die with the controller.
|
||||
tasks: Vec<Task<()>>,
|
||||
}
|
||||
|
||||
impl CaretBlink {
|
||||
fn new(_cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
interval: CARET_BLINK_INTERVAL,
|
||||
generation: 0,
|
||||
visible: true,
|
||||
active: false,
|
||||
paused: false,
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn visible(&self) -> bool {
|
||||
self.visible
|
||||
}
|
||||
|
||||
/// Starts blinking; the caret shows first and hides after one interval.
|
||||
fn enable(&mut self, cx: &mut Context<Self>) {
|
||||
if self.active {
|
||||
return;
|
||||
}
|
||||
self.active = true;
|
||||
self.visible = true;
|
||||
self.paused = false;
|
||||
self.schedule(cx);
|
||||
}
|
||||
|
||||
/// Stops blinking. The caret stays hidden; paint gates on focus anyway.
|
||||
fn disable(&mut self, cx: &mut Context<Self>) {
|
||||
self.active = false;
|
||||
self.visible = false;
|
||||
self.paused = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Holds the caret visible and restarts the hide timer.
|
||||
fn pause(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.active {
|
||||
return;
|
||||
}
|
||||
if !self.visible {
|
||||
self.visible = true;
|
||||
cx.notify();
|
||||
}
|
||||
self.paused = true;
|
||||
self.generation += 1;
|
||||
self.schedule_pause(cx);
|
||||
}
|
||||
|
||||
fn tick(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.active || self.paused {
|
||||
return;
|
||||
}
|
||||
self.visible = !self.visible;
|
||||
cx.notify();
|
||||
self.schedule(cx);
|
||||
}
|
||||
|
||||
fn schedule(&mut self, cx: &mut Context<Self>) {
|
||||
self.generation += 1;
|
||||
self.schedule_pause(cx);
|
||||
}
|
||||
|
||||
fn schedule_pause(&mut self, cx: &mut Context<Self>) {
|
||||
let generation = self.generation;
|
||||
let interval = self.interval;
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(interval).await;
|
||||
this.update(cx, |this, cx| {
|
||||
if this.generation == generation {
|
||||
this.paused = false;
|
||||
this.tick(cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
/// The application-owned state of a text field.
|
||||
pub struct InputState {
|
||||
text: String,
|
||||
@@ -606,20 +517,18 @@ pub struct InputState {
|
||||
scroll_offset: gpui::Point<Pixels>,
|
||||
/// Set by every caret-affecting change.
|
||||
scroll_to_caret_pending: bool,
|
||||
/// Drives the focused caret's blink.
|
||||
caret_blink: Entity<CaretBlink>,
|
||||
/// The shaped line and element bounds, recorded during each paint,
|
||||
/// and shared with hit-testing and the IME geometry conversions.
|
||||
/// Bumped on every edit.
|
||||
caret_epoch: usize,
|
||||
/// When the current caret epoch started.
|
||||
caret_epoch_started: Option<Instant>,
|
||||
/// The shaped line and element bounds, recorded during each paint.
|
||||
layout: Rc<RefCell<Option<InputLayout>>>,
|
||||
/// Keeps the field repainting on blink ticks. Dropping it unsubscribes.
|
||||
_caret_blink_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl EventEmitter<InputStateEvent> for InputState {}
|
||||
|
||||
impl InputState {
|
||||
fn new_inner(cx: &mut Context<Self>) -> Self {
|
||||
let caret_blink = cx.new(CaretBlink::new);
|
||||
Self {
|
||||
text: String::new(),
|
||||
cursor: 0,
|
||||
@@ -649,9 +558,9 @@ impl InputState {
|
||||
goal_x: None,
|
||||
scroll_offset: point(Pixels::ZERO, Pixels::ZERO),
|
||||
scroll_to_caret_pending: true,
|
||||
caret_blink: caret_blink.clone(),
|
||||
caret_epoch: 0,
|
||||
caret_epoch_started: Some(Instant::now()),
|
||||
layout: Rc::new(RefCell::new(None)),
|
||||
_caret_blink_subscription: cx.observe(&caret_blink, |_, _, cx| cx.notify()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,9 +570,6 @@ impl InputState {
|
||||
}
|
||||
|
||||
/// Creates the state entity with initial text, caret at the end.
|
||||
///
|
||||
/// Newlines become spaces; flip the field to multiline with [`set_multiline`]
|
||||
/// before `set_value` to seed lines.
|
||||
pub fn new_with_text(text: impl Into<String>, cx: &mut App) -> Entity<Self> {
|
||||
let text = sanitize(text.into(), false);
|
||||
cx.new(|cx| {
|
||||
@@ -681,10 +587,6 @@ impl InputState {
|
||||
}
|
||||
|
||||
/// Replaces the whole value and collapses the selection to the end.
|
||||
///
|
||||
/// Newlines become spaces unless the field is multiline. Emits
|
||||
/// [`InputStateEvent::TextChanged`] when the value changed; setting the
|
||||
/// current value is a no-op.
|
||||
pub fn set_value<V>(&mut self, value: V, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
V: Into<String>,
|
||||
@@ -866,7 +768,8 @@ impl InputState {
|
||||
cx.notify();
|
||||
self.scroll_to_caret_pending = true;
|
||||
self.goal_x = None;
|
||||
self.caret_blink.update(cx, |blink, cx| blink.pause(cx));
|
||||
self.caret_epoch += 1;
|
||||
self.caret_epoch_started = Some(Instant::now());
|
||||
window.notify_text_input_changed(&self.focus_handle);
|
||||
}
|
||||
|
||||
@@ -899,10 +802,11 @@ impl InputState {
|
||||
}
|
||||
self.focused = focused;
|
||||
|
||||
// A fresh focus starts a fresh blink epoch, so the caret shows solid
|
||||
// for one interval before blinking resumes.
|
||||
if focused {
|
||||
self.caret_blink.update(cx, |blink, cx| blink.enable(cx));
|
||||
} else {
|
||||
self.caret_blink.update(cx, |blink, cx| blink.disable(cx));
|
||||
self.caret_epoch += 1;
|
||||
self.caret_epoch_started = Some(Instant::now());
|
||||
}
|
||||
|
||||
cx.emit(if focused {
|
||||
@@ -913,8 +817,22 @@ impl InputState {
|
||||
}
|
||||
|
||||
/// Whether the caret should paint.
|
||||
pub fn caret_visible(&mut self, focused: bool, cx: &mut Context<Self>) -> bool {
|
||||
focused && !self.read_only && self.caret_blink.read(cx).visible()
|
||||
pub fn caret_visible(&mut self, focused: bool) -> bool {
|
||||
let elapsed = self
|
||||
.caret_epoch_started
|
||||
.map(|started| started.elapsed())
|
||||
.unwrap_or_default();
|
||||
self.caret_blink_visible_at(focused, elapsed)
|
||||
}
|
||||
|
||||
/// The visibility math at a given time since the epoch started.
|
||||
pub fn caret_blink_visible_at(&self, focused: bool, elapsed: Duration) -> bool {
|
||||
if !focused || self.read_only {
|
||||
return false;
|
||||
}
|
||||
let period = CARET_BLINK_INTERVAL * 2;
|
||||
(elapsed.as_millis() as u64 % period.as_millis() as u64)
|
||||
< CARET_BLINK_INTERVAL.as_millis() as u64
|
||||
}
|
||||
|
||||
/// Replaces a byte range with `text` and collapses the caret to its end.
|
||||
@@ -1315,7 +1233,7 @@ impl InputState {
|
||||
}
|
||||
|
||||
// A caret promises that typing lands there; in read-only it does not.
|
||||
if focused && self.caret_visible(focused, cx) && self.selection().is_empty() {
|
||||
if focused && self.caret_visible(focused) && self.selection().is_empty() {
|
||||
self.paint_caret(origin, font_size, colors.foreground, window);
|
||||
}
|
||||
|
||||
@@ -1901,9 +1819,13 @@ pub struct Input {
|
||||
placeholder: SharedString,
|
||||
size: Size,
|
||||
disabled: bool,
|
||||
/// The placeholder stays visible while focused.
|
||||
placeholder_when_focused: bool,
|
||||
a11y_label: Option<SharedString>,
|
||||
/// Multiline field height in rows; ignored when the state is single-line.
|
||||
/// Multiline field height in row, ignored when the state is single-line.
|
||||
rows: usize,
|
||||
prefix: Option<AnyElement>,
|
||||
suffix: Option<AnyElement>,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
@@ -1918,8 +1840,11 @@ impl Input {
|
||||
placeholder: SharedString::default(),
|
||||
size: Size::default(),
|
||||
disabled: false,
|
||||
placeholder_when_focused: false,
|
||||
a11y_label: None,
|
||||
rows: DEFAULT_ROWS,
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1938,6 +1863,12 @@ impl Input {
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows the placeholder while the field is focused.
|
||||
pub fn placeholder_when_focused(mut self, when_focused: bool) -> Self {
|
||||
self.placeholder_when_focused = when_focused;
|
||||
self
|
||||
}
|
||||
|
||||
/// Renders the field inert, unfocusable, and dimmed; disabled always wins.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
@@ -1952,6 +1883,24 @@ impl Input {
|
||||
self.a11y_label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Docks an element inside the field's leading edge.
|
||||
pub fn prefix<E>(mut self, prefix: E) -> Self
|
||||
where
|
||||
E: IntoElement,
|
||||
{
|
||||
self.prefix = Some(prefix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Docks an element inside the field's trailing edge.
|
||||
pub fn suffix<E>(mut self, suffix: E) -> Self
|
||||
where
|
||||
E: IntoElement,
|
||||
{
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Input {
|
||||
@@ -1967,25 +1916,28 @@ impl RenderOnce for Input {
|
||||
let radius = theme.radius;
|
||||
let metrics = theme.sizes.resolve(self.size);
|
||||
let focus_handle = self.state.read(cx).focus_handle.clone();
|
||||
|
||||
let focused = focus_handle.is_focused(window);
|
||||
|
||||
let is_empty = self.state.read(cx).text.is_empty();
|
||||
let is_password = self.state.read(cx).is_password();
|
||||
let revealed = self.state.read(cx).is_revealed();
|
||||
let multiline = self.state.read(cx).is_multiline();
|
||||
let recording = focused && !self.disabled && !self.state.read(cx).is_read_only();
|
||||
|
||||
let right_padding = if is_password {
|
||||
metrics.dense_padding_x + metrics.dense_icon_size
|
||||
} else {
|
||||
metrics.dense_padding_x
|
||||
};
|
||||
let small = theme.sizes.resolve(Size::Small);
|
||||
let docked_height = small.control_height + BORDER_WIDTH * 2.0;
|
||||
let edge_inset = ((metrics.input_height - docked_height) / 2.0).max(Pixels::ZERO);
|
||||
|
||||
// The field's own breathing room; the text starts here from the top when multiline.
|
||||
let vertical_inset = metrics.dense_padding_x * 0.5;
|
||||
let reveal_width = small.dense_padding_x * 2.0 + small.icon_size;
|
||||
|
||||
let field_padding = FieldPadding {
|
||||
left: metrics.dense_padding_x,
|
||||
right: right_padding,
|
||||
left: Pixels::ZERO,
|
||||
right: if is_password {
|
||||
(edge_inset + reveal_width - metrics.dense_padding_x).max(Pixels::ZERO)
|
||||
} else {
|
||||
Pixels::ZERO
|
||||
},
|
||||
top: vertical_inset,
|
||||
};
|
||||
|
||||
@@ -1993,9 +1945,10 @@ impl RenderOnce for Input {
|
||||
DefiniteLength::Fraction(ratio) => metrics.text_size * ratio,
|
||||
DefiniteLength::Absolute(length) => length.to_pixels(window.rem_size()),
|
||||
});
|
||||
|
||||
let field_height = line_height.map_or(metrics.input_height, |line_height| {
|
||||
line_height * self.rows as f32 + vertical_inset * 2.0
|
||||
});
|
||||
}) + BORDER_WIDTH * 2.0;
|
||||
|
||||
let fallback_label = self
|
||||
.a11y_label
|
||||
@@ -2003,8 +1956,111 @@ impl RenderOnce for Input {
|
||||
.or_else(|| (!self.placeholder.is_empty()).then_some(self.placeholder.clone()))
|
||||
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
|
||||
|
||||
let edge_zone = |name: &'static str, element: AnyElement| {
|
||||
div()
|
||||
.debug_selector(|| name.to_string())
|
||||
.flex_none()
|
||||
.h_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
.child(element)
|
||||
};
|
||||
|
||||
let canvas_element = {
|
||||
let canvas = canvas(
|
||||
{
|
||||
let state = self.state.clone();
|
||||
move |bounds, window, cx| {
|
||||
// The hitbox backs the I-beam hover cursor.
|
||||
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
|
||||
let wrap_width = multiline.then(|| {
|
||||
(bounds.size.width - field_padding.left - field_padding.right)
|
||||
.max(Pixels::ZERO)
|
||||
});
|
||||
let text = state.read(cx).shape_current_text(wrap_width, window);
|
||||
(text, hitbox)
|
||||
}
|
||||
},
|
||||
{
|
||||
let state = self.state.clone();
|
||||
let focus = focus_handle.clone();
|
||||
let disabled = self.disabled;
|
||||
|
||||
move |bounds, (text, hitbox), window, cx| {
|
||||
if !disabled {
|
||||
window.set_cursor_style(CursorStyle::IBeam, &hitbox);
|
||||
}
|
||||
|
||||
let dragging = state.update(cx, |state, cx| {
|
||||
state.paint(text, bounds, field_padding, colors, window, cx)
|
||||
});
|
||||
|
||||
if disabled {
|
||||
return;
|
||||
}
|
||||
|
||||
window.handle_input(
|
||||
&focus,
|
||||
gpui::ElementInputHandler::new(bounds, state.clone()),
|
||||
cx,
|
||||
);
|
||||
|
||||
window.on_mouse_event({
|
||||
let state = state.clone();
|
||||
move |event: &ScrollEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble
|
||||
|| !bounds.contains(&event.position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let delta = match event.delta {
|
||||
ScrollDelta::Pixels(delta) => delta,
|
||||
ScrollDelta::Lines(lines) => point(
|
||||
px(lines.x * SCROLL_LINES_HEIGHT),
|
||||
px(lines.y * SCROLL_LINES_HEIGHT),
|
||||
),
|
||||
};
|
||||
|
||||
let consumed = state
|
||||
.update(cx, |state, cx| state.scroll_by(delta, window, cx));
|
||||
|
||||
if consumed {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if dragging {
|
||||
InputState::register_drag_listeners(&state, window);
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.inset_0();
|
||||
if recording {
|
||||
let epoch = self.state.read(cx).caret_epoch;
|
||||
let animation_id = ElementId::from(SharedString::from(format!(
|
||||
"{self_id}-caret-blink-{epoch}",
|
||||
self_id = self.id,
|
||||
)));
|
||||
canvas
|
||||
.with_animation(
|
||||
animation_id,
|
||||
Animation::new(CARET_BLINK_INTERVAL * 2).repeat(),
|
||||
|canvas, _| canvas,
|
||||
)
|
||||
.into_any_element()
|
||||
} else {
|
||||
canvas.into_any_element()
|
||||
}
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id(self.id.clone())
|
||||
.debug_selector(|| "input-field".to_string())
|
||||
.key_context(INPUT_CONTEXT)
|
||||
.role(Role::TextInput)
|
||||
.aria_label(fallback_label)
|
||||
@@ -2012,117 +2068,84 @@ impl RenderOnce for Input {
|
||||
.overflow_hidden()
|
||||
.w_full()
|
||||
.h(field_height)
|
||||
.px(metrics.dense_padding_x)
|
||||
.pl(if self.prefix.is_some() {
|
||||
edge_inset
|
||||
} else {
|
||||
metrics.dense_padding_x
|
||||
})
|
||||
.pr(if self.suffix.is_some() {
|
||||
edge_inset
|
||||
} else {
|
||||
metrics.dense_padding_x
|
||||
})
|
||||
.gap_1p5()
|
||||
.rounded(radius.control)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.input)
|
||||
.text_size(metrics.text_size)
|
||||
.text_color(colors.foreground)
|
||||
.when_some(line_height, |input, line_height| {
|
||||
input.items_start().line_height(line_height)
|
||||
.border_1()
|
||||
.border_color(if theme.borders.fields {
|
||||
colors.border
|
||||
} else {
|
||||
colors.input
|
||||
})
|
||||
.when(self.disabled, |input| input.opacity(0.5))
|
||||
.when(!self.disabled, |input| {
|
||||
input.track_focus(&focus_handle).tab_stop(true)
|
||||
})
|
||||
.when(focused, |input| input.border_color(colors.ring))
|
||||
.when(is_empty && !focused, |input| {
|
||||
input.child(
|
||||
div()
|
||||
.text_color(colors.muted_foreground)
|
||||
.when(multiline, |placeholder| placeholder.pt(vertical_inset))
|
||||
.child(self.placeholder.clone()),
|
||||
)
|
||||
.when(focused && !theme.borders.focus_ring, |input| {
|
||||
input.border_color(colors.ring)
|
||||
})
|
||||
.when(focused && theme.borders.focus_ring, |input| {
|
||||
input.shadow(focus_ring(colors.ring))
|
||||
})
|
||||
.when_some(self.prefix, |input, prefix| {
|
||||
input.child(edge_zone("input-prefix", prefix))
|
||||
})
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
let state = self.state.clone();
|
||||
move |bounds, window, cx| {
|
||||
// The hitbox backs the I-beam hover cursor.
|
||||
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
|
||||
let wrap_width = multiline.then(|| {
|
||||
(bounds.size.width - field_padding.left - field_padding.right)
|
||||
.max(Pixels::ZERO)
|
||||
});
|
||||
let text = state.read(cx).shape_current_text(wrap_width, window);
|
||||
(text, hitbox)
|
||||
}
|
||||
},
|
||||
{
|
||||
let state = self.state.clone();
|
||||
let focus = focus_handle.clone();
|
||||
let disabled = self.disabled;
|
||||
|
||||
move |bounds, (text, hitbox), window, cx| {
|
||||
if !disabled {
|
||||
window.set_cursor_style(CursorStyle::IBeam, &hitbox);
|
||||
}
|
||||
|
||||
let dragging = state.update(cx, |state, cx| {
|
||||
state.paint(text, bounds, field_padding, colors, window, cx)
|
||||
});
|
||||
|
||||
if disabled {
|
||||
return;
|
||||
}
|
||||
|
||||
window.handle_input(
|
||||
&focus,
|
||||
gpui::ElementInputHandler::new(bounds, state.clone()),
|
||||
cx,
|
||||
);
|
||||
|
||||
window.on_mouse_event({
|
||||
let state = state.clone();
|
||||
move |event: &ScrollEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble
|
||||
|| !bounds.contains(&event.position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let delta = match event.delta {
|
||||
ScrollDelta::Pixels(delta) => delta,
|
||||
ScrollDelta::Lines(lines) => point(
|
||||
px(lines.x * SCROLL_LINES_HEIGHT),
|
||||
px(lines.y * SCROLL_LINES_HEIGHT),
|
||||
),
|
||||
};
|
||||
|
||||
let consumed = state
|
||||
.update(cx, |state, cx| state.scroll_by(delta, window, cx));
|
||||
|
||||
if consumed {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if dragging {
|
||||
InputState::register_drag_listeners(&state, window);
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.overflow_hidden()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.items_center()
|
||||
.when_some(line_height, |region, line_height| {
|
||||
region.items_start().line_height(line_height)
|
||||
})
|
||||
.when(
|
||||
is_empty && (!focused || self.placeholder_when_focused),
|
||||
|region| {
|
||||
region.child(
|
||||
div()
|
||||
.text_color(colors.muted_foreground)
|
||||
.when(multiline, |placeholder| placeholder.pt(vertical_inset))
|
||||
.child(self.placeholder.clone()),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(canvas_element),
|
||||
)
|
||||
.when_some(self.suffix, |input, suffix| {
|
||||
input.child(edge_zone("input-suffix", suffix))
|
||||
})
|
||||
.when(is_password && !self.disabled, |input| {
|
||||
input.child(
|
||||
div()
|
||||
.id((self.id.clone(), "input-reveal"))
|
||||
.debug_selector(|| "input-reveal".to_string())
|
||||
.absolute()
|
||||
.right(metrics.dense_padding_x / 2.0)
|
||||
.top_0p5()
|
||||
.bottom_0p5()
|
||||
.right(edge_inset)
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
.child(
|
||||
Button::new((self.id.clone(), "input-reveal-button"))
|
||||
.debug_selector(|| "input-reveal-button".to_string())
|
||||
.icon(if revealed {
|
||||
IconRole::EyeOff
|
||||
} else {
|
||||
|
||||
+50
-47
@@ -7,7 +7,7 @@ use gpui::{
|
||||
Entity, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||
ParentElement as _, Pixels, Point, RenderOnce, Role, ScrollHandle, SharedString,
|
||||
StatefulInteractiveElement as _, Styled as _, Window, anchored, canvas, deferred, div,
|
||||
ease_out_quint, px,
|
||||
ease_out_quint, point, px, relative,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon as icon_glyph};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
@@ -127,6 +127,7 @@ impl MenuItem {
|
||||
struct MenuChrome {
|
||||
surface: gpui::Hsla,
|
||||
border: gpui::Hsla,
|
||||
panel_border: gpui::Hsla,
|
||||
foreground: gpui::Hsla,
|
||||
muted_foreground: gpui::Hsla,
|
||||
primary: gpui::Hsla,
|
||||
@@ -137,6 +138,9 @@ struct MenuChrome {
|
||||
radius_sm: Pixels,
|
||||
icon_size: Pixels,
|
||||
dense_icon_size: Pixels,
|
||||
menu_item_height: Pixels,
|
||||
spacing_unit: Pixels,
|
||||
text_size: Pixels,
|
||||
shadow: Vec<BoxShadow>,
|
||||
}
|
||||
|
||||
@@ -242,6 +246,11 @@ impl RenderOnce for Menu {
|
||||
MenuChrome {
|
||||
surface: theme.colors.surface,
|
||||
border: theme.colors.border,
|
||||
panel_border: if theme.borders.overlays {
|
||||
theme.colors.border
|
||||
} else {
|
||||
theme.colors.surface
|
||||
},
|
||||
foreground: theme.colors.foreground,
|
||||
muted_foreground: theme.colors.muted_foreground,
|
||||
primary: theme.ramps.primary.base,
|
||||
@@ -252,6 +261,9 @@ impl RenderOnce for Menu {
|
||||
radius_sm: theme.radius.sm,
|
||||
icon_size: theme.sizes.medium.icon_size,
|
||||
dense_icon_size: theme.sizes.medium.dense_icon_size,
|
||||
menu_item_height: theme.sizes.medium.menu_item_height,
|
||||
spacing_unit: theme.spacing.unit,
|
||||
text_size: theme.sizes.medium.text_size,
|
||||
shadow: theme.elevations.popover.shadows(),
|
||||
},
|
||||
theme.motion.overlay,
|
||||
@@ -259,7 +271,6 @@ impl RenderOnce for Menu {
|
||||
};
|
||||
|
||||
let trigger_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
|
||||
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
let selection = window.use_keyed_state("menu-selection", cx, |_, _| 0usize);
|
||||
let was_open = window.use_keyed_state("menu-was-open", cx, |_, _| self.open);
|
||||
@@ -363,19 +374,36 @@ impl RenderOnce for Menu {
|
||||
|
||||
let panel = v_flex()
|
||||
.id("menu-panel")
|
||||
.debug_selector(|| "menu-panel".to_string())
|
||||
.occlude()
|
||||
.track_focus(&focus)
|
||||
.role(Role::Menu)
|
||||
.aria_label(label)
|
||||
.mt_1()
|
||||
.min_w_56()
|
||||
.p_1()
|
||||
.p(chrome.spacing_unit)
|
||||
.bg(chrome.surface)
|
||||
.text_color(chrome.foreground)
|
||||
.rounded(chrome.radius_overlay)
|
||||
.border_1()
|
||||
.border_color(chrome.border)
|
||||
.border_color(chrome.panel_border)
|
||||
.shadow(chrome.shadow.clone())
|
||||
.overflow_hidden()
|
||||
.on_mouse_down_out({
|
||||
let on_change = on_change.clone();
|
||||
let trigger_bounds = trigger_bounds.clone();
|
||||
|
||||
move |event, window, cx| {
|
||||
if trigger_bounds
|
||||
.borrow()
|
||||
.is_some_and(|bounds| bounds.contains(&event.position))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(on_change) = &on_change {
|
||||
on_change(false, window, cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_key_down({
|
||||
let items = items.clone();
|
||||
let on_change = on_change.clone();
|
||||
@@ -392,44 +420,14 @@ impl RenderOnce for Menu {
|
||||
.children(rows),
|
||||
)
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
let cell = panel_bounds.clone();
|
||||
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
|
||||
},
|
||||
{
|
||||
let on_change = on_change.clone();
|
||||
move |_, _, window, cx| {
|
||||
if !focus.contains_focused(window, cx) {
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
|
||||
window.on_mouse_event({
|
||||
let on_change = on_change.clone();
|
||||
move |event: &MouseDownEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble {
|
||||
return;
|
||||
}
|
||||
|
||||
let on_panel = panel_bounds
|
||||
.borrow()
|
||||
.is_some_and(|bounds| bounds.contains(&event.position));
|
||||
let on_trigger = trigger_bounds
|
||||
.borrow()
|
||||
.is_some_and(|bounds| bounds.contains(&event.position));
|
||||
if on_panel || on_trigger {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(on_change) = &on_change {
|
||||
on_change(false, window, cx);
|
||||
}
|
||||
cx.stop_propagation();
|
||||
}
|
||||
});
|
||||
canvas(|_, _, _| {}, {
|
||||
let focus = focus.clone();
|
||||
move |_, _, window, cx| {
|
||||
if !focus.contains_focused(window, cx) {
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
);
|
||||
@@ -437,7 +435,8 @@ impl RenderOnce for Menu {
|
||||
let anchored = match self.open_at {
|
||||
Some(position) => anchored().position(position),
|
||||
None => anchored(),
|
||||
};
|
||||
}
|
||||
.offset(point(px(0.), chrome.spacing_unit));
|
||||
|
||||
wrapper = wrapper.child(deferred(anchored.child(panel.with_animation(
|
||||
"menu-enter",
|
||||
@@ -462,8 +461,9 @@ fn render_item(
|
||||
match item.kind {
|
||||
MenuItemKind::Separator => div()
|
||||
.h(px(1.))
|
||||
.mx_2()
|
||||
.my_1()
|
||||
.debug_selector(move || format!("menu-separator-{index}"))
|
||||
.mx(chrome.spacing_unit * 2.0)
|
||||
.my(chrome.spacing_unit)
|
||||
.bg(chrome.border)
|
||||
.into_any_element(),
|
||||
MenuItemKind::Action => {
|
||||
@@ -476,11 +476,14 @@ fn render_item(
|
||||
|
||||
div()
|
||||
.id(("menu-item", index))
|
||||
.debug_selector(move || format!("menu-item-{index}"))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.min_h(chrome.menu_item_height)
|
||||
.px(chrome.spacing_unit * 2.0)
|
||||
.text_size(chrome.text_size)
|
||||
.line_height(relative(1.0))
|
||||
.rounded(chrome.radius_sm)
|
||||
.map(|row| {
|
||||
if disabled {
|
||||
|
||||
+42
-52
@@ -6,7 +6,7 @@ use gpui::{
|
||||
Animation, AnimationExt as _, AnyElement, App, Bounds, DispatchPhase, ElementId,
|
||||
InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||
ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
||||
Styled as _, Window, anchored, canvas, deferred, div, ease_out_quint,
|
||||
Styled as _, Window, anchored, canvas, deferred, div, ease_out_quint, point, px,
|
||||
};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
|
||||
@@ -91,18 +91,23 @@ impl Popover {
|
||||
|
||||
impl RenderOnce for Popover {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let (colors, radius, shadows, enter_duration) = {
|
||||
let (colors, radius, shadows, enter_duration, panel_border, anchor_gap) = {
|
||||
let theme = cx.theme();
|
||||
(
|
||||
theme.colors,
|
||||
theme.radius,
|
||||
theme.elevations.popover.shadows(),
|
||||
theme.motion.overlay,
|
||||
if theme.borders.overlays {
|
||||
theme.colors.border
|
||||
} else {
|
||||
theme.colors.surface
|
||||
},
|
||||
theme.spacing.unit,
|
||||
)
|
||||
};
|
||||
|
||||
let trigger_bounds: BoundsCell = Rc::new(RefCell::new(None));
|
||||
let panel_bounds: BoundsCell = Rc::new(RefCell::new(None));
|
||||
|
||||
let fallback_label = self
|
||||
.a11y_label
|
||||
@@ -166,18 +171,34 @@ impl RenderOnce for Popover {
|
||||
|
||||
let panel = v_flex()
|
||||
.id("popover-panel")
|
||||
.debug_selector(|| "popover-panel".to_string())
|
||||
.occlude()
|
||||
.track_focus(&focus)
|
||||
.role(Role::Dialog)
|
||||
.aria_label(label)
|
||||
.mt_1()
|
||||
.min_w_32()
|
||||
.bg(colors.surface)
|
||||
.text_color(colors.surface_foreground)
|
||||
.rounded(radius.overlay)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.border_color(panel_border)
|
||||
.shadow(shadows)
|
||||
.overflow_hidden()
|
||||
.on_mouse_down_out({
|
||||
let on_change = on_change.clone();
|
||||
let trigger_bounds = trigger_bounds.clone();
|
||||
move |event, window, cx| {
|
||||
if trigger_bounds
|
||||
.borrow()
|
||||
.is_some_and(|bounds| bounds.contains(&event.position))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(on_change) = &on_change {
|
||||
on_change(false, window, cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_key_down({
|
||||
let on_change = on_change.clone();
|
||||
move |event: &KeyDownEvent, window, cx| {
|
||||
@@ -191,58 +212,27 @@ impl RenderOnce for Popover {
|
||||
})
|
||||
.when_some(content, |panel, content| panel.child(content))
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
let cell = panel_bounds.clone();
|
||||
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
|
||||
},
|
||||
{
|
||||
let on_change = on_change.clone();
|
||||
let focus = focus;
|
||||
move |_, _, window, cx| {
|
||||
if !focus.contains_focused(window, cx) {
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
window.on_mouse_event({
|
||||
let on_change = on_change.clone();
|
||||
move |event: &MouseDownEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble
|
||||
|| event.button != MouseButton::Left
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let on_panel = panel_bounds
|
||||
.borrow()
|
||||
.is_some_and(|bounds| bounds.contains(&event.position));
|
||||
|
||||
let on_trigger = trigger_bounds
|
||||
.borrow()
|
||||
.is_some_and(|bounds| bounds.contains(&event.position));
|
||||
|
||||
if on_panel || on_trigger {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(on_change) = &on_change {
|
||||
on_change(false, window, cx);
|
||||
}
|
||||
|
||||
cx.stop_propagation();
|
||||
}
|
||||
});
|
||||
canvas(|_, _, _| {}, {
|
||||
let focus = focus;
|
||||
move |_, _, window, cx| {
|
||||
if !focus.contains_focused(window, cx) {
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
);
|
||||
|
||||
wrapper = wrapper.child(deferred(anchored().child(panel.with_animation(
|
||||
"popover-enter",
|
||||
Animation::new(enter_duration).with_easing(ease_out_quint()),
|
||||
|panel, delta| panel.opacity(delta),
|
||||
))));
|
||||
wrapper = wrapper.child(deferred(
|
||||
anchored()
|
||||
.offset(point(px(0.), anchor_gap))
|
||||
.child(panel.with_animation(
|
||||
"popover-enter",
|
||||
Animation::new(enter_duration).with_easing(ease_out_quint()),
|
||||
|panel, delta| panel.opacity(delta),
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
wrapper
|
||||
|
||||
@@ -6,7 +6,8 @@ use gpui::{
|
||||
SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
||||
use gpui_native_utils::focus_ring;
|
||||
|
||||
use crate::common::{Sizable, StyledExt, h_flex};
|
||||
use crate::menu::{Menu, MenuItem};
|
||||
@@ -169,6 +170,8 @@ where
|
||||
let muted_foreground = theme.colors.muted_foreground;
|
||||
let radius = theme.radius.control;
|
||||
let icon_size = metrics.dense_icon_size;
|
||||
let fields_bordered = theme.borders.fields;
|
||||
let focus_ring_on = theme.borders.focus_ring;
|
||||
|
||||
let label = self
|
||||
.a11y_label
|
||||
@@ -180,7 +183,22 @@ where
|
||||
.use_keyed_state("select-open", cx, |_, _| false)
|
||||
.clone()
|
||||
});
|
||||
|
||||
let is_open = *open.read(cx);
|
||||
let open_for_items = open.clone();
|
||||
|
||||
let (rest_border, hover_border) = if fields_bordered {
|
||||
(
|
||||
if is_open && !focus_ring_on {
|
||||
ring
|
||||
} else {
|
||||
border
|
||||
},
|
||||
border,
|
||||
)
|
||||
} else {
|
||||
(input, muted)
|
||||
};
|
||||
|
||||
let selected_index = self.selected.as_ref().and_then(|value| {
|
||||
self.options
|
||||
@@ -194,6 +212,7 @@ where
|
||||
let trigger_id = self.id.clone();
|
||||
let trigger_label = label.clone();
|
||||
let trigger_style = self.style;
|
||||
|
||||
let trigger = move |_window: &mut Window, _cx: &mut App| {
|
||||
let text = trigger_selected
|
||||
.as_ref()
|
||||
@@ -207,17 +226,18 @@ where
|
||||
|
||||
h_flex()
|
||||
.id((trigger_id.clone(), "select-trigger"))
|
||||
.debug_selector(|| "select-trigger".to_string())
|
||||
.role(Role::ComboBox)
|
||||
.aria_label(trigger_label.clone())
|
||||
.cursor_pointer()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.h(metrics.input_height)
|
||||
.h(metrics.input_height + BORDER_WIDTH * 2.0)
|
||||
.px(metrics.dense_padding_x)
|
||||
.rounded(radius)
|
||||
.border_1()
|
||||
.border_color(if is_open { ring } else { border })
|
||||
.border_color(rest_border)
|
||||
.bg(input)
|
||||
.text_size(metrics.text_size)
|
||||
.text_color(if trigger_selected.is_some() {
|
||||
@@ -225,7 +245,10 @@ where
|
||||
} else {
|
||||
muted_foreground
|
||||
})
|
||||
.hover(|style| style.bg(muted))
|
||||
.when(is_open && focus_ring_on, |trigger| {
|
||||
trigger.shadow(focus_ring(ring))
|
||||
})
|
||||
.hover(|style| style.bg(muted).border_color(hover_border))
|
||||
.child(text)
|
||||
.child(
|
||||
icon(IconRole::ChevronDown)
|
||||
@@ -236,7 +259,6 @@ where
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
let open_for_items = open.clone();
|
||||
let items = move |_window: &mut Window, _cx: &mut App| {
|
||||
self.options
|
||||
.iter()
|
||||
|
||||
@@ -41,6 +41,7 @@ pub struct Spinner {
|
||||
value: Option<f32>,
|
||||
variant: SpinnerVariant,
|
||||
size: Size,
|
||||
color: Option<Hsla>,
|
||||
a11y_label: Option<SharedString>,
|
||||
}
|
||||
|
||||
@@ -55,10 +56,17 @@ impl Spinner {
|
||||
value: None,
|
||||
variant: SpinnerVariant::default(),
|
||||
size: Size::default(),
|
||||
color: None,
|
||||
a11y_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the arc color, for accent-filled surfaces.
|
||||
pub fn color(mut self, color: Hsla) -> Self {
|
||||
self.color = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the spinner's visual language.
|
||||
pub fn variant(mut self, variant: SpinnerVariant) -> Self {
|
||||
self.variant = variant;
|
||||
@@ -93,7 +101,7 @@ impl RenderOnce for Spinner {
|
||||
let side = theme.sizes.resolve(self.size).icon_size;
|
||||
let stroke = side * RING_STROKE;
|
||||
let track = theme.colors.border;
|
||||
let accent = theme.ramps.primary.base;
|
||||
let accent = self.color.unwrap_or(theme.ramps.primary.base);
|
||||
let value = self.value.map(|value| value.clamp(0.0, 1.0));
|
||||
|
||||
div()
|
||||
|
||||
@@ -96,16 +96,17 @@ impl RenderOnce for Switch {
|
||||
let ramps = theme.ramps;
|
||||
let metrics = theme.sizes.resolve(self.size);
|
||||
|
||||
let track_height = metrics.control_height;
|
||||
let track_width = metrics.control_height * 1.65;
|
||||
let track_height = metrics.switch_height;
|
||||
let track_width = metrics.switch_width;
|
||||
|
||||
let knob = metrics.indicator_size;
|
||||
let inset = metrics.plate_inset;
|
||||
let knob = metrics.switch_knob;
|
||||
let inset = (track_height - knob) / 2.;
|
||||
let travel = track_width - knob - inset * 2.;
|
||||
|
||||
let next = !self.checked;
|
||||
let on_change = self.on_change;
|
||||
let a11y_label = self.a11y_label.or_else(|| self.label.clone());
|
||||
let id = self.id.clone();
|
||||
|
||||
let anim_config = SpringConfig::new(500., 35., 1.);
|
||||
let anim = SpringAnimation::new(anim_config).to(if self.checked { travel } else { px(0.) });
|
||||
@@ -132,6 +133,10 @@ impl RenderOnce for Switch {
|
||||
div()
|
||||
.flex_none()
|
||||
.relative()
|
||||
.debug_selector({
|
||||
let name = format!("{id}-track");
|
||||
move || name
|
||||
})
|
||||
.w(track_width)
|
||||
.h(track_height)
|
||||
.rounded(theme.radius.full)
|
||||
@@ -149,8 +154,6 @@ impl RenderOnce for Switch {
|
||||
.size(knob)
|
||||
.rounded(theme.radius.full)
|
||||
.bg(colors.primary_foreground)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.with_spring("switch-thumb", anim, move |thumb, offset| {
|
||||
thumb.left(inset + offset)
|
||||
}),
|
||||
|
||||
+20
-25
@@ -12,7 +12,8 @@ use gpui::{
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
|
||||
use crate::common::DismissHandler;
|
||||
use crate::button::{Button, ButtonVariant};
|
||||
use crate::common::{DismissHandler, Sizable as _};
|
||||
|
||||
/// The action handler of a [`Toast`].
|
||||
pub type ActionHandler = Rc<dyn Fn(&mut App)>;
|
||||
@@ -256,12 +257,13 @@ impl RenderOnce for ToastsView {
|
||||
fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful<gpui::Div> {
|
||||
let theme = cx.theme();
|
||||
let surface = theme.colors.surface;
|
||||
let border = theme.colors.border;
|
||||
let border = if theme.borders.overlays {
|
||||
theme.colors.border
|
||||
} else {
|
||||
surface
|
||||
};
|
||||
let foreground = theme.colors.foreground;
|
||||
let muted = theme.colors.muted;
|
||||
let primary = theme.ramps.primary.base;
|
||||
let primary_hover = theme.ramps.primary.hover;
|
||||
let primary_foreground = theme.colors.primary_foreground;
|
||||
let radius = theme.radius.full;
|
||||
let shadows = theme.elevations.popover.shadows();
|
||||
let icon_size = theme.sizes.small.icon_size;
|
||||
@@ -304,25 +306,18 @@ fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful
|
||||
.when_some(toast.action.clone(), |pill, action| {
|
||||
let toasts = toasts.clone();
|
||||
pill.child(
|
||||
div()
|
||||
.id(("toast-action", id))
|
||||
.flex_none()
|
||||
.ml_1p5()
|
||||
.cursor_pointer()
|
||||
.px_2()
|
||||
.py_0p5()
|
||||
.rounded(radius)
|
||||
.bg(primary)
|
||||
.hover(|style| style.bg(primary_hover))
|
||||
.text_size(text_size)
|
||||
.text_color(primary_foreground)
|
||||
.child(action.label.clone())
|
||||
.on_click(move |_, _window, cx| {
|
||||
(action.handler.clone())(cx);
|
||||
toasts.update(cx, |toasts, cx| {
|
||||
toasts.dismiss(id, cx);
|
||||
});
|
||||
}),
|
||||
div().flex_none().ml_2p5().child(
|
||||
Button::new(("toast-action", id))
|
||||
.label(action.label.clone())
|
||||
.variant(ButtonVariant::Primary)
|
||||
.small()
|
||||
.on_click(move |_, _window, cx| {
|
||||
(action.handler.clone())(cx);
|
||||
toasts.update(cx, |toasts, cx| {
|
||||
toasts.dismiss(id, cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
@@ -331,7 +326,7 @@ fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful
|
||||
.flex_none()
|
||||
.ml_4()
|
||||
.cursor_pointer()
|
||||
.p_0p5()
|
||||
.p_1()
|
||||
.rounded(theme.radius.sm)
|
||||
.hover(|style| style.bg(muted))
|
||||
.child(
|
||||
|
||||
+100
-16
@@ -1,13 +1,17 @@
|
||||
use gpui::AppContext as _;
|
||||
use gpui::{
|
||||
AnyView, App, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
||||
Styled as _, Window, div, px,
|
||||
AnyView, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Pixels, Render, SharedString, Styled as _, Window, canvas, div, point, px,
|
||||
};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH};
|
||||
use gpui_native_utils::{anchor_bounds, record_anchor_bounds};
|
||||
|
||||
const PANEL_BOUNDS_ID: &str = "tooltip-panel-bounds";
|
||||
|
||||
/// A small text panel gpui shows next to a hovered element.
|
||||
pub struct Tooltip {
|
||||
text: SharedString,
|
||||
anchor: Option<Bounds<Pixels>>,
|
||||
}
|
||||
|
||||
impl Tooltip {
|
||||
@@ -16,7 +20,28 @@ impl Tooltip {
|
||||
where
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
cx.new(|_| Self { text: text.into() })
|
||||
cx.new(|_| Self {
|
||||
text: text.into(),
|
||||
anchor: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets the anchor element ID for the tooltip.
|
||||
pub fn anchored<L, I>(text: L, anchor_id: I) -> impl Fn(&mut Window, &mut App) -> AnyView
|
||||
where
|
||||
L: Into<SharedString>,
|
||||
I: Into<ElementId>,
|
||||
{
|
||||
let text = text.into();
|
||||
let anchor_id = anchor_id.into();
|
||||
move |_window, cx| {
|
||||
let anchor = anchor_bounds(&anchor_id, cx);
|
||||
cx.new(|_| Self {
|
||||
text: text.clone(),
|
||||
anchor,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `.tooltip(...)` closure for a plain text tooltip.
|
||||
@@ -30,20 +55,79 @@ impl 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 {
|
||||
// Copied out: the mutable reads below need `cx` free of this borrow.
|
||||
let theme = cx.theme();
|
||||
let text_size = theme.sizes.small.text_size;
|
||||
let spacing_unit = theme.spacing.unit;
|
||||
let surface = theme.colors.surface;
|
||||
let surface_foreground = theme.colors.surface_foreground;
|
||||
let radius_sm = theme.radius.sm;
|
||||
let shadows = theme.elevations.popover.shadows();
|
||||
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.max_w(px(360.))
|
||||
.bg(theme.colors.surface)
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.rounded(theme.radius.sm)
|
||||
.text_size(text_size)
|
||||
.text_color(theme.colors.surface_foreground)
|
||||
.child(self.text.clone())
|
||||
let border = if theme.borders.overlays {
|
||||
theme.colors.border
|
||||
} else {
|
||||
theme.colors.surface
|
||||
};
|
||||
|
||||
let build_panel = || {
|
||||
div()
|
||||
.debug_selector(|| "tooltip-panel".to_string())
|
||||
.px_2()
|
||||
.py_1()
|
||||
.max_w(px(360.))
|
||||
.bg(surface)
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
.rounded(radius_sm)
|
||||
.shadow(shadows.clone())
|
||||
.text_size(text_size)
|
||||
.text_color(surface_foreground)
|
||||
.child(self.text.clone())
|
||||
};
|
||||
|
||||
let (wrapper, panel) = match self.anchor {
|
||||
Some(parent) => {
|
||||
let origin = window.mouse_position() + point(px(1.), px(1.));
|
||||
let viewport_height = window.viewport_size().height;
|
||||
|
||||
let height = anchor_bounds(&ElementId::from(PANEL_BOUNDS_ID), cx)
|
||||
.map(|bounds| bounds.size.height)
|
||||
.unwrap_or_else(|| {
|
||||
text_size * 1.5 + window.rem_size() * 0.5 + BORDER_WIDTH * 2.0
|
||||
});
|
||||
|
||||
let below = viewport_height - spacing_unit - parent.bottom();
|
||||
let above = parent.top() - spacing_unit;
|
||||
let hang_below = below >= height || below >= above;
|
||||
|
||||
let panel = build_panel()
|
||||
.ml(parent.left() - origin.x)
|
||||
.mt(if hang_below {
|
||||
parent.bottom() + spacing_unit - origin.y
|
||||
} else {
|
||||
parent.top() - spacing_unit - height - origin.y
|
||||
})
|
||||
.child(
|
||||
canvas(
|
||||
record_anchor_bounds(ElementId::from(PANEL_BOUNDS_ID)),
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
);
|
||||
(div().debug_selector(|| "tooltip-anchor".to_string()), panel)
|
||||
}
|
||||
None => {
|
||||
let wrapper = div()
|
||||
.debug_selector(|| "tooltip-anchor".to_string())
|
||||
.pl(spacing_unit * 2.0)
|
||||
.pt(spacing_unit * 2.0);
|
||||
(wrapper, build_panel())
|
||||
}
|
||||
};
|
||||
|
||||
wrapper.child(panel)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user