From 81adec5c479a1077c67412a1d2fa5cb06f760578 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 22:13:27 -0400 Subject: [PATCH] =?UTF-8?q?T44d=20Web=20=C2=B7=20Hidden=20demos=20keep=20s?= =?UTF-8?q?till,=20wide=20ones=20scroll=20whole,=20pastes=20read=20the=20w?= =?UTF-8?q?eb's=20clipboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 +-- examples/gallery/index.html | 6 +-- examples/gallery/main.rs | 11 ++--- examples/gallery/pages/charts/cartesian.rs | 4 +- examples/gallery/pages/primitives/native.rs | 26 ++++++------ examples/gallery/pages/tables/grids.rs | 6 +-- examples/gallery/pages/terminal/mod.rs | 2 +- examples/gallery/pages/typography/motion.rs | 47 ++++++++++++--------- examples/gallery/shell.rs | 17 ++++++-- examples/gallery/step.rs | 14 +++--- examples/gallery/ui.rs | 33 ++++++++++----- examples/gallery/web.rs | 12 +++--- scripts/stories.py | 2 +- scripts/web.sh | 6 +-- src/editor/gutter.rs | 2 + src/editor/keys.rs | 2 +- src/editor/state.rs | 11 ++++- src/editor/tests.rs | 29 ++++++++++++- src/forms/text/actions.rs | 2 +- src/tables/gridkeys.rs | 32 +++++++++----- src/tables/tests/clipboard.rs | 33 +++++++++++++++ src/tables/tests/mod.rs | 1 + src/terminal/ansi.rs | 2 +- src/terminal/colors.rs | 2 +- src/theme/mod.rs | 2 +- src/theme/tests.rs | 2 +- 26 files changed, 211 insertions(+), 101 deletions(-) create mode 100644 src/tables/tests/clipboard.rs diff --git a/AGENTS.md b/AGENTS.md index 50e8c61..dfd5e4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. ## Commands -- `cargo run --example gallery` opens the gallery. `-- --page ` starts on a page; `--story ` with it draws that section alone. +- `cargo run --example gallery` opens the gallery. `-- --page <slug>` starts on a page; `--story <title or slug>` with it draws that section alone, and `--capture` then shoots it without the page's script. - `cargo run --example gallery -- --capture <dir>` writes PNGs of every page, light and dark, top to bottom, then each page's scripted states, including windows the demos open. macOS only. - `cargo run --example gallery -- --narrow 280 --capture <dir>` lays each page, header and body, at that width in a padded card and shoots it without its script, whose aims are measured at full width. Demos that set their own width run past the card. - `RUSTC_BOOTSTRAP=1 cargo check --lib --target wasm32-unknown-unknown` checks the web build; gpui_web's `wasm_thread` needs the bootstrap, as Zed's web examples do. @@ -180,9 +180,9 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - 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. A story the page lacks fails in the page. +- 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. - 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`: gpui_web reads no clipboard on demand, so Paste stays unbound there (`TextInput`, `CodeEditor`). +- 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. - The system time zone comes from `typography::format::system_zone`, which fails loud; jiff's `TimeZone::system()` falls back to UTC in silence. - A Select with nothing to pick stays shut: disabled, it shows its placeholder; open, it fails (`media::DeviceSelector` says no device was found). diff --git a/examples/gallery/index.html b/examples/gallery/index.html index e2621bf..7a5dbd1 100644 --- a/examples/gallery/index.html +++ b/examples/gallery/index.html @@ -9,14 +9,14 @@ html.dark, html.dark body { background: #131110; } </style> <script> - // Ely's own background under the canvas until the first frame, in the mode asked for. + // Ely's ground under the canvas until the first frame. if (new URLSearchParams(location.search).get("theme") === "dark") { document.documentElement.classList.add("dark"); } </script> <script type="module"> import init, { set_theme } from "./gallery.js"; - // The host sets the mode with gallery.setTheme(mode) or a message { ely: "theme", theme }, early or late. + // The host sets the mode by setTheme or a message. let early = null; let apply = (theme) => { early = theme; }; const setTheme = (theme) => { @@ -30,7 +30,7 @@ await init(); apply = set_theme; if (early) set_theme(early); - // A host that waits for this knows the mode it sends lands. + // Tells the host that a mode it sends now lands. window.parent.postMessage({ ely: "ready" }, "*"); </script> </head> diff --git a/examples/gallery/main.rs b/examples/gallery/main.rs index 2ca4940..81080e9 100644 --- a/examples/gallery/main.rs +++ b/examples/gallery/main.rs @@ -12,7 +12,7 @@ macro_rules! asset { }; } -/// On the web, the address the site serves it at: the build names the folder. +/// On the web, the build names the pictures' address. #[cfg(target_family = "wasm")] macro_rules! asset { ($name:literal) => { @@ -51,7 +51,7 @@ use gpui::{ actions!(gallery, [Quit, About, LightMode, DarkMode, NewWindow]); -/// The gallery's menu bar: NativeMenu at work, and what MenuBar draws. +/// The gallery's menu bar, drawn natively and by MenuBar. pub fn app_menus() -> Vec<Menu> { vec![ Menu { @@ -74,7 +74,7 @@ pub fn app_menus() -> Vec<Menu> { ] } -/// The menu bar and Dock menu (JumpList). Set before the window opens, whose first frame may draw them. +/// Menu bar and Dock menu, set before the window opens. fn menus(cx: &mut App) { cx.set_menus(app_menus()); cx.set_dock_menu(vec![MenuItem::action("New Window", NewWindow)]); @@ -104,7 +104,7 @@ pub struct Start { pub choice: shell::Choice, /// Narrow check width, if any. pub narrow: Option<f32>, - /// Why the start could not be read, shown in place of a page. + /// Why the start could not be read, shown instead. pub failure: Option<SharedString>, } @@ -195,7 +195,8 @@ fn main() -> Result<()> { page, capture, } = parse_args()?; - let scripted = start.narrow.is_none(); + // A script aims across its page; cards and stories hold part. + let scripted = start.narrow.is_none() && start.story.is_none(); gpui_platform::application() .with_assets(Assets) .run(move |cx: &mut App| { diff --git a/examples/gallery/pages/charts/cartesian.rs b/examples/gallery/pages/charts/cartesian.rs index 44d013f..27f6835 100644 --- a/examples/gallery/pages/charts/cartesian.rs +++ b/examples/gallery/pages/charts/cartesian.rs @@ -12,7 +12,7 @@ use gpui::{App, ClipboardItem, IntoElement, ParentElement, SharedString, Styled, use crate::{ probe::probe, - ui::{keep, noise, row, section, set}, + ui::{keep, live, noise, row, section, set}, }; const MONTHS: [&str; 12] = [ @@ -176,7 +176,7 @@ pub fn realtime(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { cx, ); let ticking = keep("stream-tick", || false, window, cx); - if !*ticking.read(cx) && !cx.theme().reduced_motion { + if !*ticking.read(cx) && !cx.theme().reduced_motion && live("RealtimeChart", cx) { set(&ticking, true, cx); let stream = stream.clone(); cx.spawn(async move |cx| { diff --git a/examples/gallery/pages/primitives/native.rs b/examples/gallery/pages/primitives/native.rs index d6c7d54..2660698 100644 --- a/examples/gallery/pages/primitives/native.rs +++ b/examples/gallery/pages/primitives/native.rs @@ -13,7 +13,7 @@ use gpui::{ use crate::{ probe::probe, - ui::{code, row, section, web_note}, + ui::{code, row, section, set}, }; pub fn boxes(cx: &App) -> impl IntoElement + use<> { @@ -442,13 +442,9 @@ pub fn clipboard(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> let muted = cx.theme().colors.fg_muted; section( "Clipboard", - "write_to_clipboard and read_from_clipboard on App.", + "write_to_clipboard and read_from_clipboard_async on App; a browser asks before it hands the clipboard over.", cx, ) - .children(web_note( - "A browser reads its clipboard only as it pastes, into a field: on the web gpui's read_from_clipboard finds nothing, so Paste reports no text. Copy works.", - cx, - )) .child( row() .gap_3() @@ -464,14 +460,18 @@ pub fn clipboard(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> .variant(ButtonVariant::Ghost) .icon(IconName::Clipboard) .on_click(move |_, _, cx| { - let text = cx.read_from_clipboard().and_then(|item| item.text()); - pasted.update(cx, |pasted, cx| { - *pasted = Some(match text { - Some(text) => text.into(), - None => "The clipboard holds no text.".into(), - }); - cx.notify(); + let (read, pasted) = (cx.read_from_clipboard_async(), pasted.clone()); + cx.spawn(async move |cx| { + let said: SharedString = match read.await { + Ok(item) => match item.and_then(|item| item.text()) { + Some(text) => text.into(), + None => "The clipboard holds no text.".into(), + }, + Err(error) => format!("The clipboard stayed shut: {error:?}").into(), + }; + cx.update(|cx| set(&pasted, Some(said), cx)); }) + .detach(); }), ) .when_some(shown, |row, text| { diff --git a/examples/gallery/pages/tables/grids.rs b/examples/gallery/pages/tables/grids.rs index c5683da..06a2924 100644 --- a/examples/gallery/pages/tables/grids.rs +++ b/examples/gallery/pages/tables/grids.rs @@ -8,7 +8,7 @@ use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, p use crate::{ probe::probe, - ui::{keep, section, set, web_note}, + ui::{keep, section, set}, }; type Cells = HashMap<(usize, usize), SharedString>; @@ -35,10 +35,6 @@ pub fn data_grid(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> "Cells to edit like a spreadsheet's. Arrows walk, Shift stretches a range, typing or a double press edits, Delete clears, and Cmd-C and Cmd-V copy and paste.", cx, ) - .children(web_note( - "A browser reads its clipboard only as it pastes into a text field, so Cmd-V pastes no cells here.", - cx, - )) .child(probe( "grid", div().child( diff --git a/examples/gallery/pages/terminal/mod.rs b/examples/gallery/pages/terminal/mod.rs index 0913998..d222cb4 100644 --- a/examples/gallery/pages/terminal/mod.rs +++ b/examples/gallery/pages/terminal/mod.rs @@ -37,7 +37,7 @@ const SCRIPT: &[Step] = &[ Step::Rest, ]; -/// A live shell natively; a browser runs no processes, so there it says so. +/// A live shell natively; on the web, why not. fn live(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { let section = section( "Terminal / TerminalTabs / TerminalSplit / TerminalToolbar / ShellSelector / TerminalSearch / TerminalLink", diff --git a/examples/gallery/pages/typography/motion.rs b/examples/gallery/pages/typography/motion.rs index ee38bab..b15b584 100644 --- a/examples/gallery/pages/typography/motion.rs +++ b/examples/gallery/pages/typography/motion.rs @@ -7,7 +7,7 @@ use ely_gpui_component::{ }; use gpui::{App, IntoElement, ParentElement, Styled, Task, Window, div}; -use crate::ui::{row, section, specimen, specimens}; +use crate::ui::{live, row, section, specimen, specimens}; const STEP: Duration = Duration::from_millis(1600); const VALUES: [f64; 5] = [1_284.0, 1_917.0, 12_406.0, 9_998.0, 10_031.0]; @@ -18,24 +18,12 @@ struct Ticker { } pub fn animated_number(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { - let ticker = window.use_keyed_state("type-ticker", cx, |window, cx| Ticker { - index: 0, - _task: cx.spawn_in(window, async move |ticker, cx| { - loop { - cx.background_executor().timer(STEP).await; - let advanced = cx.update(|_, cx| { - ticker.update(cx, |ticker: &mut Ticker, cx| { - ticker.index = (ticker.index + 1) % VALUES.len(); - cx.notify(); - }) - }); - if advanced.and_then(|inner| inner).is_err() { - return; - } - } - }), - }); - let value = VALUES[ticker.read(cx).index]; + let index = if live("AnimatedNumber", cx) { + ticker(window, cx).read(cx).index + } else { + 0 + }; + let value = VALUES[index]; section( "AnimatedNumber", "Digits roll by place; or the value counts up.", @@ -55,6 +43,27 @@ pub fn animated_number(window: &mut Window, cx: &mut App) -> impl IntoElement + ) } +/// Steps through the values on a clock of its own. +fn ticker(window: &mut Window, cx: &mut App) -> gpui::Entity<Ticker> { + window.use_keyed_state("type-ticker", cx, |window, cx| Ticker { + index: 0, + _task: cx.spawn_in(window, async move |ticker, cx| { + loop { + cx.background_executor().timer(STEP).await; + let advanced = cx.update(|_, cx| { + ticker.update(cx, |ticker: &mut Ticker, cx| { + ticker.index = (ticker.index + 1) % VALUES.len(); + cx.notify(); + }) + }); + if advanced.and_then(|inner| inner).is_err() { + return; + } + } + }), + }) +} + pub fn typewriter(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { let take = window.use_keyed_state("type-take", cx, |_, _| 0usize); let lines = [ diff --git a/examples/gallery/shell.rs b/examples/gallery/shell.rs index 2b5405e..d2eafaf 100644 --- a/examples/gallery/shell.rs +++ b/examples/gallery/shell.rs @@ -228,7 +228,7 @@ impl Gallery { } } - /// The window's root: the focus scope, over Ely's ground, ink and type. + /// The window's root: the focus scope over Ely's ground. fn root(&self, cx: &App) -> FocusScope { let theme = cx.theme(); FocusScope::new(&self.focus) @@ -241,7 +241,7 @@ impl Gallery { .text_size(theme.text_size(TextSize::Base)) } - /// The page's demos, or the story's alone; why not, once the start or the story failed. + /// The page's demos, the story's alone, or why not. fn body( &mut self, page: &pages::Page, @@ -290,8 +290,17 @@ impl Render for Gallery { .size_full() .overflow_scroll() .track_scroll(&self.scroll) - // The page column's 848 plus padding; narrower, the story scrolls sideways. - .child(div().min_w(px(896.0)).p_6().child(body)), + // The widest demo, 900, plus padding; narrower, it scrolls sideways. + .child( + div() + .min_w(px(948.0)) + .p_6() + // Natively the traffic lights take the top 56. + .when(cfg!(not(target_family = "wasm")), |story| { + story.pt(px(56.0)) + }) + .child(body), + ), ); } let sidebar = self.sidebar(cx); diff --git a/examples/gallery/step.rs b/examples/gallery/step.rs index adce0cc..08ea487 100644 --- a/examples/gallery/step.rs +++ b/examples/gallery/step.rs @@ -2,7 +2,7 @@ pub enum Step { Rest, Hover(&'static str), - /// Moves the pointer to an offset from the probe's top-left corner. + /// Moves the pointer to an offset in the probe. HoverAt(&'static str, f32, f32), Down(&'static str), Up(&'static str), @@ -13,13 +13,13 @@ pub enum Step { DownAt(&'static str, f32, f32), /// Right-clicks at an offset from the probe's top-left corner. RightAt(&'static str, f32, f32), - /// Drags from the last point to an offset, in small steps. + /// Drags from the last point to an offset, in steps. DragTo(&'static str, f32, f32), - /// Swipes two fingers sideways across the probe's middle, as a trackpad does: begin, move, end. + /// Swipes two fingers sideways across the probe's middle. Swipe(&'static str, f32), UpAt(&'static str, f32, f32), Key(&'static str), - /// Types text into whatever holds focus, one key at a time. + /// Types text into the focus, one key at a time. Type(&'static str), Wait(u64), Shot(&'static str), @@ -28,14 +28,14 @@ pub enum Step { CloseWindow(&'static str), /// Sends a keystroke to a window a demo opened. KeyWindow(&'static str, &'static str), - /// Asks AppKit to close a window, as its close button would. + /// Asks AppKit to close a window, as its button would. CloseNative(&'static str), /// Fails unless the window is gone. ExpectClosed(&'static str), /// Photographs this app's frontmost popup, such as the share picker. ShotPopup(&'static str), - /// Posts a key down to AppKit itself; `escape` and `5` are known. + /// Posts a key down to AppKit: `escape` or `5`. NativeKey(&'static str), - /// Cancels the open file panel, whose content runs out of process. + /// Cancels the open file panel, which runs out of process. CancelPanel, } diff --git a/examples/gallery/ui.rs b/examples/gallery/ui.rs index 5bf571e..ee7d8d6 100644 --- a/examples/gallery/ui.rs +++ b/examples/gallery/ui.rs @@ -6,10 +6,11 @@ use gpui::{ Resource, SharedString, Styled, Window, div, prelude::*, }; -/// The one section a story draws, while a page renders; the rest draw nothing. +/// The one section a story draws while its page renders. pub struct Story { wanted: SharedString, met: RefCell<Vec<SharedString>>, + live: RefCell<Vec<SharedString>>, } impl Global for Story {} @@ -19,6 +20,7 @@ impl Story { Self { wanted, met: RefCell::default(), + live: RefCell::default(), } } @@ -26,9 +28,12 @@ impl Story { *self.wanted == *title || *self.wanted == slug(title) } - /// Why the page drew no single section by this name, if it did not. + /// Why no single section answered this story, if none did. pub fn missing(&self, page: &str) -> Option<String> { let met = self.met.borrow(); + if let Some(stray) = self.live.borrow().iter().find(|title| !met.contains(title)) { + return Some(format!("ui::live names {stray}, no section on {page}.")); + } match met.iter().filter(|title| self.answers(title)).count() { 1 => None, 0 => Some(format!( @@ -41,7 +46,15 @@ impl Story { } } -/// A title as a story's address: lowercase letters and digits, dashes between. +/// Whether a demo's clock runs: on its page or story. +pub fn live(title: &str, cx: &App) -> bool { + cx.try_global::<Story>().is_none_or(|story| { + story.live.borrow_mut().push(title.to_string().into()); + story.answers(title) + }) +} + +/// A title as a story's address: lowercase, dashes between. pub fn slug(title: &str) -> String { let mut slug = String::new(); for c in title.chars() { @@ -54,7 +67,7 @@ pub fn slug(title: &str) -> String { slug.trim_end_matches('-').to_string() } -/// Titled block of demos. Under a story, only the one asked for draws, without its title. +/// Titled block of demos; a story draws one, untitled. pub fn section(title: impl Into<SharedString>, note: impl Into<SharedString>, cx: &App) -> Section { let title = title.into(); let theme = cx.theme(); @@ -95,7 +108,7 @@ pub fn section(title: impl Into<SharedString>, note: impl Into<SharedString>, cx ) } -/// A section's box. One a story passes over drops its demos, so none lays out, loads or moves. +/// A section's box; one a story passes drops its demos. pub struct Section { div: Div, shown: bool, @@ -117,7 +130,7 @@ impl IntoElement for Section { } } -/// A picture under the gallery's assets: a file natively, an address on the web. +/// A gallery picture: a file, or a web address. pub fn resource(path: impl Into<SharedString>) -> Resource { let path = path.into(); if cfg!(target_family = "wasm") { @@ -195,15 +208,15 @@ pub const UNREAD: &str = if cfg!(target_family = "wasm") { "gpui builds an AccessKit tree from elements with an id and a role, and no screen reader here reads it" }; -/// Why a demo that takes files takes none on the web. +/// Why a demo takes no files on the web. pub const NO_FILES: &str = "A browser hands gpui no files: on the web the file dialog fails and dropped files never arrive, so nothing is taken here."; -/// What a browser cannot do here, and why: a note on the web, nothing natively. +/// On the web, what a browser cannot do. pub fn web_note(text: &'static str, cx: &App) -> Option<AnyElement> { cfg!(target_family = "wasm").then(|| blocked(text, cx).into_any_element()) } -/// A demo a browser cannot run: on the web its note stands in. +/// A demo browsers cannot run; there, its note instead. pub fn native_only(element: impl IntoElement, note: &'static str, cx: &App) -> AnyElement { match web_note(note, cx) { Some(note) => note, @@ -211,7 +224,7 @@ pub fn native_only(element: impl IntoElement, note: &'static str, cx: &App) -> A } } -/// A button that opens a window of its own, which a browser page cannot. +/// A button opening its own window, which browsers cannot. pub fn own_window(button: impl IntoElement, cx: &App) -> AnyElement { native_only( button, diff --git a/examples/gallery/web.rs b/examples/gallery/web.rs index 948dabb..2237a38 100644 --- a/examples/gallery/web.rs +++ b/examples/gallery/web.rs @@ -17,13 +17,13 @@ use crate::{ shell::{Choice, Gallery}, }; -/// The i18n page's Hebrew: gpui_web asks the browser for emoji and CJK alone. +/// Hebrew for i18n; browsers lend only emoji and CJK. const HEBREW: &[u8] = include_bytes!("fonts/NotoSansHebrew-Regular.ttf"); thread_local! { - /// The running app and its window, for the host page's calls. + /// The running app and its window, for the host's calls. static HOST: RefCell<Option<(AsyncApp, WindowHandle<Gallery>)>> = const { RefCell::new(None) }; - /// A mode the host page chose before the window opened, which it opens in. + /// A mode the host chose before the window opened. static EARLY: Cell<Option<Choice>> = const { Cell::new(None) }; } @@ -72,7 +72,7 @@ fn read(search: &str) -> Result<Start> { Ok(start) } -/// The gallery in a browser: one window over the page's canvas. +/// The gallery in a browser: one window on a canvas. #[wasm_bindgen(start)] pub fn start() { gpui_platform::web_init(); @@ -88,7 +88,7 @@ pub fn start() { ..Start::default() } }); - // One thread: a static site sends no cross-origin isolation, so there is no shared memory. + // One thread: a static site gets no shared memory. let platform = Rc::new(WebPlatform::new_with_backend_and_font_fallback( false, WebBackendPreference::Auto, @@ -110,7 +110,7 @@ pub fn start() { }); } -/// Switches Ely's mode from the host page: `light` or `dark`, kept until the window opens. +/// Sets Ely's mode from the host, even before opening. #[wasm_bindgen] pub fn set_theme(theme: &str) -> Result<(), JsError> { let choice = choice(theme).map_err(|error| JsError::new(&format!("{error:#}")))?; diff --git a/scripts/stories.py b/scripts/stories.py index 87e887b..a14c4c8 100644 --- a/scripts/stories.py +++ b/scripts/stories.py @@ -11,7 +11,7 @@ OUT = ROOT / "examples/gallery/stories.json" CALL = re.compile(r"(?<![\w.])(?:(\w+)::)?(\w+)\s*\(") CHAIN = re.compile(r"\s*\.\s*\w+\s*(?:::<[^>]*>\s*)?\(") -# Components a story draws beyond its title's, by page and the title's first name. +# What a story draws beyond its title's names. SHOWN = { ("forms", "Form"): ["FormLabel", "FormError"], ("tables", "DataTable"): ["Sparkline"], diff --git a/scripts/web.sh b/scripts/web.sh index e53e2f4..957fa38 100755 --- a/scripts/web.sh +++ b/scripts/web.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Builds the browser gallery into <out>; ELY_GALLERY_ASSETS is the address of <out>/assets/. +# Builds the browser gallery; ELY_GALLERY_ASSETS addresses <out>/assets/. set -eu out=$(mkdir -p "${1:?usage: scripts/web.sh <out dir>}" && cd "$1" && pwd) : "${ELY_GALLERY_ASSETS:?set ELY_GALLERY_ASSETS to the address of <out>/assets/, ending in /}" @@ -10,12 +10,12 @@ wasm-bindgen --version | grep -qx "wasm-bindgen $version" || { exit 1 } command -v wasm-opt >/dev/null || { echo "wasm-opt needed: brew install binaryen, or npm i -g binaryen" >&2; exit 1; } -# gpui_web's wasm_thread asks for an unstable feature even single-threaded, as Zed's own web build does. +# gpui_web's wasm_thread needs an unstable feature, as in Zed. RUSTC_BOOTSTRAP=1 cargo build --example gallery --target wasm32-unknown-unknown --profile web wasm="${CARGO_TARGET_DIR:-target}/wasm32-unknown-unknown/web/examples/gallery.wasm" wasm-bindgen "$wasm" --out-dir "$out" --out-name gallery --target web --no-typescript \ --remove-name-section --remove-producers-section -# Cloudflare serves no file over 25 MiB; -Oz takes a seventh off, though not off the gzip. +# Cloudflare serves no file over 25 MiB; -Oz leaves room. wasm-opt -Oz --enable-bulk-memory --enable-nontrapping-float-to-int --enable-sign-ext \ --enable-mutable-globals --enable-reference-types --enable-multivalue \ "$out/gallery_bg.wasm" -o "$out/gallery_bg.wasm" diff --git a/src/editor/gutter.rs b/src/editor/gutter.rs index 070cdd8..5044a0e 100644 --- a/src/editor/gutter.rs +++ b/src/editor/gutter.rs @@ -161,6 +161,8 @@ impl CodeEditor { div() .w(advance * digits as f32) .text_right() + // Snapped to device pixels, the box can run short. + .whitespace_nowrap() .when(current, |number| number.text_color(colors.fg)) .child(number), ) diff --git a/src/editor/keys.rs b/src/editor/keys.rs index 4eba029..c3feea5 100644 --- a/src/editor/keys.rs +++ b/src/editor/keys.rs @@ -97,7 +97,7 @@ pub(crate) fn bind_keys(cx: &mut App) { KeyBinding::new("secondary-alt-[", Fold, context), KeyBinding::new("secondary-alt-]", Unfold, context), ]; - // The browser's paste event carries the clipboard to the input handler. + // The browser's paste event reaches the input handler. if !cfg!(target_family = "wasm") { bindings.push(KeyBinding::new("secondary-v", Paste, context)); } diff --git a/src/editor/state.rs b/src/editor/state.rs index db6a0bc..db50dd6 100644 --- a/src/editor/state.rs +++ b/src/editor/state.rs @@ -112,6 +112,8 @@ pub struct CodeEditor { pub(crate) metrics: Metrics, pub(crate) dragging: bool, pub(crate) caret_on: bool, + /// Blink runs only while focused, or unseen cursors redraw forever. + focused: bool, pub(crate) chat_field: gpui::Entity<TextInput>, pub(crate) frame: Frame, blink_epoch: u64, @@ -133,8 +135,12 @@ impl CodeEditor { let chat_field = cx.new(|cx| TextInput::new(window, cx).placeholder("Ask to change this code")); let subscriptions = vec![ - cx.on_focus(&focus, window, |editor, _, cx| editor.restart_blink(cx)), + cx.on_focus(&focus, window, |editor, _, cx| { + editor.focused = true; + editor.restart_blink(cx); + }), cx.on_blur(&focus, window, |editor, _, cx| { + editor.focused = false; editor.dragging = false; editor._blink = None; cx.notify(); @@ -178,6 +184,7 @@ impl CodeEditor { metrics: Metrics::default(), dragging: false, caret_on: true, + focused: false, chat_field, frame: Frame::default(), blink_epoch: 0, @@ -462,7 +469,7 @@ impl CodeEditor { 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 |editor, cx| { loop { cx.background_executor().timer(BLINK).await; diff --git a/src/editor/tests.rs b/src/editor/tests.rs index 6e712cd..c0d1d85 100644 --- a/src/editor/tests.rs +++ b/src/editor/tests.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{cell::Cell, rc::Rc, time::Duration}; use gpui::{ ClipboardItem, Entity, EntityInputHandler, KeyUpEvent, Keystroke, TestAppContext, @@ -256,3 +256,30 @@ fn a_browser_paste_gives_each_cursor_its_line(cx: &mut TestAppContext) { }) }); } + +#[gpui::test] +fn only_a_focused_editor_blinks(cx: &mut TestAppContext) { + let (editor, cx) = editor("a\nb", cx); + let redraws = Rc::new(Cell::new(0)); + let counted = redraws.clone(); + let _count = cx.update(|_, cx| cx.observe(&editor, move |_, _| counted.set(counted.get() + 1))); + editor.update(cx, |editor, cx| { + let second = 2..2; + editor.select([second], cx); + }); + cx.run_until_parked(); + redraws.set(0); + cx.executor().advance_clock(Duration::from_secs(3)); + cx.run_until_parked(); + assert_eq!(redraws.get(), 0, "an unfocused editor keeps still"); + cx.update(|window, cx| { + window.activate_window(); + let focus = editor.read(cx).focus.clone(); + focus.focus(window, cx); + }); + cx.run_until_parked(); + redraws.set(0); + cx.executor().advance_clock(Duration::from_secs(3)); + cx.run_until_parked(); + assert!(redraws.get() > 2, "a focused one blinks"); +} diff --git a/src/forms/text/actions.rs b/src/forms/text/actions.rs index 91b94df..d9df79d 100644 --- a/src/forms/text/actions.rs +++ b/src/forms/text/actions.rs @@ -89,7 +89,7 @@ pub(crate) fn bind_keys(cx: &mut App) { KeyBinding::new("secondary-enter", Submit, Some(CONTEXT)), KeyBinding::new("secondary-enter", Submit, Some(FORM_CONTEXT)), ]; - // The browser's paste event carries the clipboard to the input handler. + // The browser's paste event reaches the input handler. if !cfg!(target_family = "wasm") { bindings.push(KeyBinding::new("secondary-v", Paste, Some(CONTEXT))); } diff --git a/src/tables/gridkeys.rs b/src/tables/gridkeys.rs index 60c1642..21aacf8 100644 --- a/src/tables/gridkeys.rs +++ b/src/tables/gridkeys.rs @@ -1,6 +1,9 @@ use std::rc::Rc; -use gpui::{App, ClipboardItem, Entity, KeyDownEvent, Pixels, Point, SharedString, Window, point}; +use anyhow::anyhow; +use gpui::{ + App, ClipboardItem, Entity, KeyDownEvent, Pixels, Point, SharedString, TaskExt, Window, point, +}; use super::{ formula::filled, @@ -283,15 +286,24 @@ pub(crate) fn keys( sheet.read(cx), ))), ("v", true) => { - let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { - log::info!("grid: the clipboard holds no text to paste"); - return; - }; - let edits = pasted(&text, (row, col), (rows, cols)); - log::info!("grid: pasted {} cells", edits.len()); - if let Some(on_change) = &face.on_change { - on_change(&edits, window, cx); - } + // The web reads its clipboard only through the async reader. + let read = cx.read_from_clipboard_async(); + let on_change = face.on_change.clone(); + window + .spawn(cx, async move |cx| { + let item = read.await.map_err(|error| anyhow!("{error:?}"))?; + let Some(text) = item.and_then(|item| item.text()) else { + log::info!("grid: the clipboard holds no text to paste"); + return Ok(()); + }; + let edits = pasted(&text, (row, col), (rows, cols)); + log::info!("grid: pasted {} cells", edits.len()); + if let Some(on_change) = on_change { + cx.update(|window, cx| on_change(&edits, window, cx))?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); } _ => { let typed = stroke.key_char.clone().filter(|typed| { diff --git a/src/tables/tests/clipboard.rs b/src/tables/tests/clipboard.rs new file mode 100644 index 0000000..5d43a22 --- /dev/null +++ b/src/tables/tests/clipboard.rs @@ -0,0 +1,33 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ClipboardItem, Modifiers, TestAppContext, point, px}; + +use super::{Grid, settle}; +use crate::theme::Theme; + +#[gpui::test] +fn a_paste_fills_cells_from_the_cursor(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + crate::forms::bind_keys(cx); + }); + let heard = Rc::new(RefCell::new(Vec::new())); + let seen = heard.clone(); + let (_, cx) = cx.add_window_view(|_, _| Grid(seen)); + settle(cx); + cx.simulate_click(point(px(48.0), px(42.0)), Modifiers::none()); + settle(cx); + cx.update(|_, cx| cx.write_to_clipboard(ClipboardItem::new_string("x\ty\nz\tw".into()))); + cx.simulate_keystrokes("cmd-v"); + settle(cx); + let cells = |row, col, text: &str| (row, col, text.to_string()); + assert_eq!( + *heard.borrow(), + [ + cells(0, 0, "x"), + cells(0, 1, "y"), + cells(1, 0, "z"), + cells(1, 1, "w") + ] + ); +} diff --git a/src/tables/tests/mod.rs b/src/tables/tests/mod.rs index fb4368a..7a76a4e 100644 --- a/src/tables/tests/mod.rs +++ b/src/tables/tests/mod.rs @@ -9,6 +9,7 @@ use gpui::{ Styled, TestAppContext, VisualTestContext, Window, div, point, px, }; +mod clipboard; mod columns; use super::{Column, DataTable, FilterBuilder, FilterRule, Row}; diff --git a/src/terminal/ansi.rs b/src/terminal/ansi.rs index 86bffb5..2c6d311 100644 --- a/src/terminal/ansi.rs +++ b/src/terminal/ansi.rs @@ -90,7 +90,7 @@ pub(crate) fn printed(source: &str) -> (String, Vec<(Range<usize>, Pen)>) { (printed.text, printed.spans) } -/// Plain text sets no colors with OSC 4, 10 or 11. +/// Plain text sets no colors through OSC 4, 10, 11. struct Unset; impl Index<usize> for Unset { diff --git a/src/terminal/colors.rs b/src/terminal/colors.rs index d7a0b67..404de21 100644 --- a/src/terminal/colors.rs +++ b/src/terminal/colors.rs @@ -93,7 +93,7 @@ impl Ink { } } -/// Colors a program set with OSC 4, 10 or 11, by index. +/// Colors a program set through OSC 4, 10, 11. pub(crate) trait Set: Index<usize, Output = Option<Rgb>> {} impl<T: Index<usize, Output = Option<Rgb>>> Set for T {} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index e8429c0..12f6985 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -136,7 +136,7 @@ impl Theme { Self::fade_to_target(cx); } - /// Sets `mode` without the fade, as before a window's first frame; a fade under way stops. + /// Sets `mode` at once, stopping any fade under way. pub fn set_mode_now(mode: Mode, cx: &mut App) { log::info!("theme: mode -> {mode:?}, at once"); let theme = cx.global_mut::<Theme>(); diff --git a/src/theme/tests.rs b/src/theme/tests.rs index 863c5a4..8a79e55 100644 --- a/src/theme/tests.rs +++ b/src/theme/tests.rs @@ -21,7 +21,7 @@ fn an_owners_palette_serves_its_mode_alone(cx: &mut TestAppContext) { assert_eq!(cx.read(|cx| cx.theme().palette()), Palette::light(false)); } -/// A mode set at once wears its palette at once, and a fade under way cannot pull it back. +/// A mode set at once keeps its palette through fades. #[gpui::test] fn a_mode_set_at_once_skips_the_fade(cx: &mut TestAppContext) { cx.update(|cx| {