components: focus-gated spin wheel, spin text entry, combo outside-dismiss
CI / Build & test (Linux) (push) Canceled after 7m56s
CI / Build & test (Windows) (push) Canceled after 6m26s

Touchpad fixes from user testing:

- SpinBox: the wheel only steps a FOCUSED field — hover-wheel is inert,
  so two-finger scrolling across a dialog no longer drifts values.
- SpinBox: direct numeric entry — double-click opens the editor (the
  app's text input; commit on blur, Escape cancels), typing a digit on
  the focused field starts editing with that character. Previously the
  field accepted no text at all.
- ComboBox: a click anywhere outside closes the open popup
  (on_mouse_down_out).
- gpui bump: the timeline header-occlusion fix.
This commit is contained in:
2026-08-25 02:51:44 +08:00
parent 186c41b894
commit 8679bef6de
2 changed files with 120 additions and 10 deletions
+119 -9
View File
@@ -41,6 +41,7 @@ use gpui::{
Entity, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point,
Render, ScrollWheelEvent, SharedString, StatefulInteractiveElement, Styled, Window, AppContext, Focusable,
};
use gpui::prelude::FluentBuilder;
use std::sync::Arc;
pub use gpui_widgets::value::{SliderValue, ValueKind};
@@ -666,6 +667,15 @@ impl Render for ComboBox {
cx.notify();
}),
)
// A click anywhere outside the combo closes the open popup (the
// dropdown-dismiss users expect; the toggle click above is inside,
// so it never triggers this).
.on_mouse_down_out(cx.listener(|this, _event: &MouseDownEvent, _window, cx| {
if this.open {
this.open = false;
cx.notify();
}
}))
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
match event.keystroke.key.as_str() {
"down" | "up" => {
@@ -779,13 +789,21 @@ pub enum SpinBoxEvent {
/// A numeric field over a [`SliderModel`].
///
/// Mouse: click focuses, the wheel steps (Shift = fine). Keyboard (once
/// focused): Up/Down step, Shift-Up/Down fine-step, Home/End jump to the
/// range ends.
/// Mouse: click focuses, double click opens a numeric editor (the app's
/// text input; commit on blur/Enter, Escape cancels), the wheel steps ONLY
/// while the field is focused (Shift = fine) — hover-wheel is deliberately
/// inert so two-finger trackpad scrolling across a dialog never drifts the
/// value. Keyboard (once focused): Up/Down step, Shift-Up/Down fine-step,
/// Home/End jump to the range ends, typing a digit starts editing.
pub struct SpinBox {
control: usize,
model: SliderModel,
focus: FocusHandle,
/// The numeric editor, while a direct-entry edit is open.
edit: Option<Entity<gpui_elements::editable_text::EditableTextState>>,
/// Whether the open editor should cancel (Escape) instead of commit
/// on blur.
edit_cancel: bool,
}
impl SpinBox {
@@ -800,6 +818,8 @@ impl SpinBox {
control,
model,
focus: cx.focus_handle(),
edit: None,
edit_cancel: false,
}
}
@@ -823,6 +843,51 @@ impl SpinBox {
}
}
/// Open the numeric editor; `seed` replaces the text (a typed digit
/// starts a fresh entry, double-click edits the current value).
fn begin_edit(&mut self, seed: Option<String>, window: &mut Window, cx: &mut Context<Self>) {
let text = seed.unwrap_or_else(|| self.display().to_string());
let editor = cx.new(|cx| {
gpui_elements::editable_text::EditableTextState::new(
gpui_elements::editable_text::StringStorage::from(text),
cx,
)
});
let weak = editor.downgrade();
let focus = editor.read(cx).focus_handle(cx);
cx.on_focus_out(&focus, window, move |this: &mut Self, _event, _window, cx| {
if let Some(editor) = weak.upgrade() {
let text = editor.read(cx).as_str().to_string();
if !this.edit_cancel {
this.apply_text(&text, cx);
}
}
this.edit = None;
this.edit_cancel = false;
cx.notify();
})
.detach();
window.focus(&editor.read(cx).focus_handle(cx), cx);
self.edit = Some(editor);
cx.notify();
}
/// Parse `text` as the model's value kind, clamp it and apply + emit.
fn apply_text(&mut self, text: &str, cx: &mut Context<Self>) {
let parsed: Option<f64> = match self.model.value() {
SliderValue::Integer(_) => text.trim().parse::<i64>().ok().map(|v| v as f64),
_ => text.trim().parse::<f64>().ok(),
};
let Some(raw) = parsed else {
return;
};
// The commit reports through ValueChanged like every other gesture
// (the hosts subscribe to it; EditCommitted stays for API parity
// with the slider-less hosts).
let changed = self.model.apply_raw(raw);
self.emit_changed(changed, cx);
}
/// Format the current value for display.
fn display(&self) -> SharedString {
match self.model.value() {
@@ -842,7 +907,7 @@ impl Render for SpinBox {
let control = self.control;
let text = self.display();
div()
let mut root = div()
.id(ElementId::named_usize("oak-spinbox", control))
.rounded_md()
.border_1()
@@ -853,14 +918,26 @@ impl Render for SpinBox {
.track_focus(&self.focus)
.cursor_text()
.text_color(colors.text)
.child(text)
// While editing, the text input replaces the readout (not stacks
// under it).
.when(self.edit.is_none(), |root| root.child(text))
.on_click(cx.listener(|this, event: &gpui::ClickEvent, window, cx| {
if event.click_count() >= 2 {
this.begin_edit(None, window, cx);
}
}))
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _event: &MouseDownEvent, window, cx| {
window.focus(&this.focus, cx);
}),
)
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| {
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, window, cx| {
// Touchpad-safe: the wheel only steps a FOCUSED field, so
// two-finger scrolling over a dialog never drifts values.
if this.edit.is_some() || !this.focus.is_focused(window) {
return;
}
let dir = match event.delta {
gpui::ScrollDelta::Pixels(p) => {
if f32::from(p.y) > 0.0 { 1 } else { -1 }
@@ -873,17 +950,50 @@ impl Render for SpinBox {
let changed = this.model.apply_step(dir, fine);
this.emit_changed(changed, cx);
}))
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
.on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
// While the numeric editor is open, Escape cancels the edit
// (blur still fires; edit_cancel makes it restore).
if this.edit.is_some() {
if event.keystroke.key.as_str() == "escape" {
this.edit_cancel = true;
}
return;
}
let fine = event.keystroke.modifiers.shift;
let changed = match event.keystroke.key.as_str() {
"up" => this.model.apply_step(1, fine),
"down" => this.model.apply_step(-1, fine),
"home" => this.model.set_fraction(0.0),
"end" => this.model.set_fraction(1.0),
_ => return,
_ => {
// Typing a number key (or sign/point) starts editing
// with that character — click, then just type.
let key = &event.keystroke.key;
let starts_number = key.len() == 1
&& key.chars().next().is_some_and(|c| {
c.is_ascii_digit() || c == '-' || c == '.'
}) && event.keystroke.modifiers == gpui::Modifiers::none();
if starts_number {
this.begin_edit(Some(key.clone()), window, cx);
}
return;
}
};
this.emit_changed(changed, cx);
cx.stop_propagation();
}))
}));
if let Some(editor) = self.edit.clone() {
// Numeric editor: an app text input bound to the editor state.
root = root.child(
crate::oakui::component::text_input(
ElementId::named_usize("oak-spinbox-edit", control),
cx,
)
.state(editor.downgrade())
.accepts_input(true),
);
}
root
}
}
+1 -1
Submodule gpui updated: 7497035c12...30aa052ca6