diff --git a/README.md b/README.md index 907ab98..7f3cc6f 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,13 @@ use gpui::App; fn main() { gpui_platform::application().with_assets(Assets).run(|cx: &mut App| { - ely_gpui_component::init(cx); + ely_gpui_component::init(cx).expect("Ely failed to start"); Theme::set_mode(Mode::Dark, cx); }); } ``` -`init` registers the fonts and the theme. It panics if `Assets` is not wired into the application. +`init` registers the fonts and the theme. It returns an error if `Assets` is not wired into the application or the asset source fails. ## Gallery diff --git a/examples/docs/focus.rs b/examples/docs/focus.rs index be9de1f..0cf3def 100644 --- a/examples/docs/focus.rs +++ b/examples/docs/focus.rs @@ -54,7 +54,7 @@ fn main() { gpui_platform::application() .with_assets(Assets) .run(|cx: &mut App| { - ely_gpui_component::init(cx); + ely_gpui_component::init(cx).expect("Ely failed to start"); cx.open_window(WindowOptions::default(), |window, cx| { cx.new(|cx| { let root = cx.focus_handle(); diff --git a/examples/docs/hello.rs b/examples/docs/hello.rs index cdaafba..cfb98f2 100644 --- a/examples/docs/hello.rs +++ b/examples/docs/hello.rs @@ -41,7 +41,7 @@ fn main() { gpui_platform::application() .with_assets(Assets) .run(|cx: &mut App| { - ely_gpui_component::init(cx); + ely_gpui_component::init(cx).expect("Ely failed to start"); cx.open_window(WindowOptions::default(), |window, cx| { cx.new(|cx| { let focus = cx.focus_handle(); diff --git a/examples/docs/i18n.rs b/examples/docs/i18n.rs index eeec755..e1e7047 100644 --- a/examples/docs/i18n.rs +++ b/examples/docs/i18n.rs @@ -53,7 +53,7 @@ fn main() { gpui_platform::application() .with_assets(Assets) .run(|cx: &mut App| { - ely_gpui_component::init(cx); + ely_gpui_component::init(cx).expect("Ely failed to start"); cx.set_global( I18n::new("en-US") .catalog( diff --git a/examples/docs/icons.rs b/examples/docs/icons.rs index a8b2694..1d62521 100644 --- a/examples/docs/icons.rs +++ b/examples/docs/icons.rs @@ -87,7 +87,7 @@ fn main() { gpui_platform::application() .with_assets(AppAssets) .run(|cx: &mut App| { - ely_gpui_component::init(cx); + ely_gpui_component::init(cx).expect("Ely failed to start"); IconTheme::default() .name("Cargo.toml", IconName::Package) .apply(cx); diff --git a/examples/docs/motion.rs b/examples/docs/motion.rs index ccfb125..b8ee6c2 100644 --- a/examples/docs/motion.rs +++ b/examples/docs/motion.rs @@ -104,7 +104,7 @@ fn main() { gpui_platform::application() .with_assets(Assets) .run(|cx: &mut App| { - ely_gpui_component::init(cx); + ely_gpui_component::init(cx).expect("Ely failed to start"); cx.open_window(WindowOptions::default(), |window, cx| { cx.new(|cx| { let focus = cx.focus_handle(); diff --git a/examples/docs/overlays.rs b/examples/docs/overlays.rs index 9ca4b64..a6838fe 100644 --- a/examples/docs/overlays.rs +++ b/examples/docs/overlays.rs @@ -78,7 +78,7 @@ fn main() { gpui_platform::application() .with_assets(Assets) .run(|cx: &mut App| { - ely_gpui_component::init(cx); + ely_gpui_component::init(cx).expect("Ely failed to start"); cx.open_window(WindowOptions::default(), |window, cx| { cx.new(|cx| { let focus = cx.focus_handle(); diff --git a/examples/docs/render.rs b/examples/docs/render.rs index 2fea1f1..1ae492f 100644 --- a/examples/docs/render.rs +++ b/examples/docs/render.rs @@ -56,7 +56,7 @@ fn main() { gpui_platform::application() .with_assets(Assets) .run(|cx: &mut App| { - ely_gpui_component::init(cx); + ely_gpui_component::init(cx).expect("Ely failed to start"); cx.open_window(WindowOptions::default(), |window, cx| { cx.new(|cx| { let focus = cx.focus_handle(); diff --git a/examples/docs/start.rs b/examples/docs/start.rs index c4c6e1f..7a087d2 100644 --- a/examples/docs/start.rs +++ b/examples/docs/start.rs @@ -45,7 +45,7 @@ fn main() { gpui_platform::application() .with_assets(Assets) .run(|cx: &mut App| { - ely_gpui_component::init(cx); + ely_gpui_component::init(cx).expect("Ely failed to start"); Theme::set_mode_now(Mode::Dark, cx); let bounds = Bounds::centered(None, size(px(640.0), px(400.0)), cx); let options = WindowOptions { diff --git a/examples/docs/theme.rs b/examples/docs/theme.rs index b362a92..a5cc60c 100644 --- a/examples/docs/theme.rs +++ b/examples/docs/theme.rs @@ -100,7 +100,7 @@ fn main() { gpui_platform::application() .with_assets(Assets) .run(|cx: &mut App| { - ely_gpui_component::init(cx); + ely_gpui_component::init(cx).expect("Ely failed to start"); cx.open_window(WindowOptions::default(), |window, cx| { cx.new(|cx| { let focus = cx.focus_handle(); diff --git a/examples/gallery/main.rs b/examples/gallery/main.rs index fa60570..1116176 100644 --- a/examples/gallery/main.rs +++ b/examples/gallery/main.rs @@ -41,7 +41,8 @@ mod web; use std::path::PathBuf; #[cfg(not(target_family = "wasm"))] -use anyhow::{Context as _, Result, bail}; +use anyhow::bail; +use anyhow::{Context as _, Result}; #[cfg(not(target_family = "wasm"))] use assets::GalleryAssets; use ely_gpui_component::theme::{Mode, Theme}; @@ -113,8 +114,8 @@ pub struct Start { } /// Sets Ely up and opens the gallery's window. -fn launch(start: Start, cx: &mut App) -> WindowHandle { - ely_gpui_component::init(cx); +fn launch(start: Start, cx: &mut App) -> Result> { + ely_gpui_component::init(cx)?; #[cfg(debug_assertions)] ely_gpui_component::tooling::install_inspector(cx); pages::bind_keys(cx); @@ -142,10 +143,10 @@ fn launch(start: Start, cx: &mut App) -> WindowHandle { .open_window(options, |window, cx| { cx.new(|cx| shell::Gallery::new(start, window, cx)) }) - .expect("gallery window failed to open"); + .context("gallery window failed to open")?; menu_actions(window, cx); cx.activate(true); - window + Ok(window) } #[cfg(not(target_family = "wasm"))] @@ -204,7 +205,13 @@ fn main() -> Result<()> { gpui_platform::application() .with_assets(GalleryAssets) .run(move |cx: &mut App| { - let window = launch(start, cx); + let window = match launch(start, cx) { + Ok(window) => window, + Err(error) => { + log::error!("gallery: {error:#}"); + return cx.quit(); + } + }; if let Some(dir) = capture { capture::run(window, dir, page, scripted, cx); } diff --git a/examples/gallery/web.rs b/examples/gallery/web.rs index 73a6132..bdfd55f 100644 --- a/examples/gallery/web.rs +++ b/examples/gallery/web.rs @@ -106,7 +106,10 @@ pub fn start() { if let Some(choice) = EARLY.take() { start.choice = choice; } - let window = launch(start, cx); + let window = match launch(start, cx) { + Ok(window) => window, + Err(error) => return log::error!("gallery: {error:#}"), + }; HOST.with(|host| *host.borrow_mut() = Some((cx.to_async(), window))); }); } diff --git a/frontend/src/docs/getting-started.md b/frontend/src/docs/getting-started.md index 950fa8c..058a3ee 100644 --- a/frontend/src/docs/getting-started.md +++ b/frontend/src/docs/getting-started.md @@ -6,8 +6,8 @@ An Ely app is a GPUI app with four more parts: Ely's assets, one call to `init`, | Part | Why | | --- | --- | -| `gpui_platform::application().with_assets(Assets)` | Ely embeds its icons and fonts. `init` panics without them. | -| `ely_gpui_component::init(cx)` | Call once, before any window. It registers the fonts, sets the theme, and binds Tab, Shift-Tab and the keys of text fields, the code editor, documents and, natively, the terminal. | +| `gpui_platform::application().with_assets(Assets)` | Ely embeds its icons and fonts. `init` returns an error without them. | +| `ely_gpui_component::init(cx)?` | Call once, before any window. It returns an error if the asset source fails. It registers the fonts, sets the theme, and binds Tab, Shift-Tab and the keys of text fields, the code editor, documents and, natively, the terminal. | | A root `FocusScope` | `FocusScope::new(&handle).root()` owns Tab for the window and takes focus back when the focused element leaves the tree. Focus its handle when the window opens. | | `cx.theme()` | Colors, sizes and radii come from the theme. Components read it; an app's own boxes read it too. | diff --git a/frontend/src/docs/icons.md b/frontend/src/docs/icons.md index 75f9a22..c91f785 100644 --- a/frontend/src/docs/icons.md +++ b/frontend/src/docs/icons.md @@ -42,9 +42,9 @@ A name rule beats an extension rule, and both beat Ely's map. ## An app's own assets -GPUI takes one asset source. An app with files of its own writes an `AssetSource` that answers its own paths and hands every other path to `Assets`. `init` panics unless `icons/check.svg` loads through it. +GPUI takes one asset source. An app with files of its own writes an `AssetSource` that answers its own paths and hands every other path to `Assets`. `init` returns an error unless `icons/check.svg` loads through it. -Use `Icon::from_path("app-icons/mark.svg")` to draw one of those SVG assets with Ely's themed sizing and color. It also accepts an owned `String` or `SharedString`. Paths are asset keys resolved by `AssetSource`; a path it lacks panics on first draw. SVGs render as single-color masks; `IconName` and `IconPicker` continue to list the bundled icons. +Use `Icon::from_path("app-icons/mark.svg")` to draw one of those SVG assets with Ely's themed sizing and color. It also accepts an owned `String` or `SharedString`. Paths are asset keys resolved by `AssetSource`; a path it lacks logs an error on first draw and shows a red broken-picture icon. SVGs render as single-color masks; `IconName` and `IconPicker` continue to list the bundled icons. Compatible single-color SVGs can come from [Lucide](https://lucide.dev/), [Hugeicons](https://github.com/hugeicons/hugeicons), [Phosphor](https://github.com/phosphor-icons/core), [Remix Icon](https://github.com/Remix-Design/RemixIcon), or [Tabler](https://github.com/tabler/tabler-icons). The app supplies its chosen files and preserves their licenses; Ely does not download or register those catalogs. diff --git a/frontend/src/docs/installation.md b/frontend/src/docs/installation.md index 1fd8952..569053d 100644 --- a/frontend/src/docs/installation.md +++ b/frontend/src/docs/installation.md @@ -41,9 +41,9 @@ A window opens with a counter and a Press button. From a clone of the repository | Symptom | Cause and fix | | --- | --- | -| Panic: `` ely: pass `ely_gpui_component::Assets` to `Application::with_assets` `` | `init` found no Ely assets. Start the app with `gpui_platform::application().with_assets(Assets)`. | -| Panic: `ely: asset source failed` | The app's asset source returned an error. The panic carries it. | -| Panic: `ely: embedded fonts failed to register` | GPUI's text system refused Ely's fonts. File a bug with the log. | +| `init` error: `` ely: pass `ely_gpui_component::Assets` to `Application::with_assets` `` | `init` found no Ely assets. Start the app with `gpui_platform::application().with_assets(Assets)`. | +| `init` error: `ely: asset source failed` | The app's asset source returned an error. The error carries it. | +| `init` error: `ely: embedded fonts failed to register` | GPUI's text system refused Ely's fonts. File a bug with the log. | | Windows open with no text on macOS | `gpui_platform` lacks the `font-kit` feature. | | `mismatched types`, with the note ``there are multiple different versions of crate `gpui` in the dependency graph`` | `gpui` points at a revision other than Ely's. Use `1a28cff4b409169bac058bca40dfbfeb7621d19b`. | | `metal shader compilation failed` | Default features are off and Xcode's Metal compiler is missing. Keep `runtime_shaders`. | diff --git a/frontend/src/docs/testing.md b/frontend/src/docs/testing.md index 5ea8b1f..2cbef9f 100644 --- a/frontend/src/docs/testing.md +++ b/frontend/src/docs/testing.md @@ -8,7 +8,7 @@ Ely tests itself on GPUI's test platform: every chapter has tests, and `cargo te ## Testing an app that uses Ely -GPUI's `TestAppContext` serves no assets, so `ely_gpui_component::init` panics there, and Ely's theme has no setup outside `init`. An app cannot draw Ely's components in a GPUI unit test today. Test the app's own state there, and see Ely's components in the gallery and in Ely's own tests. +GPUI's `TestAppContext` serves no assets, so `ely_gpui_component::init` returns an error there. Call `ely_gpui_component::init_for_tests(cx)` instead, under the `test-support` feature: it sets the theme and key bindings without assets or fonts. ## Writing a test inside Ely diff --git a/src/lib.rs b/src/lib.rs index 94c2173..21156cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,19 +47,23 @@ pub mod typography; pub use assets::Assets; +use anyhow::Context as _; use gpui::{App, KeyBinding}; use crate::primitives::{FocusNext, FocusPrev, IconName}; /// Loads fonts and the theme, binds Tab and text keys. Call once, first. -pub fn init(cx: &mut App) { +pub fn init(cx: &mut App) -> anyhow::Result<()> { match cx.asset_source().load(IconName::Check.path()) { Ok(Some(_)) => {} - Ok(None) => panic!("ely: pass `ely_gpui_component::Assets` to `Application::with_assets`"), - Err(error) => panic!("ely: asset source failed: {error:#}"), + Ok(None) => { + anyhow::bail!("ely: pass `ely_gpui_component::Assets` to `Application::with_assets`") + } + Err(error) => return Err(error.context("ely: asset source failed")), } - assets::load_fonts(cx).expect("ely: embedded fonts failed to register"); + assets::load_fonts(cx).context("ely: embedded fonts failed to register")?; setup(cx); + Ok(()) } /// `init` without assets or fonts, for app tests that render. diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index 9443b68..1eb9eeb 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::HashMap; use gpui::{ App, AssetSource, Global, Hsla, IntoElement, Radians, RenderOnce, SharedString, Transformation, @@ -362,7 +362,7 @@ impl Icon { } } - /// Draws an SVG supplied by the application's asset source; a missing one fails at render. + /// Draws an SVG supplied by the application's asset source; a missing one logs and shows `broken`. pub fn from_path(path: impl Into) -> Self { Self { path: path.into(), @@ -414,80 +414,67 @@ impl From for Icon { } } -/// App paths already found in the asset source. +/// App paths already looked up, and whether the source held them. #[derive(Default)] -struct FoundIcons(HashSet); +struct FoundIcons(HashMap); impl Global for FoundIcons {} -fn ensure_found(source: &dyn AssetSource, path: &str) { +fn found(source: &dyn AssetSource, path: &str) -> bool { match source.load(path) { - Ok(Some(_)) => {} - Ok(None) => panic!("ely: icon {path} is not in the app's asset source"), - Err(error) => panic!("ely: icon {path} failed to load: {error:#}"), + Ok(Some(_)) => true, + Ok(None) => { + log::error!("ely: icon {path} is not in the app's asset source"); + false + } + Err(error) => { + log::error!("ely: icon {path} failed to load: {error:#}"); + false + } + } +} + +impl Icon { + /// What a missing app icon shows: a broken picture in danger. + fn broken(self, cx: &App) -> Self { + Self { + path: IconName::ImageOff.path().into(), + bundled: true, + color: Some(cx.theme().colors.danger), + hover: None, + ..self + } } } impl RenderOnce for Icon { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - if !self.bundled && !cx.default_global::().0.contains(&self.path) { - ensure_found(cx.asset_source().as_ref(), &self.path); - cx.default_global::() + let source = cx.asset_source().clone(); + let missing = !self.bundled + && !*cx + .default_global::() .0 - .insert(self.path.clone()); - } + .entry(self.path.clone()) + .or_insert_with(|| found(source.as_ref(), &self.path)); + let icon = if missing { self.broken(cx) } else { self }; let theme = cx.theme(); - let color = self.color.unwrap_or(theme.colors.fg); + let color = icon.color.unwrap_or(theme.colors.fg); svg() - .path(self.path) - .size(theme.icon_size(self.size)) + .path(icon.path) + .size(theme.icon_size(icon.size)) .flex_none() .text_color(color) - .when_some(self.hover, |svg, (group, hover)| { + .when_some(icon.hover, |svg, (group, hover)| { svg.group_hover(group, |style| style.text_color(hover)) }) - .when(self.rotation.is_some() || self.scale != 1.0, |svg| { + .when(icon.rotation.is_some() || icon.scale != 1.0, |svg| { svg.with_transformation( - Transformation::rotate(self.rotation.unwrap_or(Radians(0.0))) - .with_scaling(size(self.scale, self.scale)), + Transformation::rotate(icon.rotation.unwrap_or(Radians(0.0))) + .with_scaling(size(icon.scale, icon.scale)), ) }) } } #[cfg(test)] -mod tests { - use super::{Icon, IconName, ensure_found}; - - #[test] - fn every_icon_ships_in_the_bundle() { - for icon in IconName::ALL { - assert!( - crate::Assets::get(icon.path()).is_some(), - "{icon:?} missing at {}", - icon.path() - ); - } - } - - #[test] - fn an_app_path_draws_as_given_and_a_name_as_its_bundled_file() { - let custom = Icon::from_path("app-icons/mark.svg"); - assert_eq!(custom.path.as_ref(), "app-icons/mark.svg"); - assert!(!custom.bundled); - let named = Icon::new(IconName::Check); - assert_eq!(named.path.as_ref(), IconName::Check.path()); - assert!(named.bundled); - } - - #[test] - fn a_path_the_source_holds_is_found() { - ensure_found(&crate::Assets, IconName::Check.path()); - } - - #[test] - #[should_panic(expected = "ely: icon app-icons/missing.svg is not in the app's asset source")] - fn a_path_the_source_lacks_fails() { - ensure_found(&crate::Assets, "app-icons/missing.svg"); - } -} +mod tests; diff --git a/src/primitives/icon/tests.rs b/src/primitives/icon/tests.rs new file mode 100644 index 0000000..ae74168 --- /dev/null +++ b/src/primitives/icon/tests.rs @@ -0,0 +1,70 @@ +use gpui::{Context, IntoElement, Render, TestAppContext, Window, black}; + +use super::{FoundIcons, Icon, IconName, found}; +use crate::theme::{ActiveTheme, Theme}; + +#[test] +fn every_icon_ships_in_the_bundle() { + for icon in IconName::ALL { + assert!( + crate::Assets::get(icon.path()).is_some(), + "{icon:?} missing at {}", + icon.path() + ); + } +} + +#[test] +fn an_app_path_draws_as_given_and_a_name_as_its_bundled_file() { + let custom = Icon::from_path("app-icons/mark.svg"); + assert_eq!(custom.path.as_ref(), "app-icons/mark.svg"); + assert!(!custom.bundled); + let named = Icon::new(IconName::Check); + assert_eq!(named.path.as_ref(), IconName::Check.path()); + assert!(named.bundled); +} + +#[test] +fn the_source_answers_whether_it_holds_a_path() { + assert!(found(&crate::Assets, IconName::Check.path())); + assert!(!found(&crate::Assets, "app-icons/missing.svg")); +} + +struct Missing; + +impl Render for Missing { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Icon::from_path("app-icons/missing.svg") + } +} + +#[gpui::test] +fn a_missing_app_icon_draws_its_mark(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (_, cx) = cx.add_window_view(|_, _| Missing); + cx.run_until_parked(); + let seen = cx.update(|_, cx| cx.global::().0.clone()); + assert_eq!(seen.get("app-icons/missing.svg"), Some(&false)); +} + +#[gpui::test] +fn a_broken_icon_shows_a_broken_picture_in_danger(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + let icon = Icon::from_path("app-icons/missing.svg") + .color(black()) + .group_hover_color("row", black()) + .broken(cx); + assert_eq!(icon.path.as_ref(), IconName::ImageOff.path()); + assert_eq!(icon.color, Some(cx.theme().colors.danger)); + assert!(icon.hover.is_none(), "hover keeps the danger color"); + }); +} + +#[gpui::test] +fn init_without_ely_assets_returns_an_error(cx: &mut TestAppContext) { + let error = cx + .update(crate::init) + .expect_err("the test source holds no Ely assets"); + assert!(format!("{error:#}").contains("pass `ely_gpui_component::Assets`")); +} diff --git a/tasks/panics.md b/tasks/panics.md index a1207a7..85ff3ae 100644 --- a/tasks/panics.md +++ b/tasks/panics.md @@ -6,14 +6,14 @@ Rules, at the owner's word: - A handler a component needs is an argument of `new`. Leaving it out fails to compile, not at render. - A state live data or the outside world can reach (a key gone from a list that changed, a failed load) logs an error naming the owner and draws a visible state: nothing chosen, or an error line in the component's box. The app keeps running. -- An assert on a caller's argument, or on a fact true by construction, stays, as std's do. One that runtime data or input can trip is fixed. +- An assert on a caller's argument, or on a fact true by construction, stays, as std's do. One that runtime data or input can trip is fixed. Reachable means what a component meets on its own: input inside it, its own later state, a call to the outside, or two arguments that agree at one time and not the next (a selected key and its list). One value checked against itself (a ratio above zero) is the caller's contract. Each item reads every site in its folders, fixes what the rules name, updates callers in the gallery, docs examples and tests, passes `scripts/check.sh`, and passes a codex review (`gpt-6.1-sol`, effort `high`) in a herdr split, five rounds at most. Then it commits and pushes. ## Items - [x] P00 Policy in AGENTS.md -- [ ] P01 Primitives — `src/primitives`, `src/lib.rs`, `src/assets.rs` +- [x] P01 Primitives — `src/primitives`, `src/lib.rs`, `src/assets.rs` - [ ] P02 Typography — `src/typography` - [ ] P03 Layout — `src/layout` - [ ] P04 Window & Shell — `src/shell` @@ -63,3 +63,4 @@ Each item reads every site in its folders, fixes what the rules name, updates ca One line per item: what changed, what stayed and why, review rounds. - P00: AGENTS.md holds the three rules; this list follows the PRD's chapters. Review: 2 rounds (round 1 found the runtime clause missing). +- P01: `init` returns `anyhow::Result`; a missing app icon logs once and shows `Icon::broken`. Kept: `layer.rs` expects (gpui's element order), `checked_ratio` (one value, the caller's). Review: 2 rounds.