forked from reya/gpui-native-kit
chore: reduce component overhead (#3)
Reviewed-on: reya/gpui-native-kit#3
This commit is contained in:
@@ -260,11 +260,19 @@ impl Alerts {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismisses every alert.
|
||||
/// Dismisses every alert, running each entry's `on_dismiss` handler.
|
||||
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.entries.is_empty() {
|
||||
self.entries.clear();
|
||||
cx.notify();
|
||||
if self.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = std::mem::take(&mut self.entries);
|
||||
cx.notify();
|
||||
|
||||
for entry in entries {
|
||||
if let Some(on_dismiss) = entry.on_dismiss {
|
||||
on_dismiss(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +312,7 @@ impl RenderOnce for AlertsView {
|
||||
let theme = cx.theme();
|
||||
let enter_duration = theme.motion.overlay;
|
||||
|
||||
let entries: Vec<AlertEntry> = self.alerts.read(cx).entries().to_vec();
|
||||
let entries = self.alerts.read(cx).entries();
|
||||
|
||||
deferred(
|
||||
div()
|
||||
|
||||
+11
-18
@@ -4,7 +4,10 @@ use gpui::{
|
||||
App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role,
|
||||
SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, div,
|
||||
};
|
||||
use gpui_native_theme::{ActiveTheme as _, Oklch, Size, ThemeVariant};
|
||||
use gpui_native_theme::{
|
||||
ActiveTheme as _, Oklch, PIXEL_AVATAR_DARK_FACTOR, PIXEL_AVATAR_DARK_FLOOR,
|
||||
PIXEL_AVATAR_LIGHT_CAP, PIXEL_AVATAR_LIGHT_LIFT, PIXEL_AVATAR_TINT_ALPHA, Size, ThemeVariant,
|
||||
};
|
||||
|
||||
use crate::common::{Sizable, StyledExt, h_flex};
|
||||
|
||||
@@ -108,12 +111,10 @@ impl RenderOnce for Avatar {
|
||||
.refine_style(&self.style);
|
||||
|
||||
match self.picture {
|
||||
Some(url) => base.child(gpui::img(url).size_full().rounded(radius)),
|
||||
Some(url) => base.child(gpui::img(url).size_full()),
|
||||
None => base.child(PixelAvatar {
|
||||
seed: fnv1a(self.id.as_bytes()),
|
||||
side,
|
||||
radius,
|
||||
style: self.style,
|
||||
}),
|
||||
}
|
||||
.into_any_element()
|
||||
@@ -125,14 +126,6 @@ impl RenderOnce for Avatar {
|
||||
struct PixelAvatar {
|
||||
seed: u64,
|
||||
side: gpui::Pixels,
|
||||
radius: gpui::Pixels,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl gpui::Styled for PixelAvatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for PixelAvatar {
|
||||
@@ -147,13 +140,13 @@ impl RenderOnce for PixelAvatar {
|
||||
|
||||
let shade = if theme.variant == ThemeVariant::Dark {
|
||||
Oklch {
|
||||
l: (accent.l * 1.6).min(0.95),
|
||||
l: (accent.l * PIXEL_AVATAR_LIGHT_LIFT).min(PIXEL_AVATAR_LIGHT_CAP),
|
||||
..Oklch::from_hsla(main)
|
||||
}
|
||||
.to_hsla()
|
||||
} else {
|
||||
Oklch {
|
||||
l: (accent.l * 0.45).max(0.18),
|
||||
l: (accent.l * PIXEL_AVATAR_DARK_FACTOR).max(PIXEL_AVATAR_DARK_FLOOR),
|
||||
..Oklch::from_hsla(main)
|
||||
}
|
||||
.to_hsla()
|
||||
@@ -182,11 +175,11 @@ impl RenderOnce for PixelAvatar {
|
||||
.grid_rows(GRID_SIZE as u16)
|
||||
.size(self.side)
|
||||
.flex_none()
|
||||
.rounded(self.radius)
|
||||
.overflow_hidden()
|
||||
.bg(gpui::Hsla { a: 0.16, ..main })
|
||||
.bg(gpui::Hsla {
|
||||
a: PIXEL_AVATAR_TINT_ALPHA,
|
||||
..main
|
||||
})
|
||||
.children(cells)
|
||||
.refine_style(&self.style)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyView, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas,
|
||||
hsla, relative,
|
||||
relative, transparent_black,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
||||
@@ -199,9 +199,9 @@ impl RenderOnce for Button {
|
||||
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),
|
||||
transparent_black(),
|
||||
transparent_black(),
|
||||
transparent_black(),
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
+23
-31
@@ -10,13 +10,13 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, Bounds, Context, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
|
||||
KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role,
|
||||
ScrollHandle, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
|
||||
Subscription, Task, Window, actions, anchored, canvas, deferred, div, point, px,
|
||||
App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement, KeyBinding,
|
||||
MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role, ScrollHandle,
|
||||
SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription, Task,
|
||||
Window, actions, anchored, canvas, deferred, div, point, px,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, POPUP_MAX_HEIGHT, Size};
|
||||
|
||||
use crate::common::{Sizable, StyledExt as _, h_flex, v_flex};
|
||||
use crate::input::{Input, InputState, InputStateEvent};
|
||||
@@ -177,9 +177,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Emitted when the combobox's value changes.
|
||||
pub struct ComboboxChanged;
|
||||
|
||||
/// The stateful combobox.
|
||||
pub struct ComboboxState<T>
|
||||
where
|
||||
@@ -209,8 +206,6 @@ where
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl<T> EventEmitter<ComboboxChanged> for ComboboxState<T> where T: Clone + PartialEq + 'static {}
|
||||
|
||||
impl<T> Sizable for ComboboxState<T>
|
||||
where
|
||||
T: Clone + PartialEq + 'static,
|
||||
@@ -316,8 +311,8 @@ where
|
||||
.map(|(_, label)| label.clone())
|
||||
}
|
||||
|
||||
/// Recomputes `visible` for `query` and hands the survivors' labels back.
|
||||
fn refilter(&mut self, query: &str) -> Vec<SharedString> {
|
||||
/// Recomputes `visible` for `query` and clamps the highlight onto it.
|
||||
fn refilter(&mut self, query: &str) {
|
||||
let filter = self.filter.clone();
|
||||
self.visible = self
|
||||
.options
|
||||
@@ -326,10 +321,7 @@ where
|
||||
.filter(|(_, (value, label))| filter(query, value, label))
|
||||
.map(|(index, _)| index)
|
||||
.collect();
|
||||
self.visible
|
||||
.iter()
|
||||
.map(|index| self.options[*index].1.clone())
|
||||
.collect()
|
||||
self.highlight = self.highlight.filter(|row| *row < self.visible.len());
|
||||
}
|
||||
|
||||
fn query_text(&self, cx: &Context<Self>) -> String {
|
||||
@@ -349,7 +341,6 @@ where
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
cx.emit(ComboboxChanged);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -375,7 +366,7 @@ where
|
||||
fn text_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let query = self.query_text(cx);
|
||||
self.refilter(&query);
|
||||
self.highlight = (!self.visible.is_empty()).then_some(0usize);
|
||||
self.highlight = Some(0);
|
||||
self.set_value(None, window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
@@ -386,7 +377,7 @@ where
|
||||
return;
|
||||
}
|
||||
self.refilter("");
|
||||
self.highlight = (!self.visible.is_empty()).then_some(0usize);
|
||||
self.highlight = Some(0);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -427,10 +418,7 @@ where
|
||||
if self.visible.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current = self
|
||||
.highlight
|
||||
.and_then(|row| self.visible.get(row).copied())
|
||||
.and_then(|option_ix| self.visible.iter().position(|&row| row == option_ix));
|
||||
let current = self.highlight.filter(|row| *row < self.visible.len());
|
||||
let next = match current {
|
||||
Some(row) => {
|
||||
let row = (row as i32 + delta).rem_euclid(self.visible.len() as i32);
|
||||
@@ -510,9 +498,13 @@ where
|
||||
if state.highlight.is_some() {
|
||||
state.highlight = None;
|
||||
} else {
|
||||
let query = state.query.read(cx).value().to_string();
|
||||
state.refilter(&query);
|
||||
state.highlight = (!state.visible.is_empty()).then_some(0usize);
|
||||
if state.selected.is_some() {
|
||||
state.refilter("");
|
||||
} else {
|
||||
let query = state.query.read(cx).value().to_string();
|
||||
state.refilter(&query);
|
||||
}
|
||||
state.highlight = Some(0);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
@@ -600,8 +592,8 @@ where
|
||||
div()
|
||||
.id(("option", row))
|
||||
.debug_selector({
|
||||
let name = format!("{id}-option-{row}");
|
||||
move || name
|
||||
let id = id.clone();
|
||||
move || format!("{id}-option-{row}")
|
||||
})
|
||||
.flex()
|
||||
.items_center()
|
||||
@@ -663,7 +655,7 @@ where
|
||||
ScrollArea::new((id.clone(), "rows"))
|
||||
.handle(scroll.read(cx).clone())
|
||||
.axes(crate::scroll_area::ScrollAxes::Vertical)
|
||||
.max_h(px(320.))
|
||||
.max_h(POPUP_MAX_HEIGHT)
|
||||
.children(rows)
|
||||
.into_any_element()
|
||||
};
|
||||
@@ -677,8 +669,8 @@ where
|
||||
let panel = v_flex()
|
||||
.id((id.clone(), "panel"))
|
||||
.debug_selector({
|
||||
let name = format!("{id}-panel");
|
||||
move || name
|
||||
let id = id.clone();
|
||||
move || format!("{id}-panel")
|
||||
})
|
||||
.w(popup_width)
|
||||
.p_1()
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
//! The command palette: a modal query field over the application's actions.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement,
|
||||
KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role,
|
||||
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window,
|
||||
actions, canvas, div, px, relative,
|
||||
AnyElement, App, Context, ElementId, Entity, InteractiveElement as _, IntoElement, KeyBinding,
|
||||
MouseButton, ParentElement as _, Render, Role, ScrollHandle, SharedString,
|
||||
StatefulInteractiveElement as _, Styled as _, Subscription, Window, actions, canvas, deferred,
|
||||
div, relative,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
use gpui_native_theme::{ActiveTheme as _, POPUP_MAX_HEIGHT, Size};
|
||||
|
||||
use crate::common::{h_flex, v_flex};
|
||||
use crate::input::{Input, InputState, InputStateEvent};
|
||||
@@ -322,6 +321,7 @@ impl Render for CommandPaletteState {
|
||||
|
||||
let id = self.id.clone();
|
||||
let state = cx.entity();
|
||||
let query_focus = self.query.read(cx).focus_handle();
|
||||
|
||||
let rows: Vec<AnyElement> = self
|
||||
.matches
|
||||
@@ -333,10 +333,10 @@ impl Render for CommandPaletteState {
|
||||
let row_ix = row;
|
||||
|
||||
h_flex()
|
||||
.id(("command", row))
|
||||
.id((ElementId::from(command.id.clone()), "command"))
|
||||
.debug_selector({
|
||||
let name = format!("{id}-command-{row}");
|
||||
move || name
|
||||
let id = id.clone();
|
||||
move || format!("{id}-command-{row}")
|
||||
})
|
||||
.gap_2()
|
||||
.px_2()
|
||||
@@ -401,19 +401,16 @@ impl Render for CommandPaletteState {
|
||||
ScrollArea::new((id.clone(), "rows"))
|
||||
.handle(scroll.read(cx).clone())
|
||||
.axes(ScrollAxes::Vertical)
|
||||
.max_h(px(320.))
|
||||
.max_h(POPUP_MAX_HEIGHT)
|
||||
.children(rows)
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
|
||||
let query_focus = self.query.read(cx).focus_handle();
|
||||
|
||||
let panel = v_flex()
|
||||
.id((id.clone(), "panel"))
|
||||
.debug_selector({
|
||||
let name = format!("{id}-panel");
|
||||
move || name
|
||||
let id = id.clone();
|
||||
move || format!("{id}-panel")
|
||||
})
|
||||
.key_context(COMMAND_PALETTE_CONTEXT)
|
||||
.role(Role::Dialog)
|
||||
@@ -460,20 +457,14 @@ impl Render for CommandPaletteState {
|
||||
)
|
||||
.child(rows)
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
let cell = panel_bounds.clone();
|
||||
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
|
||||
},
|
||||
{
|
||||
let focus = query_focus.clone();
|
||||
move |_, _, window, cx| {
|
||||
if !focus.contains_focused(window, cx) {
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
canvas(|_, _, _| {}, {
|
||||
let focus = query_focus.clone();
|
||||
move |_, _, window, cx| {
|
||||
if !focus.contains_focused(window, cx) {
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
);
|
||||
@@ -485,28 +476,23 @@ impl Render for CommandPaletteState {
|
||||
.occlude()
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let state = state.clone();
|
||||
let panel_bounds = panel_bounds.clone();
|
||||
move |event: &MouseDownEvent, _window, cx| {
|
||||
let on_panel = panel_bounds
|
||||
.borrow()
|
||||
.is_some_and(|bounds| bounds.contains(&event.position));
|
||||
if on_panel {
|
||||
return;
|
||||
}
|
||||
move |_, _window, cx| {
|
||||
state.update(cx, |state, cx| state.close(cx));
|
||||
cx.stop_propagation();
|
||||
}
|
||||
});
|
||||
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_start()
|
||||
.pt_10()
|
||||
.child(scrim)
|
||||
.child(panel)
|
||||
.into_any_element()
|
||||
deferred(
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_start()
|
||||
.pt_10()
|
||||
.child(scrim)
|
||||
.child(panel),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
+11
-30
@@ -1,12 +1,10 @@
|
||||
use std::cell::RefCell;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, AnyElement, App, Bounds, ElementId, FontWeight,
|
||||
InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||
ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
||||
Styled as _, Window, canvas, div, ease_out_quint, relative,
|
||||
Animation, AnimationExt as _, AnyElement, App, ElementId, FontWeight, InteractiveElement as _,
|
||||
IntoElement, KeyDownEvent, MouseButton, ParentElement as _, RenderOnce, Role, SharedString,
|
||||
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, ease_out_quint, relative,
|
||||
};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
|
||||
@@ -16,9 +14,6 @@ use crate::common::v_flex;
|
||||
pub type ChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
type ContentFn = Box<dyn Fn(&mut Window, &mut App) -> AnyElement>;
|
||||
type ActionsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<AnyElement>>;
|
||||
/// Bounds captured during the last paint, shared with the scrim listener.
|
||||
type BoundsCell = Rc<RefCell<Option<Bounds<Pixels>>>>;
|
||||
|
||||
/// A centred panel over a dim scrim: the platform's modal.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Dialog {
|
||||
@@ -165,7 +160,6 @@ impl RenderOnce for Dialog {
|
||||
|
||||
let content = self.content.map(|build| build(window, cx));
|
||||
let actions = self.actions.map(|build| build(window, cx));
|
||||
let panel_bounds: BoundsCell = Rc::new(RefCell::new(None));
|
||||
|
||||
let panel = v_flex()
|
||||
.id("dialog-panel")
|
||||
@@ -211,19 +205,13 @@ impl RenderOnce for Dialog {
|
||||
panel.child(div().flex().justify_end().gap_2().children(actions))
|
||||
})
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
let cell = panel_bounds.clone();
|
||||
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
|
||||
},
|
||||
{
|
||||
move |_, _, window, cx| {
|
||||
if !focus.contains_focused(window, cx) {
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
canvas(|_, _, _| {}, {
|
||||
move |_, _, window, cx| {
|
||||
if !focus.contains_focused(window, cx) {
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
);
|
||||
@@ -235,15 +223,8 @@ impl RenderOnce for Dialog {
|
||||
.occlude()
|
||||
.when(!alert, |scrim| {
|
||||
scrim.on_mouse_down(MouseButton::Left, {
|
||||
let panel_bounds = panel_bounds.clone();
|
||||
let on_change = on_change.clone();
|
||||
move |event: &MouseDownEvent, _window, cx| {
|
||||
let on_panel = panel_bounds
|
||||
.borrow()
|
||||
.is_some_and(|bounds| bounds.contains(&event.position));
|
||||
if on_panel {
|
||||
return;
|
||||
}
|
||||
move |_, _window, cx| {
|
||||
if let Some(on_change) = &on_change {
|
||||
on_change(false, _window, cx);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, ClickEvent, DispatchPhase, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent,
|
||||
MouseButton, MouseDownEvent, ParentElement as _, RenderOnce, Role, SharedString,
|
||||
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, hsla, relative,
|
||||
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, relative,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
||||
@@ -193,7 +193,7 @@ impl RenderOnce for DropdownMenu {
|
||||
let border = if show_border {
|
||||
colors.border
|
||||
} else {
|
||||
background.unwrap_or_else(|| hsla(0.0, 0.0, 0.0, 0.0))
|
||||
background.unwrap_or_else(gpui::transparent_black)
|
||||
};
|
||||
|
||||
let split = self.on_click.is_some();
|
||||
@@ -207,7 +207,8 @@ impl RenderOnce for DropdownMenu {
|
||||
let a11y_label = self.a11y_label.or_else(|| self.label.clone());
|
||||
let focus = focus_handle(&self.id, window, cx).tab_stop(self.tab_stop);
|
||||
// How the menu was last opened, read by the frame that mounts the panel.
|
||||
let keyboard_open = window.use_keyed_state("dropdown-open-origin", cx, |_, _| false);
|
||||
let keyboard_open =
|
||||
window.use_keyed_state((self.id.clone(), "dropdown-open-origin"), cx, |_, _| false);
|
||||
|
||||
let on_open: ChangeHandler = {
|
||||
let focus = focus.clone();
|
||||
@@ -421,10 +422,10 @@ impl RenderOnce for DropdownMenu {
|
||||
|
||||
let items = if self.open {
|
||||
self.items
|
||||
.map(|build| build(window, cx))
|
||||
.unwrap_or_default()
|
||||
.map(|build| Rc::from(build(window, cx)))
|
||||
.unwrap_or_else(|| Rc::from([]))
|
||||
} else {
|
||||
Vec::new()
|
||||
Rc::from([])
|
||||
};
|
||||
|
||||
if let Some(panel) = anchored_menu_panel(
|
||||
|
||||
@@ -95,8 +95,8 @@ impl RenderOnce for Field {
|
||||
|
||||
let title = div()
|
||||
.debug_selector({
|
||||
let name = format!("{id}-label");
|
||||
move || name
|
||||
let id = id.clone();
|
||||
move || format!("{id}-label")
|
||||
})
|
||||
.text_size(metrics.text_size)
|
||||
.child(self.label.clone());
|
||||
@@ -105,16 +105,16 @@ impl RenderOnce for Field {
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.debug_selector({
|
||||
let name = format!("{id}-control");
|
||||
move || name
|
||||
let id = id.clone();
|
||||
move || format!("{id}-control")
|
||||
})
|
||||
.children(self.control);
|
||||
|
||||
let error = self.error.map(|error| {
|
||||
div()
|
||||
.debug_selector({
|
||||
let name = format!("{id}-error");
|
||||
move || name
|
||||
let id = id.clone();
|
||||
move || format!("{id}-error")
|
||||
})
|
||||
.text_size(metrics.caption_size)
|
||||
.text_color(error_color)
|
||||
|
||||
+127
-106
@@ -1,4 +1,3 @@
|
||||
use std::cell::RefCell;
|
||||
use std::ops::Range;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -16,7 +15,10 @@ use gpui::{
|
||||
WrappedLine, actions, canvas, div, fill, point, px,
|
||||
};
|
||||
use gpui_native_icons::IconRole;
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, ColorTokens, Size};
|
||||
use gpui_native_theme::{
|
||||
ActiveTheme as _, BORDER_WIDTH, CARET_WIDTH, ColorTokens, Size, UNDERLINE_WIDTH,
|
||||
WHEEL_LINE_HEIGHT,
|
||||
};
|
||||
use gpui_native_utils::focus_ring;
|
||||
|
||||
use crate::button::{Button, ButtonVariant};
|
||||
@@ -29,7 +31,7 @@ const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300);
|
||||
/// How often the focused caret blinks.
|
||||
const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
|
||||
/// Pixel height of one scroll "line" for wheel scrolling.
|
||||
const SCROLL_LINES_HEIGHT: f32 = 20.0;
|
||||
const SCROLL_LINES_HEIGHT: f32 = WHEEL_LINE_HEIGHT;
|
||||
/// The row count a multiline field shows until the app sets one.
|
||||
const DEFAULT_ROWS: usize = 4;
|
||||
/// Maximum undo entries kept; the oldest drops off the front.
|
||||
@@ -270,7 +272,7 @@ impl InputBindings {
|
||||
}
|
||||
|
||||
/// Collects the table into keybindings.
|
||||
pub fn into_bindings(self) -> Vec<KeyBinding> {
|
||||
pub(crate) fn into_bindings(self) -> Vec<KeyBinding> {
|
||||
let mut bindings: Vec<KeyBinding> = [
|
||||
self.escape,
|
||||
self.submit,
|
||||
@@ -491,9 +493,9 @@ pub struct InputState {
|
||||
focused: bool,
|
||||
focus_handle: gpui::FocusHandle,
|
||||
/// The key-down handler, bound to this entity with `cx.listener`.
|
||||
key_down_handler: Option<KeyDownHandler>,
|
||||
key_down_handler: KeyDownHandler,
|
||||
/// The mouse-down handler, bound to this entity with `cx.listener`.
|
||||
mouse_down_handler: Option<MouseDownHandler>,
|
||||
mouse_down_handler: MouseDownHandler,
|
||||
/// Whether a drag-select is in progress.
|
||||
dragging: bool,
|
||||
/// Patch history. `redo_stack` holds undone entries awaiting redo.
|
||||
@@ -520,9 +522,9 @@ pub struct InputState {
|
||||
/// Bumped on every edit.
|
||||
caret_epoch: usize,
|
||||
/// When the current caret epoch started.
|
||||
caret_epoch_started: Option<Instant>,
|
||||
caret_epoch_started: Instant,
|
||||
/// The shaped line and element bounds, recorded during each paint.
|
||||
layout: Rc<RefCell<Option<InputLayout>>>,
|
||||
layout: Option<InputLayout>,
|
||||
}
|
||||
|
||||
impl EventEmitter<InputStateEvent> for InputState {}
|
||||
@@ -536,16 +538,14 @@ impl InputState {
|
||||
marked_range: None,
|
||||
focused: false,
|
||||
focus_handle: cx.focus_handle(),
|
||||
key_down_handler: Some(Rc::new(cx.listener(
|
||||
|state, event: &KeyDownEvent, window, cx| {
|
||||
state.handle_key_down(event, window, cx);
|
||||
},
|
||||
))),
|
||||
mouse_down_handler: Some(Rc::new(cx.listener(
|
||||
key_down_handler: Rc::new(cx.listener(|state, event: &KeyDownEvent, window, cx| {
|
||||
state.handle_key_down(event, window, cx);
|
||||
})),
|
||||
mouse_down_handler: Rc::new(cx.listener(
|
||||
|state, event: &MouseDownEvent, window, cx| {
|
||||
state.handle_mouse_down(event, window, cx);
|
||||
},
|
||||
))),
|
||||
)),
|
||||
dragging: false,
|
||||
undo_stack: Vec::new(),
|
||||
redo_stack: Vec::new(),
|
||||
@@ -559,8 +559,8 @@ impl InputState {
|
||||
scroll_offset: point(Pixels::ZERO, Pixels::ZERO),
|
||||
scroll_to_caret_pending: true,
|
||||
caret_epoch: 0,
|
||||
caret_epoch_started: Some(Instant::now()),
|
||||
layout: Rc::new(RefCell::new(None)),
|
||||
caret_epoch_started: Instant::now(),
|
||||
layout: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,12 +696,7 @@ impl InputState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which keystroke submits a multiline field.
|
||||
pub fn submit_on(&self) -> SubmitOn {
|
||||
self.submit_on
|
||||
}
|
||||
|
||||
/// Whether the painted line masks the buffer.
|
||||
// Whether the painted line masks the buffer.
|
||||
fn masked(&self) -> bool {
|
||||
self.password && !self.revealed
|
||||
}
|
||||
@@ -711,32 +706,43 @@ impl InputState {
|
||||
self.redo_stack.clear();
|
||||
|
||||
let now = Instant::now();
|
||||
if let Some(last) = self.undo_stack.last_mut()
|
||||
&& now.duration_since(last.timestamp) < self.group_interval
|
||||
{
|
||||
if let Some(last) = self.undo_stack.last_mut() {
|
||||
let last_end = last.range.start + last.new_len;
|
||||
|
||||
// Typing that continues right after the previous edit.
|
||||
if range.start == last_end {
|
||||
last.new_len += new_len;
|
||||
last.timestamp = now;
|
||||
return;
|
||||
if self.marked_range.is_some() {
|
||||
// A pre-edit step overlapping the previous entry's region.
|
||||
if range.start >= last.range.start {
|
||||
last.new_len += new_len;
|
||||
let replaced = range.len();
|
||||
last.new_len -= replaced;
|
||||
last.timestamp = now;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A deletion of text that sat right before the previous edit.
|
||||
if range.end == last.range.start && new_len == 0 {
|
||||
last.range.start = range.start;
|
||||
last.old_text = old_text + &last.old_text;
|
||||
last.timestamp = now;
|
||||
return;
|
||||
}
|
||||
if now.duration_since(last.timestamp) < self.group_interval {
|
||||
// Typing that continues right after the previous edit.
|
||||
if range.start == last_end {
|
||||
last.new_len += new_len;
|
||||
last.timestamp = now;
|
||||
return;
|
||||
}
|
||||
|
||||
// An edit fully inside the previous edit's replacement.
|
||||
if range.start >= last.range.start && range.end <= last_end {
|
||||
last.new_len += new_len;
|
||||
last.new_len -= range.len();
|
||||
last.timestamp = now;
|
||||
return;
|
||||
// A deletion of text that sat right before the previous edit.
|
||||
if range.end == last.range.start && new_len == 0 {
|
||||
last.range.start = range.start;
|
||||
last.old_text = old_text + &last.old_text;
|
||||
last.timestamp = now;
|
||||
return;
|
||||
}
|
||||
|
||||
// An edit fully inside the previous edit's replacement.
|
||||
if range.start >= last.range.start && range.end <= last_end {
|
||||
last.new_len += new_len;
|
||||
last.new_len -= range.len();
|
||||
last.timestamp = now;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,7 +775,7 @@ impl InputState {
|
||||
self.scroll_to_caret_pending = true;
|
||||
self.goal_x = None;
|
||||
self.caret_epoch += 1;
|
||||
self.caret_epoch_started = Some(Instant::now());
|
||||
self.caret_epoch_started = Instant::now();
|
||||
window.notify_text_input_changed(&self.focus_handle);
|
||||
}
|
||||
|
||||
@@ -781,20 +787,38 @@ impl InputState {
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
if self.multiline {
|
||||
self.scroll_offset.y = (self.scroll_offset.y + delta.y).max(Pixels::ZERO);
|
||||
let next_y = (self.scroll_offset.y + delta.y).max(Pixels::ZERO);
|
||||
if next_y == self.scroll_offset.y {
|
||||
return self.multiline_scrollable();
|
||||
}
|
||||
self.scroll_offset.y = next_y;
|
||||
} else {
|
||||
self.scroll_offset.x = (self.scroll_offset.x - delta.x).max(Pixels::ZERO);
|
||||
let next_x = (self.scroll_offset.x - delta.x).max(Pixels::ZERO);
|
||||
if next_x == self.scroll_offset.x {
|
||||
return false;
|
||||
}
|
||||
self.scroll_offset.x = next_x;
|
||||
}
|
||||
|
||||
let scrollable = self
|
||||
.layout
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.is_some_and(|layout| layout.content_height > layout.visible_height);
|
||||
|
||||
cx.notify();
|
||||
window.notify_text_input_changed(&self.focus_handle);
|
||||
self.multiline && scrollable
|
||||
}
|
||||
|
||||
/// Whether a multiline field's content overflows vertically.
|
||||
fn multiline_scrollable(&self) -> bool {
|
||||
self.multiline
|
||||
&& self
|
||||
.layout
|
||||
.as_ref()
|
||||
.is_some_and(|layout| layout.content_height > layout.visible_height)
|
||||
}
|
||||
|
||||
/// Emits `Focused`/`Blurred` when the field's focus state changed since the last paint.
|
||||
fn sync_focus(&mut self, focused: bool, cx: &mut Context<Self>) {
|
||||
if self.focused == focused {
|
||||
@@ -802,11 +826,9 @@ impl InputState {
|
||||
}
|
||||
self.focused = focused;
|
||||
|
||||
// A fresh focus starts a fresh blink epoch, so the caret shows solid
|
||||
// for one interval before blinking resumes.
|
||||
if focused {
|
||||
self.caret_epoch += 1;
|
||||
self.caret_epoch_started = Some(Instant::now());
|
||||
self.caret_epoch_started = Instant::now();
|
||||
}
|
||||
|
||||
cx.emit(if focused {
|
||||
@@ -817,11 +839,8 @@ impl InputState {
|
||||
}
|
||||
|
||||
/// Whether the caret should paint.
|
||||
pub fn caret_visible(&mut self, focused: bool) -> bool {
|
||||
let elapsed = self
|
||||
.caret_epoch_started
|
||||
.map(|started| started.elapsed())
|
||||
.unwrap_or_default();
|
||||
pub fn caret_visible(&self, focused: bool) -> bool {
|
||||
let elapsed = self.caret_epoch_started.elapsed();
|
||||
self.caret_blink_visible_at(focused, elapsed)
|
||||
}
|
||||
|
||||
@@ -901,15 +920,21 @@ impl InputState {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.cursor = to.clamp(0, self.text.len());
|
||||
if !extend {
|
||||
self.anchor = self.cursor;
|
||||
let cursor = to.clamp(0, self.text.len());
|
||||
let anchor = if extend { self.anchor } else { cursor };
|
||||
if cursor == self.cursor && anchor == self.anchor {
|
||||
return;
|
||||
}
|
||||
self.cursor = cursor;
|
||||
self.anchor = anchor;
|
||||
self.notify(window, cx);
|
||||
}
|
||||
|
||||
/// Selects `range`.
|
||||
fn select_range(&mut self, range: Range<usize>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if range.start == self.anchor && range.end == self.cursor {
|
||||
return;
|
||||
}
|
||||
self.anchor = range.start;
|
||||
self.cursor = range.end;
|
||||
self.notify(window, cx);
|
||||
@@ -1085,7 +1110,7 @@ impl InputState {
|
||||
);
|
||||
|
||||
let text_width = (bounds.size.width - padding.left - padding.right).max(Pixels::ZERO);
|
||||
*self.layout.borrow_mut() = Some(InputLayout {
|
||||
self.layout = Some(InputLayout {
|
||||
lines: text.lines,
|
||||
rows,
|
||||
bounds,
|
||||
@@ -1107,8 +1132,7 @@ impl InputState {
|
||||
return;
|
||||
}
|
||||
|
||||
let layout = self.layout.borrow();
|
||||
let Some(layout) = layout.as_ref() else {
|
||||
let Some(layout) = self.layout.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1138,8 +1162,7 @@ impl InputState {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let layout = self.layout.borrow();
|
||||
let Some(layout) = layout.as_ref() else {
|
||||
let Some(layout) = self.layout.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1155,17 +1178,16 @@ impl InputState {
|
||||
let mut line_top = Pixels::ZERO;
|
||||
for entry in &layout.lines {
|
||||
let line_origin = point(origin.x, origin.y + line_top);
|
||||
entry
|
||||
.line
|
||||
.paint(
|
||||
line_origin,
|
||||
layout.line_height,
|
||||
TextAlign::Left,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.ok();
|
||||
if let Err(err) = entry.line.paint(
|
||||
line_origin,
|
||||
layout.line_height,
|
||||
TextAlign::Left,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
) {
|
||||
eprintln!("painting input text failed: {err}");
|
||||
}
|
||||
line_top += layout.line_height * (entry.line.wrap_boundaries().len() + 1) as f32;
|
||||
}
|
||||
});
|
||||
@@ -1179,8 +1201,7 @@ impl InputState {
|
||||
color: gpui::Hsla,
|
||||
window: &mut Window,
|
||||
) {
|
||||
let layout = self.layout.borrow();
|
||||
let Some(layout) = layout.as_ref() else {
|
||||
let Some(layout) = self.layout.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1192,7 +1213,7 @@ impl InputState {
|
||||
let caret_top = origin.y + layout.rows[row_ix].y + (layout.line_height - font_size) / 2.0;
|
||||
let caret = Bounds::from_corners(
|
||||
point(origin.x + caret_x, caret_top),
|
||||
point(origin.x + caret_x + px(1.5), caret_top + font_size),
|
||||
point(origin.x + caret_x + CARET_WIDTH, caret_top + font_size),
|
||||
);
|
||||
|
||||
window.paint_quad(fill(caret, color));
|
||||
@@ -1242,8 +1263,7 @@ impl InputState {
|
||||
|
||||
/// The text index at a window point, using the last painted layout.
|
||||
fn index_at(&self, position: gpui::Point<Pixels>) -> usize {
|
||||
let layout = self.layout.borrow();
|
||||
let Some(layout) = layout.as_ref() else {
|
||||
let Some(layout) = self.layout.as_ref() else {
|
||||
return 0;
|
||||
};
|
||||
real_index(&layout.mask_map, layout.index_at(position))
|
||||
@@ -1256,7 +1276,11 @@ impl InputState {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.cursor = self.index_at(position);
|
||||
let cursor = self.index_at(position);
|
||||
if cursor == self.cursor {
|
||||
return;
|
||||
}
|
||||
self.cursor = cursor;
|
||||
self.notify(window, cx);
|
||||
}
|
||||
|
||||
@@ -1268,14 +1292,13 @@ impl InputState {
|
||||
color: gpui::Hsla,
|
||||
window: &mut Window,
|
||||
) {
|
||||
let layout = self.layout.borrow();
|
||||
let Some(layout) = layout.as_ref() else {
|
||||
let Some(layout) = self.layout.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let start = display_index(&layout.mask_map, range.start);
|
||||
let end = display_index(&layout.mask_map, range.end);
|
||||
let thickness = px(1.0);
|
||||
let thickness = UNDERLINE_WIDTH;
|
||||
|
||||
for (row_ix, clipped, beyond) in layout.row_overlaps(start..end) {
|
||||
let top = origin.y + layout.rows[row_ix].y + layout.line_height - thickness;
|
||||
@@ -1329,7 +1352,7 @@ impl InputState {
|
||||
|
||||
if event.click_count >= 3 {
|
||||
// Triple-click selects the clicked row, which is all of it when single-line.
|
||||
let row_range = self.layout.borrow().as_ref().and_then(|layout| {
|
||||
let row_range = self.layout.as_ref().and_then(|layout| {
|
||||
let display = display_index(&layout.mask_map, index);
|
||||
layout.row_range_at(display).map(|range| {
|
||||
real_index(&layout.mask_map, range.start)
|
||||
@@ -1462,15 +1485,16 @@ impl InputState {
|
||||
}
|
||||
|
||||
let target = {
|
||||
let layout = self.layout.borrow();
|
||||
let Some(layout) = layout.as_ref() else {
|
||||
let Some(layout) = self.layout.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let display = display_index(&layout.mask_map, self.cursor);
|
||||
|
||||
let Some((row_ix, caret_x)) = layout.row_for_index(display) else {
|
||||
return;
|
||||
};
|
||||
let target = row_ix as isize + delta;
|
||||
|
||||
let (display, goal_x) = if target < 0 {
|
||||
(0, None)
|
||||
} else if target as usize >= layout.rows.len() {
|
||||
@@ -1479,6 +1503,7 @@ impl InputState {
|
||||
let goal_x = self.goal_x.unwrap_or(caret_x);
|
||||
(layout.index_at_row_x(target as usize, goal_x), Some(goal_x))
|
||||
};
|
||||
|
||||
Some((real_index(&layout.mask_map, display), goal_x))
|
||||
};
|
||||
|
||||
@@ -1748,8 +1773,7 @@ impl EntityInputHandler for InputState {
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
let layout = self.layout.borrow();
|
||||
let layout = layout.as_ref()?;
|
||||
let layout = self.layout.as_ref()?;
|
||||
|
||||
let start = display_index(
|
||||
&layout.mask_map,
|
||||
@@ -1797,9 +1821,10 @@ impl EntityInputHandler for InputState {
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
let layout = self.layout.borrow();
|
||||
let layout = layout.as_ref()?;
|
||||
Some(real_index(&layout.mask_map, layout.index_at(window_point)).min(self.text.len()))
|
||||
let layout = self.layout.as_ref()?;
|
||||
let index =
|
||||
real_index(&layout.mask_map, layout.index_at(window_point)).min(self.text.len());
|
||||
Some(utf8_range_to_utf16(&self.text, index..index).start)
|
||||
}
|
||||
|
||||
fn text_length_utf16(
|
||||
@@ -2049,7 +2074,11 @@ impl RenderOnce for Input {
|
||||
canvas
|
||||
.with_animation(
|
||||
animation_id,
|
||||
Animation::new(CARET_BLINK_INTERVAL * 2).repeat(),
|
||||
// The blink needs four repaints per period,
|
||||
// not the display's refresh rate.
|
||||
Animation::new(CARET_BLINK_INTERVAL * 2)
|
||||
.repeat()
|
||||
.with_max_fps(4.0),
|
||||
|canvas, _| canvas,
|
||||
)
|
||||
.into_any_element()
|
||||
@@ -2172,23 +2201,13 @@ impl RenderOnce for Input {
|
||||
.when(!self.disabled, |input| {
|
||||
input
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let mouse_down_handler = self
|
||||
.state
|
||||
.read(cx)
|
||||
.mouse_down_handler
|
||||
.clone()
|
||||
.expect("input state always carries its mouse-down handler");
|
||||
let mouse_down_handler = self.state.read(cx).mouse_down_handler.clone();
|
||||
move |event: &MouseDownEvent, window, cx| {
|
||||
mouse_down_handler(event, window, cx);
|
||||
}
|
||||
})
|
||||
.on_key_down({
|
||||
let key_down_handler = self
|
||||
.state
|
||||
.read(cx)
|
||||
.key_down_handler
|
||||
.clone()
|
||||
.expect("input state always carries its key handler");
|
||||
let key_down_handler = self.state.read(cx).key_down_handler.clone();
|
||||
move |event: &KeyDownEvent, window, cx| {
|
||||
key_down_handler(event, window, cx);
|
||||
}
|
||||
@@ -2516,7 +2535,9 @@ fn utf16_to_utf8_offset(text: &str, utf16_offset: usize) -> usize {
|
||||
|
||||
/// Converts a UTF-8 byte range to a UTF-16 range.
|
||||
fn utf8_range_to_utf16(text: &str, range: Range<usize>) -> Range<usize> {
|
||||
utf16_to_utf8_offset(text, range.start)..utf16_to_utf8_offset(text, range.end)
|
||||
let start = text[..range.start].encode_utf16().count();
|
||||
let end = start + text[range.start..range.end].encode_utf16().count();
|
||||
start..end
|
||||
}
|
||||
|
||||
/// Converts a UTF-16 range to a UTF-8 byte range.
|
||||
|
||||
+15
-14
@@ -10,7 +10,7 @@ use gpui::{
|
||||
ease_out_quint, point, px, relative,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon as icon_glyph};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
use gpui_native_theme::{ActiveTheme as _, POPUP_MAX_HEIGHT};
|
||||
|
||||
use crate::common::v_flex;
|
||||
use crate::scroll_area::{ScrollArea, ScrollAxes};
|
||||
@@ -35,7 +35,7 @@ pub struct MenuItem {
|
||||
handler: Option<ItemHandler>,
|
||||
disabled: bool,
|
||||
destructive: bool,
|
||||
checked: Option<bool>,
|
||||
checked: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -58,7 +58,7 @@ impl MenuItem {
|
||||
handler: None,
|
||||
disabled: false,
|
||||
destructive: false,
|
||||
checked: None,
|
||||
checked: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ impl MenuItem {
|
||||
handler: None,
|
||||
disabled: false,
|
||||
destructive: false,
|
||||
checked: None,
|
||||
checked: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ impl MenuItem {
|
||||
|
||||
/// Shows a check slot before the label.
|
||||
pub fn checked(mut self, checked: bool) -> Self {
|
||||
self.checked = Some(checked);
|
||||
self.checked = checked;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -336,11 +336,11 @@ impl RenderOnce for Menu {
|
||||
|
||||
let items = if self.open {
|
||||
match self.items {
|
||||
Some(build) => build(window, cx),
|
||||
None => Vec::new(),
|
||||
Some(build) => Rc::from(build(window, cx)),
|
||||
None => Rc::from([]),
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
Rc::from([])
|
||||
};
|
||||
|
||||
if let Some(panel) = anchored_menu_panel(
|
||||
@@ -372,7 +372,7 @@ pub(crate) fn anchored_menu_panel(
|
||||
cx: &mut App,
|
||||
id: &ElementId,
|
||||
open: bool,
|
||||
items: Vec<MenuItem>,
|
||||
items: Rc<[MenuItem]>,
|
||||
initial_selection: Option<usize>,
|
||||
preselect: bool,
|
||||
on_change: Option<ChangeHandler>,
|
||||
@@ -382,7 +382,7 @@ pub(crate) fn anchored_menu_panel(
|
||||
chrome: &MenuChrome,
|
||||
enter_duration: std::time::Duration,
|
||||
) -> Option<AnyElement> {
|
||||
let was_open = window.use_keyed_state("menu-was-open", cx, |_, _| false);
|
||||
let was_open = window.use_keyed_state((id.clone(), "menu-was-open"), cx, |_, _| false);
|
||||
|
||||
if !open {
|
||||
was_open.update(cx, |was_open, _| *was_open = false);
|
||||
@@ -392,8 +392,9 @@ pub(crate) fn anchored_menu_panel(
|
||||
let on_change = on_change.clone();
|
||||
// Keyboard focus rides the panel across frames
|
||||
let focus = gpui_native_utils::focus_handle(&(id.clone(), "panel").into(), window, cx);
|
||||
let scroll = window.use_keyed_state("menu-scroll", cx, |_, _| ScrollHandle::new());
|
||||
let selection = window.use_keyed_state("menu-selection", cx, |_, _| None);
|
||||
let scroll =
|
||||
window.use_keyed_state((id.clone(), "menu-scroll"), cx, |_, _| ScrollHandle::new());
|
||||
let selection = window.use_keyed_state((id.clone(), "menu-selection"), cx, |_, _| None);
|
||||
|
||||
let selected = if *was_open.read(cx) {
|
||||
*selection.read(cx)
|
||||
@@ -469,7 +470,7 @@ pub(crate) fn anchored_menu_panel(
|
||||
ScrollArea::new("menu-items")
|
||||
.handle(scroll.read(cx).clone())
|
||||
.axes(ScrollAxes::Vertical)
|
||||
.max_h(px(320.))
|
||||
.max_h(POPUP_MAX_HEIGHT)
|
||||
.child(v_flex().gap_0p5().children(rows)),
|
||||
)
|
||||
.child(
|
||||
@@ -595,7 +596,7 @@ fn render_item(
|
||||
.child(shortcut),
|
||||
)
|
||||
})
|
||||
.when(checked == Some(true), |row| {
|
||||
.when(checked, |row| {
|
||||
row.child(
|
||||
icon_glyph(IconRole::Check)
|
||||
.size(chrome.dense_icon_size)
|
||||
|
||||
+20
-301
@@ -1,17 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
//! A scrollable container with an overlay scrollbar.
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, Bounds, DispatchPhase, ElementId, Entity, EntityId, InteractiveElement as _, IntoElement,
|
||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, RenderOnce, ScrollEvent,
|
||||
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled, Task, Window, canvas, div,
|
||||
fill, point,
|
||||
App, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce, ScrollHandle,
|
||||
SharedString, StatefulInteractiveElement as _, Styled, Window, div,
|
||||
};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
|
||||
use crate::common::StyledExt;
|
||||
use crate::scrollbar::{Scrollbar, ScrollbarVisibility};
|
||||
|
||||
/// Which directions the content scrolls in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
@@ -25,108 +21,6 @@ pub enum ScrollAxes {
|
||||
Both,
|
||||
}
|
||||
|
||||
/// When an overlay scrollbar shows.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ScrollbarVisibility {
|
||||
/// The running platform's behavior, from the theme.
|
||||
#[default]
|
||||
Platform,
|
||||
Always,
|
||||
Autohide,
|
||||
}
|
||||
|
||||
/// How long an autohide thumb stays fully visible before its fade begins.
|
||||
const AUTOHIDE_DELAY: Duration = Duration::from_millis(700);
|
||||
/// How long the autohide fade runs.
|
||||
const AUTOHIDE_FADE: Duration = Duration::from_millis(250);
|
||||
/// How often the fade repaints while it runs.
|
||||
const AUTOHIDE_FADE_STEP: Duration = Duration::from_millis(50);
|
||||
|
||||
/// The shared autohide timeline for an overlay scrollbar.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Autohide {
|
||||
view: EntityId,
|
||||
state: Entity<AutohideState>,
|
||||
}
|
||||
|
||||
struct AutohideState {
|
||||
last_activity: Option<Instant>,
|
||||
hide_task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl Autohide {
|
||||
pub(crate) fn new(key: ElementId, window: &mut Window, cx: &mut App) -> Self {
|
||||
let view = window.current_view();
|
||||
let state = window.use_keyed_state(key, cx, |_, _| AutohideState {
|
||||
last_activity: None,
|
||||
hide_task: None,
|
||||
});
|
||||
Self { view, state }
|
||||
}
|
||||
|
||||
/// Marks interaction.
|
||||
pub(crate) fn touch(&self, window: &mut Window, cx: &mut App) {
|
||||
let view = self.view;
|
||||
let reduce_motion = cx.theme().accessibility.reduce_motion;
|
||||
|
||||
let task = window.spawn(cx, async move |cx| {
|
||||
if reduce_motion {
|
||||
cx.background_executor()
|
||||
.timer(AUTOHIDE_DELAY + AUTOHIDE_FADE)
|
||||
.await;
|
||||
cx.update(|_, cx| cx.notify(view)).ok();
|
||||
return;
|
||||
}
|
||||
|
||||
let steps = (AUTOHIDE_FADE.as_millis() / AUTOHIDE_FADE_STEP.as_millis()).max(1);
|
||||
cx.background_executor().timer(AUTOHIDE_DELAY).await;
|
||||
|
||||
for _ in 0..steps {
|
||||
cx.update(|_, cx| cx.notify(view)).ok();
|
||||
cx.background_executor().timer(AUTOHIDE_FADE_STEP).await;
|
||||
}
|
||||
|
||||
cx.update(|_, cx| cx.notify(view)).ok();
|
||||
});
|
||||
|
||||
self.state.update(cx, |state, _| {
|
||||
state.last_activity = Some(Instant::now());
|
||||
state.hide_task = Some(task);
|
||||
});
|
||||
}
|
||||
|
||||
/// The thumb's opacity.
|
||||
pub(crate) fn alpha(&self, cx: &App, dragging: bool) -> f32 {
|
||||
if dragging {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let Some(activity) = self.state.read(cx).last_activity else {
|
||||
return 0.0;
|
||||
};
|
||||
|
||||
let idle = activity.elapsed();
|
||||
if idle <= AUTOHIDE_DELAY {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let fade = idle.saturating_sub(AUTOHIDE_DELAY);
|
||||
if fade >= AUTOHIDE_FADE {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
1.0 - fade.as_secs_f32() / AUTOHIDE_FADE.as_secs_f32()
|
||||
}
|
||||
}
|
||||
|
||||
/// An overlay scrollbar thumb's style for one paint: `None` hides it.
|
||||
pub(crate) fn thumb_style(color: gpui::Hsla, alpha: f32) -> Option<gpui::Hsla> {
|
||||
(alpha > 0.0).then_some(gpui::Hsla {
|
||||
a: color.a * alpha,
|
||||
..color
|
||||
})
|
||||
}
|
||||
|
||||
/// A scrollable container with an overlay scrollbar.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ScrollArea {
|
||||
@@ -196,32 +90,8 @@ impl ScrollArea {
|
||||
}
|
||||
}
|
||||
|
||||
/// Names keyed state under the scrollbar's own id.
|
||||
pub fn scrollbar_state_key(name: &'static str, id: &ElementId) -> ElementId {
|
||||
ElementId::NamedChild(Arc::new(id.clone()), SharedString::from(name))
|
||||
}
|
||||
|
||||
/// Pure thumb geometry for an overlay scrollbar.
|
||||
pub(crate) fn thumb_geometry(
|
||||
viewport: Pixels,
|
||||
content: Pixels,
|
||||
offset: Pixels,
|
||||
track: Pixels,
|
||||
min_thumb: Pixels,
|
||||
) -> (Pixels, Pixels) {
|
||||
if content <= viewport || content <= Pixels::ZERO {
|
||||
return (track, Pixels::ZERO);
|
||||
}
|
||||
|
||||
let length = (viewport / content * track).max(min_thumb).min(track);
|
||||
let overflow = content - viewport;
|
||||
let progress = (-offset / overflow).clamp(0., 1.);
|
||||
let travel = track - length;
|
||||
(length, travel * progress)
|
||||
}
|
||||
|
||||
impl RenderOnce for ScrollArea {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let mut pane_style = self.style.clone();
|
||||
pane_style.padding = Default::default();
|
||||
|
||||
@@ -252,175 +122,24 @@ impl RenderOnce for ScrollArea {
|
||||
|
||||
if let Some(handle) = self.handle {
|
||||
let id = self.id.clone();
|
||||
let autohide = match self.visibility {
|
||||
ScrollbarVisibility::Platform => cx.theme().scrollbar.autohide,
|
||||
ScrollbarVisibility::Always => false,
|
||||
ScrollbarVisibility::Autohide => true,
|
||||
};
|
||||
|
||||
wrapper.extend(std::iter::once(
|
||||
canvas(|bounds, _, _| bounds, {
|
||||
let handle = handle.clone();
|
||||
move |overlay, _, window, cx| {
|
||||
paint_scrollbar(&handle, &id, autohide, overlay, window, cx);
|
||||
}
|
||||
})
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.into_any_element(),
|
||||
));
|
||||
if self.axes != ScrollAxes::Horizontal {
|
||||
wrapper.extend(std::iter::once(
|
||||
Scrollbar::vertical((id.clone(), "v-scrollbar"), handle.clone())
|
||||
.visibility(self.visibility)
|
||||
.into_any_element(),
|
||||
));
|
||||
}
|
||||
|
||||
if self.axes != ScrollAxes::Vertical {
|
||||
wrapper.extend(std::iter::once(
|
||||
Scrollbar::horizontal((id, "h-scrollbar"), handle)
|
||||
.visibility(self.visibility)
|
||||
.into_any_element(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
wrapper
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints the overlay scrollbar thumb.
|
||||
fn paint_scrollbar(
|
||||
handle: &ScrollHandle,
|
||||
key: &ElementId,
|
||||
autohide: bool,
|
||||
overlay: Bounds<Pixels>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let theme = cx.theme();
|
||||
let view = window.current_view();
|
||||
|
||||
let color = theme.colors.scrollbar_thumb;
|
||||
let width = theme.scrollbar.width;
|
||||
let inset = theme.scrollbar.inset;
|
||||
let min_thumb = theme.scrollbar.min_thumb;
|
||||
|
||||
let viewport = handle.bounds();
|
||||
let max_offset = handle.max_offset().y;
|
||||
|
||||
if max_offset <= Pixels::ZERO || viewport.size.height <= Pixels::ZERO {
|
||||
return;
|
||||
}
|
||||
|
||||
let autohide_state =
|
||||
autohide.then(|| Autohide::new(scrollbar_state_key("autohide", key), window, cx));
|
||||
let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", key), cx, |_, _| {
|
||||
Option::<(Pixels, Pixels)>::None
|
||||
});
|
||||
let dragging = drag.read(cx).is_some();
|
||||
|
||||
let alpha = match (&autohide_state, autohide) {
|
||||
(Some(state), true) => state.alpha(cx, dragging),
|
||||
_ => 1.0,
|
||||
};
|
||||
|
||||
let track = overlay.size.height - inset * 2.0;
|
||||
let (thumb_length, thumb_top) = thumb_geometry(
|
||||
viewport.size.height,
|
||||
viewport.size.height + max_offset,
|
||||
handle.offset().y,
|
||||
track,
|
||||
min_thumb,
|
||||
);
|
||||
|
||||
let thumb_x = overlay.right() - width - inset;
|
||||
let thumb_bounds = Bounds::from_corners(
|
||||
point(thumb_x, overlay.top() + inset + thumb_top),
|
||||
point(
|
||||
thumb_x + width,
|
||||
overlay.top() + inset + thumb_top + thumb_length,
|
||||
),
|
||||
);
|
||||
|
||||
if let Some(color) = thumb_style(color, alpha) {
|
||||
window.paint_quad(fill(thumb_bounds, color).corner_radii(width / 2.0));
|
||||
}
|
||||
|
||||
if let Some(state) = &autohide_state {
|
||||
window.on_mouse_event({
|
||||
let state = state.clone();
|
||||
move |event: &ScrollEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble || !overlay.contains(&event.position) {
|
||||
return;
|
||||
}
|
||||
state.touch(window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.on_mouse_event({
|
||||
let handle = handle.clone();
|
||||
let drag = drag.clone();
|
||||
let autohide_state = autohide_state.clone();
|
||||
move |event: &MouseDownEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble
|
||||
|| event.button != gpui::MouseButton::Left
|
||||
|| !thumb_bounds.contains(&event.position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
drag.update(cx, |drag, _| {
|
||||
*drag = Some((event.position.y, handle.offset().y));
|
||||
});
|
||||
|
||||
if let Some(state) = &autohide_state {
|
||||
state.touch(window, cx);
|
||||
}
|
||||
|
||||
cx.stop_propagation();
|
||||
}
|
||||
});
|
||||
|
||||
window.on_mouse_event({
|
||||
let drag = drag.clone();
|
||||
let autohide_state = autohide_state.clone();
|
||||
move |event: &MouseUpEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble || event.button != gpui::MouseButton::Left {
|
||||
return;
|
||||
}
|
||||
|
||||
if drag.read(cx).is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
drag.update(cx, |drag, _| {
|
||||
*drag = None;
|
||||
});
|
||||
|
||||
if let Some(state) = &autohide_state {
|
||||
state.touch(window, cx);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.on_mouse_event({
|
||||
let handle = handle.clone();
|
||||
let drag = drag.clone();
|
||||
let autohide_state = autohide_state.clone();
|
||||
move |event: &MouseMoveEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(state) = &autohide_state
|
||||
&& drag.read(cx).is_some()
|
||||
{
|
||||
state.touch(window, cx);
|
||||
}
|
||||
|
||||
if event.pressed_button != Some(gpui::MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((start_y, start_offset)) = *drag.read(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if max_offset > Pixels::ZERO && track > thumb_length {
|
||||
let scale = max_offset / (track - thumb_length);
|
||||
let offset = (start_offset - (event.position.y - start_y) * scale)
|
||||
.clamp(-max_offset, Pixels::ZERO);
|
||||
handle.set_offset(point(handle.offset().x, offset));
|
||||
cx.notify(view);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+143
-28
@@ -1,18 +1,141 @@
|
||||
//! Overlay scrollbars for scroll surfaces.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, DispatchPhase, ElementId, IntoElement, ListState, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, MouseUpEvent, Pixels, Point, RenderOnce, ScrollEvent, ScrollHandle,
|
||||
Styled as _, UniformListScrollHandle, Window, canvas, fill, point,
|
||||
App, Bounds, DispatchPhase, ElementId, Entity, EntityId, IntoElement, ListState, MouseButton,
|
||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, RenderOnce, ScrollEvent,
|
||||
ScrollHandle, SharedString, Styled as _, Task, UniformListScrollHandle, Window, canvas, fill,
|
||||
point,
|
||||
};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
|
||||
use crate::scroll_area::{Autohide, scrollbar_state_key, thumb_geometry, thumb_style};
|
||||
/// When an overlay scrollbar shows.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ScrollbarVisibility {
|
||||
/// The running platform's behavior, from the theme.
|
||||
#[default]
|
||||
Platform,
|
||||
Always,
|
||||
Autohide,
|
||||
}
|
||||
|
||||
/// Re-exported so callers only need the scrollbar module.
|
||||
pub use crate::scroll_area::ScrollbarVisibility;
|
||||
/// How long an autohide thumb stays fully visible before its fade begins.
|
||||
const AUTOHIDE_DELAY: Duration = Duration::from_millis(700);
|
||||
/// How long the autohide fade runs.
|
||||
const AUTOHIDE_FADE: Duration = Duration::from_millis(250);
|
||||
/// How often the fade repaints while it runs.
|
||||
const AUTOHIDE_FADE_STEP: Duration = Duration::from_millis(50);
|
||||
|
||||
/// The shared autohide timeline for an overlay scrollbar.
|
||||
#[derive(Clone)]
|
||||
struct Autohide {
|
||||
view: EntityId,
|
||||
state: Entity<AutohideState>,
|
||||
}
|
||||
|
||||
struct AutohideState {
|
||||
last_activity: Option<Instant>,
|
||||
hide_task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl Autohide {
|
||||
fn new(key: ElementId, window: &mut Window, cx: &mut App) -> Self {
|
||||
let view = window.current_view();
|
||||
let state = window.use_keyed_state(key, cx, |_, _| AutohideState {
|
||||
last_activity: None,
|
||||
hide_task: None,
|
||||
});
|
||||
Self { view, state }
|
||||
}
|
||||
|
||||
/// Marks interaction.
|
||||
fn touch(&self, window: &mut Window, cx: &mut App) {
|
||||
let view = self.view;
|
||||
let reduce_motion = cx.theme().accessibility.reduce_motion;
|
||||
|
||||
let task = window.spawn(cx, async move |cx| {
|
||||
if reduce_motion {
|
||||
cx.background_executor()
|
||||
.timer(AUTOHIDE_DELAY + AUTOHIDE_FADE)
|
||||
.await;
|
||||
cx.update(|_, cx| cx.notify(view)).ok();
|
||||
return;
|
||||
}
|
||||
|
||||
let steps = (AUTOHIDE_FADE.as_millis() / AUTOHIDE_FADE_STEP.as_millis()).max(1);
|
||||
cx.background_executor().timer(AUTOHIDE_DELAY).await;
|
||||
|
||||
for _ in 0..steps {
|
||||
cx.update(|_, cx| cx.notify(view)).ok();
|
||||
cx.background_executor().timer(AUTOHIDE_FADE_STEP).await;
|
||||
}
|
||||
|
||||
cx.update(|_, cx| cx.notify(view)).ok();
|
||||
});
|
||||
|
||||
self.state.update(cx, |state, _| {
|
||||
state.last_activity = Some(Instant::now());
|
||||
state.hide_task = Some(task);
|
||||
});
|
||||
}
|
||||
|
||||
/// The thumb's opacity.
|
||||
fn alpha(&self, cx: &App, dragging: bool) -> f32 {
|
||||
if dragging {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let Some(activity) = self.state.read(cx).last_activity else {
|
||||
return 0.0;
|
||||
};
|
||||
|
||||
let idle = activity.elapsed();
|
||||
if idle <= AUTOHIDE_DELAY {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let fade = idle.saturating_sub(AUTOHIDE_DELAY);
|
||||
if fade >= AUTOHIDE_FADE {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
1.0 - fade.as_secs_f32() / AUTOHIDE_FADE.as_secs_f32()
|
||||
}
|
||||
}
|
||||
|
||||
fn scrollbar_state_key(name: &'static str, id: &ElementId) -> ElementId {
|
||||
ElementId::NamedChild(Arc::new(id.clone()), SharedString::from(name))
|
||||
}
|
||||
|
||||
/// Pure thumb geometry for an overlay scrollbar.
|
||||
fn thumb_geometry(
|
||||
viewport: Pixels,
|
||||
content: Pixels,
|
||||
offset: Pixels,
|
||||
track: Pixels,
|
||||
min_thumb: Pixels,
|
||||
) -> (Pixels, Pixels) {
|
||||
if content <= viewport || content <= Pixels::ZERO {
|
||||
return (track, Pixels::ZERO);
|
||||
}
|
||||
|
||||
let length = (viewport / content * track).max(min_thumb).min(track);
|
||||
let overflow = content - viewport;
|
||||
let progress = (-offset / overflow).clamp(0., 1.);
|
||||
let travel = track - length;
|
||||
(length, travel * progress)
|
||||
}
|
||||
|
||||
/// An overlay scrollbar thumb's style for one paint: `None` hides it.
|
||||
fn thumb_style(color: gpui::Hsla, alpha: f32) -> Option<gpui::Hsla> {
|
||||
(alpha > 0.0).then_some(gpui::Hsla {
|
||||
a: color.a * alpha,
|
||||
..color
|
||||
})
|
||||
}
|
||||
|
||||
/// The scroll state a scrollbar drives, implemented for gpui's scroll handles.
|
||||
pub trait ScrollbarHandle: 'static {
|
||||
@@ -128,33 +251,35 @@ pub struct Scrollbar {
|
||||
axis: Axis,
|
||||
handle: Rc<dyn ScrollbarHandle>,
|
||||
visibility: ScrollbarVisibility,
|
||||
key: ElementId,
|
||||
id: ElementId,
|
||||
}
|
||||
|
||||
impl Scrollbar {
|
||||
/// A vertical scrollbar driven by `handle`.
|
||||
pub fn vertical<H>(handle: H) -> Self
|
||||
/// A vertical scrollbar named by `id`, driven by `handle`.
|
||||
pub fn vertical<I, H>(id: I, handle: H) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
H: ScrollbarHandle,
|
||||
{
|
||||
Self {
|
||||
axis: Axis::Vertical,
|
||||
handle: Rc::new(handle),
|
||||
visibility: ScrollbarVisibility::default(),
|
||||
key: ElementId::Name("scrollbar".into()),
|
||||
id: id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A horizontal scrollbar driven by `handle`.
|
||||
pub fn horizontal<H>(handle: H) -> Self
|
||||
/// A horizontal scrollbar named by `id`, driven by `handle`.
|
||||
pub fn horizontal<I, H>(id: I, handle: H) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
H: ScrollbarHandle,
|
||||
{
|
||||
Self {
|
||||
axis: Axis::Horizontal,
|
||||
handle: Rc::new(handle),
|
||||
visibility: ScrollbarVisibility::default(),
|
||||
key: ElementId::Name("scrollbar".into()),
|
||||
id: id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,12 +288,6 @@ impl Scrollbar {
|
||||
self.visibility = visibility;
|
||||
self
|
||||
}
|
||||
|
||||
/// Names this scrollbar's state.
|
||||
pub fn key(mut self, key: impl Into<ElementId>) -> Self {
|
||||
self.key = key.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Scrollbar {
|
||||
@@ -177,8 +296,8 @@ impl RenderOnce for Scrollbar {
|
||||
let handle = self.handle;
|
||||
let axis = self.axis;
|
||||
let visibility = self.visibility;
|
||||
let key = self.key;
|
||||
move |_, _, window, cx| paint_scrollbar(&handle, axis, visibility, &key, window, cx)
|
||||
let id = self.id;
|
||||
move |_, _, window, cx| paint_scrollbar(&handle, axis, visibility, &id, window, cx)
|
||||
})
|
||||
.absolute()
|
||||
.inset_0()
|
||||
@@ -192,7 +311,7 @@ fn paint_scrollbar(
|
||||
handle: &Rc<dyn ScrollbarHandle>,
|
||||
axis: Axis,
|
||||
visibility: ScrollbarVisibility,
|
||||
key: &ElementId,
|
||||
id: &ElementId,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
@@ -217,12 +336,10 @@ fn paint_scrollbar(
|
||||
ScrollbarVisibility::Always => false,
|
||||
ScrollbarVisibility::Autohide => true,
|
||||
};
|
||||
|
||||
let autohide_state =
|
||||
autohide.then(|| Autohide::new(scrollbar_state_key("autohide", key), window, cx));
|
||||
autohide.then(|| Autohide::new(scrollbar_state_key("autohide", id), window, cx));
|
||||
|
||||
let track_length = viewport_along - inset * 2.0;
|
||||
|
||||
let (thumb_length, thumb_travel) = thumb_geometry(
|
||||
viewport_along,
|
||||
viewport_along + max_offset,
|
||||
@@ -267,9 +384,7 @@ fn paint_scrollbar(
|
||||
}
|
||||
};
|
||||
|
||||
let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", key), cx, |_, _| {
|
||||
Drag::None
|
||||
});
|
||||
let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", id), cx, |_, _| Drag::None);
|
||||
let dragging = drag.read(cx).is_some();
|
||||
|
||||
let alpha = match &autohide_state {
|
||||
|
||||
@@ -9,7 +9,7 @@ use gpui::{
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
use gpui_native_utils::{adjacent_selectable, focus_handle, focus_ring};
|
||||
use gpui_native_utils::{focus_handle, focus_ring};
|
||||
|
||||
use crate::common::{Sizable, StyledExt, h_flex};
|
||||
|
||||
@@ -64,7 +64,8 @@ where
|
||||
T: Clone + PartialEq + 'static,
|
||||
{
|
||||
id: ElementId,
|
||||
options: Vec<SegmentedOption<T>>,
|
||||
/// Shared with the per-segment and key handlers, so captures are bumps.
|
||||
options: Rc<[SegmentedOption<T>]>,
|
||||
selected: T,
|
||||
a11y_label: Option<SharedString>,
|
||||
size: Size,
|
||||
@@ -82,7 +83,7 @@ where
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
options: Vec::new(),
|
||||
options: Rc::from([]),
|
||||
selected,
|
||||
a11y_label: None,
|
||||
size: Size::Small,
|
||||
@@ -163,12 +164,12 @@ where
|
||||
.options
|
||||
.iter()
|
||||
.position(|segment| segment.value == self.selected);
|
||||
let option_count = self.options.len();
|
||||
|
||||
let selectable: Vec<usize> = (0..self.options.len()).collect();
|
||||
let on_change = self.on_change.clone();
|
||||
let key_on_change = on_change.clone();
|
||||
let key_options = self.options.clone();
|
||||
let key_selectable = selectable.clone();
|
||||
let options = self.options.clone();
|
||||
|
||||
let label = self
|
||||
.a11y_label
|
||||
@@ -176,14 +177,13 @@ where
|
||||
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
|
||||
|
||||
window.with_element_namespace(self.id.clone(), |window| {
|
||||
let focus = focus_handle(&self.id, window, cx);
|
||||
|
||||
let geometry: Entity<Vec<Option<Bounds<gpui::Pixels>>>> =
|
||||
window.use_keyed_state("segmented-geometry", cx, |_, _| Vec::new());
|
||||
|
||||
let track_origin: Entity<Option<Point<gpui::Pixels>>> =
|
||||
window.use_keyed_state("segmented-track-origin", cx, |_, _| None);
|
||||
|
||||
let focus = focus_handle(&self.id, window, cx);
|
||||
let anim_config = SpringConfig::new(500., 35., 1.);
|
||||
let track_point = *track_origin.read(cx);
|
||||
|
||||
@@ -197,12 +197,13 @@ where
|
||||
|
||||
let segments = self
|
||||
.options
|
||||
.into_iter()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(move |(index, segment)| {
|
||||
let is_selected = Some(index) == selected_index;
|
||||
let on_change = on_change.clone();
|
||||
let value = segment.value;
|
||||
let options = options.clone();
|
||||
|
||||
let content_color = if is_selected {
|
||||
foreground
|
||||
} else {
|
||||
@@ -228,7 +229,7 @@ where
|
||||
.when_some(segment.icon, |segment, role| {
|
||||
segment.child(icon(role).size(icon_size).color(content_color))
|
||||
})
|
||||
.child(segment.label)
|
||||
.child(segment.label.clone())
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
@@ -251,8 +252,9 @@ where
|
||||
move |_, window, cx| {
|
||||
if Some(index) != selected_index
|
||||
&& let Some(on_change) = &on_change
|
||||
&& let Some(option) = options.get(index)
|
||||
{
|
||||
on_change(value.clone(), window, cx);
|
||||
on_change(option.value.clone(), window, cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -319,22 +321,26 @@ where
|
||||
.on_key_down({
|
||||
move |event: &KeyDownEvent, window, cx| {
|
||||
let target = match event.keystroke.key.as_str() {
|
||||
"right" => {
|
||||
adjacent_selectable(&key_selectable, selected_index, true)
|
||||
}
|
||||
"left" => {
|
||||
adjacent_selectable(&key_selectable, selected_index, false)
|
||||
}
|
||||
"home" => key_selectable.first().copied(),
|
||||
"end" => key_selectable.last().copied(),
|
||||
"right" => match selected_index {
|
||||
Some(index) => {
|
||||
index.checked_add(1).filter(|next| *next < option_count)
|
||||
}
|
||||
None => (option_count > 0).then_some(0),
|
||||
},
|
||||
"left" => selected_index.and_then(|index| index.checked_sub(1)),
|
||||
"home" => (option_count > 0).then_some(0),
|
||||
"end" => option_count.checked_sub(1),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
if let Some(next) = target
|
||||
&& Some(next) != selected_index
|
||||
&& let Some(on_change) = &key_on_change
|
||||
&& let Some(option) = key_options.get(next)
|
||||
{
|
||||
on_change(key_options[next].value.clone(), window, cx);
|
||||
on_change(option.value.clone(), window, cx);
|
||||
}
|
||||
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
|
||||
@@ -206,24 +206,18 @@ where
|
||||
.position(|option| &option.value == value)
|
||||
});
|
||||
|
||||
let trigger_options = self.options.clone();
|
||||
let trigger_selected = self.selected.clone();
|
||||
let trigger_placeholder = self.placeholder.clone();
|
||||
// The trigger's text is fixed at render time; only it moves into the closure.
|
||||
let trigger_text = selected_index
|
||||
.and_then(|index| self.options.get(index))
|
||||
.map(|option| option.label.clone())
|
||||
.unwrap_or_else(|| self.placeholder.clone());
|
||||
|
||||
let trigger_has_value = self.selected.is_some();
|
||||
let trigger_id = self.id.clone();
|
||||
let trigger_label = label.clone();
|
||||
let trigger_style = self.style;
|
||||
|
||||
let trigger = move |_window: &mut Window, _cx: &mut App| {
|
||||
let text = trigger_selected
|
||||
.as_ref()
|
||||
.and_then(|value| {
|
||||
trigger_options
|
||||
.iter()
|
||||
.find(|option| &option.value == value)
|
||||
.map(|option| option.label.clone())
|
||||
})
|
||||
.unwrap_or_else(|| trigger_placeholder.clone());
|
||||
|
||||
h_flex()
|
||||
.id((trigger_id.clone(), "select-trigger"))
|
||||
.debug_selector(|| "select-trigger".to_string())
|
||||
@@ -240,7 +234,7 @@ where
|
||||
.border_color(rest_border)
|
||||
.bg(input)
|
||||
.text_size(metrics.text_size)
|
||||
.text_color(if trigger_selected.is_some() {
|
||||
.text_color(if trigger_has_value {
|
||||
foreground
|
||||
} else {
|
||||
muted_foreground
|
||||
@@ -249,7 +243,7 @@ where
|
||||
trigger.shadow(focus_ring(ring))
|
||||
})
|
||||
.hover(|style| style.bg(muted).border_color(hover_border))
|
||||
.child(text)
|
||||
.child(trigger_text.clone())
|
||||
.child(
|
||||
icon(IconRole::ChevronDown)
|
||||
.size(icon_size)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{App, InteractiveElement as _, IntoElement, RenderOnce, Styled as _, Window, div, px};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
use gpui::{App, InteractiveElement as _, IntoElement, RenderOnce, Styled as _, Window, div};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH};
|
||||
|
||||
/// Which axis the rule runs along.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -69,8 +69,8 @@ impl RenderOnce for Separator {
|
||||
rule.debug_selector(move || selector())
|
||||
})
|
||||
.map(|rule| match self.orientation {
|
||||
SeparatorOrientation::Horizontal => rule.w_full().h(px(1.)),
|
||||
SeparatorOrientation::Vertical => rule.self_stretch().w(px(1.)),
|
||||
SeparatorOrientation::Horizontal => rule.w_full().h(BORDER_WIDTH),
|
||||
SeparatorOrientation::Vertical => rule.self_stretch().w(BORDER_WIDTH),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,12 +79,6 @@ impl Slider {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the current value; the slider never owns it.
|
||||
pub fn value(mut self, value: f32) -> Self {
|
||||
self.value = value;
|
||||
self
|
||||
}
|
||||
|
||||
/// Renders the slider inert, unfocusable, and dimmed; disabled always wins.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
@@ -135,16 +129,12 @@ impl RenderOnce for Slider {
|
||||
0.0
|
||||
};
|
||||
|
||||
// The track bounds, recorded by the overlay canvas each frame,
|
||||
// turn pointer positions into values inside the listeners.
|
||||
let bounds_cell = Rc::new(Cell::new(None::<Bounds<Pixels>>));
|
||||
// Dragging crosses frames, so it lives in keyed element state,
|
||||
// a per-render flag would reset when the owner's update repaints.
|
||||
let dragging = window.use_keyed_state("slider-dragging", cx, |_, _| false);
|
||||
// A held handle gives the arrow-key listener something to check focus against,
|
||||
// and the click-to-focus behavior a target.
|
||||
let focus_handle = gpui_native_utils::focus_handle(&self.id, window, cx);
|
||||
|
||||
let dragging =
|
||||
window.use_keyed_state((self.id.clone(), "slider-dragging"), cx, |_, _| false);
|
||||
|
||||
let set_value: ValueSetter = {
|
||||
let bounds_cell = bounds_cell.clone();
|
||||
let on_change = self.on_change.clone();
|
||||
|
||||
@@ -133,10 +133,7 @@ impl RenderOnce for Switch {
|
||||
div()
|
||||
.flex_none()
|
||||
.relative()
|
||||
.debug_selector({
|
||||
let name = format!("{id}-track");
|
||||
move || name
|
||||
})
|
||||
.debug_selector(move || format!("{id}-track"))
|
||||
.w(track_width)
|
||||
.h(track_height)
|
||||
.rounded(theme.radius.full)
|
||||
|
||||
+13
-11
@@ -1,9 +1,9 @@
|
||||
use gpui::AppContext as _;
|
||||
use gpui::{
|
||||
AnyView, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Pixels, Render, SharedString, Styled as _, Window, canvas, div, point, px,
|
||||
ParentElement as _, Pixels, Render, SharedString, Styled as _, Window, canvas, div, point,
|
||||
};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, TOOLTIP_MAX_WIDTH, TOOLTIP_OFFSET};
|
||||
use gpui_native_utils::{anchor_bounds, record_anchor_bounds};
|
||||
|
||||
const PANEL_BOUNDS_ID: &str = "tooltip-panel-bounds";
|
||||
@@ -34,8 +34,8 @@ impl Tooltip {
|
||||
{
|
||||
let text = text.into();
|
||||
let anchor_id = anchor_id.into();
|
||||
move |_window, cx| {
|
||||
let anchor = anchor_bounds(&anchor_id, cx);
|
||||
move |window, cx| {
|
||||
let anchor = anchor_bounds(window.window_handle().window_id(), &anchor_id, cx);
|
||||
cx.new(|_| Self {
|
||||
text: text.clone(),
|
||||
anchor,
|
||||
@@ -76,7 +76,7 @@ impl Render for Tooltip {
|
||||
.debug_selector(|| "tooltip-panel".to_string())
|
||||
.px_2()
|
||||
.py_1()
|
||||
.max_w(px(360.))
|
||||
.max_w(TOOLTIP_MAX_WIDTH)
|
||||
.bg(surface)
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
@@ -89,14 +89,16 @@ impl Render for Tooltip {
|
||||
|
||||
let (wrapper, panel) = match self.anchor {
|
||||
Some(parent) => {
|
||||
let origin = window.mouse_position() + point(px(1.), px(1.));
|
||||
let origin = window.mouse_position() + point(TOOLTIP_OFFSET, TOOLTIP_OFFSET);
|
||||
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 height = anchor_bounds(
|
||||
window.window_handle().window_id(),
|
||||
&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;
|
||||
|
||||
@@ -322,3 +322,136 @@ fn disabled_trigger_ignores_clicks() {
|
||||
assert!(changes.borrow().is_empty());
|
||||
assert!(visual.debug_bounds("menu-panel").is_none());
|
||||
}
|
||||
|
||||
struct PairHarness {
|
||||
open_a: bool,
|
||||
open_b: bool,
|
||||
changes_a: Changes,
|
||||
changes_b: Changes,
|
||||
activated_a: Activated,
|
||||
activated_b: Activated,
|
||||
}
|
||||
|
||||
impl Render for PairHarness {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (changes_a, changes_b) = (self.changes_a.clone(), self.changes_b.clone());
|
||||
let (activated_a, activated_b) = (self.activated_a.clone(), self.activated_b.clone());
|
||||
|
||||
let menu_a = DropdownMenu::new("pair-a")
|
||||
.label("Alpha")
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.open(self.open_a)
|
||||
.on_open_change({
|
||||
let view = cx.entity();
|
||||
let changes = changes_a.clone();
|
||||
move |next, _, cx| {
|
||||
changes.borrow_mut().push(next);
|
||||
view.update(cx, |harness, cx| {
|
||||
harness.open_a = next;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.items(move |_, _| {
|
||||
vec![
|
||||
MenuItem::new("png").handler({
|
||||
let activated = activated_a.clone();
|
||||
move |_, _| activated.borrow_mut().push("png")
|
||||
}),
|
||||
MenuItem::new("pdf").handler({
|
||||
let activated = activated_a.clone();
|
||||
move |_, _| activated.borrow_mut().push("pdf")
|
||||
}),
|
||||
]
|
||||
});
|
||||
|
||||
let menu_b = DropdownMenu::new("pair-b")
|
||||
.label("Beta")
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.open(self.open_b)
|
||||
.on_open_change({
|
||||
let view = cx.entity();
|
||||
let changes = changes_b.clone();
|
||||
move |next, _, cx| {
|
||||
changes.borrow_mut().push(next);
|
||||
view.update(cx, |harness, cx| {
|
||||
harness.open_b = next;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.items(move |_, _| {
|
||||
vec![
|
||||
MenuItem::new("zip").handler({
|
||||
let activated = activated_b.clone();
|
||||
move |_, _| activated.borrow_mut().push("zip")
|
||||
}),
|
||||
MenuItem::new("7z").handler({
|
||||
let activated = activated_b.clone();
|
||||
move |_, _| activated.borrow_mut().push("7z")
|
||||
}),
|
||||
]
|
||||
});
|
||||
|
||||
div().size_full().flex().gap_2().child(menu_a).child(menu_b)
|
||||
}
|
||||
}
|
||||
|
||||
fn pair_window() -> (
|
||||
VisualTestContext,
|
||||
AnyWindowHandle,
|
||||
Changes,
|
||||
Changes,
|
||||
Activated,
|
||||
Activated,
|
||||
) {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(Theme::init);
|
||||
let changes_a = Rc::new(RefCell::new(Vec::new()));
|
||||
let changes_b = Rc::new(RefCell::new(Vec::new()));
|
||||
let activated_a = Rc::new(RefCell::new(Vec::new()));
|
||||
let activated_b = Rc::new(RefCell::new(Vec::new()));
|
||||
let (changes_a_for_window, changes_b_for_window) = (changes_a.clone(), changes_b.clone());
|
||||
let (activated_a_for_window, activated_b_for_window) =
|
||||
(activated_a.clone(), activated_b.clone());
|
||||
let window = cx.add_window(move |_, _| PairHarness {
|
||||
open_a: false,
|
||||
open_b: false,
|
||||
changes_a: changes_a_for_window,
|
||||
changes_b: changes_b_for_window,
|
||||
activated_a: activated_a_for_window,
|
||||
activated_b: activated_b_for_window,
|
||||
});
|
||||
let window = window.into();
|
||||
cx.update_window(window, |_, window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
})
|
||||
.unwrap();
|
||||
(
|
||||
VisualTestContext::from_window(window, &cx),
|
||||
window,
|
||||
changes_a,
|
||||
changes_b,
|
||||
activated_a,
|
||||
activated_b,
|
||||
)
|
||||
}
|
||||
|
||||
/// With a closed sibling dropdown on screen, a pointer-opened menu must still
|
||||
/// arm rows with the keyboard: the menu's open/selection state is per
|
||||
/// instance, not shared keyed state.
|
||||
#[test]
|
||||
fn sibling_dropdowns_keep_independent_menu_state() {
|
||||
let (mut visual, window, changes_a, changes_b, activated_a, activated_b) = pair_window();
|
||||
|
||||
// Menu A sits at the window's top-left; menu B stays closed beside it.
|
||||
click(&mut visual, point(px(30.), px(14.)));
|
||||
assert_eq!(*changes_a.borrow(), vec![true]);
|
||||
draw(&mut visual, window);
|
||||
|
||||
visual.simulate_keystrokes("down enter");
|
||||
assert_eq!(*activated_a.borrow(), vec!["png"]);
|
||||
assert_eq!(*changes_a.borrow(), vec![true, false]);
|
||||
assert!(changes_b.borrow().is_empty(), "B must stay closed");
|
||||
assert!(activated_b.borrow().is_empty());
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::{cell::Cell, rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
||||
Styled as _, TestAppContext, VisualTestContext, Window, div,
|
||||
@@ -9,10 +11,12 @@ use gpui_native_core::input::{Input, InputState};
|
||||
|
||||
struct FieldHost {
|
||||
url: Entity<InputState>,
|
||||
renders: Rc<Cell<usize>>,
|
||||
}
|
||||
|
||||
impl Render for FieldHost {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
self.renders.set(self.renders.get() + 1);
|
||||
let url_error = self
|
||||
.url
|
||||
.read(cx)
|
||||
@@ -34,15 +38,27 @@ impl Render for FieldHost {
|
||||
|
||||
/// A field page in a real window; the url input is focused unless
|
||||
/// `focus` is false, which leaves the page truly idle.
|
||||
fn field_host(focus: bool) -> (TestAppContext, Entity<InputState>, VisualTestContext) {
|
||||
fn field_host(
|
||||
focus: bool,
|
||||
) -> (
|
||||
TestAppContext,
|
||||
Entity<InputState>,
|
||||
VisualTestContext,
|
||||
Rc<Cell<usize>>,
|
||||
) {
|
||||
let cx = TestAppContext::single();
|
||||
cx.update(init);
|
||||
let renders = Rc::new(Cell::new(0));
|
||||
let renders_for_host = renders.clone();
|
||||
let (url, window) = cx.update(|cx| {
|
||||
let url = InputState::new(cx);
|
||||
let url_for_host = url.clone();
|
||||
let window = cx
|
||||
.open_window(gpui::WindowOptions::default(), |_, cx| {
|
||||
cx.new(|_| FieldHost { url: url_for_host })
|
||||
cx.new(|_| FieldHost {
|
||||
url: url_for_host,
|
||||
renders: renders_for_host.clone(),
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
(url, window)
|
||||
@@ -55,12 +71,12 @@ fn field_host(focus: bool) -> (TestAppContext, Entity<InputState>, VisualTestCon
|
||||
}
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
(cx, url, visual)
|
||||
(cx, url, visual, renders)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_idle_field_page_schedules_no_frames() {
|
||||
let (_cx, _url, mut visual) = field_host(false);
|
||||
let (_cx, _url, mut visual, _renders) = field_host(false);
|
||||
let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
|
||||
assert_eq!(
|
||||
frames, 0,
|
||||
@@ -68,12 +84,25 @@ fn an_idle_field_page_schedules_no_frames() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The caret blink is fps-capped: it must never demand a next frame, and its
|
||||
/// redraws arrive on the executor timer at the blink cadence.
|
||||
#[test]
|
||||
fn a_focused_field_page_schedules_frames_for_its_blink() {
|
||||
let (_cx, _url, mut visual) = field_host(true);
|
||||
fn a_focused_field_blink_is_fps_capped_and_still_redraws() {
|
||||
let (cx, _url, mut visual, renders) = field_host(true);
|
||||
let renders_after_focus = renders.get();
|
||||
|
||||
let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
|
||||
assert_eq!(
|
||||
frames, 0,
|
||||
"the blink must be fps-capped, not per-frame scheduled"
|
||||
);
|
||||
|
||||
// The blink redraws through the executor timer (4 fps cap).
|
||||
cx.executor().advance_clock(Duration::from_millis(251));
|
||||
cx.run_until_parked();
|
||||
visual.update(|window, cx| window.draw(cx).clear(cx));
|
||||
assert!(
|
||||
frames > 0,
|
||||
"the focused caret's blink needs frames to redraw at the blink cadence"
|
||||
renders.get() > renders_after_focus,
|
||||
"the capped blink must still redraw its page"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ impl Render for UniformShell {
|
||||
.h_full()
|
||||
.w_full(),
|
||||
)
|
||||
.child(Scrollbar::vertical(self.handle.clone())),
|
||||
.child(Scrollbar::vertical("rows", self.handle.clone())),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ impl Render for ListShell {
|
||||
.h_full()
|
||||
.w_full(),
|
||||
)
|
||||
.child(Scrollbar::vertical(self.state.clone())),
|
||||
.child(Scrollbar::vertical("rows", self.state.clone())),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -271,3 +271,99 @@ fn dragging_the_thumb_drives_a_list_state_and_reports_the_drag() {
|
||||
"the drag ends with the mouse"
|
||||
);
|
||||
}
|
||||
|
||||
struct TwoShell {
|
||||
left: UniformListScrollHandle,
|
||||
right: UniformListScrollHandle,
|
||||
}
|
||||
|
||||
impl Render for TwoShell {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.h(px(VIEWPORT_HEIGHT))
|
||||
.w(px(300.))
|
||||
.child(
|
||||
uniform_list("left-rows", ROWS, |range, _, _| {
|
||||
range
|
||||
.map(|ix| div().h(px(ROW_HEIGHT)).child(format!("row {ix}")))
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&self.left)
|
||||
.h_full()
|
||||
.w_full(),
|
||||
)
|
||||
.child(Scrollbar::vertical("left", self.left.clone())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.h(px(VIEWPORT_HEIGHT))
|
||||
.w(px(300.))
|
||||
.child(
|
||||
uniform_list("right-rows", ROWS, |range, _, _| {
|
||||
range
|
||||
.map(|ix| div().h(px(ROW_HEIGHT)).child(format!("row {ix}")))
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&self.right)
|
||||
.h_full()
|
||||
.w_full(),
|
||||
)
|
||||
.child(Scrollbar::vertical("right", self.right.clone())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn two_shell() -> (
|
||||
TestAppContext,
|
||||
VisualTestContext,
|
||||
UniformListScrollHandle,
|
||||
UniformListScrollHandle,
|
||||
) {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(init);
|
||||
let (left, right) = (
|
||||
UniformListScrollHandle::new(),
|
||||
UniformListScrollHandle::new(),
|
||||
);
|
||||
let (left_for_view, right_for_view) = (left.clone(), right.clone());
|
||||
let window = cx.add_window(move |_, _| TwoShell {
|
||||
left: left_for_view,
|
||||
right: right_for_view,
|
||||
});
|
||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
(cx, visual, left, right)
|
||||
}
|
||||
|
||||
/// Dragging one scrollbar's thumb must never move a sibling scrollbar: the
|
||||
/// drag state is keyed per scrollbar id, not shared across instances.
|
||||
#[test]
|
||||
fn sibling_scrollbars_drag_independently() {
|
||||
let (cx, mut visual, left, right) = two_shell();
|
||||
let metrics = Metrics::measure(&cx, &left);
|
||||
assert!(metrics.max_offset > px(0.));
|
||||
|
||||
press(&mut visual, metrics.thumb_center());
|
||||
drag_to(&mut visual, metrics.full_drag_target());
|
||||
release(&mut visual, metrics.full_drag_target());
|
||||
|
||||
let offset = left.offset().y;
|
||||
assert!(
|
||||
(offset.abs() - metrics.max_offset).abs() < px(0.5),
|
||||
"the left drag reaches the end, got {offset}"
|
||||
);
|
||||
assert_eq!(
|
||||
right.offset().y,
|
||||
px(0.),
|
||||
"the left drag leaked into the right scrollbar"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,3 +153,132 @@ fn arrow_keys_step_the_value_while_focused() {
|
||||
|
||||
assert_eq!(*changes.borrow(), vec![0.0, 10.0, 20.0, 10.0]);
|
||||
}
|
||||
|
||||
struct TwoSliders {
|
||||
changes_a: Rc<RefCell<Vec<f32>>>,
|
||||
changes_b: Rc<RefCell<Vec<f32>>>,
|
||||
value_a: f32,
|
||||
value_b: f32,
|
||||
}
|
||||
|
||||
impl Render for TwoSliders {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (changes_a, changes_b) = (self.changes_a.clone(), self.changes_b.clone());
|
||||
div()
|
||||
.size_full()
|
||||
.child(
|
||||
div().absolute().top_0().left_0().w(px(200.)).child(
|
||||
Slider::new("slider-a", self.value_a)
|
||||
.min(0.0)
|
||||
.max(100.0)
|
||||
.step(10.0)
|
||||
.on_change({
|
||||
let view = cx.entity();
|
||||
let changes_a = changes_a.clone();
|
||||
move |next, _, cx| {
|
||||
changes_a.borrow_mut().push(next);
|
||||
view.update(cx, |harness, cx| {
|
||||
harness.value_a = next;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().absolute().top(px(40.)).left_0().w(px(200.)).child(
|
||||
Slider::new("slider-b", self.value_b)
|
||||
.min(0.0)
|
||||
.max(100.0)
|
||||
.step(10.0)
|
||||
.on_change({
|
||||
let view = cx.entity();
|
||||
move |next, _, cx| {
|
||||
changes_b.borrow_mut().push(next);
|
||||
view.update(cx, |harness, cx| {
|
||||
harness.value_b = next;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn two_slider_window() -> (
|
||||
VisualTestContext,
|
||||
Rc<RefCell<Vec<f32>>>,
|
||||
Rc<RefCell<Vec<f32>>>,
|
||||
) {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(Theme::init);
|
||||
let changes_a = Rc::new(RefCell::new(Vec::new()));
|
||||
let changes_b = Rc::new(RefCell::new(Vec::new()));
|
||||
let (changes_a_for_window, changes_b_for_window) = (changes_a.clone(), changes_b.clone());
|
||||
let window = cx.add_window(move |_, _| TwoSliders {
|
||||
changes_a: changes_a_for_window,
|
||||
changes_b: changes_b_for_window,
|
||||
value_a: 0.0,
|
||||
value_b: 0.0,
|
||||
});
|
||||
let window = window.into();
|
||||
cx.update_window(window, |_, window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
})
|
||||
.unwrap();
|
||||
(
|
||||
VisualTestContext::from_window(window, &cx),
|
||||
changes_a,
|
||||
changes_b,
|
||||
)
|
||||
}
|
||||
|
||||
/// Dragging one slider must never drive a sibling slider: the drag flag is
|
||||
/// per instance, not shared keyed state.
|
||||
#[test]
|
||||
fn two_sliders_drag_independently() {
|
||||
let (mut cx, changes_a, changes_b) = two_slider_window();
|
||||
|
||||
// Slider A occupies y 0..16, slider B starts at y 40.
|
||||
cx.simulate_mouse_down(
|
||||
point(px(100.), px(10.)),
|
||||
MouseButton::Left,
|
||||
Modifiers::default(),
|
||||
);
|
||||
cx.simulate_mouse_move(
|
||||
point(px(120.), px(10.)),
|
||||
MouseButton::Left,
|
||||
Modifiers::default(),
|
||||
);
|
||||
cx.simulate_mouse_up(
|
||||
point(px(120.), px(10.)),
|
||||
MouseButton::Left,
|
||||
Modifiers::default(),
|
||||
);
|
||||
assert_eq!(*changes_a.borrow(), vec![50.0, 60.0]);
|
||||
assert!(changes_b.borrow().is_empty(), "A's drag leaked into B");
|
||||
|
||||
cx.simulate_mouse_down(
|
||||
point(px(100.), px(50.)),
|
||||
MouseButton::Left,
|
||||
Modifiers::default(),
|
||||
);
|
||||
cx.simulate_mouse_move(
|
||||
point(px(40.), px(50.)),
|
||||
MouseButton::Left,
|
||||
Modifiers::default(),
|
||||
);
|
||||
cx.simulate_mouse_up(
|
||||
point(px(40.), px(50.)),
|
||||
MouseButton::Left,
|
||||
Modifiers::default(),
|
||||
);
|
||||
assert_eq!(*changes_b.borrow(), vec![50.0, 20.0]);
|
||||
assert_eq!(
|
||||
*changes_a.borrow(),
|
||||
vec![50.0, 60.0],
|
||||
"B's drag leaked into A"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user