Move layout metrics into theme and apply audit cleanups

This commit is contained in:
2026-11-09 15:59:13 +07:00
parent 122f9b12ae
commit 2e7711ee2e
32 changed files with 762 additions and 972 deletions
+12 -4
View File
@@ -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);
}
}
}
+11 -18
View File
@@ -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 -4
View File
@@ -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(),
),
}
};
+13 -25
View File
@@ -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);
@@ -516,7 +504,7 @@ where
let query = state.query.read(cx).value().to_string();
state.refilter(&query);
}
state.highlight = (!state.visible.is_empty()).then_some(0usize);
state.highlight = Some(0);
}
cx.notify();
});
@@ -667,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()
};
+4 -4
View File
@@ -7,10 +7,10 @@ use gpui::{
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, px, relative,
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};
@@ -333,7 +333,7 @@ impl Render for CommandPaletteState {
let row_ix = row;
h_flex()
.id(("command", row))
.id((ElementId::from(command.id.clone()), "command"))
.debug_selector({
let id = id.clone();
move || format!("{id}-command-{row}")
@@ -401,7 +401,7 @@ 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()
};
+11 -30
View File
@@ -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);
}
+2 -2
View File
@@ -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();
+116 -103
View File
@@ -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,8 +1821,7 @@ impl EntityInputHandler for InputState {
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<usize> {
let layout = self.layout.borrow();
let layout = layout.as_ref()?;
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)
@@ -2178,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);
}
+7 -7
View File
@@ -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
}
@@ -470,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(
@@ -596,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
View File
@@ -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);
}
}
});
}
+129 -6
View File
@@ -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 {
+4 -4
View File
@@ -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),
})
}
}
-6
View File
@@ -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;
+4 -4
View File
@@ -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";
@@ -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,7 +89,7 @@ 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(