chore: clean up

This commit is contained in:
2026-11-04 08:57:07 +07:00
parent ded23ab2f2
commit fa73124ee4
5 changed files with 161 additions and 19 deletions
+4 -1
View File
@@ -66,7 +66,10 @@ GPUI is a UI framework which also provides primitives for state and concurrency
- Keep `render` declarative; use `.when(...)` / `.when_some(...)` for small conditional refinements. - Keep `render` declarative; use `.when(...)` / `.when_some(...)` for small conditional refinements.
- Controlled components: the owner passes the current value, callbacks report intent, the owner updates state and calls `cx.notify()`. No second source of truth. - Controlled components: the owner passes the current value, callbacks report intent, the owner updates state and calls `cx.notify()`. No second source of truth.
- Call `cx.notify()` after a mutation that changes rendering; notify the narrowest owning entity. Never notify from `render`. - Call `cx.notify()` after a mutation that changes rendering; notify the narrowest owning entity. Never notify from `render`.
- Use `cx.emit(...)` for semantic events (declare `impl EventEmitter<EventType>`), `cx.subscribe(...)` / `cx.observe(...)` for reactions, and store returned `Subscription`s in the owner (commonly `_subscriptions: Vec<Subscription>`). - `render` is side-effect free: no `cx.notify()`, no `entity.update(...)`, no writing derived text or state back into other entities during render. A write there forces another frame, and a write that emits an event re-enters the event handler — an unbounded redraw loop. Reconcile state in event handlers instead, with silent setters (`set_value_silent`-style) so a control's own write does not re-enter the events it mirrors.
- Use `cx.emit(...)` for semantic events (declare `impl EventEmitter<EventType>`), `cx.subscribe(...)` / `cx.observe(...)` for reactions, and store returned `Subscription`s in the owner (commonly `_subscriptions: Vec<Subscription>`). Never `.detach()` a component's own subscription — detached subscriptions outlive the component and fire into dropped state; detach is only for app-lifetime wiring registered once at startup.
- To take a key a focused child has already bound, add bindings in a deeper context (`"Parent > Child"` predicate) registered after the child's — gpui breaks equal-depth ties by later registration. Do not intercept keystrokes globally for component-scoped keys; an interceptor runs for every keystroke in every window.
- Animations schedule frames that re-render the whole window. Continuous animations (`repeat()`) must cap their frame rate (`with_max_fps`); skip entry animations on surfaces that redraw with typing (popups over text fields), where each frame is a full window redraw.
- Use stable domain-derived `ElementId`s (`("delete-project", project.id)`); never list indexes or freshly generated random IDs for reorderable content. - Use stable domain-derived `ElementId`s (`("delete-project", project.id)`); never list indexes or freshly generated random IDs for reorderable content.
- `cx.listener(...)` adapts event handlers to update the current entity. - `cx.listener(...)` adapts event handlers to update the current entity.
- Actions: `actions!(namespace, [Action])` for data-free actions, the `Action` derive otherwise; register `on_action` on the focused region with a matching `key_context`. - Actions: `actions!(namespace, [Action])` for data-free actions, the `Action` derive otherwise; register `on_action` on the focused region with a matching `key_context`.
+2 -2
View File
@@ -191,12 +191,12 @@ impl RenderOnce for Alert {
.flex_none() .flex_none()
.cursor_pointer() .cursor_pointer()
.p_0p5() .p_0p5()
.rounded(px(4.)) .rounded(theme.radius.sm)
.hover(|style| style.bg(muted)) .hover(|style| style.bg(muted))
.child( .child(
icon(IconRole::Close) icon(IconRole::Close)
.size(theme.sizes.small.dense_icon_size) .size(theme.sizes.small.dense_icon_size)
.color(foreground.alpha(0.55)), .color(theme.colors.muted_foreground),
) )
.on_click(move |_, _window, cx| on_dismiss(cx)), .on_click(move |_, _window, cx| on_dismiss(cx)),
) )
+3 -5
View File
@@ -1,6 +1,4 @@
//! Toasts: succinct, temporary messages at the bottom center of the window //! Toasts: succinct, temporary messages at the bottom center of the window.
//! — Adwaita Toast, the macOS banner. One toast at a time: content, an
//! optional action, and a close button; fades in, then fades out.
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration; use std::time::Duration;
@@ -333,12 +331,12 @@ fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful
.ml_4() .ml_4()
.cursor_pointer() .cursor_pointer()
.p_0p5() .p_0p5()
.rounded(px(4.)) .rounded(theme.radius.sm)
.hover(|style| style.bg(muted)) .hover(|style| style.bg(muted))
.child( .child(
icon(IconRole::Close) icon(IconRole::Close)
.size(dense_icon_size) .size(dense_icon_size)
.color(foreground.alpha(0.55)), .color(theme.colors.muted_foreground),
) )
.on_click(move |_, _window, cx| { .on_click(move |_, _window, cx| {
toasts.update(cx, |toasts, cx| { toasts.update(cx, |toasts, cx| {
+113
View File
@@ -0,0 +1,113 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Instant;
use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
Styled as _, TestAppContext, VisualTestContext, Window, div,
};
use gpui_native_core::common::v_flex;
use gpui_native_core::field::Field;
use gpui_native_core::init;
use gpui_native_core::input::{Input, InputState};
struct FieldHost {
url: Entity<InputState>,
renders: Rc<RefCell<usize>>,
}
impl Render for FieldHost {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
*self.renders.borrow_mut() += 1;
let url_error = self
.url
.read(cx)
.value()
.is_empty()
.then_some("Required")
.map(SharedString::from);
div().size_full().p_4().child(
v_flex().gap_3().child(
Field::new("bench-url", "Server URL")
.description("Where your patches are sent.")
.error(url_error)
.child(Input::new("bench-url-input", self.url.clone())),
),
)
}
}
/// A field page in a real window, with the url input focused.
fn field_host() -> (
TestAppContext,
Entity<InputState>,
Rc<RefCell<usize>>,
VisualTestContext,
) {
let cx = TestAppContext::single();
cx.update(init);
let (url, renders, window) = cx.update(|cx| {
let renders = Rc::new(RefCell::new(0));
let url = InputState::new(cx);
let url_for_host = url.clone();
let renders_for_host = renders.clone();
let window = cx
.open_window(gpui::WindowOptions::default(), |_, cx| {
cx.new(|_| FieldHost {
url: url_for_host,
renders: renders_for_host,
})
})
.unwrap();
(url, renders, window)
});
let mut visual = VisualTestContext::from_window(window.into(), &cx);
visual.update(|window, cx| {
let focus = url.read(cx).focus_handle();
window.focus(&focus, cx);
window.draw(cx).clear(cx);
});
(cx, url, renders, visual)
}
fn draw(visual: &mut VisualTestContext) {
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
}
fn type_and_time(visual: &mut VisualTestContext, text: &str) -> std::time::Duration {
let started = Instant::now();
for letter in text.chars() {
visual.simulate_keystrokes(&letter.to_string());
draw(visual);
}
let elapsed = started.elapsed();
println!(
"{} keystrokes: {elapsed:?} ({:.2} ms per keystroke)",
text.chars().count(),
elapsed.as_secs_f64() / text.chars().count() as f64 * 1000.
);
elapsed
}
#[test]
fn an_idle_field_page_schedules_no_frames() {
let (_cx, _url, _renders, mut visual) = field_host();
let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
assert_eq!(
frames, 0,
"an idle field page must not request animation frames"
);
}
#[test]
fn field_typing_frame_cost() {
let (_cx, _url, _renders, mut visual) = field_host();
let elapsed = type_and_time(&mut visual, "dsdsadsadadadada");
assert!(
elapsed.as_millis() < 1000,
"typing 16 characters took {elapsed:?}"
);
}
+39 -11
View File
@@ -17,7 +17,7 @@ use gpui_native_core::context_menu::ContextMenu;
use gpui_native_core::dialog::Dialog; use gpui_native_core::dialog::Dialog;
use gpui_native_core::empty_state::EmptyState; use gpui_native_core::empty_state::EmptyState;
use gpui_native_core::field::{Field, FieldVariant}; use gpui_native_core::field::{Field, FieldVariant};
use gpui_native_core::input::{Input, InputState, InputStateEvent}; use gpui_native_core::input::{Input, InputState};
use gpui_native_core::link::Link; use gpui_native_core::link::Link;
use gpui_native_core::menu::{Menu, MenuItem}; use gpui_native_core::menu::{Menu, MenuItem};
use gpui_native_core::popover::Popover; use gpui_native_core::popover::Popover;
@@ -389,7 +389,9 @@ impl Gallery {
cx, cx,
); );
dock.register_panel( dock.register_panel(
PanelDescriptor::new("field", "Field", |_, cx| cx.new(FieldDemo::new).into()), PanelDescriptor::new("field", "Field", |window, cx| {
cx.new(|cx| FieldDemo::new(window, cx)).into()
}),
cx, cx,
); );
dock.register_panel( dock.register_panel(
@@ -822,6 +824,7 @@ struct InputDemo {
text: Entity<InputState>, text: Entity<InputState>,
password: Entity<InputState>, password: Entity<InputState>,
notes: Entity<InputState>, notes: Entity<InputState>,
_subscriptions: Vec<gpui::Subscription>,
} }
impl InputDemo { impl InputDemo {
@@ -831,10 +834,18 @@ impl InputDemo {
password.update(cx, |state, cx| state.set_password(true, cx)); password.update(cx, |state, cx| state.set_password(true, cx));
let notes = InputState::new(cx); let notes = InputState::new(cx);
notes.update(cx, |state, cx| state.set_multiline(true, cx)); notes.update(cx, |state, cx| state.set_multiline(true, cx));
// The panel is a cached view in the dock: the value readouts re-render
// only when the inputs it reads are notified.
let subscriptions = vec![
cx.observe(&text, |_, _, cx| cx.notify()),
cx.observe(&password, |_, _, cx| cx.notify()),
cx.observe(&notes, |_, _, cx| cx.notify()),
];
Self { Self {
text, text,
password, password,
notes, notes,
_subscriptions: subscriptions,
} }
} }
} }
@@ -871,24 +882,25 @@ struct FieldDemo {
name: Entity<InputState>, name: Entity<InputState>,
notifications: bool, notifications: bool,
sync_on_open: bool, sync_on_open: bool,
_subscription: gpui::Subscription, _subscriptions: (gpui::Subscription, gpui::Subscription),
} }
impl FieldDemo { impl FieldDemo {
fn new(cx: &mut Context<Self>) -> Self { fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
let url = InputState::new(cx); let url = InputState::new(cx);
let name = InputState::new_with_text("reya", cx); let name = InputState::new_with_text("reya", cx);
let subscription = cx.subscribe(&url, |_, _, event, cx| { // The panel is a cached view in the dock: it re-renders only when the
if matches!(event, InputStateEvent::TextChanged) { // entities it reads are notified. Both inputs are read at render time
cx.notify(); // (validation, error text), so every change must bust the cache.
} let url_observation = cx.observe(&url, |_, _, cx| cx.notify());
}); let name_observation = cx.observe(&name, |_, _, cx| cx.notify());
Self { Self {
url, url,
name, name,
notifications: false, notifications: false,
sync_on_open: true, sync_on_open: true,
_subscription: subscription, _subscriptions: (url_observation, name_observation),
} }
} }
} }
@@ -1036,6 +1048,7 @@ struct ComboboxDemo {
branch: Entity<ComboboxState<SharedString>>, branch: Entity<ComboboxState<SharedString>>,
label: Entity<ComboboxState<SharedString>>, label: Entity<ComboboxState<SharedString>>,
label_created: Option<SharedString>, label_created: Option<SharedString>,
_subscriptions: Vec<gpui::Subscription>,
} }
impl ComboboxDemo { impl ComboboxDemo {
@@ -1087,10 +1100,17 @@ impl ComboboxDemo {
}); });
}); });
let label = cx.new(|cx| ComboboxState::new(label_builder, window, cx)); let label = cx.new(|cx| ComboboxState::new(label_builder, window, cx));
// The panel is a cached view in the dock: the value readouts re-render
// only when the picker states it reads are notified.
let subscriptions = vec![
cx.observe(&branch, |_, _, cx| cx.notify()),
cx.observe(&label, |_, _, cx| cx.notify()),
];
Self { Self {
branch, branch,
label, label,
label_created: None, label_created: None,
_subscriptions: subscriptions,
} }
} }
} }
@@ -2835,7 +2855,15 @@ fn main() {
gallery.downgrade(), gallery.downgrade(),
cx, cx,
); );
dock.open_panel("welcome", window, cx); // TEMPORARY perf probe: open the field panel under the
// autoplay env var so the probe types into a real window.
let first = if std::env::var("GPUI_FIELD_AUTOPLAY").is_ok() {
eprintln!("[field-probe] autoplay: opening field panel");
"field"
} else {
"welcome"
};
dock.open_panel(first, window, cx);
}); });
gallery gallery
}, },