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 ## 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 -- --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. - `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. - `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. - 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. - 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 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. - 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 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. - 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). - 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; } html.dark, html.dark body { background: #131110; }
</style> </style>
<script> <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") { if (new URLSearchParams(location.search).get("theme") === "dark") {
document.documentElement.classList.add("dark"); document.documentElement.classList.add("dark");
} }
</script> </script>
<script type="module"> <script type="module">
import init, { set_theme } from "./gallery.js"; 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 early = null;
let apply = (theme) => { early = theme; }; let apply = (theme) => { early = theme; };
const setTheme = (theme) => { const setTheme = (theme) => {
@@ -30,7 +30,7 @@
await init(); await init();
apply = set_theme; apply = set_theme;
if (early) set_theme(early); 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" }, "*"); window.parent.postMessage({ ely: "ready" }, "*");
</script> </script>
</head> </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")] #[cfg(target_family = "wasm")]
macro_rules! asset { macro_rules! asset {
($name:literal) => { ($name:literal) => {
@@ -51,7 +51,7 @@ use gpui::{
actions!(gallery, [Quit, About, LightMode, DarkMode, NewWindow]); 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> { pub fn app_menus() -> Vec<Menu> {
vec![ vec![
Menu { 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) { fn menus(cx: &mut App) {
cx.set_menus(app_menus()); cx.set_menus(app_menus());
cx.set_dock_menu(vec![MenuItem::action("New Window", NewWindow)]); cx.set_dock_menu(vec![MenuItem::action("New Window", NewWindow)]);
@@ -104,7 +104,7 @@ pub struct Start {
pub choice: shell::Choice, pub choice: shell::Choice,
/// Narrow check width, if any. /// Narrow check width, if any.
pub narrow: Option<f32>, 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>, pub failure: Option<SharedString>,
} }
@@ -195,7 +195,8 @@ fn main() -> Result<()> {
page, page,
capture, capture,
} = parse_args()?; } = 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() gpui_platform::application()
.with_assets(Assets) .with_assets(Assets)
.run(move |cx: &mut App| { .run(move |cx: &mut App| {
+2 -2
View File
@@ -12,7 +12,7 @@ use gpui::{App, ClipboardItem, IntoElement, ParentElement, SharedString, Styled,
use crate::{ use crate::{
probe::probe, probe::probe,
ui::{keep, noise, row, section, set}, ui::{keep, live, noise, row, section, set},
}; };
const MONTHS: [&str; 12] = [ const MONTHS: [&str; 12] = [
@@ -176,7 +176,7 @@ pub fn realtime(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
cx, cx,
); );
let ticking = keep("stream-tick", || false, window, 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); set(&ticking, true, cx);
let stream = stream.clone(); let stream = stream.clone();
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
+13 -13
View File
@@ -13,7 +13,7 @@ use gpui::{
use crate::{ use crate::{
probe::probe, probe::probe,
ui::{code, row, section, web_note}, ui::{code, row, section, set},
}; };
pub fn boxes(cx: &App) -> impl IntoElement + use<> { 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; let muted = cx.theme().colors.fg_muted;
section( section(
"Clipboard", "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, 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( .child(
row() row()
.gap_3() .gap_3()
@@ -464,14 +460,18 @@ pub fn clipboard(window: &mut Window, cx: &mut App) -> impl IntoElement + use<>
.variant(ButtonVariant::Ghost) .variant(ButtonVariant::Ghost)
.icon(IconName::Clipboard) .icon(IconName::Clipboard)
.on_click(move |_, _, cx| { .on_click(move |_, _, cx| {
let text = cx.read_from_clipboard().and_then(|item| item.text()); let (read, pasted) = (cx.read_from_clipboard_async(), pasted.clone());
pasted.update(cx, |pasted, cx| { cx.spawn(async move |cx| {
*pasted = Some(match text { let said: SharedString = match read.await {
Some(text) => text.into(), Ok(item) => match item.and_then(|item| item.text()) {
None => "The clipboard holds no text.".into(), Some(text) => text.into(),
}); None => "The clipboard holds no text.".into(),
cx.notify(); },
Err(error) => format!("The clipboard stayed shut: {error:?}").into(),
};
cx.update(|cx| set(&pasted, Some(said), cx));
}) })
.detach();
}), }),
) )
.when_some(shown, |row, text| { .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::{ use crate::{
probe::probe, probe::probe,
ui::{keep, section, set, web_note}, ui::{keep, section, set},
}; };
type Cells = HashMap<(usize, usize), SharedString>; 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.", "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, 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( .child(probe(
"grid", "grid",
div().child( div().child(
+1 -1
View File
@@ -37,7 +37,7 @@ const SCRIPT: &[Step] = &[
Step::Rest, 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<> { fn live(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let section = section( let section = section(
"Terminal / TerminalTabs / TerminalSplit / TerminalToolbar / ShellSelector / TerminalSearch / TerminalLink", "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 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 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]; 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<> { 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 { let index = if live("AnimatedNumber", cx) {
index: 0, ticker(window, cx).read(cx).index
_task: cx.spawn_in(window, async move |ticker, cx| { } else {
loop { 0
cx.background_executor().timer(STEP).await; };
let advanced = cx.update(|_, cx| { let value = VALUES[index];
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];
section( section(
"AnimatedNumber", "AnimatedNumber",
"Digits roll by place; or the value counts up.", "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<> { pub fn typewriter(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let take = window.use_keyed_state("type-take", cx, |_, _| 0usize); let take = window.use_keyed_state("type-take", cx, |_, _| 0usize);
let lines = [ 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 { fn root(&self, cx: &App) -> FocusScope {
let theme = cx.theme(); let theme = cx.theme();
FocusScope::new(&self.focus) FocusScope::new(&self.focus)
@@ -241,7 +241,7 @@ impl Gallery {
.text_size(theme.text_size(TextSize::Base)) .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( fn body(
&mut self, &mut self,
page: &pages::Page, page: &pages::Page,
@@ -290,8 +290,17 @@ impl Render for Gallery {
.size_full() .size_full()
.overflow_scroll() .overflow_scroll()
.track_scroll(&self.scroll) .track_scroll(&self.scroll)
// The page column's 848 plus padding; narrower, the story scrolls sideways. // The widest demo, 900, plus padding; narrower, it scrolls sideways.
.child(div().min_w(px(896.0)).p_6().child(body)), .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); let sidebar = self.sidebar(cx);
+7 -7
View File
@@ -2,7 +2,7 @@
pub enum Step { pub enum Step {
Rest, Rest,
Hover(&'static str), 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), HoverAt(&'static str, f32, f32),
Down(&'static str), Down(&'static str),
Up(&'static str), Up(&'static str),
@@ -13,13 +13,13 @@ pub enum Step {
DownAt(&'static str, f32, f32), DownAt(&'static str, f32, f32),
/// Right-clicks at an offset from the probe's top-left corner. /// Right-clicks at an offset from the probe's top-left corner.
RightAt(&'static str, f32, f32), 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), 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), Swipe(&'static str, f32),
UpAt(&'static str, f32, f32), UpAt(&'static str, f32, f32),
Key(&'static str), 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), Type(&'static str),
Wait(u64), Wait(u64),
Shot(&'static str), Shot(&'static str),
@@ -28,14 +28,14 @@ pub enum Step {
CloseWindow(&'static str), CloseWindow(&'static str),
/// Sends a keystroke to a window a demo opened. /// Sends a keystroke to a window a demo opened.
KeyWindow(&'static str, &'static str), 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), CloseNative(&'static str),
/// Fails unless the window is gone. /// Fails unless the window is gone.
ExpectClosed(&'static str), ExpectClosed(&'static str),
/// Photographs this app's frontmost popup, such as the share picker. /// Photographs this app's frontmost popup, such as the share picker.
ShotPopup(&'static str), 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), 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, CancelPanel,
} }
+23 -10
View File
@@ -6,10 +6,11 @@ use gpui::{
Resource, SharedString, Styled, Window, div, prelude::*, 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 { pub struct Story {
wanted: SharedString, wanted: SharedString,
met: RefCell<Vec<SharedString>>, met: RefCell<Vec<SharedString>>,
live: RefCell<Vec<SharedString>>,
} }
impl Global for Story {} impl Global for Story {}
@@ -19,6 +20,7 @@ impl Story {
Self { Self {
wanted, wanted,
met: RefCell::default(), met: RefCell::default(),
live: RefCell::default(),
} }
} }
@@ -26,9 +28,12 @@ impl Story {
*self.wanted == *title || *self.wanted == slug(title) *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> { pub fn missing(&self, page: &str) -> Option<String> {
let met = self.met.borrow(); 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() { match met.iter().filter(|title| self.answers(title)).count() {
1 => None, 1 => None,
0 => Some(format!( 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 { pub fn slug(title: &str) -> String {
let mut slug = String::new(); let mut slug = String::new();
for c in title.chars() { for c in title.chars() {
@@ -54,7 +67,7 @@ pub fn slug(title: &str) -> String {
slug.trim_end_matches('-').to_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 { pub fn section(title: impl Into<SharedString>, note: impl Into<SharedString>, cx: &App) -> Section {
let title = title.into(); let title = title.into();
let theme = cx.theme(); 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 { pub struct Section {
div: Div, div: Div,
shown: bool, 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 { pub fn resource(path: impl Into<SharedString>) -> Resource {
let path = path.into(); let path = path.into();
if cfg!(target_family = "wasm") { 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" "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."; 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> { pub fn web_note(text: &'static str, cx: &App) -> Option<AnyElement> {
cfg!(target_family = "wasm").then(|| blocked(text, cx).into_any_element()) 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 { pub fn native_only(element: impl IntoElement, note: &'static str, cx: &App) -> AnyElement {
match web_note(note, cx) { match web_note(note, cx) {
Some(note) => note, 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 { pub fn own_window(button: impl IntoElement, cx: &App) -> AnyElement {
native_only( native_only(
button, button,
+6 -6
View File
@@ -17,13 +17,13 @@ use crate::{
shell::{Choice, Gallery}, 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"); const HEBREW: &[u8] = include_bytes!("fonts/NotoSansHebrew-Regular.ttf");
thread_local! { 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) }; 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) }; static EARLY: Cell<Option<Choice>> = const { Cell::new(None) };
} }
@@ -72,7 +72,7 @@ fn read(search: &str) -> Result<Start> {
Ok(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)] #[wasm_bindgen(start)]
pub fn start() { pub fn start() {
gpui_platform::web_init(); gpui_platform::web_init();
@@ -88,7 +88,7 @@ pub fn start() {
..Start::default() ..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( let platform = Rc::new(WebPlatform::new_with_backend_and_font_fallback(
false, false,
WebBackendPreference::Auto, 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] #[wasm_bindgen]
pub fn set_theme(theme: &str) -> Result<(), JsError> { pub fn set_theme(theme: &str) -> Result<(), JsError> {
let choice = choice(theme).map_err(|error| JsError::new(&format!("{error:#}")))?; 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*\(") CALL = re.compile(r"(?<![\w.])(?:(\w+)::)?(\w+)\s*\(")
CHAIN = re.compile(r"\s*\.\s*\w+\s*(?:::<[^>]*>\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 = { SHOWN = {
("forms", "Form"): ["FormLabel", "FormError"], ("forms", "Form"): ["FormLabel", "FormError"],
("tables", "DataTable"): ["Sparkline"], ("tables", "DataTable"): ["Sparkline"],
+3 -3
View File
@@ -1,5 +1,5 @@
#!/bin/sh #!/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 set -eu
out=$(mkdir -p "${1:?usage: scripts/web.sh <out dir>}" && cd "$1" && pwd) 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 /}" : "${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 exit 1
} }
command -v wasm-opt >/dev/null || { echo "wasm-opt needed: brew install binaryen, or npm i -g binaryen" >&2; 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 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="${CARGO_TARGET_DIR:-target}/wasm32-unknown-unknown/web/examples/gallery.wasm"
wasm-bindgen "$wasm" --out-dir "$out" --out-name gallery --target web --no-typescript \ wasm-bindgen "$wasm" --out-dir "$out" --out-name gallery --target web --no-typescript \
--remove-name-section --remove-producers-section --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 \ wasm-opt -Oz --enable-bulk-memory --enable-nontrapping-float-to-int --enable-sign-ext \
--enable-mutable-globals --enable-reference-types --enable-multivalue \ --enable-mutable-globals --enable-reference-types --enable-multivalue \
"$out/gallery_bg.wasm" -o "$out/gallery_bg.wasm" "$out/gallery_bg.wasm" -o "$out/gallery_bg.wasm"
+2
View File
@@ -161,6 +161,8 @@ impl CodeEditor {
div() div()
.w(advance * digits as f32) .w(advance * digits as f32)
.text_right() .text_right()
// Snapped to device pixels, the box can run short.
.whitespace_nowrap()
.when(current, |number| number.text_color(colors.fg)) .when(current, |number| number.text_color(colors.fg))
.child(number), .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-[", Fold, context),
KeyBinding::new("secondary-alt-]", Unfold, 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") { if !cfg!(target_family = "wasm") {
bindings.push(KeyBinding::new("secondary-v", Paste, context)); 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) metrics: Metrics,
pub(crate) dragging: bool, pub(crate) dragging: bool,
pub(crate) caret_on: 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) chat_field: gpui::Entity<TextInput>,
pub(crate) frame: Frame, pub(crate) frame: Frame,
blink_epoch: u64, blink_epoch: u64,
@@ -133,8 +135,12 @@ impl CodeEditor {
let chat_field = let chat_field =
cx.new(|cx| TextInput::new(window, cx).placeholder("Ask to change this code")); cx.new(|cx| TextInput::new(window, cx).placeholder("Ask to change this code"));
let subscriptions = vec![ 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| { cx.on_blur(&focus, window, |editor, _, cx| {
editor.focused = false;
editor.dragging = false; editor.dragging = false;
editor._blink = None; editor._blink = None;
cx.notify(); cx.notify();
@@ -178,6 +184,7 @@ impl CodeEditor {
metrics: Metrics::default(), metrics: Metrics::default(),
dragging: false, dragging: false,
caret_on: true, caret_on: true,
focused: false,
chat_field, chat_field,
frame: Frame::default(), frame: Frame::default(),
blink_epoch: 0, blink_epoch: 0,
@@ -462,7 +469,7 @@ impl CodeEditor {
self.blink_epoch += 1; self.blink_epoch += 1;
let epoch = self.blink_epoch; let epoch = self.blink_epoch;
let still = cx.theme().reduced_motion; let still = cx.theme().reduced_motion;
self._blink = (!still).then(|| { self._blink = (!still && self.focused).then(|| {
cx.spawn(async move |editor, cx| { cx.spawn(async move |editor, cx| {
loop { loop {
cx.background_executor().timer(BLINK).await; 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::{ use gpui::{
ClipboardItem, Entity, EntityInputHandler, KeyUpEvent, Keystroke, TestAppContext, 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(CONTEXT)),
KeyBinding::new("secondary-enter", Submit, Some(FORM_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") { if !cfg!(target_family = "wasm") {
bindings.push(KeyBinding::new("secondary-v", Paste, Some(CONTEXT))); bindings.push(KeyBinding::new("secondary-v", Paste, Some(CONTEXT)));
} }
+22 -10
View File
@@ -1,6 +1,9 @@
use std::rc::Rc; 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::{ use super::{
formula::filled, formula::filled,
@@ -283,15 +286,24 @@ pub(crate) fn keys(
sheet.read(cx), sheet.read(cx),
))), ))),
("v", true) => { ("v", true) => {
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { // The web reads its clipboard only through the async reader.
log::info!("grid: the clipboard holds no text to paste"); let read = cx.read_from_clipboard_async();
return; let on_change = face.on_change.clone();
}; window
let edits = pasted(&text, (row, col), (rows, cols)); .spawn(cx, async move |cx| {
log::info!("grid: pasted {} cells", edits.len()); let item = read.await.map_err(|error| anyhow!("{error:?}"))?;
if let Some(on_change) = &face.on_change { let Some(text) = item.and_then(|item| item.text()) else {
on_change(&edits, window, cx); 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| { 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, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
}; };
mod clipboard;
mod columns; mod columns;
use super::{Column, DataTable, FilterBuilder, FilterRule, Row}; 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) (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; struct Unset;
impl Index<usize> for 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>> {} pub(crate) trait Set: Index<usize, Output = Option<Rgb>> {}
impl<T: Index<usize, Output = Option<Rgb>>> Set for T {} 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); 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) { pub fn set_mode_now(mode: Mode, cx: &mut App) {
log::info!("theme: mode -> {mode:?}, at once"); log::info!("theme: mode -> {mode:?}, at once");
let theme = cx.global_mut::<Theme>(); 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)); 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] #[gpui::test]
fn a_mode_set_at_once_skips_the_fade(cx: &mut TestAppContext) { fn a_mode_set_at_once_skips_the_fade(cx: &mut TestAppContext) {
cx.update(|cx| { cx.update(|cx| {