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_foreground = theme.colors.muted_foreground;
|
||||||
let muted = theme.colors.muted;
|
let muted = theme.colors.muted;
|
||||||
let surface = theme.colors.surface;
|
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 shadows = theme.elevations.popover.shadows();
|
||||||
let metrics = theme.sizes.resolve(Size::Medium);
|
let metrics = theme.sizes.resolve(Size::Medium);
|
||||||
let icon_size = metrics.icon_size;
|
let icon_size = metrics.icon_size;
|
||||||
@@ -190,7 +196,7 @@ impl RenderOnce for Alert {
|
|||||||
.id((self.id, "alert-close"))
|
.id((self.id, "alert-close"))
|
||||||
.flex_none()
|
.flex_none()
|
||||||
.cursor_pointer()
|
.cursor_pointer()
|
||||||
.p_0p5()
|
.p_1()
|
||||||
.rounded(theme.radius.sm)
|
.rounded(theme.radius.sm)
|
||||||
.hover(|style| style.bg(muted))
|
.hover(|style| style.bg(muted))
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -315,10 +315,4 @@ mod tests {
|
|||||||
assert!(filled >= MIN_FILLED * 2, "seed {seed} filled {filled}");
|
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::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyView, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
|
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_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 gpui_native_utils::{focus_ring, record_anchor_bounds};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use crate::common::{Sizable, h_flex};
|
use crate::common::{Sizable, h_flex};
|
||||||
|
use crate::spinner::Spinner;
|
||||||
|
|
||||||
/// The single activation handler, shared by pointer, Enter, and Space.
|
/// The single activation handler, shared by pointer, Enter, and Space.
|
||||||
type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
|
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.
|
/// Which visual treatment a button uses.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub enum ButtonVariant {
|
pub enum ButtonVariant {
|
||||||
/// Accent-filled; the platform's default action.
|
/// The platform's default action.
|
||||||
#[default]
|
#[default]
|
||||||
Primary,
|
Primary,
|
||||||
/// Neutral and bordered; the everyday companion to `Primary`.
|
/// The everyday companion to `Primary`.
|
||||||
Secondary,
|
Secondary,
|
||||||
/// Unpainted until hovered, for toolbars and dense UI.
|
/// Unpainted until hovered, for toolbars and dense UI.
|
||||||
Ghost,
|
Ghost,
|
||||||
@@ -40,6 +42,7 @@ pub struct Button {
|
|||||||
variant: ButtonVariant,
|
variant: ButtonVariant,
|
||||||
size: Size,
|
size: Size,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
|
loading: bool,
|
||||||
tab_stop: bool,
|
tab_stop: bool,
|
||||||
debug_selector: Option<Rc<dyn Fn() -> String>>,
|
debug_selector: Option<Rc<dyn Fn() -> String>>,
|
||||||
a11y_label: Option<SharedString>,
|
a11y_label: Option<SharedString>,
|
||||||
@@ -60,6 +63,7 @@ impl Button {
|
|||||||
variant: ButtonVariant::default(),
|
variant: ButtonVariant::default(),
|
||||||
size: Size::default(),
|
size: Size::default(),
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
loading: false,
|
||||||
tab_stop: true,
|
tab_stop: true,
|
||||||
debug_selector: None,
|
debug_selector: None,
|
||||||
a11y_label: None,
|
a11y_label: None,
|
||||||
@@ -119,6 +123,12 @@ impl Button {
|
|||||||
self
|
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.
|
/// Overrides the accessibility label, which defaults to the visible label.
|
||||||
pub fn accessibility_label<L>(mut self, label: L) -> Self
|
pub fn accessibility_label<L>(mut self, label: L) -> Self
|
||||||
where
|
where
|
||||||
@@ -149,11 +159,12 @@ impl RenderOnce for Button {
|
|||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let colors = theme.colors;
|
let colors = theme.colors;
|
||||||
let ramps = theme.ramps;
|
let ramps = theme.ramps;
|
||||||
|
let borders = theme.borders;
|
||||||
let metrics = theme.sizes.resolve(self.size);
|
let metrics = theme.sizes.resolve(self.size);
|
||||||
|
|
||||||
|
let show_border = borders.buttons && self.variant != ButtonVariant::Ghost;
|
||||||
let on_click = self.on_click;
|
let on_click = self.on_click;
|
||||||
let a11y_label = self.a11y_label.or_else(|| self.label.clone());
|
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 {
|
let (background, foreground, hover, pressed) = match self.variant {
|
||||||
ButtonVariant::Primary => (
|
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()
|
h_flex()
|
||||||
.id(self.id)
|
.id(self.id.clone())
|
||||||
.when_some(self.debug_selector, |button, selector| {
|
.when_some(self.debug_selector, |button, selector| {
|
||||||
button.debug_selector(move || selector())
|
button.debug_selector(move || selector())
|
||||||
})
|
})
|
||||||
.role(Role::Button)
|
.role(Role::Button)
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.when(icon_only, |button| button.size(metrics.control_height))
|
.h(metrics.control_height + BORDER_WIDTH * 2.0)
|
||||||
.when(!icon_only, |button| {
|
.when(self.icon.is_some(), |button| {
|
||||||
button.h(metrics.control_height).px(metrics.padding_x)
|
button.px(metrics.dense_padding_x)
|
||||||
})
|
})
|
||||||
|
.when(self.icon.is_none(), |button| button.px(metrics.padding_x))
|
||||||
.line_height(relative(1.0))
|
.line_height(relative(1.0))
|
||||||
.gap_1p5()
|
.gap_2()
|
||||||
.rounded(theme.radius.control)
|
.rounded(theme.radius.control)
|
||||||
.text_size(metrics.text_size)
|
.text_size(metrics.text_size)
|
||||||
.when(self.variant == ButtonVariant::Secondary, |button| {
|
.border_1()
|
||||||
button.border_1().border_color(colors.border)
|
.border_color(border)
|
||||||
})
|
|
||||||
.when_some(background, |button, background| button.bg(background))
|
.when_some(background, |button, background| button.bg(background))
|
||||||
.text_color(foreground)
|
.text_color(foreground)
|
||||||
.when_some(a11y_label, |button, label| button.aria_label(label))
|
.when_some(a11y_label, |button, label| button.aria_label(label))
|
||||||
.when_some(self.icon, |button, role| {
|
.when(self.loading, |button| {
|
||||||
button.child(icon(role).size(metrics.icon_size).color(foreground))
|
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))
|
.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(self.disabled, |button| button.opacity(0.5))
|
||||||
.when_some(self.tooltip, |button, tooltip| {
|
.when_some(self.tooltip, |button, tooltip| {
|
||||||
button.tooltip(move |window, cx| tooltip(window, cx))
|
button.tooltip(move |window, cx| tooltip(window, cx))
|
||||||
})
|
})
|
||||||
.when(!self.disabled, |button| {
|
.when(!self.disabled && !self.loading, |button| {
|
||||||
button
|
button
|
||||||
.cursor_pointer()
|
.cursor_pointer()
|
||||||
.focusable()
|
.focusable()
|
||||||
.tab_stop(self.tab_stop)
|
.tab_stop(self.tab_stop)
|
||||||
.hover(move |style| style.bg(hover))
|
.hover(move |style| style.bg(hover).border_color(hover_border))
|
||||||
.active(move |style| style.bg(pressed))
|
.active(move |style| style.bg(pressed).border_color(pressed_border))
|
||||||
.focus_visible(|style| style.shadow(focus_ring(colors.ring)))
|
.focus_visible(|style| style.shadow(focus_ring(colors.ring)))
|
||||||
.when_some(on_click, |button, on_click| {
|
.when_some(on_click, |button, on_click| {
|
||||||
button.on_click(move |event, window, cx| on_click(event, window, cx))
|
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::{
|
use gpui::{
|
||||||
App, Bounds, Context, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
|
App, Bounds, Context, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
|
||||||
KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role,
|
KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role,
|
||||||
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task,
|
ScrollHandle, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
|
||||||
Window, actions, canvas, deferred, div, px,
|
Subscription, Task, Window, actions, anchored, canvas, deferred, div, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{IconRole, icon};
|
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::input::{Input, InputState, InputStateEvent};
|
||||||
use crate::scroll_area::ScrollArea;
|
use crate::scroll_area::ScrollArea;
|
||||||
|
|
||||||
@@ -81,6 +81,7 @@ where
|
|||||||
filter: Option<ComboboxFilter<T>>,
|
filter: Option<ComboboxFilter<T>>,
|
||||||
unmatched: UnmatchedText,
|
unmatched: UnmatchedText,
|
||||||
size: Size,
|
size: Size,
|
||||||
|
style: StyleRefinement,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> Combobox<T>
|
impl<T> Combobox<T>
|
||||||
@@ -103,6 +104,7 @@ where
|
|||||||
filter: None,
|
filter: None,
|
||||||
unmatched: UnmatchedText::default(),
|
unmatched: UnmatchedText::default(),
|
||||||
size: Size::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.
|
/// Emitted when the combobox's value changes.
|
||||||
pub struct ComboboxChanged;
|
pub struct ComboboxChanged;
|
||||||
|
|
||||||
@@ -189,6 +201,8 @@ where
|
|||||||
filter: ComboboxFilter<T>,
|
filter: ComboboxFilter<T>,
|
||||||
unmatched: UnmatchedText,
|
unmatched: UnmatchedText,
|
||||||
size: Size,
|
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.
|
/// Handler reports in flight; held so they die with the state.
|
||||||
tasks: Vec<Task<()>>,
|
tasks: Vec<Task<()>>,
|
||||||
/// Keeps the query-event subscription tied to the state's lifetime.
|
/// Keeps the query-event subscription tied to the state's lifetime.
|
||||||
@@ -225,6 +239,7 @@ where
|
|||||||
|
|
||||||
let subscription =
|
let subscription =
|
||||||
cx.subscribe_in(&query, window, |state, _, event, window, cx| match event {
|
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::TextChanged => state.text_changed(window, cx),
|
||||||
InputStateEvent::Blurred => state.blurred(window, cx),
|
InputStateEvent::Blurred => state.blurred(window, cx),
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -248,6 +263,7 @@ where
|
|||||||
}),
|
}),
|
||||||
unmatched: combobox.unmatched,
|
unmatched: combobox.unmatched,
|
||||||
size: combobox.size,
|
size: combobox.size,
|
||||||
|
style: combobox.style,
|
||||||
tasks: Vec::new(),
|
tasks: Vec::new(),
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
}
|
}
|
||||||
@@ -364,6 +380,16 @@ where
|
|||||||
cx.notify();
|
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.
|
/// Losing focus. [`UnmatchedText`] decides what the text becomes.
|
||||||
fn blurred(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn blurred(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.highlight = None;
|
self.highlight = None;
|
||||||
@@ -447,11 +473,25 @@ where
|
|||||||
T: Clone + PartialEq + 'static,
|
T: Clone + PartialEq + 'static,
|
||||||
{
|
{
|
||||||
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 {
|
||||||
let (metrics, colors, radius) = {
|
let (metrics, colors, radius, panel_border, spacing_unit, shadows) = {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let metrics = theme.sizes.resolve(self.size);
|
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 icon_size = metrics.dense_icon_size;
|
||||||
let id = self.id.clone();
|
let id = self.id.clone();
|
||||||
let state = cx.entity();
|
let state = cx.entity();
|
||||||
@@ -490,7 +530,9 @@ where
|
|||||||
.id(id.clone())
|
.id(id.clone())
|
||||||
.key_context(COMBOBOX_CONTEXT)
|
.key_context(COMBOBOX_CONTEXT)
|
||||||
.relative()
|
.relative()
|
||||||
.w_full()
|
.w(window.rem_size() * 12.0)
|
||||||
|
.min_w(window.rem_size() * 6.0)
|
||||||
|
.refine_style(&self.style)
|
||||||
.role(Role::ComboBox)
|
.role(Role::ComboBox)
|
||||||
.aria_expanded(open)
|
.aria_expanded(open)
|
||||||
.aria_label(self.name.clone())
|
.aria_label(self.name.clone())
|
||||||
@@ -532,13 +574,14 @@ where
|
|||||||
.child(
|
.child(
|
||||||
Input::new((id.clone(), "query"), self.query.clone())
|
Input::new((id.clone(), "query"), self.query.clone())
|
||||||
.placeholder(self.placeholder.clone())
|
.placeholder(self.placeholder.clone())
|
||||||
|
.placeholder_when_focused(true)
|
||||||
.with_size(self.size),
|
.with_size(self.size),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.absolute()
|
.absolute()
|
||||||
.right(metrics.dense_padding_x)
|
.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),
|
.child(chevron),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -626,6 +669,10 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
let panel_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 popup_width = wrapper_bounds
|
||||||
|
.borrow()
|
||||||
|
.map(|bounds| bounds.size.width)
|
||||||
|
.unwrap_or_else(|| window.rem_size() * 12.0);
|
||||||
|
|
||||||
let panel = v_flex()
|
let panel = v_flex()
|
||||||
.id((id.clone(), "panel"))
|
.id((id.clone(), "panel"))
|
||||||
@@ -633,14 +680,14 @@ where
|
|||||||
let name = format!("{id}-panel");
|
let name = format!("{id}-panel");
|
||||||
move || name
|
move || name
|
||||||
})
|
})
|
||||||
.mt_1()
|
.w(popup_width)
|
||||||
.w_full()
|
|
||||||
.p_1()
|
.p_1()
|
||||||
.bg(colors.surface)
|
.bg(colors.surface)
|
||||||
.text_color(colors.foreground)
|
.text_color(colors.foreground)
|
||||||
.rounded(radius.overlay)
|
.rounded(radius.overlay)
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(colors.border)
|
.border_color(panel_border)
|
||||||
|
.shadow(shadows)
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(rows)
|
.child(rows)
|
||||||
.child(
|
.child(
|
||||||
@@ -684,7 +731,9 @@ where
|
|||||||
.inset_0(),
|
.inset_0(),
|
||||||
);
|
);
|
||||||
|
|
||||||
wrapper = wrapper.child(deferred(panel));
|
wrapper = wrapper.child(deferred(
|
||||||
|
anchored().offset(point(px(0.), spacing_unit)).child(panel),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapper
|
wrapper
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use gpui::{
|
|||||||
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window,
|
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window,
|
||||||
actions, canvas, div, px, relative,
|
actions, canvas, div, px, relative,
|
||||||
};
|
};
|
||||||
|
use gpui_native_icons::{IconRole, icon};
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||||
|
|
||||||
use crate::common::{h_flex, v_flex};
|
use crate::common::{h_flex, v_flex};
|
||||||
@@ -48,18 +49,27 @@ pub struct Command {
|
|||||||
pub label: SharedString,
|
pub label: SharedString,
|
||||||
/// Gray text on the row's right edge, e.g. the keybinding.
|
/// Gray text on the row's right edge, e.g. the keybinding.
|
||||||
pub hint: Option<SharedString>,
|
pub hint: Option<SharedString>,
|
||||||
|
/// Leading glyph shown before the label.
|
||||||
|
pub icon: Option<IconRole>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Command {
|
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 {
|
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
hint: None,
|
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.
|
/// Sets the row's right-edge hint.
|
||||||
pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
|
pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
|
||||||
self.hint = Some(hint.into());
|
self.hint = Some(hint.into());
|
||||||
@@ -295,15 +305,21 @@ impl Render for CommandPaletteState {
|
|||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
}
|
}
|
||||||
|
|
||||||
let (colors, radius, metrics, shadows) = {
|
let (colors, radius, metrics, panel_border, shadows) = {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
(
|
(
|
||||||
theme.colors,
|
theme.colors,
|
||||||
theme.radius,
|
theme.radius,
|
||||||
theme.sizes.resolve(Size::default()),
|
theme.sizes.resolve(Size::default()),
|
||||||
|
if theme.borders.overlays {
|
||||||
|
theme.colors.border
|
||||||
|
} else {
|
||||||
|
theme.colors.surface
|
||||||
|
},
|
||||||
theme.elevations.dialog.shadows(),
|
theme.elevations.dialog.shadows(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let id = self.id.clone();
|
let id = self.id.clone();
|
||||||
let state = cx.entity();
|
let state = cx.entity();
|
||||||
|
|
||||||
@@ -345,6 +361,9 @@ impl Render for CommandPaletteState {
|
|||||||
state.update(cx, |state, cx| state.choose_row(row_ix, window, cx));
|
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(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
@@ -407,7 +426,7 @@ impl Render for CommandPaletteState {
|
|||||||
.text_color(colors.foreground)
|
.text_color(colors.foreground)
|
||||||
.rounded(radius.overlay)
|
.rounded(radius.overlay)
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(colors.border)
|
.border_color(panel_border)
|
||||||
.shadow(shadows)
|
.shadow(shadows)
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||||
|
|||||||
@@ -130,7 +130,11 @@ impl RenderOnce for Dialog {
|
|||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
(
|
(
|
||||||
theme.colors.overlay,
|
theme.colors.overlay,
|
||||||
theme.colors.border,
|
if theme.borders.overlays {
|
||||||
|
theme.colors.border
|
||||||
|
} else {
|
||||||
|
theme.colors.surface
|
||||||
|
},
|
||||||
theme.colors.foreground,
|
theme.colors.foreground,
|
||||||
theme.colors.muted_foreground,
|
theme.colors.muted_foreground,
|
||||||
theme.colors.surface,
|
theme.colors.surface,
|
||||||
|
|||||||
+243
-220
@@ -7,15 +7,17 @@ use unicode_segmentation::UnicodeSegmentation;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext as _, Bounds, ClipboardItem, ContentMask, Context, CursorStyle, DefiniteLength,
|
Animation, AnimationExt as _, AnyElement, App, AppContext as _, Bounds, ClipboardItem,
|
||||||
DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter, Font, HitboxBehavior,
|
ContentMask, Context, CursorStyle, DefiniteLength, DispatchPhase, ElementId, Entity,
|
||||||
InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent,
|
EntityInputHandler, EventEmitter, Font, HitboxBehavior, InteractiveElement as _, IntoElement,
|
||||||
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta,
|
KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
|
||||||
ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task,
|
ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta, ScrollEvent, SharedString,
|
||||||
TextAlign, TextRun, UTF16Selection, Window, WrappedLine, actions, canvas, div, fill, point, px,
|
StatefulInteractiveElement as _, Styled as _, TextAlign, TextRun, UTF16Selection, Window,
|
||||||
|
WrappedLine, actions, canvas, div, fill, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::IconRole;
|
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::button::{Button, ButtonVariant};
|
||||||
use crate::common::{Sizable, h_flex};
|
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.
|
/// The application-owned state of a text field.
|
||||||
pub struct InputState {
|
pub struct InputState {
|
||||||
text: String,
|
text: String,
|
||||||
@@ -606,20 +517,18 @@ pub struct InputState {
|
|||||||
scroll_offset: gpui::Point<Pixels>,
|
scroll_offset: gpui::Point<Pixels>,
|
||||||
/// Set by every caret-affecting change.
|
/// Set by every caret-affecting change.
|
||||||
scroll_to_caret_pending: bool,
|
scroll_to_caret_pending: bool,
|
||||||
/// Drives the focused caret's blink.
|
/// Bumped on every edit.
|
||||||
caret_blink: Entity<CaretBlink>,
|
caret_epoch: usize,
|
||||||
/// The shaped line and element bounds, recorded during each paint,
|
/// When the current caret epoch started.
|
||||||
/// and shared with hit-testing and the IME geometry conversions.
|
caret_epoch_started: Option<Instant>,
|
||||||
|
/// The shaped line and element bounds, recorded during each paint.
|
||||||
layout: Rc<RefCell<Option<InputLayout>>>,
|
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 EventEmitter<InputStateEvent> for InputState {}
|
||||||
|
|
||||||
impl InputState {
|
impl InputState {
|
||||||
fn new_inner(cx: &mut Context<Self>) -> Self {
|
fn new_inner(cx: &mut Context<Self>) -> Self {
|
||||||
let caret_blink = cx.new(CaretBlink::new);
|
|
||||||
Self {
|
Self {
|
||||||
text: String::new(),
|
text: String::new(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
@@ -649,9 +558,9 @@ impl InputState {
|
|||||||
goal_x: None,
|
goal_x: None,
|
||||||
scroll_offset: point(Pixels::ZERO, Pixels::ZERO),
|
scroll_offset: point(Pixels::ZERO, Pixels::ZERO),
|
||||||
scroll_to_caret_pending: true,
|
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)),
|
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.
|
/// 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> {
|
pub fn new_with_text(text: impl Into<String>, cx: &mut App) -> Entity<Self> {
|
||||||
let text = sanitize(text.into(), false);
|
let text = sanitize(text.into(), false);
|
||||||
cx.new(|cx| {
|
cx.new(|cx| {
|
||||||
@@ -681,10 +587,6 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the whole value and collapses the selection to the end.
|
/// 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>)
|
pub fn set_value<V>(&mut self, value: V, window: &mut Window, cx: &mut Context<Self>)
|
||||||
where
|
where
|
||||||
V: Into<String>,
|
V: Into<String>,
|
||||||
@@ -866,7 +768,8 @@ impl InputState {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
self.scroll_to_caret_pending = true;
|
self.scroll_to_caret_pending = true;
|
||||||
self.goal_x = None;
|
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);
|
window.notify_text_input_changed(&self.focus_handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,10 +802,11 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
self.focused = focused;
|
self.focused = focused;
|
||||||
|
|
||||||
|
// A fresh focus starts a fresh blink epoch, so the caret shows solid
|
||||||
|
// for one interval before blinking resumes.
|
||||||
if focused {
|
if focused {
|
||||||
self.caret_blink.update(cx, |blink, cx| blink.enable(cx));
|
self.caret_epoch += 1;
|
||||||
} else {
|
self.caret_epoch_started = Some(Instant::now());
|
||||||
self.caret_blink.update(cx, |blink, cx| blink.disable(cx));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.emit(if focused {
|
cx.emit(if focused {
|
||||||
@@ -913,8 +817,22 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the caret should paint.
|
/// Whether the caret should paint.
|
||||||
pub fn caret_visible(&mut self, focused: bool, cx: &mut Context<Self>) -> bool {
|
pub fn caret_visible(&mut self, focused: bool) -> bool {
|
||||||
focused && !self.read_only && self.caret_blink.read(cx).visible()
|
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.
|
/// 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.
|
// 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);
|
self.paint_caret(origin, font_size, colors.foreground, window);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1901,9 +1819,13 @@ pub struct Input {
|
|||||||
placeholder: SharedString,
|
placeholder: SharedString,
|
||||||
size: Size,
|
size: Size,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
|
/// The placeholder stays visible while focused.
|
||||||
|
placeholder_when_focused: bool,
|
||||||
a11y_label: Option<SharedString>,
|
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,
|
rows: usize,
|
||||||
|
prefix: Option<AnyElement>,
|
||||||
|
suffix: Option<AnyElement>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Input {
|
impl Input {
|
||||||
@@ -1918,8 +1840,11 @@ impl Input {
|
|||||||
placeholder: SharedString::default(),
|
placeholder: SharedString::default(),
|
||||||
size: Size::default(),
|
size: Size::default(),
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
placeholder_when_focused: false,
|
||||||
a11y_label: None,
|
a11y_label: None,
|
||||||
rows: DEFAULT_ROWS,
|
rows: DEFAULT_ROWS,
|
||||||
|
prefix: None,
|
||||||
|
suffix: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1938,6 +1863,12 @@ impl Input {
|
|||||||
self
|
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.
|
/// Renders the field inert, unfocusable, and dimmed; disabled always wins.
|
||||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||||
self.disabled = disabled;
|
self.disabled = disabled;
|
||||||
@@ -1952,6 +1883,24 @@ impl Input {
|
|||||||
self.a11y_label = Some(label.into());
|
self.a11y_label = Some(label.into());
|
||||||
self
|
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 {
|
impl Sizable for Input {
|
||||||
@@ -1967,25 +1916,28 @@ impl RenderOnce for Input {
|
|||||||
let radius = theme.radius;
|
let radius = theme.radius;
|
||||||
let metrics = theme.sizes.resolve(self.size);
|
let metrics = theme.sizes.resolve(self.size);
|
||||||
let focus_handle = self.state.read(cx).focus_handle.clone();
|
let focus_handle = self.state.read(cx).focus_handle.clone();
|
||||||
|
|
||||||
let focused = focus_handle.is_focused(window);
|
let focused = focus_handle.is_focused(window);
|
||||||
|
|
||||||
let is_empty = self.state.read(cx).text.is_empty();
|
let is_empty = self.state.read(cx).text.is_empty();
|
||||||
let is_password = self.state.read(cx).is_password();
|
let is_password = self.state.read(cx).is_password();
|
||||||
let revealed = self.state.read(cx).is_revealed();
|
let revealed = self.state.read(cx).is_revealed();
|
||||||
let multiline = self.state.read(cx).is_multiline();
|
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 {
|
let small = theme.sizes.resolve(Size::Small);
|
||||||
metrics.dense_padding_x + metrics.dense_icon_size
|
let docked_height = small.control_height + BORDER_WIDTH * 2.0;
|
||||||
} else {
|
let edge_inset = ((metrics.input_height - docked_height) / 2.0).max(Pixels::ZERO);
|
||||||
metrics.dense_padding_x
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 vertical_inset = metrics.dense_padding_x * 0.5;
|
||||||
|
let reveal_width = small.dense_padding_x * 2.0 + small.icon_size;
|
||||||
|
|
||||||
let field_padding = FieldPadding {
|
let field_padding = FieldPadding {
|
||||||
left: metrics.dense_padding_x,
|
left: Pixels::ZERO,
|
||||||
right: right_padding,
|
right: if is_password {
|
||||||
|
(edge_inset + reveal_width - metrics.dense_padding_x).max(Pixels::ZERO)
|
||||||
|
} else {
|
||||||
|
Pixels::ZERO
|
||||||
|
},
|
||||||
top: vertical_inset,
|
top: vertical_inset,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1993,9 +1945,10 @@ impl RenderOnce for Input {
|
|||||||
DefiniteLength::Fraction(ratio) => metrics.text_size * ratio,
|
DefiniteLength::Fraction(ratio) => metrics.text_size * ratio,
|
||||||
DefiniteLength::Absolute(length) => length.to_pixels(window.rem_size()),
|
DefiniteLength::Absolute(length) => length.to_pixels(window.rem_size()),
|
||||||
});
|
});
|
||||||
|
|
||||||
let field_height = line_height.map_or(metrics.input_height, |line_height| {
|
let field_height = line_height.map_or(metrics.input_height, |line_height| {
|
||||||
line_height * self.rows as f32 + vertical_inset * 2.0
|
line_height * self.rows as f32 + vertical_inset * 2.0
|
||||||
});
|
}) + BORDER_WIDTH * 2.0;
|
||||||
|
|
||||||
let fallback_label = self
|
let fallback_label = self
|
||||||
.a11y_label
|
.a11y_label
|
||||||
@@ -2003,8 +1956,111 @@ impl RenderOnce for Input {
|
|||||||
.or_else(|| (!self.placeholder.is_empty()).then_some(self.placeholder.clone()))
|
.or_else(|| (!self.placeholder.is_empty()).then_some(self.placeholder.clone()))
|
||||||
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
|
.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()
|
h_flex()
|
||||||
.id(self.id.clone())
|
.id(self.id.clone())
|
||||||
|
.debug_selector(|| "input-field".to_string())
|
||||||
.key_context(INPUT_CONTEXT)
|
.key_context(INPUT_CONTEXT)
|
||||||
.role(Role::TextInput)
|
.role(Role::TextInput)
|
||||||
.aria_label(fallback_label)
|
.aria_label(fallback_label)
|
||||||
@@ -2012,117 +2068,84 @@ impl RenderOnce for Input {
|
|||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.w_full()
|
.w_full()
|
||||||
.h(field_height)
|
.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)
|
.rounded(radius.control)
|
||||||
.border_1()
|
|
||||||
.border_color(colors.border)
|
|
||||||
.bg(colors.input)
|
.bg(colors.input)
|
||||||
.text_size(metrics.text_size)
|
.text_size(metrics.text_size)
|
||||||
.text_color(colors.foreground)
|
.text_color(colors.foreground)
|
||||||
.when_some(line_height, |input, line_height| {
|
.border_1()
|
||||||
input.items_start().line_height(line_height)
|
.border_color(if theme.borders.fields {
|
||||||
|
colors.border
|
||||||
|
} else {
|
||||||
|
colors.input
|
||||||
})
|
})
|
||||||
.when(self.disabled, |input| input.opacity(0.5))
|
.when(self.disabled, |input| input.opacity(0.5))
|
||||||
.when(!self.disabled, |input| {
|
.when(!self.disabled, |input| {
|
||||||
input.track_focus(&focus_handle).tab_stop(true)
|
input.track_focus(&focus_handle).tab_stop(true)
|
||||||
})
|
})
|
||||||
.when(focused, |input| input.border_color(colors.ring))
|
.when(focused && !theme.borders.focus_ring, |input| {
|
||||||
.when(is_empty && !focused, |input| {
|
input.border_color(colors.ring)
|
||||||
input.child(
|
})
|
||||||
div()
|
.when(focused && theme.borders.focus_ring, |input| {
|
||||||
.text_color(colors.muted_foreground)
|
input.shadow(focus_ring(colors.ring))
|
||||||
.when(multiline, |placeholder| placeholder.pt(vertical_inset))
|
})
|
||||||
.child(self.placeholder.clone()),
|
.when_some(self.prefix, |input, prefix| {
|
||||||
)
|
input.child(edge_zone("input-prefix", prefix))
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
canvas(
|
div()
|
||||||
{
|
.relative()
|
||||||
let state = self.state.clone();
|
.flex_1()
|
||||||
move |bounds, window, cx| {
|
.min_w_0()
|
||||||
// The hitbox backs the I-beam hover cursor.
|
.h_full()
|
||||||
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
|
.overflow_hidden()
|
||||||
let wrap_width = multiline.then(|| {
|
.flex()
|
||||||
(bounds.size.width - field_padding.left - field_padding.right)
|
.flex_row()
|
||||||
.max(Pixels::ZERO)
|
.items_center()
|
||||||
});
|
.when_some(line_height, |region, line_height| {
|
||||||
let text = state.read(cx).shape_current_text(wrap_width, window);
|
region.items_start().line_height(line_height)
|
||||||
(text, hitbox)
|
})
|
||||||
}
|
.when(
|
||||||
},
|
is_empty && (!focused || self.placeholder_when_focused),
|
||||||
{
|
|region| {
|
||||||
let state = self.state.clone();
|
region.child(
|
||||||
let focus = focus_handle.clone();
|
div()
|
||||||
let disabled = self.disabled;
|
.text_color(colors.muted_foreground)
|
||||||
|
.when(multiline, |placeholder| placeholder.pt(vertical_inset))
|
||||||
move |bounds, (text, hitbox), window, cx| {
|
.child(self.placeholder.clone()),
|
||||||
if !disabled {
|
)
|
||||||
window.set_cursor_style(CursorStyle::IBeam, &hitbox);
|
},
|
||||||
}
|
)
|
||||||
|
.child(canvas_element),
|
||||||
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(),
|
|
||||||
)
|
)
|
||||||
|
.when_some(self.suffix, |input, suffix| {
|
||||||
|
input.child(edge_zone("input-suffix", suffix))
|
||||||
|
})
|
||||||
.when(is_password && !self.disabled, |input| {
|
.when(is_password && !self.disabled, |input| {
|
||||||
input.child(
|
input.child(
|
||||||
div()
|
div()
|
||||||
.id((self.id.clone(), "input-reveal"))
|
.id((self.id.clone(), "input-reveal"))
|
||||||
.debug_selector(|| "input-reveal".to_string())
|
.debug_selector(|| "input-reveal".to_string())
|
||||||
.absolute()
|
.absolute()
|
||||||
.right(metrics.dense_padding_x / 2.0)
|
.right(edge_inset)
|
||||||
.top_0p5()
|
.top_0()
|
||||||
.bottom_0p5()
|
.bottom_0()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||||
.child(
|
.child(
|
||||||
Button::new((self.id.clone(), "input-reveal-button"))
|
Button::new((self.id.clone(), "input-reveal-button"))
|
||||||
|
.debug_selector(|| "input-reveal-button".to_string())
|
||||||
.icon(if revealed {
|
.icon(if revealed {
|
||||||
IconRole::EyeOff
|
IconRole::EyeOff
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+50
-47
@@ -7,7 +7,7 @@ use gpui::{
|
|||||||
Entity, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
|
Entity, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||||
ParentElement as _, Pixels, Point, RenderOnce, Role, ScrollHandle, SharedString,
|
ParentElement as _, Pixels, Point, RenderOnce, Role, ScrollHandle, SharedString,
|
||||||
StatefulInteractiveElement as _, Styled as _, Window, anchored, canvas, deferred, div,
|
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_icons::{IconRole, icon as icon_glyph};
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
@@ -127,6 +127,7 @@ impl MenuItem {
|
|||||||
struct MenuChrome {
|
struct MenuChrome {
|
||||||
surface: gpui::Hsla,
|
surface: gpui::Hsla,
|
||||||
border: gpui::Hsla,
|
border: gpui::Hsla,
|
||||||
|
panel_border: gpui::Hsla,
|
||||||
foreground: gpui::Hsla,
|
foreground: gpui::Hsla,
|
||||||
muted_foreground: gpui::Hsla,
|
muted_foreground: gpui::Hsla,
|
||||||
primary: gpui::Hsla,
|
primary: gpui::Hsla,
|
||||||
@@ -137,6 +138,9 @@ struct MenuChrome {
|
|||||||
radius_sm: Pixels,
|
radius_sm: Pixels,
|
||||||
icon_size: Pixels,
|
icon_size: Pixels,
|
||||||
dense_icon_size: Pixels,
|
dense_icon_size: Pixels,
|
||||||
|
menu_item_height: Pixels,
|
||||||
|
spacing_unit: Pixels,
|
||||||
|
text_size: Pixels,
|
||||||
shadow: Vec<BoxShadow>,
|
shadow: Vec<BoxShadow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +246,11 @@ impl RenderOnce for Menu {
|
|||||||
MenuChrome {
|
MenuChrome {
|
||||||
surface: theme.colors.surface,
|
surface: theme.colors.surface,
|
||||||
border: theme.colors.border,
|
border: theme.colors.border,
|
||||||
|
panel_border: if theme.borders.overlays {
|
||||||
|
theme.colors.border
|
||||||
|
} else {
|
||||||
|
theme.colors.surface
|
||||||
|
},
|
||||||
foreground: theme.colors.foreground,
|
foreground: theme.colors.foreground,
|
||||||
muted_foreground: theme.colors.muted_foreground,
|
muted_foreground: theme.colors.muted_foreground,
|
||||||
primary: theme.ramps.primary.base,
|
primary: theme.ramps.primary.base,
|
||||||
@@ -252,6 +261,9 @@ impl RenderOnce for Menu {
|
|||||||
radius_sm: theme.radius.sm,
|
radius_sm: theme.radius.sm,
|
||||||
icon_size: theme.sizes.medium.icon_size,
|
icon_size: theme.sizes.medium.icon_size,
|
||||||
dense_icon_size: theme.sizes.medium.dense_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(),
|
shadow: theme.elevations.popover.shadows(),
|
||||||
},
|
},
|
||||||
theme.motion.overlay,
|
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 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 selection = window.use_keyed_state("menu-selection", cx, |_, _| 0usize);
|
||||||
let was_open = window.use_keyed_state("menu-was-open", cx, |_, _| self.open);
|
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()
|
let panel = v_flex()
|
||||||
.id("menu-panel")
|
.id("menu-panel")
|
||||||
|
.debug_selector(|| "menu-panel".to_string())
|
||||||
|
.occlude()
|
||||||
.track_focus(&focus)
|
.track_focus(&focus)
|
||||||
.role(Role::Menu)
|
.role(Role::Menu)
|
||||||
.aria_label(label)
|
.aria_label(label)
|
||||||
.mt_1()
|
|
||||||
.min_w_56()
|
.min_w_56()
|
||||||
.p_1()
|
.p(chrome.spacing_unit)
|
||||||
.bg(chrome.surface)
|
.bg(chrome.surface)
|
||||||
.text_color(chrome.foreground)
|
.text_color(chrome.foreground)
|
||||||
.rounded(chrome.radius_overlay)
|
.rounded(chrome.radius_overlay)
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(chrome.border)
|
.border_color(chrome.panel_border)
|
||||||
.shadow(chrome.shadow.clone())
|
.shadow(chrome.shadow.clone())
|
||||||
.overflow_hidden()
|
.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({
|
.on_key_down({
|
||||||
let items = items.clone();
|
let items = items.clone();
|
||||||
let on_change = on_change.clone();
|
let on_change = on_change.clone();
|
||||||
@@ -392,44 +420,14 @@ impl RenderOnce for Menu {
|
|||||||
.children(rows),
|
.children(rows),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
canvas(
|
canvas(|_, _, _| {}, {
|
||||||
{
|
let focus = focus.clone();
|
||||||
let cell = panel_bounds.clone();
|
move |_, _, window, cx| {
|
||||||
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
|
if !focus.contains_focused(window, cx) {
|
||||||
},
|
window.focus(&focus, cx);
|
||||||
{
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
})
|
||||||
.absolute()
|
.absolute()
|
||||||
.inset_0(),
|
.inset_0(),
|
||||||
);
|
);
|
||||||
@@ -437,7 +435,8 @@ impl RenderOnce for Menu {
|
|||||||
let anchored = match self.open_at {
|
let anchored = match self.open_at {
|
||||||
Some(position) => anchored().position(position),
|
Some(position) => anchored().position(position),
|
||||||
None => anchored(),
|
None => anchored(),
|
||||||
};
|
}
|
||||||
|
.offset(point(px(0.), chrome.spacing_unit));
|
||||||
|
|
||||||
wrapper = wrapper.child(deferred(anchored.child(panel.with_animation(
|
wrapper = wrapper.child(deferred(anchored.child(panel.with_animation(
|
||||||
"menu-enter",
|
"menu-enter",
|
||||||
@@ -462,8 +461,9 @@ fn render_item(
|
|||||||
match item.kind {
|
match item.kind {
|
||||||
MenuItemKind::Separator => div()
|
MenuItemKind::Separator => div()
|
||||||
.h(px(1.))
|
.h(px(1.))
|
||||||
.mx_2()
|
.debug_selector(move || format!("menu-separator-{index}"))
|
||||||
.my_1()
|
.mx(chrome.spacing_unit * 2.0)
|
||||||
|
.my(chrome.spacing_unit)
|
||||||
.bg(chrome.border)
|
.bg(chrome.border)
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
MenuItemKind::Action => {
|
MenuItemKind::Action => {
|
||||||
@@ -476,11 +476,14 @@ fn render_item(
|
|||||||
|
|
||||||
div()
|
div()
|
||||||
.id(("menu-item", index))
|
.id(("menu-item", index))
|
||||||
|
.debug_selector(move || format!("menu-item-{index}"))
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.px_2()
|
.min_h(chrome.menu_item_height)
|
||||||
.py_1()
|
.px(chrome.spacing_unit * 2.0)
|
||||||
|
.text_size(chrome.text_size)
|
||||||
|
.line_height(relative(1.0))
|
||||||
.rounded(chrome.radius_sm)
|
.rounded(chrome.radius_sm)
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
if disabled {
|
if disabled {
|
||||||
|
|||||||
+42
-52
@@ -6,7 +6,7 @@ use gpui::{
|
|||||||
Animation, AnimationExt as _, AnyElement, App, Bounds, DispatchPhase, ElementId,
|
Animation, AnimationExt as _, AnyElement, App, Bounds, DispatchPhase, ElementId,
|
||||||
InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
|
InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||||
ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
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 _;
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
|
|
||||||
@@ -91,18 +91,23 @@ impl Popover {
|
|||||||
|
|
||||||
impl RenderOnce for Popover {
|
impl RenderOnce for Popover {
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
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();
|
let theme = cx.theme();
|
||||||
(
|
(
|
||||||
theme.colors,
|
theme.colors,
|
||||||
theme.radius,
|
theme.radius,
|
||||||
theme.elevations.popover.shadows(),
|
theme.elevations.popover.shadows(),
|
||||||
theme.motion.overlay,
|
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 trigger_bounds: BoundsCell = Rc::new(RefCell::new(None));
|
||||||
let panel_bounds: BoundsCell = Rc::new(RefCell::new(None));
|
|
||||||
|
|
||||||
let fallback_label = self
|
let fallback_label = self
|
||||||
.a11y_label
|
.a11y_label
|
||||||
@@ -166,18 +171,34 @@ impl RenderOnce for Popover {
|
|||||||
|
|
||||||
let panel = v_flex()
|
let panel = v_flex()
|
||||||
.id("popover-panel")
|
.id("popover-panel")
|
||||||
|
.debug_selector(|| "popover-panel".to_string())
|
||||||
|
.occlude()
|
||||||
.track_focus(&focus)
|
.track_focus(&focus)
|
||||||
.role(Role::Dialog)
|
.role(Role::Dialog)
|
||||||
.aria_label(label)
|
.aria_label(label)
|
||||||
.mt_1()
|
|
||||||
.min_w_32()
|
.min_w_32()
|
||||||
.bg(colors.surface)
|
.bg(colors.surface)
|
||||||
.text_color(colors.surface_foreground)
|
.text_color(colors.surface_foreground)
|
||||||
.rounded(radius.overlay)
|
.rounded(radius.overlay)
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(colors.border)
|
.border_color(panel_border)
|
||||||
.shadow(shadows)
|
.shadow(shadows)
|
||||||
.overflow_hidden()
|
.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({
|
.on_key_down({
|
||||||
let on_change = on_change.clone();
|
let on_change = on_change.clone();
|
||||||
move |event: &KeyDownEvent, window, cx| {
|
move |event: &KeyDownEvent, window, cx| {
|
||||||
@@ -191,58 +212,27 @@ impl RenderOnce for Popover {
|
|||||||
})
|
})
|
||||||
.when_some(content, |panel, content| panel.child(content))
|
.when_some(content, |panel, content| panel.child(content))
|
||||||
.child(
|
.child(
|
||||||
canvas(
|
canvas(|_, _, _| {}, {
|
||||||
{
|
let focus = focus;
|
||||||
let cell = panel_bounds.clone();
|
move |_, _, window, cx| {
|
||||||
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
|
if !focus.contains_focused(window, cx) {
|
||||||
},
|
window.focus(&focus, cx);
|
||||||
{
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
})
|
||||||
.absolute()
|
.absolute()
|
||||||
.inset_0(),
|
.inset_0(),
|
||||||
);
|
);
|
||||||
|
|
||||||
wrapper = wrapper.child(deferred(anchored().child(panel.with_animation(
|
wrapper = wrapper.child(deferred(
|
||||||
"popover-enter",
|
anchored()
|
||||||
Animation::new(enter_duration).with_easing(ease_out_quint()),
|
.offset(point(px(0.), anchor_gap))
|
||||||
|panel, delta| panel.opacity(delta),
|
.child(panel.with_animation(
|
||||||
))));
|
"popover-enter",
|
||||||
|
Animation::new(enter_duration).with_easing(ease_out_quint()),
|
||||||
|
|panel, delta| panel.opacity(delta),
|
||||||
|
)),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapper
|
wrapper
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ use gpui::{
|
|||||||
SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window,
|
SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{IconRole, icon};
|
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::common::{Sizable, StyledExt, h_flex};
|
||||||
use crate::menu::{Menu, MenuItem};
|
use crate::menu::{Menu, MenuItem};
|
||||||
@@ -169,6 +170,8 @@ where
|
|||||||
let muted_foreground = theme.colors.muted_foreground;
|
let muted_foreground = theme.colors.muted_foreground;
|
||||||
let radius = theme.radius.control;
|
let radius = theme.radius.control;
|
||||||
let icon_size = metrics.dense_icon_size;
|
let icon_size = metrics.dense_icon_size;
|
||||||
|
let fields_bordered = theme.borders.fields;
|
||||||
|
let focus_ring_on = theme.borders.focus_ring;
|
||||||
|
|
||||||
let label = self
|
let label = self
|
||||||
.a11y_label
|
.a11y_label
|
||||||
@@ -180,7 +183,22 @@ where
|
|||||||
.use_keyed_state("select-open", cx, |_, _| false)
|
.use_keyed_state("select-open", cx, |_, _| false)
|
||||||
.clone()
|
.clone()
|
||||||
});
|
});
|
||||||
|
|
||||||
let is_open = *open.read(cx);
|
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| {
|
let selected_index = self.selected.as_ref().and_then(|value| {
|
||||||
self.options
|
self.options
|
||||||
@@ -194,6 +212,7 @@ where
|
|||||||
let trigger_id = self.id.clone();
|
let trigger_id = self.id.clone();
|
||||||
let trigger_label = label.clone();
|
let trigger_label = label.clone();
|
||||||
let trigger_style = self.style;
|
let trigger_style = self.style;
|
||||||
|
|
||||||
let trigger = move |_window: &mut Window, _cx: &mut App| {
|
let trigger = move |_window: &mut Window, _cx: &mut App| {
|
||||||
let text = trigger_selected
|
let text = trigger_selected
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -207,17 +226,18 @@ where
|
|||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.id((trigger_id.clone(), "select-trigger"))
|
.id((trigger_id.clone(), "select-trigger"))
|
||||||
|
.debug_selector(|| "select-trigger".to_string())
|
||||||
.role(Role::ComboBox)
|
.role(Role::ComboBox)
|
||||||
.aria_label(trigger_label.clone())
|
.aria_label(trigger_label.clone())
|
||||||
.cursor_pointer()
|
.cursor_pointer()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.w_full()
|
.w_full()
|
||||||
.h(metrics.input_height)
|
.h(metrics.input_height + BORDER_WIDTH * 2.0)
|
||||||
.px(metrics.dense_padding_x)
|
.px(metrics.dense_padding_x)
|
||||||
.rounded(radius)
|
.rounded(radius)
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(if is_open { ring } else { border })
|
.border_color(rest_border)
|
||||||
.bg(input)
|
.bg(input)
|
||||||
.text_size(metrics.text_size)
|
.text_size(metrics.text_size)
|
||||||
.text_color(if trigger_selected.is_some() {
|
.text_color(if trigger_selected.is_some() {
|
||||||
@@ -225,7 +245,10 @@ where
|
|||||||
} else {
|
} else {
|
||||||
muted_foreground
|
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(text)
|
||||||
.child(
|
.child(
|
||||||
icon(IconRole::ChevronDown)
|
icon(IconRole::ChevronDown)
|
||||||
@@ -236,7 +259,6 @@ where
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
};
|
};
|
||||||
|
|
||||||
let open_for_items = open.clone();
|
|
||||||
let items = move |_window: &mut Window, _cx: &mut App| {
|
let items = move |_window: &mut Window, _cx: &mut App| {
|
||||||
self.options
|
self.options
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ pub struct Spinner {
|
|||||||
value: Option<f32>,
|
value: Option<f32>,
|
||||||
variant: SpinnerVariant,
|
variant: SpinnerVariant,
|
||||||
size: Size,
|
size: Size,
|
||||||
|
color: Option<Hsla>,
|
||||||
a11y_label: Option<SharedString>,
|
a11y_label: Option<SharedString>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +56,17 @@ impl Spinner {
|
|||||||
value: None,
|
value: None,
|
||||||
variant: SpinnerVariant::default(),
|
variant: SpinnerVariant::default(),
|
||||||
size: Size::default(),
|
size: Size::default(),
|
||||||
|
color: None,
|
||||||
a11y_label: 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.
|
/// Sets the spinner's visual language.
|
||||||
pub fn variant(mut self, variant: SpinnerVariant) -> Self {
|
pub fn variant(mut self, variant: SpinnerVariant) -> Self {
|
||||||
self.variant = variant;
|
self.variant = variant;
|
||||||
@@ -93,7 +101,7 @@ impl RenderOnce for Spinner {
|
|||||||
let side = theme.sizes.resolve(self.size).icon_size;
|
let side = theme.sizes.resolve(self.size).icon_size;
|
||||||
let stroke = side * RING_STROKE;
|
let stroke = side * RING_STROKE;
|
||||||
let track = theme.colors.border;
|
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));
|
let value = self.value.map(|value| value.clamp(0.0, 1.0));
|
||||||
|
|
||||||
div()
|
div()
|
||||||
|
|||||||
@@ -96,16 +96,17 @@ impl RenderOnce for Switch {
|
|||||||
let ramps = theme.ramps;
|
let ramps = theme.ramps;
|
||||||
let metrics = theme.sizes.resolve(self.size);
|
let metrics = theme.sizes.resolve(self.size);
|
||||||
|
|
||||||
let track_height = metrics.control_height;
|
let track_height = metrics.switch_height;
|
||||||
let track_width = metrics.control_height * 1.65;
|
let track_width = metrics.switch_width;
|
||||||
|
|
||||||
let knob = metrics.indicator_size;
|
let knob = metrics.switch_knob;
|
||||||
let inset = metrics.plate_inset;
|
let inset = (track_height - knob) / 2.;
|
||||||
let travel = track_width - knob - inset * 2.;
|
let travel = track_width - knob - inset * 2.;
|
||||||
|
|
||||||
let next = !self.checked;
|
let next = !self.checked;
|
||||||
let on_change = self.on_change;
|
let on_change = self.on_change;
|
||||||
let a11y_label = self.a11y_label.or_else(|| self.label.clone());
|
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_config = SpringConfig::new(500., 35., 1.);
|
||||||
let anim = SpringAnimation::new(anim_config).to(if self.checked { travel } else { px(0.) });
|
let anim = SpringAnimation::new(anim_config).to(if self.checked { travel } else { px(0.) });
|
||||||
@@ -132,6 +133,10 @@ impl RenderOnce for Switch {
|
|||||||
div()
|
div()
|
||||||
.flex_none()
|
.flex_none()
|
||||||
.relative()
|
.relative()
|
||||||
|
.debug_selector({
|
||||||
|
let name = format!("{id}-track");
|
||||||
|
move || name
|
||||||
|
})
|
||||||
.w(track_width)
|
.w(track_width)
|
||||||
.h(track_height)
|
.h(track_height)
|
||||||
.rounded(theme.radius.full)
|
.rounded(theme.radius.full)
|
||||||
@@ -149,8 +154,6 @@ impl RenderOnce for Switch {
|
|||||||
.size(knob)
|
.size(knob)
|
||||||
.rounded(theme.radius.full)
|
.rounded(theme.radius.full)
|
||||||
.bg(colors.primary_foreground)
|
.bg(colors.primary_foreground)
|
||||||
.border_1()
|
|
||||||
.border_color(colors.border)
|
|
||||||
.with_spring("switch-thumb", anim, move |thumb, offset| {
|
.with_spring("switch-thumb", anim, move |thumb, offset| {
|
||||||
thumb.left(inset + offset)
|
thumb.left(inset + offset)
|
||||||
}),
|
}),
|
||||||
|
|||||||
+20
-25
@@ -12,7 +12,8 @@ use gpui::{
|
|||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::{IconRole, icon};
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
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`].
|
/// The action handler of a [`Toast`].
|
||||||
pub type ActionHandler = Rc<dyn Fn(&mut App)>;
|
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> {
|
fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful<gpui::Div> {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let surface = theme.colors.surface;
|
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 foreground = theme.colors.foreground;
|
||||||
let muted = theme.colors.muted;
|
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 radius = theme.radius.full;
|
||||||
let shadows = theme.elevations.popover.shadows();
|
let shadows = theme.elevations.popover.shadows();
|
||||||
let icon_size = theme.sizes.small.icon_size;
|
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| {
|
.when_some(toast.action.clone(), |pill, action| {
|
||||||
let toasts = toasts.clone();
|
let toasts = toasts.clone();
|
||||||
pill.child(
|
pill.child(
|
||||||
div()
|
div().flex_none().ml_2p5().child(
|
||||||
.id(("toast-action", id))
|
Button::new(("toast-action", id))
|
||||||
.flex_none()
|
.label(action.label.clone())
|
||||||
.ml_1p5()
|
.variant(ButtonVariant::Primary)
|
||||||
.cursor_pointer()
|
.small()
|
||||||
.px_2()
|
.on_click(move |_, _window, cx| {
|
||||||
.py_0p5()
|
(action.handler.clone())(cx);
|
||||||
.rounded(radius)
|
toasts.update(cx, |toasts, cx| {
|
||||||
.bg(primary)
|
toasts.dismiss(id, cx);
|
||||||
.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);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
@@ -331,7 +326,7 @@ fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful
|
|||||||
.flex_none()
|
.flex_none()
|
||||||
.ml_4()
|
.ml_4()
|
||||||
.cursor_pointer()
|
.cursor_pointer()
|
||||||
.p_0p5()
|
.p_1()
|
||||||
.rounded(theme.radius.sm)
|
.rounded(theme.radius.sm)
|
||||||
.hover(|style| style.bg(muted))
|
.hover(|style| style.bg(muted))
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
+100
-16
@@ -1,13 +1,17 @@
|
|||||||
use gpui::AppContext as _;
|
use gpui::AppContext as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyView, App, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
AnyView, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement,
|
||||||
Styled as _, Window, div, px,
|
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.
|
/// A small text panel gpui shows next to a hovered element.
|
||||||
pub struct Tooltip {
|
pub struct Tooltip {
|
||||||
text: SharedString,
|
text: SharedString,
|
||||||
|
anchor: Option<Bounds<Pixels>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tooltip {
|
impl Tooltip {
|
||||||
@@ -16,7 +20,28 @@ impl Tooltip {
|
|||||||
where
|
where
|
||||||
L: Into<SharedString>,
|
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.
|
/// The `.tooltip(...)` closure for a plain text tooltip.
|
||||||
@@ -30,20 +55,79 @@ impl Tooltip {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Tooltip {
|
impl Render for Tooltip {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
// Copied out: the mutable reads below need `cx` free of this borrow.
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let text_size = theme.sizes.small.text_size;
|
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()
|
let border = if theme.borders.overlays {
|
||||||
.px_2()
|
theme.colors.border
|
||||||
.py_1()
|
} else {
|
||||||
.max_w(px(360.))
|
theme.colors.surface
|
||||||
.bg(theme.colors.surface)
|
};
|
||||||
.border_1()
|
|
||||||
.border_color(theme.colors.border)
|
let build_panel = || {
|
||||||
.rounded(theme.radius.sm)
|
div()
|
||||||
.text_size(text_size)
|
.debug_selector(|| "tooltip-panel".to_string())
|
||||||
.text_color(theme.colors.surface_foreground)
|
.px_2()
|
||||||
.child(self.text.clone())
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,32 +54,6 @@ fn alert_window(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A banner with only a message paints, so the title stays optional.
|
|
||||||
#[test]
|
|
||||||
fn a_message_only_alert_renders() {
|
|
||||||
let mut cx = TestAppContext::single();
|
|
||||||
cx.update(Theme::init);
|
|
||||||
let window = cx.add_window(|_, _| MessageOnlyHarness);
|
|
||||||
let window = window.into();
|
|
||||||
let mut cx = VisualTestContext::from_window(window, &cx);
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
window.draw(cx).clear(cx);
|
|
||||||
});
|
|
||||||
assert!(cx.debug_bounds("message-only-alert").is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MessageOnlyHarness;
|
|
||||||
|
|
||||||
impl Render for MessageOnlyHarness {
|
|
||||||
fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context<Self>) -> impl IntoElement {
|
|
||||||
div().size_full().p_4().child(
|
|
||||||
div()
|
|
||||||
.debug_selector(|| "message-only-alert".to_string())
|
|
||||||
.child(Alert::new("message-only").message("Only line.")),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clicking the close button is how the user acts on the alert; the banner
|
/// Clicking the close button is how the user acts on the alert; the banner
|
||||||
/// reports the intent instead of hiding itself.
|
/// reports the intent instead of hiding itself.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -94,20 +68,3 @@ fn closing_the_alert_reports_dismissal() {
|
|||||||
|
|
||||||
assert!(*dismissed.borrow(), "the close button reported dismissal");
|
assert!(*dismissed.borrow(), "the close button reported dismissal");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every severity paints a banner.
|
|
||||||
#[test]
|
|
||||||
fn every_severity_renders_a_banner() {
|
|
||||||
for severity in [
|
|
||||||
AlertSeverity::Info,
|
|
||||||
AlertSeverity::Success,
|
|
||||||
AlertSeverity::Warning,
|
|
||||||
AlertSeverity::Danger,
|
|
||||||
] {
|
|
||||||
let (mut cx, _window, _dismissed) = alert_window(severity);
|
|
||||||
assert!(
|
|
||||||
cx.debug_bounds("test-alert").is_some(),
|
|
||||||
"{severity:?} alert rendered"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use gpui_native_theme::Theme;
|
|||||||
struct Harness {
|
struct Harness {
|
||||||
clicks: Rc<RefCell<usize>>,
|
clicks: Rc<RefCell<usize>>,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
|
loading: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Harness {
|
impl Render for Harness {
|
||||||
@@ -20,14 +21,18 @@ impl Render for Harness {
|
|||||||
Button::new("test-button")
|
Button::new("test-button")
|
||||||
.label("Save")
|
.label("Save")
|
||||||
.disabled(self.disabled)
|
.disabled(self.disabled)
|
||||||
|
.loading(self.loading)
|
||||||
.on_click(move |_, _, _| *clicks.borrow_mut() += 1),
|
.on_click(move |_, _, _| *clicks.borrow_mut() += 1),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A maximized window whose button stretches across the top 28px, so
|
/// A maximized window whose button stretches across the top band, so
|
||||||
/// `point(px(60.), px(14.))` always lands on it.
|
/// `point(px(60.), px(14.))` always lands on it.
|
||||||
fn button_window(disabled: bool) -> (VisualTestContext, AnyWindowHandle, Rc<RefCell<usize>>) {
|
fn button_window(
|
||||||
|
disabled: bool,
|
||||||
|
loading: bool,
|
||||||
|
) -> (VisualTestContext, AnyWindowHandle, Rc<RefCell<usize>>) {
|
||||||
let mut cx = TestAppContext::single();
|
let mut cx = TestAppContext::single();
|
||||||
cx.update(Theme::init);
|
cx.update(Theme::init);
|
||||||
let clicks = Rc::new(RefCell::new(0));
|
let clicks = Rc::new(RefCell::new(0));
|
||||||
@@ -35,6 +40,7 @@ fn button_window(disabled: bool) -> (VisualTestContext, AnyWindowHandle, Rc<RefC
|
|||||||
let window = cx.add_window(move |_, _| Harness {
|
let window = cx.add_window(move |_, _| Harness {
|
||||||
clicks: harness_clicks,
|
clicks: harness_clicks,
|
||||||
disabled,
|
disabled,
|
||||||
|
loading,
|
||||||
});
|
});
|
||||||
let window = window.into();
|
let window = window.into();
|
||||||
cx.update_window(window, |_, window, cx| {
|
cx.update_window(window, |_, window, cx| {
|
||||||
@@ -53,21 +59,28 @@ fn draw(cx: &mut VisualTestContext, window: AnyWindowHandle) {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pointer_click_fires_on_click() {
|
fn pointer_click_fires_on_click() {
|
||||||
let (mut cx, _window, clicks) = button_window(false);
|
let (mut cx, _window, clicks) = button_window(false, false);
|
||||||
cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
|
cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
|
||||||
assert_eq!(*clicks.borrow(), 1);
|
assert_eq!(*clicks.borrow(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn disabled_button_ignores_clicks() {
|
fn disabled_button_ignores_clicks() {
|
||||||
let (mut cx, _window, clicks) = button_window(true);
|
let (mut cx, _window, clicks) = button_window(true, false);
|
||||||
|
cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
|
||||||
|
assert_eq!(*clicks.borrow(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loading_button_ignores_clicks() {
|
||||||
|
let (mut cx, _window, clicks) = button_window(false, true);
|
||||||
cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
|
cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
|
||||||
assert_eq!(*clicks.borrow(), 0);
|
assert_eq!(*clicks.borrow(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_and_space_fire_on_click_while_focused() {
|
fn enter_and_space_fire_on_click_while_focused() {
|
||||||
let (mut cx, window, clicks) = button_window(false);
|
let (mut cx, window, clicks) = button_window(false, false);
|
||||||
cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
|
cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
|
||||||
assert_eq!(*clicks.borrow(), 1, "the pointer click itself activates");
|
assert_eq!(*clicks.borrow(), 1, "the pointer click itself activates");
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::cell::RefCell;
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
AppContext as _, Context, Entity, IntoElement, MouseButton, ParentElement as _, Render,
|
||||||
Styled as _, TestAppContext, VisualTestContext, Window, div,
|
SharedString, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState};
|
use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState};
|
||||||
use gpui_native_core::init;
|
use gpui_native_core::init;
|
||||||
@@ -99,6 +99,31 @@ fn draw(visual: &mut VisualTestContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
||||||
|
visual.simulate_mouse_down(position, MouseButton::Left, gpui::Modifiers::default());
|
||||||
|
visual.simulate_mouse_up(position, MouseButton::Left, gpui::Modifiers::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clicking into a valueless combobox field opens the options right away:
|
||||||
|
/// no keystroke is needed to see the choices.
|
||||||
|
#[test]
|
||||||
|
fn clicking_the_empty_field_opens_the_popup() {
|
||||||
|
let mut host = combo_host(false, false);
|
||||||
|
|
||||||
|
// Inside the field, which sits at the top of the padded host.
|
||||||
|
click(&mut host.visual, point(px(100.), px(27.)));
|
||||||
|
draw(&mut host.visual);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
host.state.read_with(&host.cx, |state, _| state.is_open()),
|
||||||
|
"clicking the empty field should show the options"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
host.visual.debug_bounds("test-combo-panel").is_some(),
|
||||||
|
"the options panel should be rendered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn focus(host: &mut ComboHost) {
|
fn focus(host: &mut ComboHost) {
|
||||||
let state = host.state.clone();
|
let state = host.state.clone();
|
||||||
host.visual.update(|window, cx| {
|
host.visual.update(|window, cx| {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use gpui::{
|
|||||||
use gpui_native_core::button::{Button, ButtonVariant};
|
use gpui_native_core::button::{Button, ButtonVariant};
|
||||||
use gpui_native_core::input::{Input, InputState};
|
use gpui_native_core::input::{Input, InputState};
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
use gpui_native_theme::Theme;
|
use gpui_native_theme::{BORDER_WIDTH, Theme};
|
||||||
|
|
||||||
struct Harness {
|
struct Harness {
|
||||||
state: Entity<InputState>,
|
state: Entity<InputState>,
|
||||||
@@ -36,10 +36,12 @@ impl Render for Harness {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A medium input is exactly `input_height`, and sits taller than the
|
/// The medium input and button render at the stated height plus the
|
||||||
/// medium button: native text fields are a touch taller than push buttons.
|
/// hairline border sized outside it, so the two match wherever the platform
|
||||||
|
/// states equal values (macOS, Windows, GNOME) and stay equal to each other
|
||||||
|
/// on every platform.
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
fn medium_input_is_taller_than_the_button(cx: &mut TestAppContext) {
|
fn controls_render_at_the_stated_heights(cx: &mut TestAppContext) {
|
||||||
cx.update(Theme::init);
|
cx.update(Theme::init);
|
||||||
let state = cx.update(|cx| InputState::new_with_text("", cx));
|
let state = cx.update(|cx| InputState::new_with_text("", cx));
|
||||||
let state_for_window = state.clone();
|
let state_for_window = state.clone();
|
||||||
@@ -58,9 +60,10 @@ fn medium_input_is_taller_than_the_button(cx: &mut TestAppContext) {
|
|||||||
.debug_bounds("input-wrapper")
|
.debug_bounds("input-wrapper")
|
||||||
.expect("input rendered");
|
.expect("input rendered");
|
||||||
|
|
||||||
// The input's height is the theme table's (platform-stated) field height;
|
// Both bounds are snapped to device pixels, so allow half a pixel. The
|
||||||
// both bounds are snapped to device pixels, so allow half a pixel.
|
// stated height is the interior; the border sizes outside it.
|
||||||
let input_height = cx.update(|cx| cx.theme().sizes.medium.input_height);
|
let input_height = cx.update(|cx| cx.theme().sizes.medium.input_height) + BORDER_WIDTH * 2.0;
|
||||||
assert!(input.size.height > button.size.height);
|
let button_height = cx.update(|cx| cx.theme().sizes.medium.control_height) + BORDER_WIDTH * 2.0;
|
||||||
assert!((input.size.height - input_height).abs() <= gpui::px(0.5));
|
assert!((input.size.height - input_height).abs() <= gpui::px(0.5));
|
||||||
|
assert!((button.size.height - button_height).abs() <= gpui::px(0.5));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,11 +158,3 @@ fn clicking_the_panel_does_not_dismiss() {
|
|||||||
click(&mut visual, point(px(960.), px(540.)));
|
click(&mut visual, point(px(960.), px(540.)));
|
||||||
assert!(changes.borrow().is_empty());
|
assert!(changes.borrow().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_closed_dialog_is_inert() {
|
|
||||||
let (_cx, mut visual, changes) = dialog_window(false, false);
|
|
||||||
visual.simulate_keystrokes("escape");
|
|
||||||
click(&mut visual, point(px(100.), px(100.)));
|
|
||||||
assert!(changes.borrow().is_empty());
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
||||||
Styled as _, TestAppContext, VisualTestContext, Window, div,
|
Styled as _, TestAppContext, VisualTestContext, Window, div,
|
||||||
@@ -13,12 +9,10 @@ use gpui_native_core::input::{Input, InputState};
|
|||||||
|
|
||||||
struct FieldHost {
|
struct FieldHost {
|
||||||
url: Entity<InputState>,
|
url: Entity<InputState>,
|
||||||
renders: Rc<RefCell<usize>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for FieldHost {
|
impl Render for FieldHost {
|
||||||
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 {
|
||||||
*self.renders.borrow_mut() += 1;
|
|
||||||
let url_error = self
|
let url_error = self
|
||||||
.url
|
.url
|
||||||
.read(cx)
|
.read(cx)
|
||||||
@@ -38,76 +32,48 @@ impl Render for FieldHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A field page in a real window, with the url input focused.
|
/// A field page in a real window; the url input is focused unless
|
||||||
fn field_host() -> (
|
/// `focus` is false, which leaves the page truly idle.
|
||||||
TestAppContext,
|
fn field_host(focus: bool) -> (TestAppContext, Entity<InputState>, VisualTestContext) {
|
||||||
Entity<InputState>,
|
|
||||||
Rc<RefCell<usize>>,
|
|
||||||
VisualTestContext,
|
|
||||||
) {
|
|
||||||
let cx = TestAppContext::single();
|
let cx = TestAppContext::single();
|
||||||
cx.update(init);
|
cx.update(init);
|
||||||
let (url, renders, window) = cx.update(|cx| {
|
let (url, window) = cx.update(|cx| {
|
||||||
let renders = Rc::new(RefCell::new(0));
|
|
||||||
let url = InputState::new(cx);
|
let url = InputState::new(cx);
|
||||||
let url_for_host = url.clone();
|
let url_for_host = url.clone();
|
||||||
let renders_for_host = renders.clone();
|
|
||||||
let window = cx
|
let window = cx
|
||||||
.open_window(gpui::WindowOptions::default(), |_, cx| {
|
.open_window(gpui::WindowOptions::default(), |_, cx| {
|
||||||
cx.new(|_| FieldHost {
|
cx.new(|_| FieldHost { url: url_for_host })
|
||||||
url: url_for_host,
|
|
||||||
renders: renders_for_host,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
(url, renders, window)
|
(url, window)
|
||||||
});
|
});
|
||||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
visual.update(|window, cx| {
|
visual.update(|window, cx| {
|
||||||
let focus = url.read(cx).focus_handle();
|
if focus {
|
||||||
window.focus(&focus, cx);
|
let focus = url.read(cx).focus_handle();
|
||||||
|
window.focus(&focus, cx);
|
||||||
|
}
|
||||||
window.draw(cx).clear(cx);
|
window.draw(cx).clear(cx);
|
||||||
});
|
});
|
||||||
(cx, url, renders, visual)
|
(cx, url, visual)
|
||||||
}
|
|
||||||
|
|
||||||
fn draw(visual: &mut VisualTestContext) {
|
|
||||||
visual.update(|window, cx| {
|
|
||||||
window.draw(cx).clear(cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn type_and_time(visual: &mut VisualTestContext, text: &str) -> std::time::Duration {
|
|
||||||
let started = Instant::now();
|
|
||||||
for letter in text.chars() {
|
|
||||||
visual.simulate_keystrokes(&letter.to_string());
|
|
||||||
draw(visual);
|
|
||||||
}
|
|
||||||
let elapsed = started.elapsed();
|
|
||||||
println!(
|
|
||||||
"{} keystrokes: {elapsed:?} ({:.2} ms per keystroke)",
|
|
||||||
text.chars().count(),
|
|
||||||
elapsed.as_secs_f64() / text.chars().count() as f64 * 1000.
|
|
||||||
);
|
|
||||||
elapsed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_idle_field_page_schedules_no_frames() {
|
fn an_idle_field_page_schedules_no_frames() {
|
||||||
let (_cx, _url, _renders, mut visual) = field_host();
|
let (_cx, _url, mut visual) = field_host(false);
|
||||||
let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
|
let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
frames, 0,
|
frames, 0,
|
||||||
"an idle field page must not request animation frames"
|
"an idle unfocused field page must not request animation frames"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn field_typing_frame_cost() {
|
fn a_focused_field_page_schedules_frames_for_its_blink() {
|
||||||
let (_cx, _url, _renders, mut visual) = field_host();
|
let (_cx, _url, mut visual) = field_host(true);
|
||||||
let elapsed = type_and_time(&mut visual, "dsdsadsadadadada");
|
let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
|
||||||
assert!(
|
assert!(
|
||||||
elapsed.as_millis() < 1000,
|
frames > 0,
|
||||||
"typing 16 characters took {elapsed:?}"
|
"the focused caret's blink needs frames to redraw at the blink cadence"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+190
-64
@@ -3,12 +3,15 @@ use gpui::{
|
|||||||
MouseUpEvent, ParentElement as _, Render, ScrollDelta, ScrollEvent, Styled as _,
|
MouseUpEvent, ParentElement as _, Render, ScrollDelta, ScrollEvent, Styled as _,
|
||||||
TestAppContext, VisualTestContext, Window, div, point, px,
|
TestAppContext, VisualTestContext, Window, div, point, px,
|
||||||
};
|
};
|
||||||
|
use gpui_native_core::button::{Button, ButtonVariant};
|
||||||
|
use gpui_native_core::common::Sizable as _;
|
||||||
use gpui_native_core::input::{
|
use gpui_native_core::input::{
|
||||||
INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, SubmitOn,
|
INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, SubmitOn,
|
||||||
bind_input_keys,
|
bind_input_keys,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::Theme;
|
use gpui_native_icons::IconRole;
|
||||||
use std::cell::RefCell;
|
use gpui_native_theme::{BORDER_WIDTH, Size, Theme};
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -266,42 +269,45 @@ fn read_only_refuses_edits_but_keeps_selection_and_copy() {
|
|||||||
assert_eq!(clipboard, "lo");
|
assert_eq!(clipboard, "lo");
|
||||||
|
|
||||||
// The caret is suppressed entirely while read-only.
|
// The caret is suppressed entirely while read-only.
|
||||||
let caret = cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(true, cx)));
|
let caret = cx.update(|cx| state.update(cx, |state, _| state.caret_visible(true)));
|
||||||
assert!(!caret);
|
assert!(!caret);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn caret_blinks_while_focused_and_holds_solid_while_typing() {
|
fn the_caret_visibility_is_pure_time_math_against_the_epoch() {
|
||||||
let (cx, state, mut visual) = input_window("", false);
|
let (cx, state, mut visual) = input_window("", false);
|
||||||
focus(&mut visual, &state);
|
focus(&mut visual, &state);
|
||||||
visual.update(|window, cx| {
|
let caret = || cx.update(|cx| state.update(cx, |state, _| state.caret_visible(true)));
|
||||||
window.draw(cx).clear(cx);
|
|
||||||
});
|
|
||||||
visual.run_until_parked();
|
|
||||||
|
|
||||||
let caret = || cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(true, cx)));
|
// Fresh focus starts a new epoch: the caret shows solid — the elapsed
|
||||||
|
// time since the epoch began is microseconds, far under one interval.
|
||||||
// The caret shows on focus, hides after one interval, and reappears
|
|
||||||
// after another.
|
|
||||||
assert!(caret());
|
|
||||||
cx.executor().advance_clock(Duration::from_millis(500));
|
|
||||||
visual.run_until_parked();
|
|
||||||
assert!(!caret());
|
|
||||||
cx.executor().advance_clock(Duration::from_millis(500));
|
|
||||||
visual.run_until_parked();
|
|
||||||
assert!(caret());
|
assert!(caret());
|
||||||
|
|
||||||
// Typing holds the caret solid and restarts the cycle.
|
// Typing restarts the epoch, so the caret stays solid through edits.
|
||||||
visual.simulate_keystrokes("a");
|
visual.simulate_keystrokes("a");
|
||||||
assert!(caret());
|
assert!(caret());
|
||||||
cx.executor().advance_clock(Duration::from_millis(500));
|
|
||||||
visual.run_until_parked();
|
|
||||||
assert!(!caret());
|
|
||||||
|
|
||||||
// An unfocused field reports no caret.
|
// The blink cadence itself: hidden in the second half of the cycle.
|
||||||
let caret_unfocused =
|
let hidden = cx.update(|cx| {
|
||||||
cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(false, cx)));
|
state.update(cx, |state, _| {
|
||||||
assert!(!caret_unfocused);
|
state.caret_blink_visible_at(true, Duration::from_millis(700))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert!(!hidden);
|
||||||
|
let visible = cx.update(|cx| {
|
||||||
|
state.update(cx, |state, _| {
|
||||||
|
state.caret_blink_visible_at(true, Duration::from_millis(300))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert!(visible);
|
||||||
|
|
||||||
|
// An unfocused field reports no caret, at any point in the cycle.
|
||||||
|
let unfocused = cx.update(|cx| {
|
||||||
|
state.update(cx, |state, _| {
|
||||||
|
state.caret_blink_visible_at(false, Duration::from_millis(100))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert!(!unfocused);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -459,15 +465,6 @@ fn disabled_input_ignores_typing_and_clicks() {
|
|||||||
assert_eq!(value(&cx, &state), "");
|
assert_eq!(value(&cx, &state), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_value_replaces_the_buffer() {
|
|
||||||
let (cx, state, mut visual) = input_window("", false);
|
|
||||||
visual.update(|window, cx| {
|
|
||||||
state.update(cx, |state, cx| state.set_value("preset", window, cx));
|
|
||||||
});
|
|
||||||
assert_eq!(value(&cx, &state), "preset");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Subscribes to `state`'s events, recording each variant into a sink that
|
/// Subscribes to `state`'s events, recording each variant into a sink that
|
||||||
/// lives for the rest of the test. The returned `Subscription` must stay
|
/// lives for the rest of the test. The returned `Subscription` must stay
|
||||||
/// bound for the same lifetime — dropping it unsubscribes.
|
/// bound for the same lifetime — dropping it unsubscribes.
|
||||||
@@ -536,26 +533,6 @@ fn set_value_silent_updates_without_emitting() {
|
|||||||
assert_eq!(count(&sink, &InputStateEvent::TextChanged), 0);
|
assert_eq!(count(&sink, &InputStateEvent::TextChanged), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn focus_and_blur_emit_events() {
|
|
||||||
let (mut cx, state, mut visual) = input_window("", false);
|
|
||||||
let (sink, _subscription) = record_events(&mut cx, &state);
|
|
||||||
|
|
||||||
focus(&mut visual, &state);
|
|
||||||
visual.update(|window, cx| {
|
|
||||||
window.draw(cx).clear(cx);
|
|
||||||
});
|
|
||||||
visual.run_until_parked();
|
|
||||||
assert_eq!(count(&sink, &InputStateEvent::Focused), 1);
|
|
||||||
|
|
||||||
visual.update(|window, cx| window.blur(cx));
|
|
||||||
visual.update(|window, cx| {
|
|
||||||
window.draw(cx).clear(cx);
|
|
||||||
});
|
|
||||||
visual.run_until_parked();
|
|
||||||
assert_eq!(count(&sink, &InputStateEvent::Blurred), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pasted_newlines_become_spaces() {
|
fn pasted_newlines_become_spaces() {
|
||||||
let (cx, state, mut visual) = input_window("", false);
|
let (cx, state, mut visual) = input_window("", false);
|
||||||
@@ -568,15 +545,6 @@ fn pasted_newlines_become_spaces() {
|
|||||||
assert_eq!(value(&cx, &state), "a bc");
|
assert_eq!(value(&cx, &state), "a bc");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_value_sanitizes_newlines() {
|
|
||||||
let (cx, state, mut visual) = input_window("", false);
|
|
||||||
visual.update(|window, cx| {
|
|
||||||
state.update(cx, |state, cx| state.set_value("a\nb", window, cx));
|
|
||||||
});
|
|
||||||
assert_eq!(value(&cx, &state), "a b");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_emits_submitted() {
|
fn enter_emits_submitted() {
|
||||||
let (mut cx, state, mut visual) = input_window("draft", false);
|
let (mut cx, state, mut visual) = input_window("draft", false);
|
||||||
@@ -763,6 +731,36 @@ fn the_reveal_toggle_flips_masking() {
|
|||||||
assert!(!revealed, "the toggle should mask the field again");
|
assert!(!revealed, "the toggle should mask the field again");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The reveal pill sits with equal insets on its three free sides; the
|
||||||
|
/// small control metric must stay whole-pixel or snapping skews it.
|
||||||
|
#[test]
|
||||||
|
fn the_reveal_pill_insets_equally() {
|
||||||
|
let (cx, _state, mut visual) = reveal_window();
|
||||||
|
|
||||||
|
let field = visual.debug_bounds("input-field").expect("field missing");
|
||||||
|
let pill = visual
|
||||||
|
.debug_bounds("input-reveal-button")
|
||||||
|
.expect("reveal pill missing");
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
let theme = Theme::global(cx);
|
||||||
|
let small = theme.sizes.resolve(Size::Small);
|
||||||
|
let border = BORDER_WIDTH;
|
||||||
|
let docked = small.control_height + border * 2.0;
|
||||||
|
let inset = ((field.size.height - border * 2.0 - docked) / 2.0).max(px(0.));
|
||||||
|
|
||||||
|
let top_gap = pill.top() - (field.top() + border);
|
||||||
|
let bottom_gap = (field.bottom() - border) - pill.bottom();
|
||||||
|
let right_gap = (field.right() - border) - pill.right();
|
||||||
|
assert!(
|
||||||
|
(top_gap - inset).abs() < px(0.01)
|
||||||
|
&& (bottom_gap - inset).abs() < px(0.01)
|
||||||
|
&& (right_gap - inset).abs() < px(0.01),
|
||||||
|
"pill gaps top {top_gap:?} bottom {bottom_gap:?} right {right_gap:?} vs inset {inset:?}"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_caret_can_rest_on_a_line_break() {
|
fn the_caret_can_rest_on_a_line_break() {
|
||||||
let (cx, state, mut visual) = multiline_window("ab\ncdef");
|
let (cx, state, mut visual) = multiline_window("ab\ncdef");
|
||||||
@@ -906,3 +904,131 @@ fn vertical_scroll_keeps_the_caret_in_view() {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
assert!(first_row_top < px(0.), "first row top: {first_row_top:?}");
|
assert!(first_row_top < px(0.), "first row top: {first_row_top:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct EdgeHarness {
|
||||||
|
state: Entity<InputState>,
|
||||||
|
clicked: Rc<Cell<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for EdgeHarness {
|
||||||
|
fn render(
|
||||||
|
&mut self,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut gpui::Context<Self>,
|
||||||
|
) -> impl gpui::IntoElement {
|
||||||
|
let clicked = self.clicked.clone();
|
||||||
|
div().w(px(400.)).p_4().child(
|
||||||
|
Input::new("edge-input", self.state.clone())
|
||||||
|
.placeholder("Search")
|
||||||
|
.prefix(
|
||||||
|
Button::new("edge-input-prefix-button")
|
||||||
|
.icon(IconRole::Search)
|
||||||
|
.variant(ButtonVariant::Ghost)
|
||||||
|
.small(),
|
||||||
|
)
|
||||||
|
.suffix(
|
||||||
|
Button::new("edge-input-suffix-button")
|
||||||
|
.debug_selector(|| "edge-suffix-button".to_string())
|
||||||
|
.icon(IconRole::Plus)
|
||||||
|
.variant(ButtonVariant::Ghost)
|
||||||
|
.small()
|
||||||
|
.on_click(move |_, _, _| clicked.set(true)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edge_input_window(
|
||||||
|
initial: &str,
|
||||||
|
) -> (
|
||||||
|
TestAppContext,
|
||||||
|
Entity<InputState>,
|
||||||
|
VisualTestContext,
|
||||||
|
Rc<Cell<bool>>,
|
||||||
|
) {
|
||||||
|
let clicked = Rc::new(Cell::new(false));
|
||||||
|
let clicked_for_window = clicked.clone();
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
cx.update(|cx| {
|
||||||
|
Theme::init(cx);
|
||||||
|
bind_input_keys(cx, InputBindings::default());
|
||||||
|
});
|
||||||
|
let state = cx.update(|cx| InputState::new_with_text(initial, cx));
|
||||||
|
let state_for_window = state.clone();
|
||||||
|
let window = cx.add_window(move |_, _| EdgeHarness {
|
||||||
|
state: state_for_window,
|
||||||
|
clicked: clicked_for_window,
|
||||||
|
});
|
||||||
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
(cx, state, visual, clicked)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefix and suffix dock flush inside the field, prefix leading, and a
|
||||||
|
/// small button in a zone centers with equal insets like the reveal pill.
|
||||||
|
#[test]
|
||||||
|
fn prefix_and_suffix_dock_inside_the_field() {
|
||||||
|
let (cx, _state, mut visual, _clicked) = edge_input_window("abc");
|
||||||
|
|
||||||
|
let field = visual.debug_bounds("input-field").expect("field missing");
|
||||||
|
let prefix = visual
|
||||||
|
.debug_bounds("input-prefix")
|
||||||
|
.expect("prefix zone missing");
|
||||||
|
let suffix = visual
|
||||||
|
.debug_bounds("input-suffix")
|
||||||
|
.expect("suffix zone missing");
|
||||||
|
let pill = visual
|
||||||
|
.debug_bounds("edge-suffix-button")
|
||||||
|
.expect("suffix button missing");
|
||||||
|
|
||||||
|
assert!(prefix.left() >= field.left());
|
||||||
|
assert!(prefix.right() <= suffix.left());
|
||||||
|
assert!(suffix.right() <= field.right());
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
let theme = Theme::global(cx);
|
||||||
|
let small = theme.sizes.resolve(Size::Small);
|
||||||
|
let border = BORDER_WIDTH;
|
||||||
|
let docked = small.control_height + border * 2.0;
|
||||||
|
let inset = ((field.size.height - border * 2.0 - docked) / 2.0).max(px(0.));
|
||||||
|
|
||||||
|
let top_gap = pill.top() - (field.top() + border);
|
||||||
|
let bottom_gap = (field.bottom() - border) - pill.bottom();
|
||||||
|
let right_gap = (field.right() - border) - pill.right();
|
||||||
|
assert!(
|
||||||
|
(top_gap - inset).abs() < px(0.01)
|
||||||
|
&& (bottom_gap - inset).abs() < px(0.01)
|
||||||
|
&& (right_gap - inset).abs() < px(0.01),
|
||||||
|
"suffix pill gaps top {top_gap:?} bottom {bottom_gap:?} right {right_gap:?} vs inset {inset:?}"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A click on a docked control activates it without running the field's
|
||||||
|
/// caret handler underneath.
|
||||||
|
#[test]
|
||||||
|
fn a_suffix_click_activates_without_focusing_the_field() {
|
||||||
|
let (cx, state, mut visual, clicked) = edge_input_window("abc");
|
||||||
|
|
||||||
|
let bounds = visual
|
||||||
|
.debug_bounds("input-suffix")
|
||||||
|
.expect("suffix zone missing");
|
||||||
|
click(
|
||||||
|
&mut visual,
|
||||||
|
point(
|
||||||
|
bounds.left() + bounds.size.width / 2.0,
|
||||||
|
bounds.top() + bounds.size.height / 2.0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(clicked.get(), "the suffix button should activate");
|
||||||
|
let focused = visual.update(|window, cx| state.read(cx).focus_handle().is_focused(window));
|
||||||
|
assert!(!focused, "the suffix click must not focus the field");
|
||||||
|
|
||||||
|
// The zone must not swallow text interaction either.
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
visual.simulate_keystrokes("x");
|
||||||
|
assert_eq!(value(&cx, &state), "abcx");
|
||||||
|
}
|
||||||
|
|||||||
+22
-17
@@ -112,6 +112,28 @@ fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
|||||||
visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
|
visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every action row renders at the platform's stated menu row height,
|
||||||
|
/// whatever its content — the metric is the whole row, not a floor under
|
||||||
|
/// padding. Item 1 of the harness menu is a separator, so it is skipped.
|
||||||
|
#[test]
|
||||||
|
fn rows_render_at_the_stated_menu_row_height() {
|
||||||
|
let (cx, mut visual, _window, _changes, _activated) = menu_window(true);
|
||||||
|
|
||||||
|
let stated = cx.update(|cx| Theme::global(cx).sizes.medium.menu_item_height);
|
||||||
|
for index in [0, 2, 3, 4] {
|
||||||
|
let selector: &'static str = Box::leak(format!("menu-item-{index}").into_boxed_str());
|
||||||
|
let row = visual
|
||||||
|
.debug_bounds(selector)
|
||||||
|
.unwrap_or_else(|| panic!("row {index} should have been laid out"));
|
||||||
|
// Painted bounds are snapped to device pixels; allow half a pixel.
|
||||||
|
assert!(
|
||||||
|
(row.size.height - stated).abs() <= px(0.5),
|
||||||
|
"row {index} is {:?} tall, stated menu row height is {stated:?}",
|
||||||
|
row.size.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn arrow_navigation_skips_separators_and_disabled_items() {
|
fn arrow_navigation_skips_separators_and_disabled_items() {
|
||||||
let (_cx, mut visual, _window, changes, activated) = menu_window(true);
|
let (_cx, mut visual, _window, changes, activated) = menu_window(true);
|
||||||
@@ -163,14 +185,6 @@ fn clicking_outside_dismisses_and_swallows_the_click() {
|
|||||||
assert_eq!(*changes.borrow(), vec![false]);
|
assert_eq!(*changes.borrow(), vec![false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clicking_the_trigger_toggles() {
|
|
||||||
let (_cx, mut visual, _window, changes, _activated) = menu_window(false);
|
|
||||||
|
|
||||||
click(&mut visual, point(px(10.), px(16.)));
|
|
||||||
assert_eq!(*changes.borrow(), vec![true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clicking_an_item_activates_and_closes() {
|
fn clicking_an_item_activates_and_closes() {
|
||||||
let (_cx, mut visual, _window, changes, activated) = menu_window(true);
|
let (_cx, mut visual, _window, changes, activated) = menu_window(true);
|
||||||
@@ -180,12 +194,3 @@ fn clicking_an_item_activates_and_closes() {
|
|||||||
assert_eq!(*activated.borrow(), vec!["cut"]);
|
assert_eq!(*activated.borrow(), vec!["cut"]);
|
||||||
assert_eq!(*changes.borrow(), vec![false]);
|
assert_eq!(*changes.borrow(), vec![false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enter_activates_the_selected_item_and_closes() {
|
|
||||||
let (_cx, mut visual, _window, changes, activated) = menu_window(true);
|
|
||||||
|
|
||||||
visual.simulate_keystrokes("enter");
|
|
||||||
assert_eq!(*activated.borrow(), vec!["cut"]);
|
|
||||||
assert_eq!(*changes.borrow(), vec![false]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use std::{cell::RefCell, rc::Rc};
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Context, IntoElement, Modifiers, MouseButton, ParentElement as _, Render, Styled as _,
|
Context, InteractiveElement as _, IntoElement, Modifiers, MouseButton, ParentElement as _,
|
||||||
TestAppContext, VisualTestContext, Window, WindowHandle, div, point, px, rgba,
|
Render, StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext,
|
||||||
|
Window, WindowHandle, div, point, px, rgba,
|
||||||
};
|
};
|
||||||
use gpui_native_core::popover::Popover;
|
use gpui_native_core::popover::Popover;
|
||||||
use gpui_native_theme::Theme;
|
use gpui_native_theme::Theme;
|
||||||
@@ -10,37 +11,54 @@ use gpui_native_theme::Theme;
|
|||||||
struct Harness {
|
struct Harness {
|
||||||
open: bool,
|
open: bool,
|
||||||
changes: Rc<RefCell<Vec<bool>>>,
|
changes: Rc<RefCell<Vec<bool>>>,
|
||||||
|
target_clicks: Rc<RefCell<usize>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Harness {
|
impl Render for Harness {
|
||||||
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 {
|
||||||
let changes = self.changes.clone();
|
let changes = self.changes.clone();
|
||||||
|
let target_clicks = self.target_clicks.clone();
|
||||||
let view = cx.entity();
|
let view = cx.entity();
|
||||||
div().size_full().child(
|
div()
|
||||||
Popover::new("test-popover")
|
.size_full()
|
||||||
.open(self.open)
|
.child(
|
||||||
.on_change(move |next, _, cx| {
|
div().absolute().left(px(400.)).top(px(300.)).child(
|
||||||
changes.borrow_mut().push(next);
|
|
||||||
view.update(cx, |harness, cx| {
|
|
||||||
harness.open = next;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.trigger(|_, _| {
|
|
||||||
div()
|
div()
|
||||||
.w(px(100.))
|
.id("popover-target")
|
||||||
.h(px(32.))
|
.size(px(40.))
|
||||||
.bg(rgba(0x000000ff))
|
.bg(rgba(0x000000ff))
|
||||||
.into_any_element()
|
.on_click(move |_, _, cx| {
|
||||||
})
|
*target_clicks.borrow_mut() += 1;
|
||||||
.content(|_, _| {
|
cx.stop_propagation();
|
||||||
div()
|
}),
|
||||||
.w(px(200.))
|
),
|
||||||
.h(px(80.))
|
)
|
||||||
.child("Popover content")
|
.child(
|
||||||
.into_any_element()
|
Popover::new("test-popover")
|
||||||
}),
|
.open(self.open)
|
||||||
)
|
.on_change(move |next, _, cx| {
|
||||||
|
changes.borrow_mut().push(next);
|
||||||
|
view.update(cx, |harness, cx| {
|
||||||
|
harness.open = next;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.trigger(|_, _| {
|
||||||
|
div()
|
||||||
|
.debug_selector(|| "popover-trigger".to_string())
|
||||||
|
.w(px(100.))
|
||||||
|
.h(px(32.))
|
||||||
|
.bg(rgba(0x000000ff))
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.content(|_, _| {
|
||||||
|
div()
|
||||||
|
.w(px(200.))
|
||||||
|
.h(px(80.))
|
||||||
|
.child("Popover content")
|
||||||
|
.into_any_element()
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,27 +66,31 @@ impl Render for Harness {
|
|||||||
/// hangs below it around `(0..200, 36..116)`, so clicks at `(10, 16)` land
|
/// hangs below it around `(0..200, 36..116)`, so clicks at `(10, 16)` land
|
||||||
/// on the trigger, `(100, 70)` on the panel, and anything past `(300, 300)`
|
/// on the trigger, `(100, 70)` on the panel, and anything past `(300, 300)`
|
||||||
/// is outside both.
|
/// is outside both.
|
||||||
fn popover_window(
|
type PopoverWindow = (
|
||||||
open: bool,
|
|
||||||
) -> (
|
|
||||||
TestAppContext,
|
TestAppContext,
|
||||||
VisualTestContext,
|
VisualTestContext,
|
||||||
WindowHandle<Harness>,
|
WindowHandle<Harness>,
|
||||||
Rc<RefCell<Vec<bool>>>,
|
Rc<RefCell<Vec<bool>>>,
|
||||||
) {
|
Rc<RefCell<usize>>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fn popover_window(open: bool) -> PopoverWindow {
|
||||||
let mut cx = TestAppContext::single();
|
let mut cx = TestAppContext::single();
|
||||||
cx.update(Theme::init);
|
cx.update(Theme::init);
|
||||||
let changes = Rc::new(RefCell::new(Vec::new()));
|
let changes = Rc::new(RefCell::new(Vec::new()));
|
||||||
let harness_changes = changes.clone();
|
let harness_changes = changes.clone();
|
||||||
|
let target_clicks = Rc::new(RefCell::new(0));
|
||||||
|
let harness_clicks = target_clicks.clone();
|
||||||
let window = cx.add_window(move |_, _| Harness {
|
let window = cx.add_window(move |_, _| Harness {
|
||||||
open,
|
open,
|
||||||
changes: harness_changes,
|
changes: harness_changes,
|
||||||
|
target_clicks: harness_clicks,
|
||||||
});
|
});
|
||||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
visual.update(|window, cx| {
|
visual.update(|window, cx| {
|
||||||
window.draw(cx).clear(cx);
|
window.draw(cx).clear(cx);
|
||||||
});
|
});
|
||||||
(cx, visual, window, changes)
|
(cx, visual, window, changes, target_clicks)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw(visual: &mut VisualTestContext) {
|
fn draw(visual: &mut VisualTestContext) {
|
||||||
@@ -84,7 +106,7 @@ fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clicking_outside_dismisses_and_reports_closed() {
|
fn clicking_outside_dismisses_and_reports_closed() {
|
||||||
let (_cx, mut visual, _window, changes) = popover_window(true);
|
let (_cx, mut visual, _window, changes, _clicks) = popover_window(true);
|
||||||
|
|
||||||
click(&mut visual, point(px(600.), px(600.)));
|
click(&mut visual, point(px(600.), px(600.)));
|
||||||
assert_eq!(*changes.borrow(), vec![false]);
|
assert_eq!(*changes.borrow(), vec![false]);
|
||||||
@@ -98,7 +120,7 @@ fn clicking_outside_dismisses_and_reports_closed() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clicking_the_trigger_toggles_and_clicking_the_panel_does_nothing() {
|
fn clicking_the_trigger_toggles_and_clicking_the_panel_does_nothing() {
|
||||||
let (_cx, mut visual, _window, changes) = popover_window(true);
|
let (_cx, mut visual, _window, changes, _clicks) = popover_window(true);
|
||||||
|
|
||||||
// On the open panel's trigger: toggles closed.
|
// On the open panel's trigger: toggles closed.
|
||||||
click(&mut visual, point(px(10.), px(16.)));
|
click(&mut visual, point(px(10.), px(16.)));
|
||||||
@@ -111,7 +133,7 @@ fn clicking_the_trigger_toggles_and_clicking_the_panel_does_nothing() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clicking_the_trigger_opens_a_closed_popover() {
|
fn clicking_the_trigger_opens_a_closed_popover() {
|
||||||
let (_cx, mut visual, _window, changes) = popover_window(false);
|
let (_cx, mut visual, _window, changes, _clicks) = popover_window(false);
|
||||||
|
|
||||||
click(&mut visual, point(px(10.), px(16.)));
|
click(&mut visual, point(px(10.), px(16.)));
|
||||||
assert_eq!(*changes.borrow(), vec![true]);
|
assert_eq!(*changes.borrow(), vec![true]);
|
||||||
@@ -119,7 +141,7 @@ fn clicking_the_trigger_opens_a_closed_popover() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn escape_dismisses_while_the_panel_holds_focus() {
|
fn escape_dismisses_while_the_panel_holds_focus() {
|
||||||
let (_cx, mut visual, _window, changes) = popover_window(true);
|
let (_cx, mut visual, _window, changes, _clicks) = popover_window(true);
|
||||||
draw(&mut visual);
|
draw(&mut visual);
|
||||||
|
|
||||||
// The panel claims focus on open, so escape reaches its key listener.
|
// The panel claims focus on open, so escape reaches its key listener.
|
||||||
@@ -134,7 +156,7 @@ fn escape_dismisses_while_the_panel_holds_focus() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_controlled_loop_reopens_from_the_app_side() {
|
fn the_controlled_loop_reopens_from_the_app_side() {
|
||||||
let (mut cx, mut visual, window, changes) = popover_window(true);
|
let (mut cx, mut visual, window, changes, _clicks) = popover_window(true);
|
||||||
|
|
||||||
// Dismiss once.
|
// Dismiss once.
|
||||||
click(&mut visual, point(px(600.), px(600.)));
|
click(&mut visual, point(px(600.), px(600.)));
|
||||||
@@ -155,3 +177,38 @@ fn the_controlled_loop_reopens_from_the_app_side() {
|
|||||||
visual.simulate_keystrokes("escape");
|
visual.simulate_keystrokes("escape");
|
||||||
assert_eq!(*changes.borrow(), vec![false, false]);
|
assert_eq!(*changes.borrow(), vec![false, false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clicking another control while the popover is open dismisses it and still
|
||||||
|
/// acts on that control: dismissing does not claim the event.
|
||||||
|
#[test]
|
||||||
|
fn clicking_another_control_dismisses_and_reaches_it() {
|
||||||
|
let (_cx, mut visual, _window, changes, clicks) = popover_window(true);
|
||||||
|
|
||||||
|
click(&mut visual, point(px(420.), px(320.)));
|
||||||
|
|
||||||
|
assert_eq!(*changes.borrow(), vec![false]);
|
||||||
|
assert_eq!(*clicks.borrow(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The panel hangs one spacing unit below the trigger: the anchor gap is
|
||||||
|
/// theme-owned, and rides the anchored element so the window-edge clamp
|
||||||
|
/// stays intact.
|
||||||
|
#[test]
|
||||||
|
fn the_panel_hangs_one_spacing_unit_below_the_trigger() {
|
||||||
|
let (cx, mut visual, _window, _changes, _clicks) = popover_window(true);
|
||||||
|
|
||||||
|
let trigger = visual
|
||||||
|
.debug_bounds("popover-trigger")
|
||||||
|
.expect("trigger rendered");
|
||||||
|
let panel = visual
|
||||||
|
.debug_bounds("popover-panel")
|
||||||
|
.expect("panel rendered");
|
||||||
|
|
||||||
|
let expected = cx.update(|cx| Theme::global(cx).spacing.unit);
|
||||||
|
let gap = panel.top() - trigger.bottom();
|
||||||
|
// Painted bounds are snapped to device pixels; allow half a pixel.
|
||||||
|
assert!(
|
||||||
|
(gap - expected).abs() <= px(0.5),
|
||||||
|
"the anchor gap is {gap:?}, expected {expected:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
use gpui::{ParentElement as _, Render, Styled as _, TestAppContext, Window, div};
|
|
||||||
use gpui_native_core::progress::Progress;
|
|
||||||
use gpui_native_theme::Theme;
|
|
||||||
|
|
||||||
struct Harness;
|
|
||||||
|
|
||||||
impl Render for Harness {
|
|
||||||
fn render(
|
|
||||||
&mut self,
|
|
||||||
_window: &mut Window,
|
|
||||||
_cx: &mut gpui::Context<Self>,
|
|
||||||
) -> impl gpui::IntoElement {
|
|
||||||
div()
|
|
||||||
.size_full()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_2()
|
|
||||||
.child(Progress::new("test-progress", 0.6).accessibility_label("Upload"))
|
|
||||||
.child(
|
|
||||||
Progress::new("test-progress-indeterminate", 0.0)
|
|
||||||
.indeterminate(true)
|
|
||||||
.accessibility_label("Working"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[gpui::test]
|
|
||||||
fn determinate_and_indeterminate_bars_draw_headlessly(cx: &mut TestAppContext) {
|
|
||||||
cx.update(Theme::init);
|
|
||||||
let (view, visual) = cx.add_window_view(|_, _| Harness);
|
|
||||||
let _ = view;
|
|
||||||
visual.run_until_parked();
|
|
||||||
visual.update(|window, cx| {
|
|
||||||
window.draw(cx).clear(cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -88,13 +88,6 @@ fn the_content_starts_unscrolled_and_horizontal_axes_scroll_x() {
|
|||||||
assert_eq!(handle.offset().x, px(0.));
|
assert_eq!(handle.offset().x, px(0.));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_thumb_paints_when_the_content_overflows() {
|
|
||||||
let (_cx, _visual, handle) = scroll_area_window(30);
|
|
||||||
// 30 rows × 20px in a 200px viewport: the thumb has something to report.
|
|
||||||
assert!(handle.max_offset().y > px(0.));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The gallery's exact shell: a window-filling root, a window-filling scroll
|
/// The gallery's exact shell: a window-filling root, a window-filling scroll
|
||||||
/// area over padded flex-column content that towers past the viewport.
|
/// area over padded flex-column content that towers past the viewport.
|
||||||
struct GalleryShell {
|
struct GalleryShell {
|
||||||
|
|||||||
@@ -121,28 +121,3 @@ fn arrows_move_the_selection_when_the_strip_has_focus() {
|
|||||||
vec![Filter::Popular, Filter::Recent, Filter::All, Filter::Recent]
|
vec![Filter::Popular, Filter::Recent, Filter::All, Filter::Recent]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The selected plate exists after a choice and settles on the chosen
|
|
||||||
/// segment's slot; without it the active background disappears entirely.
|
|
||||||
#[test]
|
|
||||||
fn the_plate_settles_on_the_chosen_segment() {
|
|
||||||
let (_cx, mut visual, changes) = filter_window();
|
|
||||||
|
|
||||||
click(&mut visual, point(px(70.), px(27.)));
|
|
||||||
assert_eq!(*changes.borrow(), vec![Filter::Popular]);
|
|
||||||
|
|
||||||
// Let the spring settle in real time (it steps on Instant::now()), then
|
|
||||||
// check the plate landed in Popular's slot (~58..124).
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(600));
|
|
||||||
visual.update(|window, cx| {
|
|
||||||
window.draw(cx).clear(cx);
|
|
||||||
});
|
|
||||||
let plate = visual
|
|
||||||
.debug_bounds("segmented-plate")
|
|
||||||
.expect("plate missing");
|
|
||||||
assert!(
|
|
||||||
plate.left() >= px(44.) && plate.left() <= px(94.),
|
|
||||||
"plate settled at x {:?}",
|
|
||||||
plate.origin
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use std::{cell::RefCell, rc::Rc};
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Context, IntoElement, MouseButton, ParentElement as _, Render, Styled as _, TestAppContext,
|
Context, Entity, IntoElement, MouseButton, ParentElement as _, Render, Styled as _,
|
||||||
VisualTestContext, Window, div, point, px,
|
TestAppContext, VisualTestContext, Window, div, point, px,
|
||||||
};
|
};
|
||||||
|
use gpui_native_core::input::{Input, InputState};
|
||||||
use gpui_native_core::select::Select;
|
use gpui_native_core::select::Select;
|
||||||
use gpui_native_theme::Theme;
|
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Theme};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
enum Strategy {
|
enum Strategy {
|
||||||
@@ -200,3 +201,80 @@ fn two_selects_keep_their_popups_independent() {
|
|||||||
assert_eq!(*first_changes.borrow(), vec![]);
|
assert_eq!(*first_changes.borrow(), vec![]);
|
||||||
assert_eq!(*second_changes.borrow(), vec![Some(Strategy::Curated)]);
|
assert_eq!(*second_changes.borrow(), vec![Some(Strategy::Curated)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_click_outside_closes_the_popup() {
|
||||||
|
let (_cx, mut visual, changes) = select_window();
|
||||||
|
|
||||||
|
click(&mut visual, point(px(100.), px(31.)));
|
||||||
|
draw(&mut visual);
|
||||||
|
click(&mut visual, point(px(100.), px(300.)));
|
||||||
|
draw(&mut visual);
|
||||||
|
|
||||||
|
// The popup held keyboard focus while open; a keystroke after the outside
|
||||||
|
// click answers nothing if the popup really closed.
|
||||||
|
visual.simulate_keystrokes("enter");
|
||||||
|
draw(&mut visual);
|
||||||
|
assert_eq!(*changes.borrow(), Vec::<Option<Strategy>>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clicking another control while the popup is open dismisses it and still
|
||||||
|
/// acts on the control: dismissing does not claim the event.
|
||||||
|
#[test]
|
||||||
|
fn a_click_on_another_control_dismisses_and_reaches_it() {
|
||||||
|
let (_cx, mut visual, first_changes, second_changes) = pair_window();
|
||||||
|
|
||||||
|
click(&mut visual, point(px(100.), px(31.)));
|
||||||
|
draw(&mut visual);
|
||||||
|
|
||||||
|
// The second select's trigger, below the first — at x=300, past the first
|
||||||
|
// popup's 182px-wide panel: this click must close the first popup AND open
|
||||||
|
// the second.
|
||||||
|
click(&mut visual, point(px(300.), px(69.)));
|
||||||
|
draw(&mut visual);
|
||||||
|
visual.simulate_keystrokes("enter");
|
||||||
|
draw(&mut visual);
|
||||||
|
|
||||||
|
assert_eq!(*first_changes.borrow(), Vec::<Option<Strategy>>::new());
|
||||||
|
assert_eq!(*second_changes.borrow(), vec![Some(Strategy::Curated)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HeightsHarness {
|
||||||
|
state: Entity<InputState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for HeightsHarness {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_4()
|
||||||
|
.child(Select::new("heights").options(STRATEGIES))
|
||||||
|
.child(Input::new("heights-field", self.state.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trigger renders at the same height as the input field: the stated
|
||||||
|
/// metric plus the always-painted hairline border, whatever the platform.
|
||||||
|
#[test]
|
||||||
|
fn the_trigger_matches_the_input_height() {
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let state = cx.update(InputState::new);
|
||||||
|
let state_for_window = state.clone();
|
||||||
|
let window = cx.add_window(move |_, _| HeightsHarness {
|
||||||
|
state: state_for_window,
|
||||||
|
});
|
||||||
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
|
draw(&mut visual);
|
||||||
|
|
||||||
|
let trigger = visual
|
||||||
|
.debug_bounds("select-trigger")
|
||||||
|
.expect("trigger rendered");
|
||||||
|
let field = visual.debug_bounds("input-field").expect("input rendered");
|
||||||
|
|
||||||
|
// Snapped bounds: allow half a pixel.
|
||||||
|
assert!((trigger.size.height - field.size.height).abs() <= px(0.5));
|
||||||
|
let expected = cx.update(|cx| cx.theme().sizes.medium.input_height) + BORDER_WIDTH * 2.0;
|
||||||
|
assert!((trigger.size.height - expected).abs() <= px(0.5));
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,25 +67,45 @@ fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
|||||||
visual.simulate_mouse_up(position, MouseButton::Left, gpui::Modifiers::default());
|
visual.simulate_mouse_up(position, MouseButton::Left, gpui::Modifiers::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The trigger band is `16..46`; the panel hangs 4 px below it with a 4 px
|
/// The three option rows must answer a pointer press wherever they were laid
|
||||||
/// inner padding, so the three rows ride `~58..90`, `~90..122`, `~122..154`.
|
/// out — a popup that clips its last item never does. Row positions come from
|
||||||
/// Every option must answer a pointer press — a popup that clips its last
|
/// the rendered bounds, so the stated menu row metric can change freely.
|
||||||
/// item never does.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_option_row_answers_the_pointer() {
|
fn every_option_row_answers_the_pointer() {
|
||||||
let (_cx, mut visual, changes) = level_window();
|
let (_cx, mut visual, changes) = level_window();
|
||||||
|
|
||||||
let expected = [
|
// Open once to measure where the rows were actually drawn.
|
||||||
(px(74.), Level::Low),
|
click(&mut visual, point(px(100.), px(31.)));
|
||||||
(px(106.), Level::Mid),
|
visual.update(|window, cx| {
|
||||||
(px(138.), Level::High),
|
window.draw(cx).clear(cx);
|
||||||
];
|
});
|
||||||
for (y, value) in expected {
|
let centers: Vec<gpui::Point<gpui::Pixels>> = (0..3)
|
||||||
|
.map(|index| {
|
||||||
|
let selector: &'static str = Box::leak(format!("menu-item-{index}").into_boxed_str());
|
||||||
|
let row = visual
|
||||||
|
.debug_bounds(selector)
|
||||||
|
.unwrap_or_else(|| panic!("row {index} should have been laid out"));
|
||||||
|
point(
|
||||||
|
row.left() + row.size.width / 2.0,
|
||||||
|
row.top() + row.size.height / 2.0,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Close again; each iteration below opens the popup fresh.
|
||||||
|
click(&mut visual, point(px(100.), px(31.)));
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let expected = [(Level::Low, 0), (Level::Mid, 1), (Level::High, 2)];
|
||||||
|
for (value, index) in expected {
|
||||||
|
// Choosing the previous row closed the popup; reopen it.
|
||||||
click(&mut visual, point(px(100.), px(31.)));
|
click(&mut visual, point(px(100.), px(31.)));
|
||||||
visual.update(|window, cx| {
|
visual.update(|window, cx| {
|
||||||
window.draw(cx).clear(cx);
|
window.draw(cx).clear(cx);
|
||||||
});
|
});
|
||||||
click(&mut visual, point(px(100.), y));
|
click(&mut visual, centers[index]);
|
||||||
visual.update(|window, cx| {
|
visual.update(|window, cx| {
|
||||||
window.draw(cx).clear(cx);
|
window.draw(cx).clear(cx);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,16 +45,3 @@ fn the_dot_is_half_the_icon_size() {
|
|||||||
dot.size
|
dot.size
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_dot_keeps_a_square_footprint() {
|
|
||||||
let (cx, window) = dot_window();
|
|
||||||
let mut visual = VisualTestContext::from_window(window, &cx);
|
|
||||||
let dot = visual.debug_bounds("test-dot").expect("dot missing");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
(dot.size.width - dot.size.height).abs() <= gpui::px(0.5),
|
|
||||||
"the dot must stay circular: {:?}",
|
|
||||||
dot.size
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -83,6 +83,30 @@ fn disabled_switch_does_not_report_changes() {
|
|||||||
assert!(changes.borrow().is_empty());
|
assert!(changes.borrow().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The track renders at the platform's stated switch dims, not the generic
|
||||||
|
/// control height — the stated metric is the whole chrome.
|
||||||
|
#[test]
|
||||||
|
fn track_renders_at_the_stated_switch_dims() {
|
||||||
|
let (mut cx, _window, _changes) = switch_window(false, false);
|
||||||
|
let (width, height) = cx.update(|_window, cx| {
|
||||||
|
let metrics = Theme::global(cx).sizes.medium;
|
||||||
|
(metrics.switch_width, metrics.switch_height)
|
||||||
|
});
|
||||||
|
let track = cx
|
||||||
|
.debug_bounds("test-switch-track")
|
||||||
|
.expect("the switch track should have been laid out");
|
||||||
|
assert!(
|
||||||
|
(track.size.width - width).abs() <= px(0.5),
|
||||||
|
"track is {:?} wide, stated switch width is {width:?}",
|
||||||
|
track.size.width
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(track.size.height - height).abs() <= px(0.5),
|
||||||
|
"track is {:?} tall, stated switch height is {height:?}",
|
||||||
|
track.size.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_and_space_report_the_next_value_while_focused() {
|
fn enter_and_space_report_the_next_value_while_focused() {
|
||||||
let (mut cx, window, changes) = switch_window(false, false);
|
let (mut cx, window, changes) = switch_window(false, false);
|
||||||
|
|||||||
+105
-12
@@ -1,17 +1,18 @@
|
|||||||
use std::cell::RefCell;
|
use std::{cell::RefCell, rc::Rc};
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Context, InteractiveElement as _, IntoElement, Modifiers, MouseMoveEvent, ParentElement as _,
|
Context, InteractiveElement as _, IntoElement, Modifiers, MouseMoveEvent, ParentElement as _,
|
||||||
Render, StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext,
|
Render, StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext,
|
||||||
Window, div, point, px,
|
Window, WindowHandle, canvas, div, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_core::button::Button;
|
use gpui_native_core::button::Button;
|
||||||
use gpui_native_core::tooltip::Tooltip;
|
use gpui_native_core::tooltip::Tooltip;
|
||||||
use gpui_native_theme::Theme;
|
use gpui_native_theme::Theme;
|
||||||
|
use gpui_native_utils::record_anchor_bounds;
|
||||||
|
|
||||||
/// Hover target at the top-left with a tooltip whose builder records every
|
/// Hover target at the top-left with a tooltip whose builder records every
|
||||||
/// invocation.
|
/// invocation. The tooltip anchors to the target itself, so it hangs below
|
||||||
|
/// the target's bottom edge instead of at the cursor.
|
||||||
struct Harness {
|
struct Harness {
|
||||||
calls: Rc<RefCell<Vec<&'static str>>>,
|
calls: Rc<RefCell<Vec<&'static str>>>,
|
||||||
}
|
}
|
||||||
@@ -21,20 +22,26 @@ impl Render for Harness {
|
|||||||
let calls = self.calls.clone();
|
let calls = self.calls.clone();
|
||||||
let target = div()
|
let target = div()
|
||||||
.id("hover-target")
|
.id("hover-target")
|
||||||
|
.debug_selector(|| "hover-target".to_string())
|
||||||
.w(px(100.))
|
.w(px(100.))
|
||||||
.h(px(40.))
|
.h(px(40.))
|
||||||
.bg(gpui::black())
|
.bg(gpui::black())
|
||||||
.tooltip(move |_, cx| {
|
.child(
|
||||||
|
canvas(record_anchor_bounds("hover-target".into()), |_, _, _, _| {})
|
||||||
|
.absolute()
|
||||||
|
.inset_0(),
|
||||||
|
)
|
||||||
|
.tooltip(move |window, cx| {
|
||||||
calls.borrow_mut().push("target");
|
calls.borrow_mut().push("target");
|
||||||
Tooltip::new("Target tooltip", cx).into()
|
Tooltip::anchored("Target tooltip", "hover-target")(window, cx)
|
||||||
});
|
});
|
||||||
|
|
||||||
let button_calls = self.calls.clone();
|
let button_calls = self.calls.clone();
|
||||||
let button = Button::new("hover-button")
|
let button = Button::new("hover-button")
|
||||||
.label("Hover me")
|
.label("Hover me")
|
||||||
.tooltip(move |_, cx| {
|
.tooltip(move |window, cx| {
|
||||||
button_calls.borrow_mut().push("button");
|
button_calls.borrow_mut().push("button");
|
||||||
Tooltip::new("Button tooltip", cx).into()
|
Tooltip::anchored("Button tooltip", "hover-button")(window, cx)
|
||||||
});
|
});
|
||||||
|
|
||||||
div()
|
div()
|
||||||
@@ -80,6 +87,40 @@ fn draw(visual: &mut VisualTestContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct LowHarness;
|
||||||
|
|
||||||
|
impl Render for LowHarness {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
// The target is pinned to the window's bottom edge: the space below it
|
||||||
|
// is nil, so the tooltip must flip above the parent.
|
||||||
|
div().size_full().flex().flex_col().justify_end().child(
|
||||||
|
div()
|
||||||
|
.id("low-target")
|
||||||
|
.debug_selector(|| "low-target".to_string())
|
||||||
|
.w(px(100.))
|
||||||
|
.h(px(40.))
|
||||||
|
.bg(gpui::black())
|
||||||
|
.child(
|
||||||
|
canvas(record_anchor_bounds("low-target".into()), |_, _, _, _| {})
|
||||||
|
.absolute()
|
||||||
|
.inset_0(),
|
||||||
|
)
|
||||||
|
.tooltip(Tooltip::anchored("Low tooltip", "low-target")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn low_tooltip_window() -> (TestAppContext, VisualTestContext, WindowHandle<LowHarness>) {
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let window = cx.add_window(move |_, _| LowHarness);
|
||||||
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
(cx, visual, window)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hovering_the_target_deploys_the_tooltip_after_the_delay() {
|
fn hovering_the_target_deploys_the_tooltip_after_the_delay() {
|
||||||
let (cx, mut visual, calls) = tooltip_window();
|
let (cx, mut visual, calls) = tooltip_window();
|
||||||
@@ -109,12 +150,64 @@ fn hovering_elsewhere_deploys_nothing() {
|
|||||||
assert!(calls.borrow().is_empty());
|
assert!(calls.borrow().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An anchored tooltip hangs below the hovered parent's bottom edge with the
|
||||||
|
/// spacing gap: it never covers the parent, wherever the cursor is inside it.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_button_passthrough_deploys_the_tooltip() {
|
fn the_anchored_panel_hangs_below_the_parent_without_overlapping() {
|
||||||
let (cx, mut visual, calls) = tooltip_window();
|
let (cx, mut visual, _calls) = tooltip_window();
|
||||||
hover(&mut visual, point(px(50.), px(100.)));
|
hover(&mut visual, point(px(50.), px(20.)));
|
||||||
cx.executor().advance_clock(SHOW_DELAY);
|
cx.executor().advance_clock(SHOW_DELAY);
|
||||||
visual.run_until_parked();
|
visual.run_until_parked();
|
||||||
draw(&mut visual);
|
draw(&mut visual);
|
||||||
assert_eq!(*calls.borrow(), vec!["button"]);
|
|
||||||
|
let target = visual
|
||||||
|
.debug_bounds("hover-target")
|
||||||
|
.expect("target laid out");
|
||||||
|
let panel = visual
|
||||||
|
.debug_bounds("tooltip-panel")
|
||||||
|
.expect("panel rendered");
|
||||||
|
|
||||||
|
let unit = cx.update(|cx| Theme::global(cx).spacing.unit);
|
||||||
|
assert!(
|
||||||
|
(panel.top() - target.bottom() - unit).abs() <= px(0.5),
|
||||||
|
"the panel hangs {gap:?} below the target",
|
||||||
|
gap = panel.top() - target.bottom()
|
||||||
|
);
|
||||||
|
assert!((panel.left() - target.left()).abs() <= px(0.5));
|
||||||
|
assert!(
|
||||||
|
panel.top() >= target.bottom(),
|
||||||
|
"the panel must never overlap the hovered parent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parent pinned to the window's bottom edge leaves no room below: the
|
||||||
|
/// panel flips above the parent's top edge instead.
|
||||||
|
#[test]
|
||||||
|
fn an_anchored_tooltip_flips_above_a_low_parent() {
|
||||||
|
let (mut cx, mut visual, window) = low_tooltip_window();
|
||||||
|
|
||||||
|
let window_height = window
|
||||||
|
.update(&mut cx, |_, window, _cx| window.viewport_size().height)
|
||||||
|
.expect("window alive");
|
||||||
|
hover(&mut visual, point(px(50.), window_height - px(20.)));
|
||||||
|
cx.executor().advance_clock(SHOW_DELAY);
|
||||||
|
visual.run_until_parked();
|
||||||
|
draw(&mut visual);
|
||||||
|
|
||||||
|
let target = visual.debug_bounds("low-target").expect("target laid out");
|
||||||
|
let panel = visual
|
||||||
|
.debug_bounds("tooltip-panel")
|
||||||
|
.expect("panel rendered");
|
||||||
|
|
||||||
|
// The flip hangs the panel above the parent's top edge.
|
||||||
|
assert!(
|
||||||
|
panel.bottom() <= target.top(),
|
||||||
|
"the panel must not cover the parent: panel bottom {:?}, parent top {:?}",
|
||||||
|
panel.bottom(),
|
||||||
|
target.top()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
panel.top() >= px(0.),
|
||||||
|
"the flipped panel must stay inside the window"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,13 +100,3 @@ fn arrow_keys_skip_disabled_tabs_and_never_wrap() {
|
|||||||
visual.simulate_keystrokes("end"); // End
|
visual.simulate_keystrokes("end"); // End
|
||||||
assert_eq!(*changes.borrow(), vec![1, 3, 1, 0, 3]);
|
assert_eq!(*changes.borrow(), vec![1, 3, 1, 0, 3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clicking_a_different_tab_reports_once() {
|
|
||||||
let (_cx, mut visual, changes) = tabs_window();
|
|
||||||
|
|
||||||
click(&mut visual, point(px(20.), px(12.))); // One: no change, just focus
|
|
||||||
click(&mut visual, point(px(62.), px(12.))); // Two
|
|
||||||
click(&mut visual, point(px(62.), px(12.))); // Two again: no change
|
|
||||||
assert_eq!(*changes.borrow(), vec![1]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -175,17 +175,3 @@ fn keyboard_navigation_skips_disabled_rows_and_never_wraps() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn disclosure_keys_skip_disabled_and_unselected_rows() {
|
|
||||||
let (_cx, mut visual, _window, changes, disclosures) = tree_window();
|
|
||||||
|
|
||||||
// Focus through the first row, then park the selection on the leaf.
|
|
||||||
click(&mut visual, point(px(50.), px(16.)));
|
|
||||||
visual.simulate_keystrokes("down"); // Child (leaf), focused tree
|
|
||||||
visual.simulate_keystrokes("left"); // leaf: no disclosure
|
|
||||||
visual.simulate_keystrokes("right"); // leaf: no disclosure
|
|
||||||
draw(&mut visual);
|
|
||||||
assert!(disclosures.borrow().is_empty());
|
|
||||||
assert_eq!(*changes.borrow(), vec![Some(0), Some(1)]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -226,12 +226,6 @@ fn rows_render_virtualized_and_the_view_jumps_to_the_first_change() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn identical_versions_produce_no_rows() {
|
|
||||||
let rows = diff_rows(OLD, OLD, Language::Rust);
|
|
||||||
assert!(rows.is_empty(), "identical versions have no diff to show");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_versions_re_diffs() {
|
fn set_versions_re_diffs() {
|
||||||
let mut cx = TestAppContext::single();
|
let mut cx = TestAppContext::single();
|
||||||
|
|||||||
@@ -241,13 +241,6 @@ fn nested_code_and_tables_carry_indent_and_quote() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn task_markers_carry_their_checked_state() {
|
|
||||||
let blocks = parse_body("- [ ] todo\n- [x] done\n");
|
|
||||||
assert_eq!(item_of(&blocks, 0).0, ListMarker::Task(false));
|
|
||||||
assert_eq!(item_of(&blocks, 1).0, ListMarker::Task(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn quotes_mark_their_paragraphs_and_lists() {
|
fn quotes_mark_their_paragraphs_and_lists() {
|
||||||
let blocks = parse_body("> quoted\n>\n> - listed\n\nplain");
|
let blocks = parse_body("> quoted\n>\n> - listed\n\nplain");
|
||||||
|
|||||||
@@ -392,12 +392,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
std::fs::remove_dir_all(&base).expect("cleanup");
|
std::fs::remove_dir_all(&base).expect("cleanup");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn every_role_states_freedesktop_candidates() {
|
|
||||||
for role in crate::IconRole::all() {
|
|
||||||
assert!(!role.freedesktop_names().is_empty());
|
|
||||||
assert!(!role.slug().is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,21 +367,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn freedesktop_asset_keys_round_trip() {
|
|
||||||
let role = IconRole::ChevronDown;
|
|
||||||
let key = format!("icons/freedesktop/{}-16.svg", role.slug());
|
|
||||||
let stem = key
|
|
||||||
.strip_prefix("icons/freedesktop/")
|
|
||||||
.unwrap()
|
|
||||||
.strip_suffix(".svg")
|
|
||||||
.unwrap();
|
|
||||||
let (slug, parsed_size) = stem.rsplit_once('-').unwrap();
|
|
||||||
let resolved = IconRole::all()
|
|
||||||
.iter()
|
|
||||||
.find(|candidate| candidate.slug() == slug);
|
|
||||||
assert_eq!(resolved, Some(&role));
|
|
||||||
assert_eq!(parsed_size, "16");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,9 +49,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_close_role_uses_the_chrome_close_glyph() {
|
|
||||||
assert_eq!(glyph(IconRole::Close), Some('\u{E8BB}'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use crate::ThemeVariant;
|
|||||||
pub struct ThemeDefinition {
|
pub struct ThemeDefinition {
|
||||||
pub mode: Option<ThemeVariant>,
|
pub mode: Option<ThemeVariant>,
|
||||||
pub control_radius: Option<f32>,
|
pub control_radius: Option<f32>,
|
||||||
|
pub overlay_radius: Option<f32>,
|
||||||
|
pub sm_radius: Option<f32>,
|
||||||
pub accent: Option<String>,
|
pub accent: Option<String>,
|
||||||
pub accent_foreground: Option<String>,
|
pub accent_foreground: Option<String>,
|
||||||
pub background: Option<String>,
|
pub background: Option<String>,
|
||||||
@@ -221,13 +223,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parses_a_full_definition() {
|
fn parses_a_full_definition() {
|
||||||
let def = ThemeDefinition::parse_toml(
|
let def = ThemeDefinition::parse_toml(
|
||||||
"mode = \"dark\"\naccent = \"#7aa2f7\"\nbackground = \"#1a1b26\"\ninfo = \"#38bdf8\"\n",
|
"mode = \"dark\"\naccent = \"#7aa2f7\"\nbackground = \"#1a1b26\"\ninfo = \"#38bdf8\"\noverlay_radius = 15.0\nsm_radius = 6.0\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(def.mode, Some(crate::ThemeVariant::Dark));
|
assert_eq!(def.mode, Some(crate::ThemeVariant::Dark));
|
||||||
assert!(def.accent().is_some());
|
assert!(def.accent().is_some());
|
||||||
assert!(def.info().is_some());
|
assert!(def.info().is_some());
|
||||||
assert!(def.foreground().is_none());
|
assert!(def.foreground().is_none());
|
||||||
|
assert_eq!(def.overlay_radius, Some(15.0));
|
||||||
|
assert_eq!(def.sm_radius, Some(6.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+138
-18
@@ -23,6 +23,10 @@ pub use system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemThe
|
|||||||
use gpui::{App, Global, Hsla, Pixels, WindowAppearance, px};
|
use gpui::{App, Global, Hsla, Pixels, WindowAppearance, px};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// The hairline width of the control border every button and field paints;
|
||||||
|
/// sized outside the stated metrics so control sizes match across platforms.
|
||||||
|
pub const BORDER_WIDTH: Pixels = px(1.0);
|
||||||
|
|
||||||
/// Light or dark, resolved from the system appearance.
|
/// Light or dark, resolved from the system appearance.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -48,6 +52,9 @@ pub struct Theme {
|
|||||||
pub colors: ColorTokens,
|
pub colors: ColorTokens,
|
||||||
pub ramps: Ramps,
|
pub ramps: Ramps,
|
||||||
pub radius: Radius,
|
pub radius: Radius,
|
||||||
|
/// Per-platform border and focus chrome.
|
||||||
|
pub borders: Borders,
|
||||||
|
pub spacing: Spacing,
|
||||||
pub elevations: Elevations,
|
pub elevations: Elevations,
|
||||||
/// Overlay transition timing; gpui's animation primitives do the rest.
|
/// Overlay transition timing; gpui's animation primitives do the rest.
|
||||||
pub motion: Motion,
|
pub motion: Motion,
|
||||||
@@ -88,6 +95,59 @@ impl Default for Radius {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which surfaces keep a 1px border, per the platform's chrome: Windows and
|
||||||
|
/// KDE frame their controls, macOS and GNOME paint fills.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Borders {
|
||||||
|
/// Filled actions keep a border (Windows, KDE).
|
||||||
|
pub buttons: bool,
|
||||||
|
/// Text fields and selects keep a border.
|
||||||
|
pub fields: bool,
|
||||||
|
/// Floating layers keep a border.
|
||||||
|
pub overlays: bool,
|
||||||
|
/// Keyboard focus paints an accent ring around the control.
|
||||||
|
pub focus_ring: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Borders {
|
||||||
|
pub(crate) fn for_platform(source: SourceKind, variant: ThemeVariant) -> Self {
|
||||||
|
let overlays = variant == ThemeVariant::Dark;
|
||||||
|
|
||||||
|
match source {
|
||||||
|
SourceKind::Windows | SourceKind::Kde => Self {
|
||||||
|
buttons: true,
|
||||||
|
fields: true,
|
||||||
|
overlays: true,
|
||||||
|
focus_ring: false,
|
||||||
|
},
|
||||||
|
SourceKind::Macos => Self {
|
||||||
|
buttons: false,
|
||||||
|
fields: true,
|
||||||
|
overlays,
|
||||||
|
focus_ring: true,
|
||||||
|
},
|
||||||
|
SourceKind::Omarchy | SourceKind::Gnome => Self {
|
||||||
|
buttons: false,
|
||||||
|
fields: false,
|
||||||
|
overlays,
|
||||||
|
focus_ring: true,
|
||||||
|
},
|
||||||
|
SourceKind::Fallback => Self {
|
||||||
|
buttons: true,
|
||||||
|
fields: true,
|
||||||
|
overlays: true,
|
||||||
|
focus_ring: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The spacing rhythm of the platform: paddings and gaps step by this unit.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct Spacing {
|
||||||
|
pub unit: Pixels,
|
||||||
|
}
|
||||||
|
|
||||||
/// One layer of a floating panel's drop shadow.
|
/// One layer of a floating panel's drop shadow.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct Shadow {
|
pub struct Shadow {
|
||||||
@@ -129,6 +189,13 @@ pub struct Elevation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Elevation {
|
impl Elevation {
|
||||||
|
/// No shadow: dark floating layers separate by border.
|
||||||
|
fn none() -> Self {
|
||||||
|
Self {
|
||||||
|
layers: [Shadow::none(), Shadow::none()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn single(key: Shadow) -> Self {
|
fn single(key: Shadow) -> Self {
|
||||||
Self {
|
Self {
|
||||||
layers: [key, Shadow::none()],
|
layers: [key, Shadow::none()],
|
||||||
@@ -172,8 +239,8 @@ impl Elevations {
|
|||||||
pub(crate) fn for_platform(source: SourceKind, variant: ThemeVariant) -> Self {
|
pub(crate) fn for_platform(source: SourceKind, variant: ThemeVariant) -> Self {
|
||||||
if variant == ThemeVariant::Dark {
|
if variant == ThemeVariant::Dark {
|
||||||
return Self {
|
return Self {
|
||||||
popover: Elevation::single(Shadow::black(0., 4., 24., 0.3)),
|
popover: Elevation::none(),
|
||||||
dialog: Elevation::single(Shadow::black(0., 8., 40., 0.35)),
|
dialog: Elevation::none(),
|
||||||
control: Elevation::single(Shadow::black(0., 1., 2., 0.25)),
|
control: Elevation::single(Shadow::black(0., 1., 2., 0.25)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -283,12 +350,18 @@ pub struct ControlMetrics {
|
|||||||
pub avatar_size: Pixels,
|
pub avatar_size: Pixels,
|
||||||
/// Dense label text.
|
/// Dense label text.
|
||||||
pub caption_size: Pixels,
|
pub caption_size: Pixels,
|
||||||
/// Tight horizontal padding inside pills, segments, and fields.
|
/// Tight horizontal padding inside pills, segments, fields, and icon-only buttons.
|
||||||
pub dense_padding_x: Pixels,
|
pub dense_padding_x: Pixels,
|
||||||
/// The inset between a control's edge and a raised inner plate.
|
/// The inset between a control's edge and a raised inner plate.
|
||||||
pub plate_inset: Pixels,
|
pub plate_inset: Pixels,
|
||||||
/// The band height of multi-segment strips.
|
/// The band height of multi-segment strips.
|
||||||
pub strip_height: Pixels,
|
pub strip_height: Pixels,
|
||||||
|
/// The height of one menu row.
|
||||||
|
pub menu_item_height: Pixels,
|
||||||
|
/// Switch track height, width, and knob size.
|
||||||
|
pub switch_height: Pixels,
|
||||||
|
pub switch_width: Pixels,
|
||||||
|
pub switch_knob: Pixels,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The control metrics for every size class.
|
/// The control metrics for every size class.
|
||||||
@@ -309,6 +382,7 @@ impl SizeTable {
|
|||||||
avatar: f32,
|
avatar: f32,
|
||||||
strip: f32,
|
strip: f32,
|
||||||
inset: f32,
|
inset: f32,
|
||||||
|
floor_icon: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
let small = ClassFactors {
|
let small = ClassFactors {
|
||||||
@@ -319,6 +393,7 @@ impl SizeTable {
|
|||||||
avatar: 1.5,
|
avatar: 1.5,
|
||||||
strip: 1.5,
|
strip: 1.5,
|
||||||
inset: 0.125,
|
inset: 0.125,
|
||||||
|
floor_icon: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let medium = ClassFactors {
|
let medium = ClassFactors {
|
||||||
@@ -329,6 +404,7 @@ impl SizeTable {
|
|||||||
avatar: 2.0,
|
avatar: 2.0,
|
||||||
strip: 2.0,
|
strip: 2.0,
|
||||||
inset: 0.25,
|
inset: 0.25,
|
||||||
|
floor_icon: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let large = ClassFactors {
|
let large = ClassFactors {
|
||||||
@@ -339,21 +415,34 @@ impl SizeTable {
|
|||||||
avatar: 3.0,
|
avatar: 3.0,
|
||||||
strip: 3.0,
|
strip: 3.0,
|
||||||
inset: 0.375,
|
inset: 0.375,
|
||||||
|
floor_icon: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let metrics = |f: &ClassFactors| ControlMetrics {
|
let metrics = |f: &ClassFactors| {
|
||||||
control_height: rem_size * f.height,
|
let icon_size = if f.floor_icon {
|
||||||
input_height: rem_size * (f.height + 0.125),
|
(rem_size * f.icon).max(px(16.))
|
||||||
padding_x: rem_size * f.padding,
|
} else {
|
||||||
text_size: rem_size * f.text,
|
rem_size * f.icon
|
||||||
icon_size: rem_size * f.icon,
|
};
|
||||||
dense_icon_size: rem_size * f.icon * 0.75,
|
let switch_height = rem_size * f.height * 0.75;
|
||||||
indicator_size: rem_size * f.height * 0.7,
|
ControlMetrics {
|
||||||
avatar_size: rem_size * f.avatar,
|
control_height: rem_size * f.height,
|
||||||
caption_size: rem_size * f.text,
|
input_height: rem_size * (f.height + 0.125),
|
||||||
dense_padding_x: rem_size * f.padding * 0.75,
|
padding_x: rem_size * f.padding,
|
||||||
plate_inset: rem_size * f.inset,
|
text_size: rem_size * f.text,
|
||||||
strip_height: rem_size * f.strip,
|
icon_size,
|
||||||
|
dense_icon_size: icon_size * 0.75,
|
||||||
|
indicator_size: rem_size * f.height * 0.7,
|
||||||
|
avatar_size: rem_size * f.avatar,
|
||||||
|
caption_size: rem_size * f.text,
|
||||||
|
dense_padding_x: rem_size * f.padding * 0.75,
|
||||||
|
plate_inset: rem_size * f.inset,
|
||||||
|
strip_height: rem_size * f.strip,
|
||||||
|
menu_item_height: rem_size * f.height * 0.85,
|
||||||
|
switch_height,
|
||||||
|
switch_width: switch_height * 2.25,
|
||||||
|
switch_knob: switch_height * 0.85,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut table = Self {
|
let mut table = Self {
|
||||||
@@ -379,9 +468,38 @@ impl SizeTable {
|
|||||||
/// Sets the medium class's text-field height to the platform's stated value.
|
/// Sets the medium class's text-field height to the platform's stated value.
|
||||||
pub fn set_input_height(&mut self, medium_input_height: Pixels) {
|
pub fn set_input_height(&mut self, medium_input_height: Pixels) {
|
||||||
let ratio = medium_input_height / self.medium.input_height;
|
let ratio = medium_input_height / self.medium.input_height;
|
||||||
self.small.input_height *= ratio;
|
self.small.input_height = (self.small.input_height * ratio).floor();
|
||||||
self.medium.input_height = medium_input_height;
|
self.medium.input_height = medium_input_height;
|
||||||
self.large.input_height *= ratio;
|
self.large.input_height = (self.large.input_height * ratio).floor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the medium class's control height to the platform's stated value.
|
||||||
|
pub fn set_control_height(&mut self, medium_control_height: Pixels) {
|
||||||
|
let ratio = medium_control_height / self.medium.control_height;
|
||||||
|
self.small.control_height = (self.small.control_height * ratio).floor();
|
||||||
|
self.medium.control_height = medium_control_height;
|
||||||
|
self.large.control_height = (self.large.control_height * ratio).floor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the medium class's menu row height to the platform's stated value.
|
||||||
|
pub fn set_menu_item_height(&mut self, medium_menu_item_height: Pixels) {
|
||||||
|
let ratio = medium_menu_item_height / self.medium.menu_item_height;
|
||||||
|
self.small.menu_item_height *= ratio;
|
||||||
|
self.medium.menu_item_height = medium_menu_item_height;
|
||||||
|
self.large.menu_item_height *= ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the medium class's switch dimensions to the platform's stated values.
|
||||||
|
pub fn set_switch_metrics(&mut self, track_height: Pixels, track_width: Pixels, knob: Pixels) {
|
||||||
|
let ratio = track_height / self.medium.switch_height;
|
||||||
|
for metrics in [&mut self.small, &mut self.large] {
|
||||||
|
metrics.switch_height *= ratio;
|
||||||
|
metrics.switch_width *= ratio;
|
||||||
|
metrics.switch_knob *= ratio;
|
||||||
|
}
|
||||||
|
self.medium.switch_height = track_height;
|
||||||
|
self.medium.switch_width = track_width;
|
||||||
|
self.medium.switch_knob = knob;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,6 +526,8 @@ impl Theme {
|
|||||||
colors: resolved.colors,
|
colors: resolved.colors,
|
||||||
ramps: resolved.ramps,
|
ramps: resolved.ramps,
|
||||||
radius: resolved.radius,
|
radius: resolved.radius,
|
||||||
|
borders: resolved.borders,
|
||||||
|
spacing: resolved.spacing,
|
||||||
elevations: resolved.elevations,
|
elevations: resolved.elevations,
|
||||||
motion: resolved.motion,
|
motion: resolved.motion,
|
||||||
scrollbar: resolved.scrollbar,
|
scrollbar: resolved.scrollbar,
|
||||||
|
|||||||
+167
-42
@@ -1,7 +1,9 @@
|
|||||||
use crate::definition::ThemeDefinition;
|
use crate::definition::ThemeDefinition;
|
||||||
use crate::ramps::{AccentShades, Oklch, contrast_foreground, derive_shades, mix};
|
use crate::ramps::{AccentShades, Oklch, contrast_foreground, derive_shades, mix};
|
||||||
use crate::system::SystemTheme;
|
use crate::system::SystemTheme;
|
||||||
use crate::{ColorTokens, Elevations, Motion, Radius, Scrollbar, SizeTable, ThemeVariant};
|
use crate::{
|
||||||
|
Borders, ColorTokens, Elevations, Motion, Radius, Scrollbar, SizeTable, Spacing, ThemeVariant,
|
||||||
|
};
|
||||||
use gpui::px;
|
use gpui::px;
|
||||||
|
|
||||||
/// The floor of a custom titlebar's height, in px at 100% scaling: the height
|
/// The floor of a custom titlebar's height, in px at 100% scaling: the height
|
||||||
@@ -26,6 +28,8 @@ pub struct ResolvedTheme {
|
|||||||
pub colors: ColorTokens,
|
pub colors: ColorTokens,
|
||||||
pub ramps: Ramps,
|
pub ramps: Ramps,
|
||||||
pub radius: Radius,
|
pub radius: Radius,
|
||||||
|
pub borders: Borders,
|
||||||
|
pub spacing: Spacing,
|
||||||
pub elevations: Elevations,
|
pub elevations: Elevations,
|
||||||
pub motion: Motion,
|
pub motion: Motion,
|
||||||
pub scrollbar: Scrollbar,
|
pub scrollbar: Scrollbar,
|
||||||
@@ -198,18 +202,35 @@ pub(crate) fn resolve(
|
|||||||
//
|
//
|
||||||
// Fallback: 16 (the web default gpui also uses for rems).
|
// Fallback: 16 (the web default gpui also uses for rems).
|
||||||
let root_font_size = system.root_font_size.unwrap_or(px(16.));
|
let root_font_size = system.root_font_size.unwrap_or(px(16.));
|
||||||
|
|
||||||
let mut sizes = SizeTable::with_root_font_size(root_font_size);
|
let mut sizes = SizeTable::with_root_font_size(root_font_size);
|
||||||
|
|
||||||
|
if let Some(control_height) = system.control_height {
|
||||||
|
sizes.set_control_height(control_height);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(input_height) = system.input_height {
|
if let Some(input_height) = system.input_height {
|
||||||
sizes.set_input_height(input_height);
|
sizes.set_input_height(input_height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(switch_height) = system.switch_height {
|
||||||
|
sizes.set_switch_metrics(
|
||||||
|
switch_height,
|
||||||
|
system.switch_width.unwrap_or(switch_height * 2.25),
|
||||||
|
system.switch_knob.unwrap_or(switch_height * 0.85),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(menu_item_height) = system.menu_item_height {
|
||||||
|
sizes.set_menu_item_height(menu_item_height);
|
||||||
|
}
|
||||||
|
|
||||||
// A platform that states its titlebar height wins.
|
// A platform that states its titlebar height wins.
|
||||||
let titlebar_height = system
|
let titlebar_height = system
|
||||||
.titlebar_height
|
.titlebar_height
|
||||||
.unwrap_or_else(|| sizes.medium.control_height.max(px(TITLEBAR_HEIGHT_MIN_PX)));
|
.unwrap_or_else(|| sizes.medium.control_height.max(px(TITLEBAR_HEIGHT_MIN_PX)));
|
||||||
|
|
||||||
let mut radius = Radius::default();
|
let mut radius = Radius::default();
|
||||||
|
|
||||||
if let Some(control) = preset
|
if let Some(control) = preset
|
||||||
.and_then(|def| def.control_radius)
|
.and_then(|def| def.control_radius)
|
||||||
.map(px)
|
.map(px)
|
||||||
@@ -218,6 +239,22 @@ pub(crate) fn resolve(
|
|||||||
radius.control = control;
|
radius.control = control;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(overlay) = preset
|
||||||
|
.and_then(|def| def.overlay_radius)
|
||||||
|
.map(px)
|
||||||
|
.or(system.overlay_radius)
|
||||||
|
{
|
||||||
|
radius.overlay = overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(sm) = preset
|
||||||
|
.and_then(|def| def.sm_radius)
|
||||||
|
.map(px)
|
||||||
|
.or(system.sm_radius)
|
||||||
|
{
|
||||||
|
radius.sm = sm;
|
||||||
|
}
|
||||||
|
|
||||||
ResolvedTheme {
|
ResolvedTheme {
|
||||||
variant,
|
variant,
|
||||||
ramps: Ramps {
|
ramps: Ramps {
|
||||||
@@ -238,6 +275,10 @@ pub(crate) fn resolve(
|
|||||||
},
|
},
|
||||||
colors,
|
colors,
|
||||||
radius,
|
radius,
|
||||||
|
borders: Borders::for_platform(system.source, variant),
|
||||||
|
spacing: Spacing {
|
||||||
|
unit: system.source.spacing_unit(),
|
||||||
|
},
|
||||||
elevations: Elevations::for_platform(system.source, variant),
|
elevations: Elevations::for_platform(system.source, variant),
|
||||||
motion: Motion::default(),
|
motion: Motion::default(),
|
||||||
scrollbar: Scrollbar {
|
scrollbar: Scrollbar {
|
||||||
@@ -283,7 +324,14 @@ mod tests {
|
|||||||
accent,
|
accent,
|
||||||
palette,
|
palette,
|
||||||
control_radius: None,
|
control_radius: None,
|
||||||
|
overlay_radius: None,
|
||||||
|
sm_radius: None,
|
||||||
|
control_height: None,
|
||||||
input_height: None,
|
input_height: None,
|
||||||
|
menu_item_height: None,
|
||||||
|
switch_height: None,
|
||||||
|
switch_width: None,
|
||||||
|
switch_knob: None,
|
||||||
titlebar_height: None,
|
titlebar_height: None,
|
||||||
root_font_size: None,
|
root_font_size: None,
|
||||||
accessibility: AccessibilityPreferences::default(),
|
accessibility: AccessibilityPreferences::default(),
|
||||||
@@ -291,24 +339,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn metric_priority_is_definition_then_system_then_fallback() {
|
|
||||||
let mut stated = system(ThemeVariant::Dark, PartialPalette::default(), None);
|
|
||||||
stated.control_radius = Some(px(4.));
|
|
||||||
|
|
||||||
let resolved = resolve(&stated, None, None);
|
|
||||||
assert_eq!(resolved.radius.control, px(4.));
|
|
||||||
|
|
||||||
let def = ThemeDefinition::parse_toml("control_radius = 12.0\n").unwrap();
|
|
||||||
let overridden = resolve(&stated, Some(&def), None);
|
|
||||||
assert_eq!(overridden.radius.control, px(12.));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_system_resolves_to_the_neutral_fallback() {
|
fn empty_system_resolves_to_the_neutral_fallback() {
|
||||||
for variant in [ThemeVariant::Light, ThemeVariant::Dark] {
|
for variant in [ThemeVariant::Light, ThemeVariant::Dark] {
|
||||||
let resolved = resolve(
|
let resolved = resolve(
|
||||||
&system(variant, PartialPalette::default(), None),
|
&SystemTheme {
|
||||||
|
source: SourceKind::Fallback,
|
||||||
|
..system(variant, PartialPalette::default(), None)
|
||||||
|
},
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@@ -333,6 +371,21 @@ mod tests {
|
|||||||
assert_eq!(resolved.sizes.medium.dense_padding_x, px(16. * 0.75));
|
assert_eq!(resolved.sizes.medium.dense_padding_x, px(16. * 0.75));
|
||||||
assert_eq!(resolved.sizes.medium.plate_inset, px(4.));
|
assert_eq!(resolved.sizes.medium.plate_inset, px(4.));
|
||||||
assert_eq!(resolved.sizes.medium.strip_height, px(32.));
|
assert_eq!(resolved.sizes.medium.strip_height, px(32.));
|
||||||
|
assert_eq!(resolved.sizes.medium.icon_size, px(16.));
|
||||||
|
assert_eq!(resolved.sizes.medium.menu_item_height, px(28. * 0.85));
|
||||||
|
assert_eq!(resolved.sizes.medium.switch_height, px(21.));
|
||||||
|
assert_eq!(resolved.sizes.medium.switch_width, px(21. * 2.25));
|
||||||
|
assert_eq!(resolved.sizes.medium.switch_knob, px(21. * 0.85));
|
||||||
|
assert_eq!(
|
||||||
|
resolved.borders,
|
||||||
|
Borders {
|
||||||
|
buttons: true,
|
||||||
|
fields: true,
|
||||||
|
overlays: true,
|
||||||
|
focus_ring: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(resolved.spacing.unit, px(4.));
|
||||||
assert_eq!(resolved.titlebar_height, px(34.));
|
assert_eq!(resolved.titlebar_height, px(34.));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,17 +436,105 @@ mod tests {
|
|||||||
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
|
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
|
||||||
stated.input_height = Some(px(24.));
|
stated.input_height = Some(px(24.));
|
||||||
let resolved = resolve(&stated, None, None);
|
let resolved = resolve(&stated, None, None);
|
||||||
|
|
||||||
// The medium class takes the stated height verbatim, and the other
|
|
||||||
// classes keep their derived ratio to the medium class.
|
|
||||||
assert_eq!(resolved.sizes.medium.input_height, px(24.));
|
assert_eq!(resolved.sizes.medium.input_height, px(24.));
|
||||||
|
|
||||||
// Derive what small would be without the statement, scale it by the
|
|
||||||
// stated-to-derived ratio, and compare.
|
|
||||||
let derived = SizeTable::with_root_font_size(px(16.));
|
let derived = SizeTable::with_root_font_size(px(16.));
|
||||||
let scale = px(24.) / derived.medium.input_height;
|
let scale = px(24.) / derived.medium.input_height;
|
||||||
let expected_small = derived.small.input_height * scale;
|
let expected_small = (derived.small.input_height * scale).floor();
|
||||||
assert!((resolved.sizes.small.input_height - expected_small).abs() < px(0.01));
|
assert_eq!(resolved.sizes.small.input_height, expected_small);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stated_control_height_overrides_the_medium_class_and_scales() {
|
||||||
|
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
|
||||||
|
stated.control_height = Some(px(32.));
|
||||||
|
let resolved = resolve(&stated, None, None);
|
||||||
|
assert_eq!(resolved.sizes.medium.control_height, px(32.));
|
||||||
|
|
||||||
|
let derived = SizeTable::with_root_font_size(px(16.));
|
||||||
|
let scale = px(32.) / derived.medium.control_height;
|
||||||
|
let expected_small = (derived.small.control_height * scale).floor();
|
||||||
|
let expected_large = (derived.large.control_height * scale).floor();
|
||||||
|
assert_eq!(resolved.sizes.small.control_height, expected_small);
|
||||||
|
assert_eq!(resolved.sizes.large.control_height, expected_large);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stated_switch_and_menu_metrics_override_the_derived_ones() {
|
||||||
|
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
|
||||||
|
stated.switch_height = Some(px(20.));
|
||||||
|
stated.switch_width = Some(px(40.));
|
||||||
|
stated.switch_knob = Some(px(12.));
|
||||||
|
stated.menu_item_height = Some(px(33.));
|
||||||
|
let resolved = resolve(&stated, None, None);
|
||||||
|
|
||||||
|
assert_eq!(resolved.sizes.medium.switch_height, px(20.));
|
||||||
|
assert_eq!(resolved.sizes.medium.switch_width, px(40.));
|
||||||
|
assert_eq!(resolved.sizes.medium.switch_knob, px(12.));
|
||||||
|
assert_eq!(resolved.sizes.medium.menu_item_height, px(33.));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_radius_ladder_follows_stated_values_with_preset_priority() {
|
||||||
|
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
|
||||||
|
stated.control_radius = Some(px(9.));
|
||||||
|
stated.overlay_radius = Some(px(15.));
|
||||||
|
stated.sm_radius = Some(px(6.));
|
||||||
|
let resolved = resolve(&stated, None, None);
|
||||||
|
assert_eq!(resolved.radius.control, px(9.));
|
||||||
|
assert_eq!(resolved.radius.overlay, px(15.));
|
||||||
|
assert_eq!(resolved.radius.sm, px(6.));
|
||||||
|
|
||||||
|
let def = ThemeDefinition::parse_toml("overlay_radius = 12.0\n").unwrap();
|
||||||
|
let overridden = resolve(&stated, Some(&def), None);
|
||||||
|
assert_eq!(overridden.radius.overlay, px(12.));
|
||||||
|
assert_eq!(overridden.radius.sm, px(6.));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn borders_and_spacing_follow_the_platform_source() {
|
||||||
|
let chrome = |source| {
|
||||||
|
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
|
||||||
|
stated.source = source;
|
||||||
|
let resolved = resolve(&stated, None, None);
|
||||||
|
(resolved.borders, resolved.spacing.unit)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (borders, unit) = chrome(SourceKind::Windows);
|
||||||
|
assert!(borders.buttons && borders.fields && borders.overlays && !borders.focus_ring);
|
||||||
|
assert_eq!(unit, px(4.));
|
||||||
|
|
||||||
|
let (borders, unit) = chrome(SourceKind::Kde);
|
||||||
|
assert!(borders.buttons && borders.fields && borders.overlays && !borders.focus_ring);
|
||||||
|
assert_eq!(unit, px(4.));
|
||||||
|
|
||||||
|
let (borders, unit) = chrome(SourceKind::Macos);
|
||||||
|
assert!(!borders.buttons && borders.fields && !borders.overlays && borders.focus_ring);
|
||||||
|
assert_eq!(unit, px(4.));
|
||||||
|
|
||||||
|
for source in [SourceKind::Gnome, SourceKind::Omarchy] {
|
||||||
|
let (borders, unit) = chrome(source);
|
||||||
|
assert!(!borders.buttons && !borders.fields && !borders.overlays && borders.focus_ring);
|
||||||
|
assert_eq!(unit, px(6.));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (borders, unit) = chrome(SourceKind::Fallback);
|
||||||
|
assert!(borders.buttons && borders.fields && borders.overlays && !borders.focus_ring);
|
||||||
|
assert_eq!(unit, px(4.));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dark_mode_separates_overlays_by_border() {
|
||||||
|
for source in [SourceKind::Macos, SourceKind::Gnome, SourceKind::Omarchy] {
|
||||||
|
let mut stated = system(ThemeVariant::Dark, PartialPalette::default(), None);
|
||||||
|
stated.source = source;
|
||||||
|
let resolved = resolve(&stated, None, None);
|
||||||
|
assert!(
|
||||||
|
resolved.borders.overlays,
|
||||||
|
"{source:?} frames floating layers in dark"
|
||||||
|
);
|
||||||
|
assert!(resolved.elevations.popover.shadows().is_empty());
|
||||||
|
assert!(resolved.elevations.dialog.shadows().is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -426,7 +567,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn elevations_and_motion_resolve_with_defaults() {
|
fn elevations_and_motion_resolve_with_defaults() {
|
||||||
let resolved = resolve(
|
let resolved = resolve(
|
||||||
&system(ThemeVariant::Dark, PartialPalette::default(), None),
|
&system(ThemeVariant::Light, PartialPalette::default(), None),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@@ -438,7 +579,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(resolved.motion.overlay > std::time::Duration::ZERO);
|
assert!(resolved.motion.overlay > std::time::Duration::ZERO);
|
||||||
let shadows = resolved.elevations.popover.shadows();
|
let shadows = resolved.elevations.popover.shadows();
|
||||||
assert_eq!(shadows.len(), 1);
|
assert_eq!(shadows.len(), 2);
|
||||||
assert!(!shadows[0].inset);
|
assert!(!shadows[0].inset);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,20 +807,4 @@ mod tests {
|
|||||||
let preset_stated = resolve(&system(ThemeVariant::Dark, palette, None), Some(&def), None);
|
let preset_stated = resolve(&system(ThemeVariant::Dark, palette, None), Some(&def), None);
|
||||||
assert_eq!(preset_stated.colors.info, hsla(0x0ea5e9));
|
assert_eq!(preset_stated.colors.info, hsla(0x0ea5e9));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn muted_hover_follows_the_overlay_chain() {
|
|
||||||
// No platform states a hover wash; a preset or user file is the only
|
|
||||||
// override above the neutral base.
|
|
||||||
let palette = PartialPalette {
|
|
||||||
muted_hover: Some(hsla(0x155e75)),
|
|
||||||
..PartialPalette::default()
|
|
||||||
};
|
|
||||||
let stated = resolve(&system(ThemeVariant::Dark, palette, None), None, None);
|
|
||||||
assert_eq!(stated.colors.muted_hover, hsla(0x155e75));
|
|
||||||
|
|
||||||
let def = ThemeDefinition::parse_toml("muted_hover = \"#0ea5e9\"\n").unwrap();
|
|
||||||
let preset_stated = resolve(&system(ThemeVariant::Dark, palette, None), Some(&def), None);
|
|
||||||
assert_eq!(preset_stated.colors.muted_hover, hsla(0x0ea5e9));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use gpui::Hsla;
|
use gpui::{Hsla, Pixels, px};
|
||||||
|
|
||||||
use crate::system::{PartialPalette, SourceKind, SystemTheme};
|
use crate::system::{PartialPalette, SourceKind, SystemTheme};
|
||||||
|
|
||||||
@@ -14,6 +14,9 @@ pub(crate) struct KdeScheme {
|
|||||||
pub(crate) accent: Option<Hsla>,
|
pub(crate) accent: Option<Hsla>,
|
||||||
pub(crate) background: Option<Hsla>,
|
pub(crate) background: Option<Hsla>,
|
||||||
pub(crate) palette: PartialPalette,
|
pub(crate) palette: PartialPalette,
|
||||||
|
/// The root font size parsed from `[General] font=` (family, pointSize,
|
||||||
|
/// pixelSize, ...).
|
||||||
|
pub(crate) root_font_size: Option<Pixels>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KdeScheme {
|
impl KdeScheme {
|
||||||
@@ -54,6 +57,7 @@ impl KdeScheme {
|
|||||||
accent,
|
accent,
|
||||||
background,
|
background,
|
||||||
palette,
|
palette,
|
||||||
|
root_font_size: root_font_size(groups.get("General")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,10 +81,17 @@ impl KdeScheme {
|
|||||||
.unwrap_or(crate::ThemeVariant::Light),
|
.unwrap_or(crate::ThemeVariant::Light),
|
||||||
accent: self.accent.or(portal_accent),
|
accent: self.accent.or(portal_accent),
|
||||||
palette: self.palette,
|
palette: self.palette,
|
||||||
control_radius: None,
|
control_radius: Some(px(5.)),
|
||||||
input_height: None,
|
overlay_radius: Some(px(5.)),
|
||||||
titlebar_height: None,
|
sm_radius: None,
|
||||||
root_font_size: None,
|
control_height: Some(px(33.)),
|
||||||
|
input_height: Some(px(29.)),
|
||||||
|
menu_item_height: Some(px(29.)),
|
||||||
|
switch_height: Some(px(18.)),
|
||||||
|
switch_width: Some(px(36.)),
|
||||||
|
switch_knob: Some(px(18.)),
|
||||||
|
titlebar_height: Some(px(44.)),
|
||||||
|
root_font_size: Some(self.root_font_size.unwrap_or(px(13.33))),
|
||||||
accessibility,
|
accessibility,
|
||||||
source: SourceKind::Kde,
|
source: SourceKind::Kde,
|
||||||
}
|
}
|
||||||
@@ -95,6 +106,22 @@ fn variant_by_lightness(background: Hsla) -> crate::ThemeVariant {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The root font size of a `[General]` group's `font=` QFont string.
|
||||||
|
fn root_font_size(group: Option<&HashMap<&str, &str>>) -> Option<Pixels> {
|
||||||
|
let fields: Vec<&str> = group?.get("font")?.split(',').map(str::trim).collect();
|
||||||
|
let points = fields.get(1)?.parse::<f32>().ok()?;
|
||||||
|
let pixels = fields
|
||||||
|
.get(2)
|
||||||
|
.and_then(|size| size.parse::<f32>().ok())
|
||||||
|
.unwrap_or(-1.0);
|
||||||
|
let size = if pixels > 0.0 {
|
||||||
|
pixels
|
||||||
|
} else {
|
||||||
|
points * 96.0 / 72.0
|
||||||
|
};
|
||||||
|
(size > 0.0).then(|| px(size))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_groups(text: &str) -> HashMap<&str, HashMap<&str, &str>> {
|
fn parse_groups(text: &str) -> HashMap<&str, HashMap<&str, &str>> {
|
||||||
let mut groups: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
|
let mut groups: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
|
||||||
let mut section: Option<&str> = None;
|
let mut section: Option<&str> = None;
|
||||||
@@ -305,4 +332,31 @@ activeBackground=39,44,49
|
|||||||
let system = scheme.into_system(Default::default(), None, Some(crate::ThemeVariant::Light));
|
let system = scheme.into_system(Default::default(), None, Some(crate::ThemeVariant::Light));
|
||||||
assert_eq!(system.variant, crate::ThemeVariant::Light);
|
assert_eq!(system.variant, crate::ThemeVariant::Light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_scheme_states_breeze_metrics_and_the_default_font() {
|
||||||
|
let system = KdeScheme::parse(BREEZE_DARK).into_system(Default::default(), None, None);
|
||||||
|
|
||||||
|
assert_eq!(system.control_height, Some(px(33.)));
|
||||||
|
assert_eq!(system.input_height, Some(px(29.)));
|
||||||
|
assert_eq!(system.menu_item_height, Some(px(29.)));
|
||||||
|
assert_eq!(system.control_radius, Some(px(5.)));
|
||||||
|
assert_eq!(system.overlay_radius, Some(px(5.)));
|
||||||
|
assert_eq!(system.switch_height, Some(px(18.)));
|
||||||
|
assert_eq!(system.switch_width, Some(px(36.)));
|
||||||
|
assert_eq!(system.switch_knob, Some(px(18.)));
|
||||||
|
assert_eq!(system.titlebar_height, Some(px(44.)));
|
||||||
|
assert!((system.root_font_size.unwrap() - px(13.33)).abs() < px(0.01));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_stated_font_beats_the_default() {
|
||||||
|
let text = "[General]\nfont=Noto Sans,10,-1,5,50,0,0,0,0,0\n";
|
||||||
|
let system = KdeScheme::parse(text).into_system(Default::default(), None, None);
|
||||||
|
assert!((system.root_font_size.unwrap() - px(10.0 * 96.0 / 72.0)).abs() < px(0.01));
|
||||||
|
|
||||||
|
let text = "[General]\nfont=Adwaita Sans,-1,12,5,50,0,0,0,0,0\n";
|
||||||
|
let system = KdeScheme::parse(text).into_system(Default::default(), None, None);
|
||||||
|
assert_eq!(system.root_font_size, Some(px(12.)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use gpui::Hsla;
|
use gpui::{Hsla, Pixels};
|
||||||
use notify::{RecursiveMode, Watcher};
|
use notify::{RecursiveMode, Watcher};
|
||||||
|
|
||||||
use super::ThemeSource;
|
use super::ThemeSource;
|
||||||
@@ -55,11 +55,12 @@ fn detect(home: &Path, portal_reachable: bool, gsettings_reachable: bool) -> Bac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What `gsettings` states for GNOME sessions without a portal.
|
/// What `gsettings` states for GNOME sessions.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||||
struct GSettingsSnapshot {
|
struct GSettingsSnapshot {
|
||||||
variant: Option<ThemeVariant>,
|
variant: Option<ThemeVariant>,
|
||||||
accent: Option<Hsla>,
|
accent: Option<Hsla>,
|
||||||
|
root_font_size: Option<Pixels>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn gsettings_value(args: &[&str]) -> Option<String> {
|
fn gsettings_value(args: &[&str]) -> Option<String> {
|
||||||
@@ -97,23 +98,31 @@ fn read_gsettings() -> Option<GSettingsSnapshot> {
|
|||||||
"prefer-light" => Some(ThemeVariant::Light),
|
"prefer-light" => Some(ThemeVariant::Light),
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let accent = gsettings_value(&["get", "org.gnome.desktop.interface", "accent-color"])
|
let accent = gsettings_value(&["get", "org.gnome.desktop.interface", "accent-color"])
|
||||||
.and_then(|value| accent_rgb(value.trim().trim_matches('\'')));
|
.and_then(|value| accent_rgb(value.trim().trim_matches('\'')));
|
||||||
|
|
||||||
if variant.is_none() && accent.is_none() {
|
let root_font_size = gsettings_value(&["get", "org.gnome.desktop.interface", "font-name"])
|
||||||
|
.and_then(|value| font_root_px(value.trim().trim_matches('\'')));
|
||||||
|
|
||||||
|
if variant.is_none() && accent.is_none() && root_font_size.is_none() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(GSettingsSnapshot { variant, accent })
|
Some(GSettingsSnapshot {
|
||||||
|
variant,
|
||||||
|
accent,
|
||||||
|
root_font_size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn font_root_px(name: &str) -> Option<Pixels> {
|
||||||
|
let points = name.split_whitespace().last()?.parse::<f32>().ok()?;
|
||||||
|
(points > 0.0).then(|| gpui::px(points * 96.0 / 72.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
||||||
let portal = portal::io::PortalClient::connect();
|
let portal = portal::io::PortalClient::connect();
|
||||||
|
let gsettings = read_gsettings();
|
||||||
let gsettings = if portal.is_none() {
|
|
||||||
read_gsettings()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let backend = home_dir()
|
let backend = home_dir()
|
||||||
.map(|home| detect(&home, portal.is_some(), gsettings.is_some()))
|
.map(|home| detect(&home, portal.is_some(), gsettings.is_some()))
|
||||||
@@ -129,6 +138,36 @@ pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LinuxSource {
|
impl LinuxSource {
|
||||||
|
fn gnome_snapshot(
|
||||||
|
&self,
|
||||||
|
variant: ThemeVariant,
|
||||||
|
accent: Option<Hsla>,
|
||||||
|
accessibility: crate::system::AccessibilityPreferences,
|
||||||
|
) -> SystemTheme {
|
||||||
|
SystemTheme {
|
||||||
|
variant,
|
||||||
|
accent,
|
||||||
|
palette: PartialPalette::default(),
|
||||||
|
control_radius: Some(gpui::px(9.)),
|
||||||
|
overlay_radius: Some(gpui::px(15.)),
|
||||||
|
sm_radius: Some(gpui::px(6.)),
|
||||||
|
control_height: Some(gpui::px(34.)),
|
||||||
|
input_height: Some(gpui::px(34.)),
|
||||||
|
menu_item_height: Some(gpui::px(32.)),
|
||||||
|
switch_height: Some(gpui::px(26.)),
|
||||||
|
switch_width: Some(gpui::px(46.)),
|
||||||
|
switch_knob: Some(gpui::px(20.)),
|
||||||
|
titlebar_height: Some(gpui::px(47.)),
|
||||||
|
root_font_size: Some(
|
||||||
|
self.gsettings
|
||||||
|
.and_then(|gsettings| gsettings.root_font_size)
|
||||||
|
.unwrap_or(gpui::px(14.67)),
|
||||||
|
),
|
||||||
|
accessibility,
|
||||||
|
source: SourceKind::Gnome,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn portal_snapshot(
|
fn portal_snapshot(
|
||||||
&self,
|
&self,
|
||||||
variant: ThemeVariant,
|
variant: ThemeVariant,
|
||||||
@@ -136,31 +175,21 @@ impl LinuxSource {
|
|||||||
accessibility: crate::system::AccessibilityPreferences,
|
accessibility: crate::system::AccessibilityPreferences,
|
||||||
) -> SystemTheme {
|
) -> SystemTheme {
|
||||||
if let Some(appearance) = portal_appearance {
|
if let Some(appearance) = portal_appearance {
|
||||||
return SystemTheme {
|
return self.gnome_snapshot(
|
||||||
variant: appearance.variant(variant),
|
appearance.variant(variant),
|
||||||
accent: appearance.accent,
|
appearance.accent,
|
||||||
palette: PartialPalette::default(),
|
|
||||||
control_radius: None,
|
|
||||||
input_height: None,
|
|
||||||
titlebar_height: None,
|
|
||||||
root_font_size: None,
|
|
||||||
accessibility,
|
accessibility,
|
||||||
source: SourceKind::Gnome,
|
);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(gsettings) = self.gsettings {
|
if let Some(gsettings) = self.gsettings {
|
||||||
return SystemTheme {
|
return self.gnome_snapshot(
|
||||||
variant: gsettings.variant.unwrap_or(variant),
|
gsettings.variant.unwrap_or(variant),
|
||||||
accent: gsettings.accent,
|
gsettings.accent,
|
||||||
palette: PartialPalette::default(),
|
|
||||||
control_radius: None,
|
|
||||||
input_height: None,
|
|
||||||
titlebar_height: None,
|
|
||||||
root_font_size: None,
|
|
||||||
accessibility,
|
accessibility,
|
||||||
source: SourceKind::Gnome,
|
);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemTheme::fallback(variant)
|
SystemTheme::fallback(variant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,4 +318,52 @@ mod tests {
|
|||||||
assert_eq!(detect(&home, true, false), Backend::Kde);
|
assert_eq!(detect(&home, true, false), Backend::Kde);
|
||||||
std::fs::remove_dir_all(&home).expect("cleanup");
|
std::fs::remove_dir_all(&home).expect("cleanup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_gsettings_font_name_states_the_root_font_size() {
|
||||||
|
assert_eq!(
|
||||||
|
font_root_px("Adwaita Sans 11"),
|
||||||
|
Some(gpui::px(11.0 * 96.0 / 72.0))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
font_root_px("Noto Sans 10"),
|
||||||
|
Some(gpui::px(10.0 * 96.0 / 72.0))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
font_root_px("Cantarell Bold 12.5"),
|
||||||
|
Some(gpui::px(12.5 * 96.0 / 72.0))
|
||||||
|
);
|
||||||
|
assert_eq!(font_root_px("Adwaita Sans"), None);
|
||||||
|
assert_eq!(font_root_px("Sans 0"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_gnome_snapshot_states_the_libadwaita_metrics() {
|
||||||
|
let source = LinuxSource {
|
||||||
|
backend: Backend::Portal,
|
||||||
|
portal: None,
|
||||||
|
gsettings: None,
|
||||||
|
file_watcher: None,
|
||||||
|
_signal: None,
|
||||||
|
};
|
||||||
|
let system = source.gnome_snapshot(
|
||||||
|
ThemeVariant::Light,
|
||||||
|
None,
|
||||||
|
crate::system::AccessibilityPreferences::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(system.source, SourceKind::Gnome);
|
||||||
|
assert_eq!(system.control_height, Some(gpui::px(34.)));
|
||||||
|
assert_eq!(system.input_height, Some(gpui::px(34.)));
|
||||||
|
assert_eq!(system.menu_item_height, Some(gpui::px(32.)));
|
||||||
|
assert_eq!(system.control_radius, Some(gpui::px(9.)));
|
||||||
|
assert_eq!(system.overlay_radius, Some(gpui::px(15.)));
|
||||||
|
assert_eq!(system.sm_radius, Some(gpui::px(6.)));
|
||||||
|
assert_eq!(system.switch_height, Some(gpui::px(26.)));
|
||||||
|
assert_eq!(system.switch_width, Some(gpui::px(46.)));
|
||||||
|
assert_eq!(system.switch_knob, Some(gpui::px(20.)));
|
||||||
|
assert_eq!(system.titlebar_height, Some(gpui::px(47.)));
|
||||||
|
// The default 11pt font when gsettings states none.
|
||||||
|
assert_eq!(system.root_font_size, Some(gpui::px(14.67)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, System
|
|||||||
const CONTROL_RADIUS_PTS: f32 = 6.0;
|
const CONTROL_RADIUS_PTS: f32 = 6.0;
|
||||||
/// The rounded-bezel text field's intrinsic height at regular size.
|
/// The rounded-bezel text field's intrinsic height at regular size.
|
||||||
const INPUT_HEIGHT_PTS: f32 = 24.0;
|
const INPUT_HEIGHT_PTS: f32 = 24.0;
|
||||||
|
/// AppKit's regular push button, measured via `NSButton::fittingSize`.
|
||||||
|
const CONTROL_HEIGHT_PTS: f32 = 24.0;
|
||||||
|
/// The standard `NSMenu` row height.
|
||||||
|
const MENU_ITEM_HEIGHT_PTS: f32 = 20.0;
|
||||||
|
/// `NSSwitch`, measured via `fittingSize`.
|
||||||
|
const SWITCH_TRACK_HEIGHT_PTS: f32 = 24.0;
|
||||||
|
const SWITCH_TRACK_WIDTH_PTS: f32 = 54.0;
|
||||||
|
const SWITCH_KNOB_PTS: f32 = 20.0;
|
||||||
|
/// The standard titled window's titlebar, measured via `contentLayoutRect`.
|
||||||
|
const TITLEBAR_HEIGHT_PTS: f32 = 32.0;
|
||||||
/// The HIG's default body text on macOS is 13 pt (system font size 13 at 1x).
|
/// The HIG's default body text on macOS is 13 pt (system font size 13 at 1x).
|
||||||
const ROOT_FONT_SIZE_PTS: f32 = 13.0;
|
const ROOT_FONT_SIZE_PTS: f32 = 13.0;
|
||||||
|
|
||||||
@@ -112,8 +122,15 @@ impl ThemeSource for MacosSource {
|
|||||||
accent: Self::color_to_hsla(&NSColor::controlAccentColor()),
|
accent: Self::color_to_hsla(&NSColor::controlAccentColor()),
|
||||||
palette,
|
palette,
|
||||||
control_radius: Some(gpui::px(CONTROL_RADIUS_PTS)),
|
control_radius: Some(gpui::px(CONTROL_RADIUS_PTS)),
|
||||||
|
overlay_radius: None,
|
||||||
|
sm_radius: None,
|
||||||
|
control_height: Some(gpui::px(CONTROL_HEIGHT_PTS)),
|
||||||
input_height: Some(gpui::px(INPUT_HEIGHT_PTS)),
|
input_height: Some(gpui::px(INPUT_HEIGHT_PTS)),
|
||||||
titlebar_height: None,
|
menu_item_height: Some(gpui::px(MENU_ITEM_HEIGHT_PTS)),
|
||||||
|
switch_height: Some(gpui::px(SWITCH_TRACK_HEIGHT_PTS)),
|
||||||
|
switch_width: Some(gpui::px(SWITCH_TRACK_WIDTH_PTS)),
|
||||||
|
switch_knob: Some(gpui::px(SWITCH_KNOB_PTS)),
|
||||||
|
titlebar_height: Some(gpui::px(TITLEBAR_HEIGHT_PTS)),
|
||||||
root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PTS)),
|
root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PTS)),
|
||||||
accessibility,
|
accessibility,
|
||||||
source: SourceKind::Macos,
|
source: SourceKind::Macos,
|
||||||
|
|||||||
@@ -60,8 +60,15 @@ impl OmarchyTheme {
|
|||||||
variant: self.variant,
|
variant: self.variant,
|
||||||
accent: self.accent,
|
accent: self.accent,
|
||||||
palette: self.palette,
|
palette: self.palette,
|
||||||
control_radius: None,
|
control_radius: Some(gpui::px(9.)),
|
||||||
input_height: None,
|
overlay_radius: Some(gpui::px(15.)),
|
||||||
|
sm_radius: Some(gpui::px(6.)),
|
||||||
|
control_height: Some(gpui::px(34.)),
|
||||||
|
input_height: Some(gpui::px(34.)),
|
||||||
|
menu_item_height: Some(gpui::px(32.)),
|
||||||
|
switch_height: Some(gpui::px(26.)),
|
||||||
|
switch_width: Some(gpui::px(46.)),
|
||||||
|
switch_knob: Some(gpui::px(20.)),
|
||||||
titlebar_height: None,
|
titlebar_height: None,
|
||||||
root_font_size: None,
|
root_font_size: None,
|
||||||
accessibility,
|
accessibility,
|
||||||
@@ -216,7 +223,19 @@ color15 = \"#635d6f\"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_toml_text_is_rejected() {
|
fn omarchy_shares_the_gnome_control_metrics() {
|
||||||
assert!(OmarchyTheme::parse("not a theme").is_none());
|
let theme = OmarchyTheme::parse(TOKYO_NIGHT).expect("parses");
|
||||||
|
let system = theme.into_system(Default::default());
|
||||||
|
|
||||||
|
assert_eq!(system.control_height, Some(gpui::px(34.)));
|
||||||
|
assert_eq!(system.input_height, Some(gpui::px(34.)));
|
||||||
|
assert_eq!(system.menu_item_height, Some(gpui::px(32.)));
|
||||||
|
assert_eq!(system.control_radius, Some(gpui::px(9.)));
|
||||||
|
assert_eq!(system.overlay_radius, Some(gpui::px(15.)));
|
||||||
|
assert_eq!(system.switch_height, Some(gpui::px(26.)));
|
||||||
|
assert_eq!(system.switch_width, Some(gpui::px(46.)));
|
||||||
|
assert_eq!(system.switch_knob, Some(gpui::px(20.)));
|
||||||
|
assert_eq!(system.titlebar_height, None);
|
||||||
|
assert_eq!(system.root_font_size, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,16 +206,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_unset_scheme_yields_the_callers_variant() {
|
|
||||||
let appearance = AppearanceValues::default().decode();
|
|
||||||
assert_eq!(
|
|
||||||
appearance.variant(crate::ThemeVariant::Dark),
|
|
||||||
crate::ThemeVariant::Dark
|
|
||||||
);
|
|
||||||
assert_eq!(appearance, Appearance::default());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accessibility_flags_forward() {
|
fn accessibility_flags_forward() {
|
||||||
let accessibility = AppearanceValues {
|
let accessibility = AppearanceValues {
|
||||||
|
|||||||
@@ -14,8 +14,18 @@ use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, System
|
|||||||
|
|
||||||
/// WinUI 11's `ControlCornerRadius`, in pixels at 100% scaling.
|
/// WinUI 11's `ControlCornerRadius`, in pixels at 100% scaling.
|
||||||
const CONTROL_RADIUS_PX: f32 = 4.0;
|
const CONTROL_RADIUS_PX: f32 = 4.0;
|
||||||
|
/// WinUI 11's `OverlayCornerRadius`, in pixels at 100% scaling.
|
||||||
|
const OVERLAY_RADIUS_PX: f32 = 8.0;
|
||||||
/// WinUI 11's default text-box height, in pixels at 100% scaling.
|
/// WinUI 11's default text-box height, in pixels at 100% scaling.
|
||||||
const INPUT_HEIGHT_PX: f32 = 32.0;
|
const INPUT_HEIGHT_PX: f32 = 32.0;
|
||||||
|
/// The default button height, in pixels at 100% scaling.
|
||||||
|
const CONTROL_HEIGHT_PX: f32 = 32.0;
|
||||||
|
/// The default menu row: item padding 4/5 + a 20px Body line + 2px item margins.
|
||||||
|
const MENU_ITEM_HEIGHT_PX: f32 = 33.0;
|
||||||
|
/// ToggleSwitch: a 40x20 track with a 12px resting knob.
|
||||||
|
const SWITCH_TRACK_HEIGHT_PX: f32 = 20.0;
|
||||||
|
const SWITCH_TRACK_WIDTH_PX: f32 = 40.0;
|
||||||
|
const SWITCH_KNOB_PX: f32 = 12.0;
|
||||||
/// The window caption's height, in pixels at 100% scaling, as Win32 reports it.
|
/// The window caption's height, in pixels at 100% scaling, as Win32 reports it.
|
||||||
const TITLEBAR_HEIGHT_PX: f32 = 32.0;
|
const TITLEBAR_HEIGHT_PX: f32 = 32.0;
|
||||||
/// WinUI 11's default body text size, in pixels at 100% scaling.
|
/// WinUI 11's default body text size, in pixels at 100% scaling.
|
||||||
@@ -159,7 +169,14 @@ impl ThemeSource for WindowsSource {
|
|||||||
..PartialPalette::default()
|
..PartialPalette::default()
|
||||||
},
|
},
|
||||||
control_radius: Some(gpui::px(CONTROL_RADIUS_PX)),
|
control_radius: Some(gpui::px(CONTROL_RADIUS_PX)),
|
||||||
|
overlay_radius: Some(gpui::px(OVERLAY_RADIUS_PX)),
|
||||||
|
sm_radius: None,
|
||||||
|
control_height: Some(gpui::px(CONTROL_HEIGHT_PX)),
|
||||||
input_height: Some(gpui::px(INPUT_HEIGHT_PX)),
|
input_height: Some(gpui::px(INPUT_HEIGHT_PX)),
|
||||||
|
menu_item_height: Some(gpui::px(MENU_ITEM_HEIGHT_PX)),
|
||||||
|
switch_height: Some(gpui::px(SWITCH_TRACK_HEIGHT_PX)),
|
||||||
|
switch_width: Some(gpui::px(SWITCH_TRACK_WIDTH_PX)),
|
||||||
|
switch_knob: Some(gpui::px(SWITCH_KNOB_PX)),
|
||||||
titlebar_height: Some(gpui::px(TITLEBAR_HEIGHT_PX)),
|
titlebar_height: Some(gpui::px(TITLEBAR_HEIGHT_PX)),
|
||||||
root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PX * text_scale as f32)),
|
root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PX * text_scale as f32)),
|
||||||
accessibility: self.accessibility(),
|
accessibility: self.accessibility(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! The normalized snapshot a platform adapter produces.
|
//! The normalized snapshot a platform adapter produces.
|
||||||
|
|
||||||
use gpui::{Hsla, Pixels};
|
use gpui::{Hsla, Pixels, px};
|
||||||
|
|
||||||
use crate::ThemeVariant;
|
use crate::ThemeVariant;
|
||||||
|
|
||||||
@@ -43,6 +43,17 @@ impl SourceKind {
|
|||||||
pub fn scrollbar_autohide(self) -> bool {
|
pub fn scrollbar_autohide(self) -> bool {
|
||||||
matches!(self, Self::Macos | Self::Omarchy | Self::Gnome)
|
matches!(self, Self::Macos | Self::Omarchy | Self::Gnome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The base spacing unit:
|
||||||
|
///
|
||||||
|
/// - GNOME lays out on a 6px grid
|
||||||
|
/// - Other platform on 4px grid
|
||||||
|
pub fn spacing_unit(self) -> Pixels {
|
||||||
|
match self {
|
||||||
|
Self::Gnome | Self::Omarchy => px(6.),
|
||||||
|
_ => px(4.),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A partial palette with optional colors.
|
/// A partial palette with optional colors.
|
||||||
@@ -83,8 +94,22 @@ pub struct SystemTheme {
|
|||||||
pub palette: PartialPalette,
|
pub palette: PartialPalette,
|
||||||
/// The platform's stated control corner radius, if it has one.
|
/// The platform's stated control corner radius, if it has one.
|
||||||
pub control_radius: Option<Pixels>,
|
pub control_radius: Option<Pixels>,
|
||||||
|
/// The platform's stated menu/popover radius, if it has one.
|
||||||
|
pub overlay_radius: Option<Pixels>,
|
||||||
|
/// The platform's stated checkbox radius, if it has one.
|
||||||
|
pub sm_radius: Option<Pixels>,
|
||||||
|
/// The platform's stated control height, if it has one.
|
||||||
|
pub control_height: Option<Pixels>,
|
||||||
/// The platform's stated text-field height, if it has one.
|
/// The platform's stated text-field height, if it has one.
|
||||||
pub input_height: Option<Pixels>,
|
pub input_height: Option<Pixels>,
|
||||||
|
/// The platform's stated menu row height, if it has one.
|
||||||
|
pub menu_item_height: Option<Pixels>,
|
||||||
|
/// The platform's stated switch track height, if it has one.
|
||||||
|
pub switch_height: Option<Pixels>,
|
||||||
|
/// The platform's stated switch track width, if it has one.
|
||||||
|
pub switch_width: Option<Pixels>,
|
||||||
|
/// The platform's stated switch knob size, if it has one.
|
||||||
|
pub switch_knob: Option<Pixels>,
|
||||||
/// The platform's native titlebar height, if it states one.
|
/// The platform's native titlebar height, if it states one.
|
||||||
pub titlebar_height: Option<Pixels>,
|
pub titlebar_height: Option<Pixels>,
|
||||||
/// The platform's native root font size, if it has one.
|
/// The platform's native root font size, if it has one.
|
||||||
@@ -100,7 +125,14 @@ impl SystemTheme {
|
|||||||
accent: None,
|
accent: None,
|
||||||
palette: PartialPalette::default(),
|
palette: PartialPalette::default(),
|
||||||
control_radius: None,
|
control_radius: None,
|
||||||
|
overlay_radius: None,
|
||||||
|
sm_radius: None,
|
||||||
|
control_height: None,
|
||||||
input_height: None,
|
input_height: None,
|
||||||
|
menu_item_height: None,
|
||||||
|
switch_height: None,
|
||||||
|
switch_width: None,
|
||||||
|
switch_knob: None,
|
||||||
titlebar_height: None,
|
titlebar_height: None,
|
||||||
root_font_size: None,
|
root_font_size: None,
|
||||||
accessibility: AccessibilityPreferences::default(),
|
accessibility: AccessibilityPreferences::default(),
|
||||||
|
|||||||
+41
-4
@@ -1,9 +1,10 @@
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, BoxShadow, Div, ElementId, FocusHandle, Hsla, IntoElement, Refineable as _,
|
App, Bounds, BoxShadow, Div, ElementId, FocusHandle, Global, Hsla, IntoElement, Pixels,
|
||||||
StatefulInteractiveElement, StyleRefinement, Styled, Window, div, px,
|
Refineable as _, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, px,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// A row whose children center on the cross axis.
|
/// A row whose children center on the cross axis.
|
||||||
pub fn h_flex() -> Div {
|
pub fn h_flex() -> Div {
|
||||||
@@ -35,9 +36,45 @@ pub fn focus_handle(id: &ElementId, window: &mut Window, cx: &mut App) -> FocusH
|
|||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a focus ring shadow with the given color.
|
/// The painted bounds of elements that tooltips anchor to, keyed by element id.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct AnchorBoundsRegistry(HashMap<ElementId, Bounds<Pixels>>);
|
||||||
|
|
||||||
|
impl Global for AnchorBoundsRegistry {}
|
||||||
|
|
||||||
|
impl AnchorBoundsRegistry {
|
||||||
|
fn record(&mut self, id: ElementId, bounds: Bounds<Pixels>) {
|
||||||
|
self.0.insert(id, bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, id: &ElementId) -> Option<Bounds<Pixels>> {
|
||||||
|
self.0.get(id).copied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last bounds an element painted at.
|
||||||
|
pub fn anchor_bounds(id: &ElementId, cx: &mut App) -> Option<Bounds<Pixels>> {
|
||||||
|
cx.try_global::<AnchorBoundsRegistry>()
|
||||||
|
.and_then(|registry| registry.get(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A canvas prepaint closure recording the element's painted bounds.
|
||||||
|
pub fn record_anchor_bounds(
|
||||||
|
id: ElementId,
|
||||||
|
) -> impl Fn(Bounds<Pixels>, &mut Window, &mut App) + 'static {
|
||||||
|
move |bounds, _window, cx| {
|
||||||
|
let mut registry = cx
|
||||||
|
.try_global::<AnchorBoundsRegistry>()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
registry.record(id.clone(), bounds);
|
||||||
|
cx.set_global(registry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a focus ring shadow with the given color at half ink.
|
||||||
pub fn focus_ring(color: Hsla) -> Vec<BoxShadow> {
|
pub fn focus_ring(color: Hsla) -> Vec<BoxShadow> {
|
||||||
vec![BoxShadow::new(px(0.), px(0.), color).spread_radius(px(3.))]
|
vec![BoxShadow::new(px(0.), px(0.), color.alpha(0.5)).spread_radius(px(3.))]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The next selectable row in the direction, or `None` at the ends.
|
/// The next selectable row in the direction, or `None` at the ends.
|
||||||
|
|||||||
+95
-2
@@ -11,6 +11,9 @@ is the first downstream consumer; its screens drive the order.
|
|||||||
|
|
||||||
## Queue — to follow
|
## Queue — to follow
|
||||||
|
|
||||||
|
Platform metric adoption continues one component at a time; each stop goes
|
||||||
|
through the review gate and a gallery pass.
|
||||||
|
|
||||||
Gaps surfaced by the signed comparison; ordered by signed's usage.
|
Gaps surfaced by the signed comparison; ordered by signed's usage.
|
||||||
|
|
||||||
- [ ] **Virtual list** — generic virtualized variable-height rows with a
|
- [ ] **Virtual list** — generic virtualized variable-height rows with a
|
||||||
@@ -47,6 +50,14 @@ Gaps surfaced by the signed comparison; ordered by signed's usage.
|
|||||||
- [x] Platform sources — macOS (NSColor), Windows 11 (UISettings), GNOME
|
- [x] Platform sources — macOS (NSColor), Windows 11 (UISettings), GNOME
|
||||||
(portal), KDE (kdeglobals), Omarchy (colors.toml), neutral fallback;
|
(portal), KDE (kdeglobals), Omarchy (colors.toml), neutral fallback;
|
||||||
live watchers for each
|
live watchers for each
|
||||||
|
- [x] Platform metric fidelity — stated control/input/menu-row/switch
|
||||||
|
metrics and the full radius ladder per platform, Linux root fonts
|
||||||
|
(GNOME gsettings `font-name`, KDE `kdeglobals font=`), per-source
|
||||||
|
border + focus chrome (`Theme::borders`), and a per-platform spacing
|
||||||
|
unit (`Theme::spacing`); overlay borders engage in dark mode, where
|
||||||
|
floating layers separate by border instead of shadow — dark elevation
|
||||||
|
shadows are dropped entirely, since they only muddied the dark scrim.
|
||||||
|
Components adopt the tokens one by one (see Next and Queue)
|
||||||
|
|
||||||
### icons (`crates/icons`)
|
### icons (`crates/icons`)
|
||||||
|
|
||||||
@@ -55,12 +66,93 @@ Gaps surfaced by the signed comparison; ordered by signed's usage.
|
|||||||
|
|
||||||
### core (`crates/core`)
|
### core (`crates/core`)
|
||||||
|
|
||||||
|
- [x] Button platform chrome — every variant always paints the hairline
|
||||||
|
border, sized outside the stated control height (`BORDER_WIDTH`); where
|
||||||
|
the platform paints no border (`Theme::borders.buttons`, ghost) the
|
||||||
|
border takes the fill's color and disappears, so sizes match across
|
||||||
|
platforms; icon-bearing buttons pad with `dense_padding_x` instead of
|
||||||
|
`padding_x`
|
||||||
|
- [x] Button loading state — `.loading(true)` shows the spinner in place of
|
||||||
|
the icon and suppresses activation; Spinner gained a `.color` override
|
||||||
|
for accent-filled surfaces
|
||||||
|
- [x] Input platform chrome — the field always paints the hairline border,
|
||||||
|
sized outside the stated field height; where the platform paints no
|
||||||
|
border (`Theme::borders.fields`) it takes the field's fill; focus
|
||||||
|
recolors the border on Windows / KDE and paints the accent ring on
|
||||||
|
macOS / GNOME via `Theme::borders.focus_ring`; the shared `focus_ring`
|
||||||
|
band paints at half ink (every focusable control picks it up)
|
||||||
|
- [x] Input prefix / suffix — elements docked inside the field's edges,
|
||||||
|
typically small ghost buttons; a docked click activates the control
|
||||||
|
without reaching the field's caret handler, and the text region shrinks
|
||||||
|
to make room
|
||||||
|
- [x] Select platform chrome — the trigger takes the input treatment: the
|
||||||
|
hairline border always painted, sized outside the stated field height
|
||||||
|
and taking the fill's color where the platform paints none; open
|
||||||
|
recolors the border on Windows / KDE and paints the accent ring on
|
||||||
|
macOS / GNOME, so the trigger matches a focused field; the menu
|
||||||
|
popup's border sits behind `Theme::borders.overlays`, disappearing
|
||||||
|
into the surface where the platform paints none
|
||||||
|
- [x] Menu platform chrome — the popup border behind `Theme::borders.overlays`
|
||||||
|
(and dark mode); the panel padding and separator margins come from
|
||||||
|
`Theme::spacing`, and every action row renders at the stated
|
||||||
|
`menu_item_height` with content centered, so the stated metric is the
|
||||||
|
whole row; rows carry debug selectors for headless geometry tests
|
||||||
|
- [x] Popover platform chrome — the panel border behind
|
||||||
|
`Theme::borders.overlays` (and dark mode); the anchor gap is the
|
||||||
|
spacing unit and rides `anchored().offset`, not a margin, so the
|
||||||
|
window-edge clamp stays intact; the panel carries a debug selector
|
||||||
|
- [x] Dialog platform chrome — the panel border behind
|
||||||
|
`Theme::borders.overlays` (and dark mode), taking the surface color
|
||||||
|
where the platform paints none
|
||||||
|
- [x] Toast platform chrome — the pill border behind
|
||||||
|
`Theme::borders.overlays` (and dark mode), taking the surface color
|
||||||
|
where the platform paints none; the pill rides the popover elevation,
|
||||||
|
so dark mode's no-shadow rule applies to it too
|
||||||
|
- [x] Tooltip platform chrome — the panel border behind
|
||||||
|
`Theme::borders.overlays` (and dark mode), taking the surface color
|
||||||
|
where the platform paints none, and riding the popover elevation for
|
||||||
|
the light-mode shadow; anchored tooltips hang below the
|
||||||
|
hovered parent (`Tooltip::anchored(text, id)`) so they never cover it —
|
||||||
|
the element records its painted bounds with `record_anchor_bounds`
|
||||||
|
(`Button` does), the panel hangs below that with the spacing gap, and
|
||||||
|
it flips above the parent when it would overflow the window bottom
|
||||||
|
(the panel records its own height after the first frame); unanchored
|
||||||
|
tooltips fall back to the cursor-offset wrapper
|
||||||
|
- [x] Alert platform chrome — the banner border behind
|
||||||
|
`Theme::borders.overlays` (and dark mode), taking the surface color
|
||||||
|
where the platform paints none; it rides the popover elevation, so
|
||||||
|
dark mode's no-shadow rule applies to it too
|
||||||
|
- [x] Combobox platform chrome — the popup border behind
|
||||||
|
`Theme::borders.overlays` (and dark mode), taking the surface color
|
||||||
|
where the platform paints none, and riding the popover elevation for
|
||||||
|
the light-mode shadow; the trigger is a plain `Input`, so it
|
||||||
|
already carries the full field treatment, and the chevron recenters on
|
||||||
|
the field's full height, border included; the popup mounts on
|
||||||
|
`deferred(anchored().offset(...))` like the Popover — out of the
|
||||||
|
wrapper's layout, one spacing gap below, window-clamped — and always
|
||||||
|
matches the combobox's width; the combobox itself is no longer full
|
||||||
|
width: it takes a compact default and style refinements on the builder
|
||||||
|
(`.w(...)`) set the width; the empty field keeps its placeholder while
|
||||||
|
focused (`Input::placeholder_when_focused`) and clicking into it opens
|
||||||
|
the options right away, with no keystroke needed
|
||||||
|
- [x] Command palette platform chrome — the panel border behind
|
||||||
|
`Theme::borders.overlays` (and dark mode), taking the surface color
|
||||||
|
where the platform paints none; it rides the dialog elevation, so dark
|
||||||
|
mode's no-shadow rule applies to it too
|
||||||
|
- [x] Switch platform chrome — the track and knob take the stated
|
||||||
|
`switch_height` / `switch_width` / `switch_knob` (the knob's inset
|
||||||
|
derived to center it in the track, its travel from the remaining
|
||||||
|
width); the knob is borderless, and the track carries a debug selector
|
||||||
|
for headless geometry tests
|
||||||
- [x] Button, Checkbox, Switch, Radio + RadioGroup, Toggle, Link, Slider,
|
- [x] Button, Checkbox, Switch, Radio + RadioGroup, Toggle, Link, Slider,
|
||||||
Progress, Avatar (image + seeded pixel-art fallback)
|
Progress, Avatar (image + seeded pixel-art fallback)
|
||||||
- [x] Spinner — painted ring + rotating-icon variant, indeterminate and
|
- [x] Spinner — painted ring + rotating-icon variant, indeterminate and
|
||||||
determinate; replaces `Tab::busy`'s placeholder
|
determinate; replaces `Tab::busy`'s placeholder
|
||||||
- [x] Input — single-line, password with reveal, multiline, undo/redo, actions
|
- [x] Input — single-line, password with reveal, multiline, undo/redo, actions
|
||||||
+ key context, caret blink
|
+ key context, caret blink (time-math driven: the focused field mounts a
|
||||||
|
repeating blink animation whose frames redraw the caret, the caret is
|
||||||
|
solid for one interval after each edit, and an idle unfocused page
|
||||||
|
schedules no frames)
|
||||||
- [x] ScrollArea, Popover, Menu, Tooltip, Dialog / AlertDialog, Toast,
|
- [x] ScrollArea, Popover, Menu, Tooltip, Dialog / AlertDialog, Toast,
|
||||||
Alert + alerts stack
|
Alert + alerts stack
|
||||||
- [x] Select, Segmented, ContextMenu, EmptyState, Badge
|
- [x] Select, Segmented, ContextMenu, EmptyState, Badge
|
||||||
@@ -70,7 +162,8 @@ Gaps surfaced by the signed comparison; ordered by signed's usage.
|
|||||||
screen-reader label
|
screen-reader label
|
||||||
- [x] Combobox — type-to-filter picker with a caller-owned matcher, create /
|
- [x] Combobox — type-to-filter picker with a caller-owned matcher, create /
|
||||||
keep / revert blur modes
|
keep / revert blur modes
|
||||||
- [x] Command palette — filtered actions; the matcher stays caller-owned
|
- [x] Command palette — filtered actions; the matcher stays caller-owned,
|
||||||
|
and commands may carry a leading icon
|
||||||
- [x] Scrollbar — overlay thumbs for gpui's `list` and `uniform_list`,
|
- [x] Scrollbar — overlay thumbs for gpui's `list` and `uniform_list`,
|
||||||
platform autohide with a `visibility` override
|
platform autohide with a `visibility` override
|
||||||
- [x] Clipboard button — copies a value, confirms with a brief check mark
|
- [x] Clipboard button — copies a value, confirms with a brief check mark
|
||||||
|
|||||||
+120
-17
@@ -205,8 +205,10 @@ fn gallery_commands() -> Vec<Command> {
|
|||||||
Command::new("open-settings", "Open Settings").hint("Cmd+,"),
|
Command::new("open-settings", "Open Settings").hint("Cmd+,"),
|
||||||
Command::new("toggle-theme", "Toggle Theme").hint("Shift+Cmd+T"),
|
Command::new("toggle-theme", "Toggle Theme").hint("Shift+Cmd+T"),
|
||||||
Command::new("save-all", "Save All").hint("Cmd+Alt+S"),
|
Command::new("save-all", "Save All").hint("Cmd+Alt+S"),
|
||||||
Command::new("close-tab", "Close Tab").hint("Cmd+W"),
|
Command::new("close-tab", "Close Tab")
|
||||||
Command::new("reload-window", "Reload Window"),
|
.hint("Cmd+W")
|
||||||
|
.icon(IconRole::Close),
|
||||||
|
Command::new("reload-window", "Reload Window").icon(IconRole::Loader),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,27 +878,83 @@ impl Render for ButtonDemo {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
if variant == ButtonVariant::Destructive {
|
if variant == ButtonVariant::Destructive {
|
||||||
button = button.tooltip(Tooltip::text("Removes the selected item"));
|
let id = SharedString::from(format!("button-demo-{name}"));
|
||||||
|
button = button
|
||||||
|
.tooltip(Tooltip::anchored("Removes the selected item", id));
|
||||||
}
|
}
|
||||||
button
|
button
|
||||||
}))
|
})),
|
||||||
|
))
|
||||||
|
.child(demo_section(
|
||||||
|
"icon + label",
|
||||||
|
colors,
|
||||||
|
h_flex()
|
||||||
|
.flex_wrap()
|
||||||
|
.gap_2()
|
||||||
|
.children(variants.map(|(name, variant)| {
|
||||||
|
let view = view.clone();
|
||||||
|
let mut button =
|
||||||
|
Button::new(SharedString::from(format!("button-demo-icon-{name}")))
|
||||||
|
.label(button_label(variant))
|
||||||
|
.icon(IconRole::Folder)
|
||||||
|
.variant(variant)
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
view.update(cx, |demo, cx| {
|
||||||
|
demo.clicks += 1;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if variant == ButtonVariant::Destructive {
|
||||||
|
let id = SharedString::from(format!("button-demo-icon-{name}"));
|
||||||
|
button = button
|
||||||
|
.tooltip(Tooltip::anchored("Removes the selected item", id));
|
||||||
|
}
|
||||||
|
button
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
.child(demo_section(
|
||||||
|
"icon only",
|
||||||
|
colors,
|
||||||
|
h_flex()
|
||||||
|
.flex_wrap()
|
||||||
|
.gap_2()
|
||||||
|
.children(variants.map(|(name, variant)| {
|
||||||
|
let view = view.clone();
|
||||||
|
let id = SharedString::from(format!("button-demo-icon-only-{name}"));
|
||||||
|
Button::new(id.clone())
|
||||||
|
.icon(IconRole::Folder)
|
||||||
|
.variant(variant)
|
||||||
|
.accessibility_label("Open the selected file")
|
||||||
|
.tooltip(Tooltip::anchored("Opens the selected file", id))
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
view.update(cx, |demo, cx| {
|
||||||
|
demo.clicks += 1;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
.child(demo_section(
|
||||||
|
"states",
|
||||||
|
colors,
|
||||||
|
h_flex()
|
||||||
|
.flex_wrap()
|
||||||
|
.gap_2()
|
||||||
.child(
|
.child(
|
||||||
Button::new("button-demo-disabled")
|
Button::new("button-demo-disabled")
|
||||||
.label("Disabled")
|
.label("Disabled")
|
||||||
.disabled(true),
|
.disabled(true),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("button-demo-icon")
|
Button::new("button-demo-loading")
|
||||||
.label("Open")
|
.label("Saving")
|
||||||
.icon(IconRole::Folder)
|
.loading(true),
|
||||||
.tooltip(Tooltip::text("Opens the selected file")),
|
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("button-demo-icon-only")
|
Button::new("button-demo-loading-icon-only")
|
||||||
.icon(IconRole::Folder)
|
.icon(IconRole::Folder)
|
||||||
.variant(ButtonVariant::Ghost)
|
.accessibility_label("Saving the selected file")
|
||||||
.accessibility_label("Open the selected file")
|
.loading(true),
|
||||||
.tooltip(Tooltip::text("Opens the selected file")),
|
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
.child(demo_section(
|
.child(demo_section(
|
||||||
@@ -923,6 +981,7 @@ struct InputDemo {
|
|||||||
text: Entity<InputState>,
|
text: Entity<InputState>,
|
||||||
password: Entity<InputState>,
|
password: Entity<InputState>,
|
||||||
notes: Entity<InputState>,
|
notes: Entity<InputState>,
|
||||||
|
search: Entity<InputState>,
|
||||||
_subscriptions: Vec<gpui::Subscription>,
|
_subscriptions: Vec<gpui::Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,17 +992,20 @@ impl InputDemo {
|
|||||||
password.update(cx, |state, cx| state.set_password(true, cx));
|
password.update(cx, |state, cx| state.set_password(true, cx));
|
||||||
let notes = InputState::new(cx);
|
let notes = InputState::new(cx);
|
||||||
notes.update(cx, |state, cx| state.set_multiline(true, cx));
|
notes.update(cx, |state, cx| state.set_multiline(true, cx));
|
||||||
|
let search = InputState::new(cx);
|
||||||
// The panel is a cached view in the dock: the value readouts re-render
|
// The panel is a cached view in the dock: the value readouts re-render
|
||||||
// only when the inputs it reads are notified.
|
// only when the inputs it reads are notified.
|
||||||
let subscriptions = vec![
|
let subscriptions = vec![
|
||||||
cx.observe(&text, |_, _, cx| cx.notify()),
|
cx.observe(&text, |_, _, cx| cx.notify()),
|
||||||
cx.observe(&password, |_, _, cx| cx.notify()),
|
cx.observe(&password, |_, _, cx| cx.notify()),
|
||||||
cx.observe(¬es, |_, _, cx| cx.notify()),
|
cx.observe(¬es, |_, _, cx| cx.notify()),
|
||||||
|
cx.observe(&search, |_, _, cx| cx.notify()),
|
||||||
];
|
];
|
||||||
Self {
|
Self {
|
||||||
text,
|
text,
|
||||||
password,
|
password,
|
||||||
notes,
|
notes,
|
||||||
|
search,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -956,7 +1018,36 @@ impl Render for InputDemo {
|
|||||||
ScrollArea::new("input-demo").size_full().p_4().child(
|
ScrollArea::new("input-demo").size_full().p_4().child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.child(Input::new("input-demo-text", self.text.clone()).placeholder("Type here…"))
|
.child(
|
||||||
|
Input::new("input-demo-search", self.search.clone())
|
||||||
|
.placeholder("Search")
|
||||||
|
.prefix(
|
||||||
|
Button::new("input-demo-search-prefix")
|
||||||
|
.icon(IconRole::Search)
|
||||||
|
.variant(ButtonVariant::Ghost)
|
||||||
|
.small()
|
||||||
|
.accessibility_label("Search"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Input::new("input-demo-text", self.text.clone())
|
||||||
|
.placeholder("Type here…")
|
||||||
|
.suffix(
|
||||||
|
Button::new("input-demo-clear")
|
||||||
|
.icon(IconRole::Close)
|
||||||
|
.variant(ButtonVariant::Ghost)
|
||||||
|
.small()
|
||||||
|
.accessibility_label("Clear")
|
||||||
|
.on_click({
|
||||||
|
let text = self.text.clone();
|
||||||
|
move |_, window, cx| {
|
||||||
|
text.update(cx, |state, cx| {
|
||||||
|
state.set_value("", window, cx)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
Input::new("input-demo-password", self.password.clone())
|
Input::new("input-demo-password", self.password.clone())
|
||||||
.placeholder("Password"),
|
.placeholder("Password"),
|
||||||
@@ -1168,7 +1259,10 @@ impl ComboboxDemo {
|
|||||||
)
|
)
|
||||||
.selected(SharedString::from("main"))
|
.selected(SharedString::from("main"))
|
||||||
.placeholder("Switch branch…")
|
.placeholder("Switch branch…")
|
||||||
.keep_unmatched_text(),
|
.keep_unmatched_text()
|
||||||
|
// Widths are style refinements on the builder; the popup
|
||||||
|
// always matches whatever the combobox ends up being.
|
||||||
|
.w(px(280.)),
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
@@ -2716,20 +2810,29 @@ impl Render for TooltipDemo {
|
|||||||
Button::new("tooltip-demo-destructive")
|
Button::new("tooltip-demo-destructive")
|
||||||
.label("Delete")
|
.label("Delete")
|
||||||
.variant(ButtonVariant::Destructive)
|
.variant(ButtonVariant::Destructive)
|
||||||
.tooltip(Tooltip::text("Removes the selected item")),
|
.tooltip(Tooltip::anchored(
|
||||||
|
"Removes the selected item",
|
||||||
|
"tooltip-demo-destructive",
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("tooltip-demo-icon")
|
Button::new("tooltip-demo-icon")
|
||||||
.label("Open")
|
.label("Open")
|
||||||
.icon(IconRole::Folder)
|
.icon(IconRole::Folder)
|
||||||
.tooltip(Tooltip::text("Opens the selected file")),
|
.tooltip(Tooltip::anchored(
|
||||||
|
"Opens the selected file",
|
||||||
|
"tooltip-demo-icon",
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("tooltip-demo-icon-only")
|
Button::new("tooltip-demo-icon-only")
|
||||||
.icon(IconRole::Folder)
|
.icon(IconRole::Folder)
|
||||||
.variant(ButtonVariant::Ghost)
|
.variant(ButtonVariant::Ghost)
|
||||||
.accessibility_label("Open the selected file")
|
.accessibility_label("Open the selected file")
|
||||||
.tooltip(Tooltip::text("Opens the selected file")),
|
.tooltip(Tooltip::anchored(
|
||||||
|
"Opens the selected file",
|
||||||
|
"tooltip-demo-icon-only",
|
||||||
|
)),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user