T44d Web · Hidden demos keep still, wide ones scroll whole, pastes read the web's clipboard

This commit is contained in:
ZacharyZhang-NY committed 2026-09-29 22:13:27 -04:00
1 parent d2018f8510
commit 752e7cba0c
26 files changed
+211 -101

No files matched your search

+3 -3
View File
@@ -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 <slug>` starts on a page; `--story <title or slug>` 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).
+3 -3
View File
@@ -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>
+6 -5
View File
@@ -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| {
+2 -2
View File
@@ -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| {
+13 -13
View File
@@ -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| {
+1 -5
View File
@@ -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(
+1 -1
View File
@@ -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",
+28 -19
View File
@@ -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 = [
+13 -4
View File
@@ -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);
+7 -7
View File
@@ -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,
}
+23 -10
View File
@@ -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,
+6 -6
View File
@@ -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:#}")))?;
+1 -1
View File
@@ -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"],
+3 -3
View File
@@ -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"
+2
View File
@@ -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),
)
+1 -1
View File
@@ -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));
}
+9 -2
View File
@@ -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;
+28 -1
View File
@@ -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");
}
+1 -1
View File
@@ -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)));
}
+22 -10
View File
@@ -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| {
+33
View File
@@ -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")
]
);
}
+1
View File
@@ -9,6 +9,7 @@ use gpui::{
Styled, TestAppContext, VisualTestContext, Window, div, point, px,
};
mod clipboard;
mod columns;
use super::{Column, DataTable, FilterBuilder, FilterRule, Row};
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 {}
+1 -1
View File
@@ -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>();
+1 -1
View File
@@ -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| {