From acc39374fa10e008c6c7ff6b14d02aff82295624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 29 Sep 2026 23:02:37 -0400 Subject: [PATCH] =?UTF-8?q?T44d=20Web=20=C2=B7=20Hidden=20builders=20start?= =?UTF-8?q?=20nothing,=20fields=20blink=20only=20in=20focus,=20grids=20pas?= =?UTF-8?q?te=20with=20Control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- examples/gallery/main.rs | 2 +- examples/gallery/pages/motion/progress.rs | 94 +++++++++++++---------- examples/gallery/pages/terminal/mod.rs | 9 ++- scripts/stories.py | 14 ++-- src/forms/tests/mod.rs | 28 +++++++ src/forms/text/mod.rs | 7 +- src/tables/gridkeys.rs | 4 +- 8 files changed, 107 insertions(+), 55 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dfd5e4d..53b9d46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,8 +179,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui clears `debug_bounds` each frame: a test sees an element leave as well as appear. - Time reads `web_time::Instant`: std's compiles for wasm32 and panics in the browser. `scripts/check.sh` refuses std's in `src` and the gallery. - What cannot build for wasm32 is absent there through `cfg(not(target_family = "wasm"))`, never faked: the live terminal, `Terminal` and `Launch` with their grid, keys and search, and the gallery's capture harness. -- A gallery demo a browser cannot run says why on the web through `ui::web_note`, in the blocked style: a second window (`own_window`), files from the dialog or a drop (`NO_FILES`), the menu bar, window moves, a clipboard read. -- A story is a gallery section: its title is a literal, one per page. Under `ui::Story` the one asked for draws without its title; the rest drop their demos (`ui::Section`), so nothing of theirs lays out, loads or moves. Their builders still run, so a demo that keeps its own clock starts it through `ui::live`, which a story grants its own section alone and fails on a title no section has. A story the page lacks fails in the page. +- A gallery demo a browser cannot run says why on the web through `ui::web_note`, in the blocked style: a second window (`own_window`), files from the dialog or a drop (`NO_FILES`), the menu bar, window moves. +- A story is a gallery section: its title is a literal, one per page. Under `ui::Story` the one asked for draws without its title; the rest drop their demos (`ui::Section`), so nothing of theirs lays out, loads or moves. Their builders still run, so a demo that starts work on its own, a clock, a load or a shell, starts it through `ui::live`, which a story grants its own section alone and fails on a title no section has. A story the page lacks fails in the page. - The gallery's pictures go through `asset!` and `ui::picture` or `ui::resource`: a file natively, on the web an address under `ELY_GALLERY_ASSETS`, since gpui reads no files there. - On the web a paste reaches the focused field as the browser's paste event, through `EntityInputHandler::paste`, so Paste stays unbound there (`TextInput`, `CodeEditor`). Code that reads the clipboard itself awaits `read_from_clipboard_async`: gpui_web's `read_from_clipboard` answers at once and finds nothing (`tables::DataGrid`'s Cmd-V). - The first frame wears the host's mode through `Theme::set_mode_now`, a mode sent before the window opens included; `set_mode` fades from the colors on screen. diff --git a/examples/gallery/main.rs b/examples/gallery/main.rs index 81080e9..06e119c 100644 --- a/examples/gallery/main.rs +++ b/examples/gallery/main.rs @@ -195,7 +195,7 @@ fn main() -> Result<()> { page, capture, } = parse_args()?; - // A script aims across its page; cards and stories hold part. + // A script aims across its page; stories hold part. let scripted = start.narrow.is_none() && start.story.is_none(); gpui_platform::application() .with_assets(Assets) diff --git a/examples/gallery/pages/motion/progress.rs b/examples/gallery/pages/motion/progress.rs index 6fd4d4b..b1e3809 100644 --- a/examples/gallery/pages/motion/progress.rs +++ b/examples/gallery/pages/motion/progress.rs @@ -26,7 +26,7 @@ use gpui::{ use super::loading::later; use crate::{ probe::probe, - ui::{keep, row, section, set, specimen, specimens}, + ui::{keep, live, row, section, set, specimen, specimens}, }; const STEPS: [f32; 5] = [0.0, 0.35, 0.72, 1.0, 0.5]; @@ -137,6 +137,56 @@ struct Report { } pub fn suspense(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let section = section( + "Suspense / AsyncView", + "A spinner while a value loads off the main thread, then the value as it fades in, or the error with Try again.", + cx, + ); + if !live("Suspense / AsyncView", cx) { + return section; + } + let (view, breaker) = report(window, cx); + let theme = cx.theme(); + section + .child( + div() + .w_128() + .min_h_40() + .flex() + .items_center() + .justify_center() + .px_4() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .child(view.clone()), + ) + .child( + row() + .child( + Button::new("report-reload", "Reload") + .variant(ButtonVariant::Ghost) + .on_click({ + let view = view.clone(); + move |_, _, cx| view.update(cx, |view, cx| view.reload(cx)) + }), + ) + .child( + Button::new("report-fail", "Reload and fail") + .variant(ButtonVariant::Ghost) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + breaker.store(true, Ordering::Relaxed); + view.update(cx, |view, cx| view.reload(cx)) + } + }), + ), + ) +} + +/// The report's view and its fail switch. +fn report(window: &mut Window, cx: &mut App) -> (Entity>, Arc) { let report = keep( "report", || Report { @@ -171,47 +221,7 @@ pub fn suspense(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { ) }); let breaker = report.read(cx).fail.clone(); - let theme = cx.theme(); - section( - "Suspense / AsyncView", - "A spinner while a value loads off the main thread, then the value as it fades in, or the error with Try again.", - cx, - ) - .child( - div() - .w_128() - .min_h_40() - .flex() - .items_center() - .justify_center() - .px_4() - .rounded(theme.radius(Radius::Lg)) - .border_1() - .border_color(theme.colors.border) - .child(view.clone()), - ) - .child( - row() - .child( - Button::new("report-reload", "Reload") - .variant(ButtonVariant::Ghost) - .on_click({ - let view = view.clone(); - move |_, _, cx| view.update(cx, |view, cx| view.reload(cx)) - }), - ) - .child( - Button::new("report-fail", "Reload and fail") - .variant(ButtonVariant::Ghost) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - breaker.store(true, Ordering::Relaxed); - view.update(cx, |view, cx| view.reload(cx)) - } - }), - ), - ) + (view, breaker) } pub fn refresh(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { diff --git a/examples/gallery/pages/terminal/mod.rs b/examples/gallery/pages/terminal/mod.rs index d222cb4..2ef19a5 100644 --- a/examples/gallery/pages/terminal/mod.rs +++ b/examples/gallery/pages/terminal/mod.rs @@ -45,7 +45,14 @@ fn live(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { cx, ); #[cfg(not(target_family = "wasm"))] - return section.child(live::shell(window, cx)); + return if crate::ui::live( + "Terminal / TerminalTabs / TerminalSplit / TerminalToolbar / ShellSelector / TerminalSearch / TerminalLink", + cx, + ) { + section.child(live::shell(window, cx)) + } else { + section + }; #[cfg(target_family = "wasm")] { let _ = window; diff --git a/scripts/stories.py b/scripts/stories.py index a14c4c8..1f39701 100644 --- a/scripts/stories.py +++ b/scripts/stories.py @@ -79,7 +79,7 @@ def literal(text, at): def functions(folder): - """Every `fn` in a page's folder, by module and name, with its body's span.""" + """Every `fn` in a page's folder, with its body's span.""" found = {} for file in sorted(folder.rglob("*.rs")): module = file.stem @@ -100,7 +100,7 @@ def functions(folder): def close(bare, at): - """The offset past the bracket that closes the one at `at`.""" + """The offset past the bracket that closes `at`.""" depth = 0 for k in range(at, len(bare)): depth += {"(": 1, "[": 1, "{": 1, ")": -1, "]": -1, "}": -1}.get(bare[k], 0) @@ -109,7 +109,7 @@ def close(bare, at): def sections(folder): - """Section titles in drawing order; fails on one never drawn or drawn inside another.""" + """Section titles in drawing order; stray or nested ones fail.""" fns = functions(folder) order, seen, opens = [], set(), {} @@ -120,7 +120,7 @@ def sections(folder): yield m, (owner, m.group(2)), m.group(2) == "section" and m.group(1) is None def draws(fn): - """Whether the fn opens a section, itself or through what it calls.""" + """Whether the fn opens a section, itself or deeper.""" if fn not in opens: opens[fn] = False opens[fn] = any(own or (callee in fns and draws(callee)) for _, callee, own in calls(*fn)) @@ -163,7 +163,7 @@ def slug(title): def entries(): - """The task lists' component names by key, and the ticked entries with a home.""" + """Component names by key, and ticked entries with a home.""" names, homed = {}, [] for file in sorted((ROOT / "tasks").glob("*.md")): for line in file.read_text().splitlines(): @@ -177,7 +177,7 @@ def entries(): def home(line, aliases): - """False for a pointer: `A → b::B` lives at B, another entry.""" + """False for a pointer: `A → b::B` lives at B.""" if " → " not in line: return True target = re.search(r" → `([\w:]+)", line) @@ -189,7 +189,7 @@ def key(name): def components(title, extra, known): - """The task lists' names for what the title and `extra` name.""" + """The task lists' names for the title's and `extra`'s names.""" names = [] for part in re.sub(r"\s*\([^)]*\)", "", title).split(" · "): for name in part.split(" → ")[0].split(" / ") + extra: diff --git a/src/forms/tests/mod.rs b/src/forms/tests/mod.rs index eab494d..491d873 100644 --- a/src/forms/tests/mod.rs +++ b/src/forms/tests/mod.rs @@ -330,3 +330,31 @@ fn shift_enter_breaks_only_a_field_of_many_lines(cx: &mut TestAppContext) { "a single line stays single" ); } + +#[gpui::test] +fn only_a_focused_field_blinks(cx: &mut TestAppContext) { + let (fields, cx) = open(1, false, cx); + let field = fields[0].clone(); + let redraws = std::rc::Rc::new(std::cell::Cell::new(0)); + let counted = redraws.clone(); + let _count = cx.update(|_, cx| cx.observe(&field, move |_, _| counted.set(counted.get() + 1))); + field.update(cx, |input, cx| { + input.set_text("seeded", cx); + let word = 0..4; + input.select(word, cx); + }); + cx.run_until_parked(); + redraws.set(0); + cx.executor() + .advance_clock(std::time::Duration::from_secs(3)); + cx.run_until_parked(); + assert_eq!(redraws.get(), 0, "an unfocused field keeps still"); + cx.update(|window, _| window.activate_window()); + focus(&field, cx); + cx.run_until_parked(); + redraws.set(0); + cx.executor() + .advance_clock(std::time::Duration::from_secs(3)); + cx.run_until_parked(); + assert!(redraws.get() > 2, "a focused one blinks"); +} diff --git a/src/forms/text/mod.rs b/src/forms/text/mod.rs index 163c0e2..f4cf52b 100644 --- a/src/forms/text/mod.rs +++ b/src/forms/text/mod.rs @@ -69,6 +69,8 @@ pub struct TextInput { pub(crate) scroll: Point, selecting: bool, caret_on: bool, + /// Blink runs only while focused, or unseen carets redraw forever. + focused: bool, blink_epoch: u64, _blink: Option>, _subscriptions: Vec, @@ -87,10 +89,12 @@ impl TextInput { let focus = cx.focus_handle().tab_stop(true); let subscriptions = vec![ cx.on_focus(&focus, window, |input, _, cx| { + input.focused = true; input.restart_blink(cx); cx.emit(InputEvent::Focus); }), cx.on_blur(&focus, window, |input, _, cx| { + input.focused = false; input.selecting = false; input._blink = None; cx.emit(InputEvent::Blur); @@ -118,6 +122,7 @@ impl TextInput { scroll: Point::default(), selecting: false, caret_on: true, + focused: false, blink_epoch: 0, _blink: None, _subscriptions: subscriptions, @@ -367,7 +372,7 @@ impl TextInput { self.blink_epoch += 1; let epoch = self.blink_epoch; let still = cx.theme().reduced_motion; - self._blink = (!still).then(|| { + self._blink = (!still && self.focused).then(|| { cx.spawn(async move |input, cx| { loop { cx.background_executor().timer(BLINK).await; diff --git a/src/tables/gridkeys.rs b/src/tables/gridkeys.rs index 21aacf8..85ee7b5 100644 --- a/src/tables/gridkeys.rs +++ b/src/tables/gridkeys.rs @@ -256,7 +256,9 @@ pub(crate) fn keys( }); return; } - match (stroke.key.as_str(), held.platform) { + // Browsers on a Mac send Cmd; wasm's secondary is Control. + let command = held.secondary() || (cfg!(target_family = "wasm") && held.platform); + match (stroke.key.as_str(), command) { ("escape", _) => sheet.update(cx, |sheet, cx| { sheet.anchor = sheet.cursor; cx.notify();