From 34a32a1bd86f1e3e9c305d6ab755b8f17a45b2a0 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 7 Mar 2026 16:01:41 +0700 Subject: [PATCH 1/8] use nostr sdk gossip --- Cargo.lock | 92 +++++- Cargo.toml | 1 + crates/chat/src/lib.rs | 25 +- crates/coop/src/panels/contact_list.rs | 21 +- crates/coop/src/panels/messaging_relays.rs | 23 +- crates/coop/src/workspace.rs | 4 +- crates/device/src/lib.rs | 74 +---- crates/state/Cargo.toml | 1 + crates/state/src/lib.rs | 325 ++------------------- 9 files changed, 135 insertions(+), 431 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fdac7ab..6fd80a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1949,7 +1949,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2013,6 +2013,18 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "1.9.0" @@ -3702,6 +3714,17 @@ dependencies = [ "redox_syscall 0.7.3", ] +[[package]] +name = "libsqlite3-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -4250,6 +4273,18 @@ dependencies = [ "nostr", ] +[[package]] +name = "nostr-gossip-sqlite" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9" +dependencies = [ + "async-utility", + "nostr", + "nostr-gossip", + "rusqlite", + "tokio", +] + [[package]] name = "nostr-lmdb" version = "0.44.0" @@ -4299,7 +4334,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5160,7 +5195,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5585,6 +5620,30 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rust-embed" version = "8.11.0" @@ -5680,7 +5739,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6307,6 +6366,18 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6361,6 +6432,7 @@ dependencies = [ "nostr", "nostr-blossom", "nostr-connect", + "nostr-gossip-sqlite", "nostr-lmdb", "nostr-sdk", "petname", @@ -6661,7 +6733,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7189,7 +7261,7 @@ checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7491,6 +7563,12 @@ dependencies = [ "sval_serde", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -8054,7 +8132,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8319337..b35859f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" } nostr-blossom = { git = "https://github.com/rust-nostr/nostr" } +nostr-gossip-sqlite = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] } diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 8ab42f2..eea3538 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -268,29 +268,20 @@ impl ChatRegistry { return; }; - let write_relays = nostr.read(cx).write_relays(&public_key, cx); - let task: Task> = cx.background_spawn(async move { let id = SubscriptionId::new("contact-list"); let opts = SubscribeAutoCloseOptions::default() .exit_policy(ReqExitPolicy::ExitOnEOSE) .timeout(Some(Duration::from_secs(TIMEOUT))); - // Get user's write relays - let urls = write_relays.await; - // Construct filter for inbox relays let filter = Filter::new() .kind(Kind::ContactList) .author(public_key) .limit(1); - // Construct target for subscription - let target: HashMap<&RelayUrl, Filter> = - urls.iter().map(|relay| (relay, filter.clone())).collect(); - // Subscribe - client.subscribe(target).close_on(opts).with_id(id).await?; + client.subscribe(filter).close_on(opts).with_id(id).await?; Ok(()) }); @@ -323,14 +314,8 @@ impl ChatRegistry { let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); - let Some(public_key) = signer.public_key() else { - return Task::ready(Err(anyhow!("User not found"))); - }; - - let write_relays = nostr.read(cx).write_relays(&public_key, cx); - cx.background_spawn(async move { - let urls = write_relays.await; + let public_key = signer.get_public_key().await?; // Construct filter for inbox relays let filter = Filter::new() @@ -338,13 +323,9 @@ impl ChatRegistry { .author(public_key) .limit(1); - // Construct target for subscription - let target: HashMap<&RelayUrl, Filter> = - urls.iter().map(|relay| (relay, filter.clone())).collect(); - // Stream events from user's write relays let mut stream = client - .stream_events(target) + .stream_events(filter) .timeout(Duration::from_secs(TIMEOUT)) .await?; diff --git a/crates/coop/src/panels/contact_list.rs b/crates/coop/src/panels/contact_list.rs index addbb82..c6899ff 100644 --- a/crates/coop/src/panels/contact_list.rs +++ b/crates/coop/src/panels/contact_list.rs @@ -4,20 +4,20 @@ use std::time::Duration; use anyhow::{Context as AnyhowContext, Error}; use gpui::prelude::FluentBuilder; use gpui::{ - div, rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, - Task, TextAlign, Window, + Task, TextAlign, Window, div, rems, }; use nostr_sdk::prelude::*; use person::PersonRegistry; -use smallvec::{smallvec, SmallVec}; +use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock_area::panel::{Panel, PanelEvent}; use ui::input::{InputEvent, InputState, TextInput}; -use ui::{h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension}; +use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| ContactListPanel::new(window, cx)) @@ -156,15 +156,6 @@ impl ContactListPanel { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - - let Some(public_key) = signer.public_key() else { - window.push_notification("Public Key not found", cx); - return; - }; - - // Get user's write relays - let write_relays = nostr.read(cx).write_relays(&public_key, cx); // Get contacts let contacts: Vec = self @@ -177,14 +168,12 @@ impl ContactListPanel { self.set_updating(true, cx); let task: Task> = cx.background_spawn(async move { - let urls = write_relays.await; - // Construct contact list event builder let builder = EventBuilder::contact_list(contacts); let event = client.sign_event_builder(builder).await?; // Set contact list - client.send_event(&event).to(urls).await?; + client.send_event(&event).to_nip65().await?; Ok(()) }); diff --git a/crates/coop/src/panels/messaging_relays.rs b/crates/coop/src/panels/messaging_relays.rs index 47d46b2..5c5c36b 100644 --- a/crates/coop/src/panels/messaging_relays.rs +++ b/crates/coop/src/panels/messaging_relays.rs @@ -1,21 +1,21 @@ use std::collections::HashSet; use std::time::Duration; -use anyhow::{anyhow, Context as AnyhowContext, Error}; +use anyhow::{Context as AnyhowContext, Error, anyhow}; use gpui::prelude::FluentBuilder; use gpui::{ - div, rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, - Task, TextAlign, Window, + Task, TextAlign, Window, div, rems, }; use nostr_sdk::prelude::*; -use smallvec::{smallvec, SmallVec}; +use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::dock_area::panel::{Panel, PanelEvent}; use ui::input::{InputEvent, InputState, TextInput}; -use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension}; +use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex}; const MSG: &str = "Messaging Relays are relays that hosted all your messages. \ Other users will find your relays and send messages to it."; @@ -170,15 +170,6 @@ impl MessagingRelayPanel { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - - let Some(public_key) = signer.public_key() else { - window.push_notification("Public Key not found", cx); - return; - }; - - // Get user's write relays - let write_relays = nostr.read(cx).write_relays(&public_key, cx); // Construct event tags let tags: Vec = self @@ -191,14 +182,12 @@ impl MessagingRelayPanel { self.set_updating(true, cx); let task: Task> = cx.background_spawn(async move { - let urls = write_relays.await; - // Construct nip17 event builder let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags); let event = client.sign_event_builder(builder).await?; // Set messaging relays - client.send_event(&event).to(urls).await?; + client.send_event(&event).to_nip65().await?; Ok(()) }); diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs index d75691e..f2a58a9 100644 --- a/crates/coop/src/workspace.rs +++ b/crates/coop/src/workspace.rs @@ -286,7 +286,7 @@ impl Workspace { Command::RefreshRelayList => { let nostr = NostrRegistry::global(cx); nostr.update(cx, |this, cx| { - this.ensure_relay_list(cx); + //this.ensure_relay_list(cx); }); } Command::ResetEncryption => { @@ -685,7 +685,7 @@ impl Workspace { }) .dropdown_menu(move |this, _window, cx| { let nostr = NostrRegistry::global(cx); - let urls = nostr.read(cx).read_only_relays(&pkey, cx); + let urls: Vec = vec![]; // Header let menu = this.min_w(px(260.)).label("Relays"); diff --git a/crates/device/src/lib.rs b/crates/device/src/lib.rs index 6a5aa5c..62c8b6f 100644 --- a/crates/device/src/lib.rs +++ b/crates/device/src/lib.rs @@ -3,22 +3,22 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; use std::time::Duration; -use anyhow::{anyhow, Context as AnyhowContext, Error}; +use anyhow::{Context as AnyhowContext, Error, anyhow}; use gpui::{ - div, App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, - Styled, Subscription, Task, Window, + App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled, + Subscription, Task, Window, div, }; use nostr_sdk::prelude::*; use person::PersonRegistry; -use smallvec::{smallvec, SmallVec}; +use smallvec::{SmallVec, smallvec}; use state::{ - app_name, Announcement, DeviceState, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, + Announcement, DEVICE_GIFTWRAP, DeviceState, NostrRegistry, RelayState, TIMEOUT, app_name, }; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::notification::Notification; -use ui::{h_flex, v_flex, Disableable, IconName, Sizable, WindowExtension}; +use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex}; const IDENTIFIER: &str = "coop:device"; const MSG: &str = "You've requested an encryption key from another device. \ @@ -248,25 +248,16 @@ impl DeviceRegistry { // Reset state before fetching announcement self.reset(cx); - // Get user's write relays - let write_relays = nostr.read(cx).write_relays(&public_key, cx); - let task: Task> = cx.background_spawn(async move { - let urls = write_relays.await; - // Construct the filter for the device announcement event let filter = Filter::new() .kind(Kind::Custom(10044)) .author(public_key) .limit(1); - // Construct target for subscription - let target: HashMap<&RelayUrl, Filter> = - urls.iter().map(|relay| (relay, filter.clone())).collect(); - // Stream events from user's write relays let mut stream = client - .stream_events(target) + .stream_events(filter) .timeout(Duration::from_secs(TIMEOUT)) .await?; @@ -307,22 +298,12 @@ impl DeviceRegistry { pub fn create_encryption(&self, cx: &App) -> Task> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - - let Some(public_key) = signer.public_key() else { - return Task::ready(Err(anyhow!("User not found"))); - }; - - // Get user's write relays - let write_relays = nostr.read(cx).write_relays(&public_key, cx); let keys = Keys::generate(); let secret = keys.secret_key().to_secret_hex(); let n = keys.public_key(); cx.background_spawn(async move { - let urls = write_relays.await; - // Construct an announcement event let event = client .sign_event_builder(EventBuilder::new(Kind::Custom(10044), "").tags(vec![ @@ -332,7 +313,7 @@ impl DeviceRegistry { .await?; // Publish announcement - client.send_event(&event).to(urls).await?; + client.send_event(&event).to_nip65().await?; // Save device keys to the database set_keys(&client, &secret).await?; @@ -409,23 +390,15 @@ impl DeviceRegistry { return; }; - let write_relays = nostr.read(cx).write_relays(&public_key, cx); - let task: Task> = cx.background_spawn(async move { - let urls = write_relays.await; - // Construct a filter for device key requests let filter = Filter::new() .kind(Kind::Custom(4454)) .author(public_key) .since(Timestamp::now()); - // Construct target for subscription - let target: HashMap<&RelayUrl, Filter> = - urls.iter().map(|relay| (relay, filter.clone())).collect(); - // Subscribe to the device key requests on user's write relays - client.subscribe(target).await?; + client.subscribe(filter).await?; Ok(()) }); @@ -443,23 +416,15 @@ impl DeviceRegistry { return; }; - let write_relays = nostr.read(cx).write_relays(&public_key, cx); - self.tasks.push(cx.background_spawn(async move { - let urls = write_relays.await; - // Construct a filter for device key requests let filter = Filter::new() .kind(Kind::Custom(4455)) .author(public_key) .since(Timestamp::now()); - // Construct target for subscription - let target: HashMap<&RelayUrl, Filter> = - urls.iter().map(|relay| (relay, filter.clone())).collect(); - // Subscribe to the device key requests on user's write relays - client.subscribe(target).await?; + client.subscribe(filter).await?; Ok(()) })); @@ -471,12 +436,6 @@ impl DeviceRegistry { let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); - let Some(public_key) = signer.public_key() else { - return; - }; - - let write_relays = nostr.read(cx).write_relays(&public_key, cx); - let app_keys = nostr.read(cx).app_keys.clone(); let app_pubkey = app_keys.public_key(); @@ -507,8 +466,6 @@ impl DeviceRegistry { Ok(Some(keys)) } None => { - let urls = write_relays.await; - // Construct an event for device key request let event = client .sign_event_builder(EventBuilder::new(Kind::Custom(4454), "").tags(vec![ @@ -518,7 +475,7 @@ impl DeviceRegistry { .await?; // Send the event to write relays - client.send_event(&event).to(urls).await?; + client.send_event(&event).to_nip65().await?; Ok(None) } @@ -586,18 +543,11 @@ impl DeviceRegistry { let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); - let Some(public_key) = signer.public_key() else { - return; - }; - // Get user's write relays - let write_relays = nostr.read(cx).write_relays(&public_key, cx); let event = event.clone(); let id: SharedString = event.id.to_hex().into(); let task: Task> = cx.background_spawn(async move { - let urls = write_relays.await; - // Get device keys let keys = get_keys(&client).await?; let secret = keys.secret_key().to_secret_hex(); @@ -626,7 +576,7 @@ impl DeviceRegistry { let event = client.sign_event_builder(builder).await?; // Send the response event to the user's relay list - client.send_event(&event).to(urls).await?; + client.send_event(&event).to_nip65().await?; Ok(()) }); diff --git a/crates/state/Cargo.toml b/crates/state/Cargo.toml index fcedd0d..81736b5 100644 --- a/crates/state/Cargo.toml +++ b/crates/state/Cargo.toml @@ -10,6 +10,7 @@ common = { path = "../common" } nostr.workspace = true nostr-sdk.workspace = true nostr-lmdb.workspace = true +nostr-gossip-sqlite.workspace = true nostr-connect.workspace = true nostr-blossom.workspace = true diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index e7fb8b6..251a38c 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -1,11 +1,12 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use anyhow::{Context as AnyhowContext, Error, anyhow}; use common::config_dir; -use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Task, Window}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window}; use nostr_connect::prelude::*; +use nostr_gossip_sqlite::prelude::*; use nostr_lmdb::prelude::*; use nostr_sdk::prelude::*; @@ -53,9 +54,6 @@ pub struct NostrRegistry { /// Local public keys npubs: Entity>, - /// Custom gossip implementation - gossip: Entity, - /// App keys /// /// Used for Nostr Connect and NIP-4e operations @@ -88,8 +86,12 @@ impl NostrRegistry { // Construct the nostr npubs entity let npubs = cx.new(|_| vec![]); - // Construct the gossip entity - let gossip = cx.new(|_| Gossip::default()); + // Construct the nostr gossip instance + let gossip = cx.foreground_executor().block_on(async move { + NostrGossipSqlite::open(config_dir().join("gossip")) + .await + .expect("Failed to initialize gossip instance") + }); // Construct the nostr lmdb instance let lmdb = cx.foreground_executor().block_on(async move { @@ -101,6 +103,7 @@ impl NostrRegistry { // Construct the nostr client let client = ClientBuilder::default() .signer(signer.clone()) + .gossip(gossip) .database(lmdb) .automatic_authentication(false) .verify_subscriptions(false) @@ -113,7 +116,6 @@ impl NostrRegistry { // Run at the end of current cycle cx.defer_in(window, |this, _window, cx| { this.connect(cx); - this.handle_notifications(cx); }); Self { @@ -121,7 +123,6 @@ impl NostrRegistry { signer, npubs, app_keys, - gossip, relay_list_state: RelayState::Idle, tasks: vec![], } @@ -173,60 +174,6 @@ impl NostrRegistry { })); } - /// Handle nostr notifications - fn handle_notifications(&mut self, cx: &mut Context) { - let client = self.client(); - let gossip = self.gossip.downgrade(); - - // Channel for communication between nostr and gpui - let (tx, rx) = flume::bounded::(2048); - - self.tasks.push(cx.background_spawn(async move { - // Handle nostr notifications - let mut notifications = client.notifications(); - let mut processed_events = HashSet::new(); - - while let Some(notification) = notifications.next().await { - if let ClientNotification::Message { - message: - RelayMessage::Event { - event, - subscription_id, - }, - .. - } = notification - { - if !processed_events.insert(event.id) { - // Skip if the event has already been processed - continue; - } - - if let Kind::RelayList = event.kind { - if subscription_id.as_str().contains("room-") { - get_events_for_room(&client, &event).await.ok(); - } - tx.send_async(event.into_owned()).await?; - } - } - } - - Ok(()) - })); - - self.tasks.push(cx.spawn(async move |_this, cx| { - while let Ok(event) = rx.recv_async().await { - if let Kind::RelayList = event.kind { - gossip.update(cx, |this, cx| { - this.insert_relays(&event); - cx.notify(); - })?; - } - } - - Ok(()) - })); - } - /// Get all used npubs fn get_npubs(&mut self, cx: &mut Context) { let npubs = self.npubs.downgrade(); @@ -307,74 +254,49 @@ impl NostrRegistry { let task: Task> = cx.background_spawn(async move { let signer = async_keys.into_nostr_signer(); - // Get default relay list + // Construct relay list event let relay_list = default_relay_list(); - - // Extract write relays - let write_urls: Vec = relay_list - .iter() - .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { - Some(url) - } else { - None - } - }) - .cloned() - .collect(); - - // Ensure connected to all relays - for (url, _metadata) in relay_list.iter() { - client.add_relay(url).and_connect().await?; - } - - // Publish relay list event let event = EventBuilder::relay_list(relay_list).sign(&signer).await?; - let output = client + + // Publish relay list + client .send_event(&event) .to(BOOTSTRAP_RELAYS) .ok_timeout(Duration::from_secs(TIMEOUT)) .await?; - log::info!("Sent gossip relay list: {output:?}"); - // Construct the default metadata let name = petname::petname(2, "-").unwrap_or("Cooper".to_string()); let avatar = Url::parse(&format!("https://avatar.vercel.sh/{name}")).unwrap(); let metadata = Metadata::new().display_name(&name).picture(avatar); + let event = EventBuilder::metadata(&metadata).sign(&signer).await?; // Publish metadata event - let event = EventBuilder::metadata(&metadata).sign(&signer).await?; client .send_event(&event) - .to(&write_urls) + .to_nip65() .ack_policy(AckPolicy::none()) .await?; // Construct the default contact list let contacts = vec![Contact::new(PublicKey::parse(COOP_PUBKEY).unwrap())]; + let event = EventBuilder::contact_list(contacts).sign(&signer).await?; // Publish contact list event - let event = EventBuilder::contact_list(contacts).sign(&signer).await?; client .send_event(&event) - .to(&write_urls) + .to_nip65() .ack_policy(AckPolicy::none()) .await?; // Construct the default messaging relay list let relays = default_messaging_relays(); - - // Ensure connected to all relays - for url in relays.iter() { - client.add_relay(url).and_connect().await?; - } + let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?; // Publish messaging relay list event - let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?; client .send_event(&event) - .to(&write_urls) + .to_nip65() .ack_policy(AckPolicy::none()) .await?; @@ -480,9 +402,6 @@ impl NostrRegistry { } }); - // Ensure relay list for the user - this.ensure_relay_list(cx); - // Emit signer changed event cx.emit(SignerEvent::Set); })?; @@ -592,164 +511,6 @@ impl NostrRegistry { })); } - /// Set the state of the relay list - fn set_relay_state(&mut self, state: RelayState, cx: &mut Context) { - self.relay_list_state = state; - cx.notify(); - } - - pub fn ensure_relay_list(&mut self, cx: &mut Context) { - let task = self.verify_relay_list(cx); - - // Set the state to idle before starting the task - self.set_relay_state(RelayState::default(), cx); - - self.tasks.push(cx.spawn(async move |this, cx| { - let result = task.await?; - - // Update state - this.update(cx, |this, cx| { - this.relay_list_state = result; - cx.notify(); - })?; - - Ok(()) - })); - } - - // Verify relay list for current user - fn verify_relay_list(&mut self, cx: &mut Context) -> Task> { - let client = self.client(); - - cx.background_spawn(async move { - let signer = client.signer().context("Signer not found")?; - let public_key = signer.get_public_key().await?; - - let filter = Filter::new() - .kind(Kind::RelayList) - .author(public_key) - .limit(1); - - // Construct target for subscription - let target: HashMap<&str, Vec> = BOOTSTRAP_RELAYS - .into_iter() - .map(|relay| (relay, vec![filter.clone()])) - .collect(); - - // Stream events from the bootstrap relays - let mut stream = client - .stream_events(target) - .timeout(Duration::from_secs(TIMEOUT)) - .await?; - - while let Some((_url, res)) = stream.next().await { - match res { - Ok(event) => { - log::info!("Received relay list event: {event:?}"); - return Ok(RelayState::Configured); - } - Err(e) => { - log::error!("Failed to receive relay list event: {e}"); - } - } - } - - Ok(RelayState::NotConfigured) - }) - } - - /// Ensure write relays for a given public key - pub fn ensure_write_relays(&self, public_key: &PublicKey, cx: &App) -> Task> { - let client = self.client(); - let public_key = *public_key; - - cx.background_spawn(async move { - let mut relays = vec![]; - - let filter = Filter::new() - .kind(Kind::RelayList) - .author(public_key) - .limit(1); - - // Construct target for subscription - let target: HashMap<&str, Vec> = BOOTSTRAP_RELAYS - .into_iter() - .map(|relay| (relay, vec![filter.clone()])) - .collect(); - - if let Ok(mut stream) = client - .stream_events(target) - .timeout(Duration::from_secs(TIMEOUT)) - .await - { - while let Some((_url, res)) = stream.next().await { - match res { - Ok(event) => { - // Extract relay urls - relays.extend(nip65::extract_owned_relay_list(event).filter_map( - |(url, metadata)| { - if metadata.is_none() || metadata == Some(RelayMetadata::Write) - { - Some(url) - } else { - None - } - }, - )); - - // Ensure connections - for url in relays.iter() { - client.add_relay(url).and_connect().await.ok(); - } - - return relays; - } - Err(e) => { - log::error!("Failed to receive relay list event: {e}"); - } - } - } - } - - relays - }) - } - - /// Get a list of write relays for a given public key - pub fn write_relays(&self, public_key: &PublicKey, cx: &App) -> Task> { - let client = self.client(); - let relays = self.gossip.read(cx).write_relays(public_key); - - cx.background_spawn(async move { - // Ensure relay connections - for url in relays.iter() { - client.add_relay(url).and_connect().await.ok(); - } - - relays - }) - } - - /// Get a list of read relays for a given public key - pub fn read_relays(&self, public_key: &PublicKey, cx: &App) -> Task> { - let client = self.client(); - let relays = self.gossip.read(cx).read_relays(public_key); - - cx.background_spawn(async move { - // Ensure relay connections - for url in relays.iter() { - client.add_relay(url).and_connect().await.ok(); - } - - relays - }) - } - - /// Get all relays for a given public key without ensuring connections - pub fn read_only_relays(&self, public_key: &PublicKey, cx: &App) -> Vec { - self.gossip.read(cx).read_only_relays(public_key) - } - /// Get the public key of a NIP-05 address pub fn get_address(&self, addr: Nip05Address, cx: &App) -> Task> { let client = self.client(); @@ -932,52 +693,6 @@ fn get_or_init_app_keys() -> Result { Ok(keys) } -async fn get_events_for_room(client: &Client, nip65: &Event) -> Result<(), Error> { - // Subscription options - let opts = SubscribeAutoCloseOptions::default() - .timeout(Some(Duration::from_secs(TIMEOUT))) - .exit_policy(ReqExitPolicy::ExitOnEOSE); - - // Extract write relays from event - let write_relays: Vec<&RelayUrl> = nip65::extract_relay_list(nip65) - .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { - Some(url) - } else { - None - } - }) - .collect(); - - // Ensure relay connections - for url in write_relays.iter() { - client.add_relay(*url).and_connect().await.ok(); - } - - // Construct filter for inbox relays - let inbox = Filter::new() - .kind(Kind::InboxRelays) - .author(nip65.pubkey) - .limit(1); - - // Construct filter for encryption announcement - let announcement = Filter::new() - .kind(Kind::Custom(10044)) - .author(nip65.pubkey) - .limit(1); - - // Construct target for subscription - let target: HashMap<&RelayUrl, Vec> = write_relays - .into_iter() - .map(|relay| (relay, vec![inbox.clone(), announcement.clone()])) - .collect(); - - // Subscribe to inbox relays and encryption announcements - client.subscribe(target).close_on(opts).await?; - - Ok(()) -} - fn default_relay_list() -> Vec<(RelayUrl, Option)> { vec![ ( -- 2.49.1 From aec32e450af164ef8918f2b89ee2a85e8fa8402f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 8 Mar 2026 08:41:46 +0700 Subject: [PATCH 2/8] add gpui skills --- .agents/skills/gpui-action/SKILL.md | 180 +++++ .agents/skills/gpui-async/SKILL.md | 175 +++++ .agents/skills/gpui-context/SKILL.md | 161 ++++ .agents/skills/gpui-element/SKILL.md | 126 ++++ .../references/advanced-patterns.md | 705 ++++++++++++++++++ .../gpui-element/references/api-reference.md | 477 ++++++++++++ .../gpui-element/references/best-practices.md | 546 ++++++++++++++ .../gpui-element/references/examples.md | 632 ++++++++++++++++ .../gpui-element/references/patterns.md | 509 +++++++++++++ .agents/skills/gpui-entity/SKILL.md | 168 +++++ .../skills/gpui-entity/references/advanced.md | 528 +++++++++++++ .../gpui-entity/references/api-reference.md | 382 ++++++++++ .../gpui-entity/references/best-practices.md | 484 ++++++++++++ .../skills/gpui-entity/references/patterns.md | 579 ++++++++++++++ .agents/skills/gpui-event/SKILL.md | 176 +++++ .agents/skills/gpui-focus-handle/SKILL.md | 232 ++++++ .agents/skills/gpui-global/SKILL.md | 204 +++++ .agents/skills/gpui-layout-and-style/SKILL.md | 177 +++++ .agents/skills/gpui-test/SKILL.md | 94 +++ .agents/skills/gpui-test/examples.md | 172 +++++ .agents/skills/gpui-test/reference.md | 350 +++++++++ 21 files changed, 7057 insertions(+) create mode 100644 .agents/skills/gpui-action/SKILL.md create mode 100644 .agents/skills/gpui-async/SKILL.md create mode 100644 .agents/skills/gpui-context/SKILL.md create mode 100644 .agents/skills/gpui-element/SKILL.md create mode 100644 .agents/skills/gpui-element/references/advanced-patterns.md create mode 100644 .agents/skills/gpui-element/references/api-reference.md create mode 100644 .agents/skills/gpui-element/references/best-practices.md create mode 100644 .agents/skills/gpui-element/references/examples.md create mode 100644 .agents/skills/gpui-element/references/patterns.md create mode 100644 .agents/skills/gpui-entity/SKILL.md create mode 100644 .agents/skills/gpui-entity/references/advanced.md create mode 100644 .agents/skills/gpui-entity/references/api-reference.md create mode 100644 .agents/skills/gpui-entity/references/best-practices.md create mode 100644 .agents/skills/gpui-entity/references/patterns.md create mode 100644 .agents/skills/gpui-event/SKILL.md create mode 100644 .agents/skills/gpui-focus-handle/SKILL.md create mode 100644 .agents/skills/gpui-global/SKILL.md create mode 100644 .agents/skills/gpui-layout-and-style/SKILL.md create mode 100644 .agents/skills/gpui-test/SKILL.md create mode 100644 .agents/skills/gpui-test/examples.md create mode 100644 .agents/skills/gpui-test/reference.md diff --git a/.agents/skills/gpui-action/SKILL.md b/.agents/skills/gpui-action/SKILL.md new file mode 100644 index 0000000..4414bfc --- /dev/null +++ b/.agents/skills/gpui-action/SKILL.md @@ -0,0 +1,180 @@ +--- +name: gpui-action +description: Action definitions and keyboard shortcuts in GPUI. Use when implementing actions, keyboard shortcuts, or key bindings. +--- + +## Overview + +Actions provide declarative keyboard-driven UI interactions in GPUI. + +**Key Concepts:** +- Define actions with `actions!` macro or `#[derive(Action)]` +- Bind keys with `cx.bind_keys()` +- Handle with `.on_action()` on elements +- Context-aware via `key_context()` + +## Quick Start + +### Simple Actions + +```rust +use gpui::actions; + +actions!(editor, [MoveUp, MoveDown, Save, Quit]); + +const CONTEXT: &str = "Editor"; + +pub fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("up", MoveUp, Some(CONTEXT)), + KeyBinding::new("down", MoveDown, Some(CONTEXT)), + KeyBinding::new("cmd-s", Save, Some(CONTEXT)), + KeyBinding::new("cmd-q", Quit, Some(CONTEXT)), + ]); +} + +impl Render for Editor { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .key_context(CONTEXT) + .on_action(cx.listener(Self::move_up)) + .on_action(cx.listener(Self::move_down)) + .on_action(cx.listener(Self::save)) + } +} + +impl Editor { + fn move_up(&mut self, _: &MoveUp, cx: &mut Context) { + // Handle move up + cx.notify(); + } + + fn move_down(&mut self, _: &MoveDown, cx: &mut Context) { + cx.notify(); + } + + fn save(&mut self, _: &Save, cx: &mut Context) { + // Save logic + cx.notify(); + } +} +``` + +### Actions with Parameters + +```rust +#[derive(Clone, PartialEq, Action, Deserialize)] +#[action(namespace = editor)] +pub struct InsertText { + pub text: String, +} + +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = editor, no_json)] +pub struct Digit(pub u8); + +cx.bind_keys([ + KeyBinding::new("0", Digit(0), Some(CONTEXT)), + KeyBinding::new("1", Digit(1), Some(CONTEXT)), + // ... +]); + +impl Editor { + fn on_digit(&mut self, action: &Digit, cx: &mut Context) { + self.insert_digit(action.0, cx); + } +} +``` + +## Key Formats + +```rust +// Modifiers +"cmd-s" // Command (macOS) / Ctrl (Windows/Linux) +"ctrl-c" // Control +"alt-f" // Alt +"shift-tab" // Shift +"cmd-ctrl-f" // Multiple modifiers + +// Keys +"a-z", "0-9" // Letters and numbers +"f1-f12" // Function keys +"up", "down", "left", "right" +"enter", "escape", "space", "tab" +"backspace", "delete" +"-", "=", "[", "]", etc. // Special characters +``` + +## Action Naming + +Prefer verb-noun pattern: + +```rust +actions!([ + OpenFile, // ✅ Good + CloseWindow, // ✅ Good + ToggleSidebar, // ✅ Good + Save, // ✅ Good (common exception) +]); +``` + +## Context-Aware Bindings + +```rust +const EDITOR_CONTEXT: &str = "Editor"; +const MODAL_CONTEXT: &str = "Modal"; + +// Same key, different contexts +cx.bind_keys([ + KeyBinding::new("escape", CloseModal, Some(MODAL_CONTEXT)), + KeyBinding::new("escape", ClearSelection, Some(EDITOR_CONTEXT)), +]); + +// Set context on element +div() + .key_context(EDITOR_CONTEXT) + .child(editor_content) +``` + +## Best Practices + +### ✅ Use Contexts + +```rust +// ✅ Good: Context-aware +div() + .key_context("MyComponent") + .on_action(cx.listener(Self::handle)) +``` + +### ✅ Name Actions Clearly + +```rust +// ✅ Good: Clear intent +actions!([ + SaveDocument, + CloseTab, + TogglePreview, +]); +``` + +### ✅ Handle with Listeners + +```rust +// ✅ Good: Proper handler naming +impl MyComponent { + fn on_action_save(&mut self, _: &Save, cx: &mut Context) { + // Handle save + cx.notify(); + } +} + +div().on_action(cx.listener(Self::on_action_save)) +``` + +## Reference Documentation + +- **Complete Guide**: See [reference.md](references/reference.md) + - Action definition, keybinding, dispatch + - Focus-based routing, best practices + - Performance, accessibility diff --git a/.agents/skills/gpui-async/SKILL.md b/.agents/skills/gpui-async/SKILL.md new file mode 100644 index 0000000..a5e531d --- /dev/null +++ b/.agents/skills/gpui-async/SKILL.md @@ -0,0 +1,175 @@ +--- +name: gpui-async +description: Async operations and background tasks in GPUI. Use when working with async, spawn, background tasks, or concurrent operations. Essential for handling async I/O, long-running computations, and coordinating between foreground UI updates and background work. +--- + +## Overview + +GPUI provides integrated async runtime for foreground UI updates and background computation. + +**Key Concepts:** +- **Foreground tasks**: UI thread, can update entities (`cx.spawn`) +- **Background tasks**: Worker threads, CPU-intensive work (`cx.background_spawn`) +- All entity updates happen on foreground thread + +## Quick Start + +### Foreground Tasks (UI Updates) + +```rust +impl MyComponent { + fn fetch_data(&mut self, cx: &mut Context) { + let entity = cx.entity().downgrade(); + + cx.spawn(async move |cx| { + // Runs on UI thread, can await and update entities + let data = fetch_from_api().await; + + entity.update(cx, |state, cx| { + state.data = Some(data); + cx.notify(); + }).ok(); + }).detach(); + } +} +``` + +### Background Tasks (Heavy Work) + +```rust +impl MyComponent { + fn process_file(&mut self, cx: &mut Context) { + let entity = cx.entity().downgrade(); + + cx.background_spawn(async move { + // Runs on background thread, CPU-intensive + let result = heavy_computation().await; + result + }) + .then(cx.spawn(move |result, cx| { + // Back to foreground to update UI + entity.update(cx, |state, cx| { + state.result = result; + cx.notify(); + }).ok(); + })) + .detach(); + } +} +``` + +### Task Management + +```rust +struct MyView { + _task: Task<()>, // Prefix with _ if stored but not accessed +} + +impl MyView { + fn new(cx: &mut Context) -> Self { + let entity = cx.entity().downgrade(); + + let _task = cx.spawn(async move |cx| { + // Task automatically cancelled when dropped + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + entity.update(cx, |state, cx| { + state.tick(); + cx.notify(); + }).ok(); + } + }); + + Self { _task } + } +} +``` + +## Core Patterns + +### 1. Async Data Fetching + +```rust +cx.spawn(async move |cx| { + let data = fetch_data().await?; + entity.update(cx, |state, cx| { + state.data = Some(data); + cx.notify(); + })?; + Ok::<_, anyhow::Error>(()) +}).detach(); +``` + +### 2. Background Computation + UI Update + +```rust +cx.background_spawn(async move { + heavy_work() +}) +.then(cx.spawn(move |result, cx| { + entity.update(cx, |state, cx| { + state.result = result; + cx.notify(); + }).ok(); +})) +.detach(); +``` + +### 3. Periodic Tasks + +```rust +cx.spawn(async move |cx| { + loop { + tokio::time::sleep(Duration::from_secs(5)).await; + // Update every 5 seconds + } +}).detach(); +``` + +### 4. Task Cancellation + +Tasks are automatically cancelled when dropped. Store in struct to keep alive. + +## Common Pitfalls + +### ❌ Don't: Update entities from background tasks + +```rust +// ❌ Wrong: Can't update entities from background thread +cx.background_spawn(async move { + entity.update(cx, |state, cx| { // Compile error! + state.data = data; + }); +}); +``` + +### ✅ Do: Use foreground task or chain + +```rust +// ✅ Correct: Chain with foreground task +cx.background_spawn(async move { data }) + .then(cx.spawn(move |data, cx| { + entity.update(cx, |state, cx| { + state.data = data; + cx.notify(); + }).ok(); + })) + .detach(); +``` + +## Reference Documentation + +### Complete Guides +- **API Reference**: See [api-reference.md](references/api-reference.md) + - Task types, spawning methods, contexts + - Executors, cancellation, error handling + +- **Patterns**: See [patterns.md](references/patterns.md) + - Data fetching, background processing + - Polling, debouncing, parallel tasks + - Pattern selection guide + +- **Best Practices**: See [best-practices.md](references/best-practices.md) + - Error handling, cancellation + - Performance optimization, testing + - Common pitfalls and solutions diff --git a/.agents/skills/gpui-context/SKILL.md b/.agents/skills/gpui-context/SKILL.md new file mode 100644 index 0000000..dc9e279 --- /dev/null +++ b/.agents/skills/gpui-context/SKILL.md @@ -0,0 +1,161 @@ +--- +name: gpui-context +description: Context management in GPUI including App, Window, and AsyncApp. Use when working with contexts, entity updates, or window operations. Different context types provide different capabilities for UI rendering, entity management, and async operations. +--- + +## Overview + +GPUI uses different context types for different scenarios: + +**Context Types:** +- **`App`**: Global app state, entity creation +- **`Window`**: Window-specific operations, painting, layout +- **`Context`**: Entity-specific context for component `T` +- **`AsyncApp`**: Async context for foreground tasks +- **`AsyncWindowContext`**: Async context with window access + +## Quick Start + +### Context - Component Context + +```rust +impl MyComponent { + fn update_state(&mut self, cx: &mut Context) { + self.value = 42; + cx.notify(); // Trigger re-render + + // Spawn async task + cx.spawn(async move |cx| { + // Async work + }).detach(); + + // Get current entity + let entity = cx.entity(); + } +} +``` + +### App - Global Context + +```rust +fn main() { + let app = Application::new(); + app.run(|cx: &mut App| { + // Create entities + let entity = cx.new(|cx| MyState::default()); + + // Open windows + cx.open_window(WindowOptions::default(), |window, cx| { + cx.new(|cx| Root::new(view, window, cx)) + }); + }); +} +``` + +### Window - Window Context + +```rust +impl Render for MyView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Window operations + let is_focused = window.is_window_focused(); + let bounds = window.bounds(); + + div().child("Content") + } +} +``` + +### AsyncApp - Async Context + +```rust +cx.spawn(async move |cx: &mut AsyncApp| { + let data = fetch_data().await; + + entity.update(cx, |state, inner_cx| { + state.data = data; + inner_cx.notify(); + }).ok(); +}).detach(); +``` + +## Common Operations + +### Entity Operations + +```rust +// Create entity +let entity = cx.new(|cx| MyState::default()); + +// Update entity +entity.update(cx, |state, cx| { + state.value = 42; + cx.notify(); +}); + +// Read entity +let value = entity.read(cx).value; +``` + +### Notifications and Events + +```rust +// Trigger re-render +cx.notify(); + +// Emit event +cx.emit(MyEvent::Updated); + +// Observe entity +cx.observe(&entity, |this, observed, cx| { + // React to changes +}).detach(); + +// Subscribe to events +cx.subscribe(&entity, |this, source, event, cx| { + // Handle event +}).detach(); +``` + +### Window Operations + +```rust +// Window state +let focused = window.is_window_focused(); +let bounds = window.bounds(); +let scale = window.scale_factor(); + +// Close window +window.remove_window(); +``` + +### Async Operations + +```rust +// Spawn foreground task +cx.spawn(async move |cx| { + // Async work with entity access +}).detach(); + +// Spawn background task +cx.background_spawn(async move { + // Heavy computation +}).detach(); +``` + +## Context Hierarchy + +``` +App (Global) + └─ Window (Per-window) + └─ Context (Per-component) + └─ AsyncApp (In async tasks) + └─ AsyncWindowContext (Async + Window) +``` + +## Reference Documentation + +- **API Reference**: See [api-reference.md](references/api-reference.md) + - Complete context API, methods, conversions + - Entity operations, window operations + - Async contexts, best practices diff --git a/.agents/skills/gpui-element/SKILL.md b/.agents/skills/gpui-element/SKILL.md new file mode 100644 index 0000000..fe62f3f --- /dev/null +++ b/.agents/skills/gpui-element/SKILL.md @@ -0,0 +1,126 @@ +--- +name: gpui-element +description: Implementing custom elements using GPUI's low-level Element API (vs. high-level Render/RenderOnce APIs). Use when you need maximum control over layout, prepaint, and paint phases for complex, performance-critical custom UI components that cannot be achieved with Render/RenderOnce traits. +--- + +## When to Use + +Use the low-level `Element` trait when: +- Need fine-grained control over layout calculation +- Building complex, performance-critical components +- Implementing custom layout algorithms (masonry, circular, etc.) +- High-level `Render`/`RenderOnce` APIs are insufficient + +**Prefer `Render`/`RenderOnce` for:** Simple components, standard layouts, declarative UI + +## Quick Start + +The `Element` trait provides direct control over three rendering phases: + +```rust +impl Element for MyElement { + type RequestLayoutState = MyLayoutState; // Data passed to later phases + type PrepaintState = MyPaintState; // Data for painting + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + // Phase 1: Calculate sizes and positions + fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, Self::RequestLayoutState) + { + let layout_id = window.request_layout( + Style { size: size(px(200.), px(100.)), ..default() }, + vec![], + cx + ); + (layout_id, MyLayoutState { /* ... */ }) + } + + // Phase 2: Create hitboxes, prepare for painting + fn prepaint(&mut self, .., bounds: Bounds, layout: &mut Self::RequestLayoutState, + window: &mut Window, cx: &mut App) -> Self::PrepaintState + { + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + MyPaintState { hitbox } + } + + // Phase 3: Render and handle interactions + fn paint(&mut self, .., bounds: Bounds, layout: &mut Self::RequestLayoutState, + paint_state: &mut Self::PrepaintState, window: &mut Window, cx: &mut App) + { + window.paint_quad(paint_quad(bounds, Corners::all(px(4.)), cx.theme().background)); + + window.on_mouse_event({ + let hitbox = paint_state.hitbox.clone(); + move |event: &MouseDownEvent, phase, window, cx| { + if hitbox.is_hovered(window) && phase.bubble() { + // Handle interaction + cx.stop_propagation(); + } + } + }); + } +} + +// Enable element to be used as child +impl IntoElement for MyElement { + type Element = Self; + fn into_element(self) -> Self::Element { self } +} +``` + +## Core Concepts + +### Three-Phase Rendering + +1. **request_layout**: Calculate sizes and positions, return layout ID and state +2. **prepaint**: Create hitboxes, compute final bounds, prepare for painting +3. **paint**: Render element, set up interactions (mouse events, cursor styles) + +### State Flow + +``` +RequestLayoutState → PrepaintState → paint +``` + +State flows in one direction through associated types, passed as mutable references between phases. + +### Key Operations + +- **Layout**: `window.request_layout(style, children, cx)` - Create layout node +- **Hitboxes**: `window.insert_hitbox(bounds, behavior)` - Create interaction area +- **Painting**: `window.paint_quad(...)` - Render visual content +- **Events**: `window.on_mouse_event(handler)` - Handle user input + +## Reference Documentation + +### Complete API Documentation +- **Element Trait API**: See [api-reference.md](references/api-reference.md) + - Associated types, methods, parameters, return values + - Hitbox system, event handling, cursor styles + +### Implementation Guides +- **Examples**: See [examples.md](references/examples.md) + - Simple text element with highlighting + - Interactive element with selection + - Complex element with child management + +- **Best Practices**: See [best-practices.md](references/best-practices.md) + - State management, performance optimization + - Interaction handling, layout strategies + - Error handling, testing, common pitfalls + +- **Common Patterns**: See [patterns.md](references/patterns.md) + - Text rendering, container, interactive, composite, scrollable patterns + - Pattern selection guide + +- **Advanced Patterns**: See [advanced-patterns.md](references/advanced-patterns.md) + - Custom layout algorithms (masonry, circular) + - Element composition with traits + - Async updates, memoization, virtual lists diff --git a/.agents/skills/gpui-element/references/advanced-patterns.md b/.agents/skills/gpui-element/references/advanced-patterns.md new file mode 100644 index 0000000..36e3d15 --- /dev/null +++ b/.agents/skills/gpui-element/references/advanced-patterns.md @@ -0,0 +1,705 @@ +# Advanced Element Patterns + +Advanced techniques and patterns for implementing sophisticated GPUI elements. + +## Custom Layout Algorithms + +Implementing custom layout algorithms not supported by GPUI's built-in layouts. + +### Masonry Layout (Pinterest-Style) + +```rust +pub struct MasonryLayout { + id: ElementId, + columns: usize, + gap: Pixels, + children: Vec, +} + +struct MasonryLayoutState { + column_layouts: Vec>, + column_heights: Vec, +} + +struct MasonryPaintState { + child_bounds: Vec>, +} + +impl Element for MasonryLayout { + type RequestLayoutState = MasonryLayoutState; + type PrepaintState = MasonryPaintState; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App + ) -> (LayoutId, MasonryLayoutState) { + // Initialize columns + let mut columns: Vec> = vec![Vec::new(); self.columns]; + let mut column_heights = vec![px(0.); self.columns]; + + // Distribute children across columns + for child in &mut self.children { + let (child_layout_id, _) = child.request_layout( + global_id, + inspector_id, + window, + cx + ); + + let child_size = window.layout_bounds(child_layout_id).size; + + // Find shortest column + let min_column_idx = column_heights + .iter() + .enumerate() + .min_by(|a, b| a.1.partial_cmp(b.1).unwrap()) + .unwrap() + .0; + + // Add child to shortest column + columns[min_column_idx].push(child_layout_id); + column_heights[min_column_idx] += child_size.height + self.gap; + } + + // Calculate total layout size + let column_width = px(200.); // Fixed column width + let total_width = column_width * self.columns as f32 + + self.gap * (self.columns - 1) as f32; + let total_height = column_heights.iter() + .max_by(|a, b| a.partial_cmp(b).unwrap()) + .copied() + .unwrap_or(px(0.)); + + let layout_id = window.request_layout( + Style { + size: size(total_width, total_height), + ..default() + }, + columns.iter().flatten().copied().collect(), + cx + ); + + (layout_id, MasonryLayoutState { + column_layouts: columns, + column_heights, + }) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + layout_state: &mut MasonryLayoutState, + window: &mut Window, + cx: &mut App + ) -> MasonryPaintState { + let column_width = px(200.); + let mut child_bounds = Vec::new(); + + // Position children in columns + for (col_idx, column) in layout_state.column_layouts.iter().enumerate() { + let x_offset = bounds.left() + + (column_width + self.gap) * col_idx as f32; + let mut y_offset = bounds.top(); + + for (child_idx, layout_id) in column.iter().enumerate() { + let child_size = window.layout_bounds(*layout_id).size; + let child_bound = Bounds::new( + point(x_offset, y_offset), + size(column_width, child_size.height) + ); + + self.children[child_idx].prepaint( + global_id, + inspector_id, + child_bound, + window, + cx + ); + + child_bounds.push(child_bound); + y_offset += child_size.height + self.gap; + } + } + + MasonryPaintState { child_bounds } + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _layout_state: &mut MasonryLayoutState, + paint_state: &mut MasonryPaintState, + window: &mut Window, + cx: &mut App + ) { + for (child, bounds) in self.children.iter_mut().zip(&paint_state.child_bounds) { + child.paint(global_id, inspector_id, *bounds, window, cx); + } + } +} +``` + +### Circular Layout + +```rust +pub struct CircularLayout { + id: ElementId, + radius: Pixels, + children: Vec, +} + +impl Element for CircularLayout { + type RequestLayoutState = Vec; + type PrepaintState = Vec>; + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App + ) -> (LayoutId, Vec) { + let child_layouts: Vec<_> = self.children + .iter_mut() + .map(|child| child.request_layout(global_id, inspector_id, window, cx).0) + .collect(); + + let diameter = self.radius * 2.; + let layout_id = window.request_layout( + Style { + size: size(diameter, diameter), + ..default() + }, + child_layouts.clone(), + cx + ); + + (layout_id, child_layouts) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + layout_ids: &mut Vec, + window: &mut Window, + cx: &mut App + ) -> Vec> { + let center = bounds.center(); + let angle_step = 2.0 * std::f32::consts::PI / self.children.len() as f32; + + let mut child_bounds = Vec::new(); + + for (i, (child, layout_id)) in self.children.iter_mut() + .zip(layout_ids.iter()) + .enumerate() + { + let angle = angle_step * i as f32; + let child_size = window.layout_bounds(*layout_id).size; + + // Position child on circle + let x = center.x + self.radius * angle.cos() - child_size.width / 2.; + let y = center.y + self.radius * angle.sin() - child_size.height / 2.; + + let child_bound = Bounds::new(point(x, y), child_size); + + child.prepaint(global_id, inspector_id, child_bound, window, cx); + child_bounds.push(child_bound); + } + + child_bounds + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _layout_ids: &mut Vec, + child_bounds: &mut Vec>, + window: &mut Window, + cx: &mut App + ) { + for (child, bounds) in self.children.iter_mut().zip(child_bounds) { + child.paint(global_id, inspector_id, *bounds, window, cx); + } + } +} +``` + +## Element Composition with Traits + +Create reusable behaviors via traits for element composition. + +### Hoverable Trait + +```rust +pub trait Hoverable: Element { + fn on_hover(&mut self, f: F) -> &mut Self + where + F: Fn(&mut Window, &mut App) + 'static; + + fn on_hover_end(&mut self, f: F) -> &mut Self + where + F: Fn(&mut Window, &mut App) + 'static; +} + +// Implementation for custom element +pub struct HoverableElement { + id: ElementId, + content: AnyElement, + hover_handlers: Vec>, + hover_end_handlers: Vec>, + was_hovered: bool, +} + +impl Hoverable for HoverableElement { + fn on_hover(&mut self, f: F) -> &mut Self + where + F: Fn(&mut Window, &mut App) + 'static + { + self.hover_handlers.push(Box::new(f)); + self + } + + fn on_hover_end(&mut self, f: F) -> &mut Self + where + F: Fn(&mut Window, &mut App) + 'static + { + self.hover_end_handlers.push(Box::new(f)); + self + } +} + +impl Element for HoverableElement { + type RequestLayoutState = LayoutId; + type PrepaintState = Hitbox; + + fn paint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _layout: &mut LayoutId, + hitbox: &mut Hitbox, + window: &mut Window, + cx: &mut App + ) { + let is_hovered = hitbox.is_hovered(window); + + // Trigger hover events + if is_hovered && !self.was_hovered { + for handler in &self.hover_handlers { + handler(window, cx); + } + } else if !is_hovered && self.was_hovered { + for handler in &self.hover_end_handlers { + handler(window, cx); + } + } + + self.was_hovered = is_hovered; + + // Paint content + self.content.paint(bounds, window, cx); + } + + // ... other methods +} +``` + +### Clickable Trait + +```rust +pub trait Clickable: Element { + fn on_click(&mut self, f: F) -> &mut Self + where + F: Fn(&MouseUpEvent, &mut Window, &mut App) + 'static; + + fn on_double_click(&mut self, f: F) -> &mut Self + where + F: Fn(&MouseUpEvent, &mut Window, &mut App) + 'static; +} + +pub struct ClickableElement { + id: ElementId, + content: AnyElement, + click_handlers: Vec>, + double_click_handlers: Vec>, + last_click_time: Option, +} + +impl Clickable for ClickableElement { + fn on_click(&mut self, f: F) -> &mut Self + where + F: Fn(&MouseUpEvent, &mut Window, &mut App) + 'static + { + self.click_handlers.push(Box::new(f)); + self + } + + fn on_double_click(&mut self, f: F) -> &mut Self + where + F: Fn(&MouseUpEvent, &mut Window, &mut App) + 'static + { + self.double_click_handlers.push(Box::new(f)); + self + } +} +``` + +## Async Element Updates + +Elements that update based on async operations. + +```rust +pub struct AsyncElement { + id: ElementId, + state: Entity, + loading: bool, + data: Option, +} + +pub struct AsyncState { + loading: bool, + data: Option, +} + +impl Element for AsyncElement { + type RequestLayoutState = (); + type PrepaintState = Hitbox; + + fn paint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _layout: &mut (), + hitbox: &mut Hitbox, + window: &mut Window, + cx: &mut App + ) { + // Display loading or data + if self.loading { + // Paint loading indicator + self.paint_loading(bounds, window, cx); + } else if let Some(data) = &self.data { + // Paint data + self.paint_data(data, bounds, window, cx); + } + + // Trigger async update on click + window.on_mouse_event({ + let state = self.state.clone(); + let hitbox = hitbox.clone(); + + move |event: &MouseUpEvent, phase, window, cx| { + if hitbox.is_hovered(window) && phase.bubble() { + // Spawn async task + cx.spawn({ + let state = state.clone(); + async move { + // Perform async operation + let result = fetch_data_async().await; + + // Update state on completion + state.update(cx, |state, cx| { + state.loading = false; + state.data = Some(result); + cx.notify(); + }); + } + }).detach(); + + // Set loading state immediately + state.update(cx, |state, cx| { + state.loading = true; + cx.notify(); + }); + + cx.stop_propagation(); + } + } + }); + } + + // ... other methods +} + +async fn fetch_data_async() -> String { + // Simulate async operation + tokio::time::sleep(Duration::from_secs(1)).await; + "Data loaded!".to_string() +} +``` + +## Element Memoization + +Optimize performance by memoizing expensive element computations. + +```rust +pub struct MemoizedElement { + id: ElementId, + value: T, + render_fn: Box AnyElement>, + cached_element: Option, + last_value: Option, +} + +impl MemoizedElement { + pub fn new(id: ElementId, value: T, render_fn: F) -> Self + where + F: Fn(&T) -> AnyElement + 'static, + { + Self { + id, + value, + render_fn: Box::new(render_fn), + cached_element: None, + last_value: None, + } + } +} + +impl Element for MemoizedElement { + type RequestLayoutState = LayoutId; + type PrepaintState = (); + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App + ) -> (LayoutId, LayoutId) { + // Check if value changed + if self.last_value.as_ref() != Some(&self.value) || self.cached_element.is_none() { + // Recompute element + self.cached_element = Some((self.render_fn)(&self.value)); + self.last_value = Some(self.value.clone()); + } + + // Request layout for cached element + let (layout_id, _) = self.cached_element + .as_mut() + .unwrap() + .request_layout(global_id, inspector_id, window, cx); + + (layout_id, layout_id) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _layout_id: &mut LayoutId, + window: &mut Window, + cx: &mut App + ) -> () { + self.cached_element + .as_mut() + .unwrap() + .prepaint(global_id, inspector_id, bounds, window, cx); + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _layout_id: &mut LayoutId, + _: &mut (), + window: &mut Window, + cx: &mut App + ) { + self.cached_element + .as_mut() + .unwrap() + .paint(global_id, inspector_id, bounds, window, cx); + } +} + +// Usage +fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + MemoizedElement::new( + ElementId::Name("memoized".into()), + self.expensive_value.clone(), + |value| { + // Expensive rendering function only called when value changes + div().child(format!("Computed: {}", value)) + } + ) +} +``` + +## Virtual List Pattern + +Efficiently render large lists by only rendering visible items. + +```rust +pub struct VirtualList { + id: ElementId, + item_count: usize, + item_height: Pixels, + viewport_height: Pixels, + scroll_offset: Pixels, + render_item: Box AnyElement>, +} + +struct VirtualListState { + visible_range: Range, + visible_item_layouts: Vec, +} + +impl Element for VirtualList { + type RequestLayoutState = VirtualListState; + type PrepaintState = Hitbox; + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App + ) -> (LayoutId, VirtualListState) { + // Calculate visible range + let start_idx = (self.scroll_offset / self.item_height).floor() as usize; + let end_idx = ((self.scroll_offset + self.viewport_height) / self.item_height) + .ceil() as usize; + let visible_range = start_idx..end_idx.min(self.item_count); + + // Request layout only for visible items + let visible_item_layouts: Vec<_> = visible_range.clone() + .map(|i| { + let mut item = (self.render_item)(i); + item.request_layout(global_id, inspector_id, window, cx).0 + }) + .collect(); + + let total_height = self.item_height * self.item_count as f32; + let layout_id = window.request_layout( + Style { + size: size(relative(1.0), self.viewport_height), + overflow: Overflow::Hidden, + ..default() + }, + visible_item_layouts.clone(), + cx + ); + + (layout_id, VirtualListState { + visible_range, + visible_item_layouts, + }) + } + + fn prepaint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + state: &mut VirtualListState, + window: &mut Window, + _cx: &mut App + ) -> Hitbox { + // Prepaint visible items at correct positions + for (i, layout_id) in state.visible_item_layouts.iter().enumerate() { + let item_idx = state.visible_range.start + i; + let y = item_idx as f32 * self.item_height - self.scroll_offset; + let item_bounds = Bounds::new( + point(bounds.left(), bounds.top() + y), + size(bounds.width(), self.item_height) + ); + + // Prepaint if visible + if item_bounds.intersects(&bounds) { + // Prepaint item... + } + } + + window.insert_hitbox(bounds, HitboxBehavior::Normal) + } + + fn paint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + state: &mut VirtualListState, + hitbox: &mut Hitbox, + window: &mut Window, + cx: &mut App + ) { + // Paint visible items + for (i, _layout_id) in state.visible_item_layouts.iter().enumerate() { + let item_idx = state.visible_range.start + i; + let y = item_idx as f32 * self.item_height - self.scroll_offset; + let item_bounds = Bounds::new( + point(bounds.left(), bounds.top() + y), + size(bounds.width(), self.item_height) + ); + + if item_bounds.intersects(&bounds) { + let mut item = (self.render_item)(item_idx); + item.paint(item_bounds, window, cx); + } + } + + // Handle scroll + window.on_mouse_event({ + let hitbox = hitbox.clone(); + let total_height = self.item_height * self.item_count as f32; + + move |event: &ScrollWheelEvent, phase, window, cx| { + if hitbox.is_hovered(window) && phase.bubble() { + self.scroll_offset -= event.delta.y; + self.scroll_offset = self.scroll_offset + .max(px(0.)) + .min(total_height - self.viewport_height); + cx.notify(); + cx.stop_propagation(); + } + } + }); + } +} + +// Usage: Efficiently render 10,000 items +let virtual_list = VirtualList { + id: ElementId::Name("large-list".into()), + item_count: 10_000, + item_height: px(40.), + viewport_height: px(400.), + scroll_offset: px(0.), + render_item: Box::new(|index| { + div().child(format!("Item {}", index)) + }), +}; +``` + +These advanced patterns enable sophisticated element implementations while maintaining performance and code quality. diff --git a/.agents/skills/gpui-element/references/api-reference.md b/.agents/skills/gpui-element/references/api-reference.md new file mode 100644 index 0000000..b2de868 --- /dev/null +++ b/.agents/skills/gpui-element/references/api-reference.md @@ -0,0 +1,477 @@ +# Element API Reference + +Complete API documentation for GPUI's low-level Element trait. + +## Element Trait Structure + +The `Element` trait requires implementing three associated types and five methods: + +```rust +pub trait Element: 'static + IntoElement { + type RequestLayoutState: 'static; + type PrepaintState: 'static; + + fn id(&self) -> Option; + fn source_location(&self) -> Option<&'static std::panic::Location<'static>>; + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState); + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState; + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ); +} +``` + +## Associated Types + +### RequestLayoutState + +Data passed from `request_layout` to `prepaint` and `paint` phases. + +**Usage:** +- Store layout calculations (styled text, child layout IDs) +- Cache expensive computations +- Pass child state between phases + +**Examples:** +```rust +// Simple: no state needed +type RequestLayoutState = (); + +// Single value +type RequestLayoutState = StyledText; + +// Multiple values +type RequestLayoutState = (StyledText, Vec); + +// Complex struct +pub struct MyLayoutState { + pub styled_text: StyledText, + pub child_layouts: Vec<(LayoutId, ChildState)>, + pub computed_bounds: Bounds, +} +type RequestLayoutState = MyLayoutState; +``` + +### PrepaintState + +Data passed from `prepaint` to `paint` phase. + +**Usage:** +- Store hitboxes for interaction +- Cache visual bounds +- Store prepaint results + +**Examples:** +```rust +// Simple: just a hitbox +type PrepaintState = Hitbox; + +// Optional hitbox +type PrepaintState = Option; + +// Multiple values +type PrepaintState = (Hitbox, Vec>); + +// Complex struct +pub struct MyPaintState { + pub hitbox: Hitbox, + pub child_bounds: Vec>, + pub visible_range: Range, +} +type PrepaintState = MyPaintState; +``` + +## Methods + +### id() + +Returns optional unique identifier for debugging and inspection. + +```rust +fn id(&self) -> Option { + Some(self.id.clone()) +} + +// Or if no ID needed +fn id(&self) -> Option { + None +} +``` + +### source_location() + +Returns source location for debugging. Usually returns `None` unless debugging is needed. + +```rust +fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None +} +``` + +### request_layout() + +Calculates sizes and positions for the element tree. + +**Parameters:** +- `global_id`: Global element identifier (optional) +- `inspector_id`: Inspector element identifier (optional) +- `window`: Mutable window reference +- `cx`: Mutable app context + +**Returns:** +- `(LayoutId, Self::RequestLayoutState)`: Layout ID and state for next phases + +**Responsibilities:** +1. Calculate child layouts by calling `child.request_layout()` +2. Create own layout using `window.request_layout()` +3. Return layout ID and state to pass to next phases + +**Example:** +```rust +fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, +) -> (LayoutId, Self::RequestLayoutState) { + // 1. Calculate child layouts + let child_layout_id = self.child.request_layout( + global_id, + inspector_id, + window, + cx + ).0; + + // 2. Create own layout + let layout_id = window.request_layout( + Style { + size: size(px(200.), px(100.)), + ..default() + }, + vec![child_layout_id], + cx + ); + + // 3. Return layout ID and state + (layout_id, MyLayoutState { child_layout_id }) +} +``` + +### prepaint() + +Prepares for painting by creating hitboxes and computing final bounds. + +**Parameters:** +- `global_id`: Global element identifier (optional) +- `inspector_id`: Inspector element identifier (optional) +- `bounds`: Final bounds calculated by layout engine +- `request_layout`: Mutable reference to layout state +- `window`: Mutable window reference +- `cx`: Mutable app context + +**Returns:** +- `Self::PrepaintState`: State for paint phase + +**Responsibilities:** +1. Compute final child bounds based on layout bounds +2. Call `child.prepaint()` for all children +3. Create hitboxes using `window.insert_hitbox()` +4. Return state for paint phase + +**Example:** +```rust +fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, +) -> Self::PrepaintState { + // 1. Compute child bounds + let child_bounds = bounds; // or calculated subset + + // 2. Prepaint children + self.child.prepaint( + global_id, + inspector_id, + child_bounds, + &mut request_layout.child_state, + window, + cx + ); + + // 3. Create hitboxes + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + + // 4. Return paint state + MyPaintState { hitbox } +} +``` + +### paint() + +Renders the element and handles interactions. + +**Parameters:** +- `global_id`: Global element identifier (optional) +- `inspector_id`: Inspector element identifier (optional) +- `bounds`: Final bounds for rendering +- `request_layout`: Mutable reference to layout state +- `prepaint`: Mutable reference to prepaint state +- `window`: Mutable window reference +- `cx`: Mutable app context + +**Responsibilities:** +1. Paint children first (bottom to top) +2. Paint own content (backgrounds, borders, etc.) +3. Set up interactions (mouse events, cursor styles) + +**Example:** +```rust +fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, +) { + // 1. Paint children first + self.child.paint( + global_id, + inspector_id, + child_bounds, + &mut request_layout.child_state, + &mut prepaint.child_paint_state, + window, + cx + ); + + // 2. Paint own content + window.paint_quad(paint_quad( + bounds, + Corners::all(px(4.)), + cx.theme().background, + )); + + // 3. Set up interactions + window.on_mouse_event({ + let hitbox = prepaint.hitbox.clone(); + move |event: &MouseDownEvent, phase, window, cx| { + if hitbox.is_hovered(window) && phase.bubble() { + // Handle click + cx.stop_propagation(); + } + } + }); + + window.set_cursor_style(CursorStyle::PointingHand, &prepaint.hitbox); +} +``` + +## IntoElement Integration + +Elements must also implement `IntoElement` to be used as children: + +```rust +impl IntoElement for MyElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} +``` + +This allows your custom element to be used directly in the element tree: + +```rust +div() + .child(MyElement::new()) // Works because of IntoElement +``` + +## Common Parameters + +### Global and Inspector IDs + +Both are optional identifiers used for debugging and inspection: +- `global_id`: Unique identifier across entire app +- `inspector_id`: Identifier for dev tools/inspector + +Usually passed through to children without modification. + +### Window and Context + +- `window: &mut Window`: Window-specific operations (painting, hitboxes, events) +- `cx: &mut App`: App-wide operations (spawning tasks, accessing globals) + +## Layout System Integration + +### window.request_layout() + +Creates a layout node with specified style and children: + +```rust +let layout_id = window.request_layout( + Style { + size: size(px(200.), px(100.)), + flex: Flex::Column, + gap: px(8.), + ..default() + }, + vec![child1_layout_id, child2_layout_id], + cx +); +``` + +### Bounds + +Represents rectangular region: + +```rust +pub struct Bounds { + pub origin: Point, + pub size: Size, +} + +// Create bounds +let bounds = Bounds::new( + point(px(10.), px(20.)), + size(px(100.), px(50.)) +); + +// Access properties +bounds.left() // origin.x +bounds.top() // origin.y +bounds.right() // origin.x + size.width +bounds.bottom() // origin.y + size.height +bounds.center() // center point +``` + +## Hitbox System + +### Creating Hitboxes + +```rust +// Normal hitbox (blocks events) +let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + +// Transparent hitbox (passes events through) +let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Transparent); +``` + +### Using Hitboxes + +```rust +// Check if hovered +if hitbox.is_hovered(window) { + // ... +} + +// Set cursor style +window.set_cursor_style(CursorStyle::PointingHand, &hitbox); + +// Use in event handlers +window.on_mouse_event(move |event, phase, window, cx| { + if hitbox.is_hovered(window) && phase.bubble() { + // Handle event + } +}); +``` + +## Event Handling + +### Mouse Events + +```rust +// Mouse down +window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { + if phase.bubble() && bounds.contains(&event.position) { + // Handle mouse down + cx.stop_propagation(); // Prevent bubbling + } +}); + +// Mouse up +window.on_mouse_event(move |event: &MouseUpEvent, phase, window, cx| { + // Handle mouse up +}); + +// Mouse move +window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| { + // Handle mouse move +}); + +// Scroll +window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| { + // Handle scroll +}); +``` + +### Event Phase + +Events go through two phases: +- **Capture**: Top-down (parent → child) +- **Bubble**: Bottom-up (child → parent) + +```rust +move |event, phase, window, cx| { + if phase.capture() { + // Handle in capture phase + } else if phase.bubble() { + // Handle in bubble phase + } + + cx.stop_propagation(); // Stop event from continuing +} +``` + +## Cursor Styles + +Available cursor styles: + +```rust +CursorStyle::Arrow +CursorStyle::IBeam // Text selection +CursorStyle::PointingHand // Clickable +CursorStyle::ResizeLeft +CursorStyle::ResizeRight +CursorStyle::ResizeUp +CursorStyle::ResizeDown +CursorStyle::ResizeLeftRight +CursorStyle::ResizeUpDown +CursorStyle::Crosshair +CursorStyle::OperationNotAllowed +``` + +Usage: + +```rust +window.set_cursor_style(CursorStyle::PointingHand, &hitbox); +``` diff --git a/.agents/skills/gpui-element/references/best-practices.md b/.agents/skills/gpui-element/references/best-practices.md new file mode 100644 index 0000000..93b14d3 --- /dev/null +++ b/.agents/skills/gpui-element/references/best-practices.md @@ -0,0 +1,546 @@ +# Element Best Practices + +Guidelines and best practices for implementing high-quality GPUI elements. + +## State Management + +### Using Associated Types Effectively + +**Good:** Use associated types to pass meaningful data between phases + +```rust +// Good: Structured state with type safety +type RequestLayoutState = (StyledText, Vec); +type PrepaintState = (Hitbox, Vec); +``` + +**Bad:** Using empty state when you need data + +```rust +// Bad: No state when you need to pass data +type RequestLayoutState = (); +type PrepaintState = (); +// Now you can't pass layout info to paint phase! +``` + +### Managing Complex State + +For elements with complex state, create dedicated structs: + +```rust +// Good: Dedicated struct for complex state +pub struct TextElementState { + pub styled_text: StyledText, + pub text_layout: TextLayout, + pub child_states: Vec, +} + +type RequestLayoutState = TextElementState; +``` + +**Benefits:** +- Clear documentation of state structure +- Easy to extend +- Type-safe access + +### State Lifecycle + +**Golden Rule:** State flows in one direction through the phases + +``` +request_layout → RequestLayoutState → +prepaint → PrepaintState → +paint +``` + +**Don't:** +- Store state in the element struct that should be in associated types +- Try to mutate element state in paint phase (use `cx.notify()` to schedule re-render) +- Pass mutable references across phase boundaries + +## Performance Considerations + +### Minimize Allocations in Paint Phase + +**Critical:** Paint phase is called every frame during animations. Minimize allocations. + +**Good:** Pre-allocate in `request_layout` or `prepaint` + +```rust +impl Element for MyElement { + fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, Vec) + { + // Allocate once during layout + let styled_texts = self.children + .iter() + .map(|child| StyledText::new(child.text.clone())) + .collect(); + + (layout_id, styled_texts) + } + + fn paint(&mut self, .., styled_texts: &mut Vec, ..) { + // Just use pre-allocated styled_texts + for text in styled_texts { + text.paint(..); + } + } +} +``` + +**Bad:** Allocate in `paint` phase + +```rust +fn paint(&mut self, ..) { + // Bad: Allocation in paint phase! + let styled_texts: Vec<_> = self.children + .iter() + .map(|child| StyledText::new(child.text.clone())) + .collect(); +} +``` + +### Cache Expensive Computations + +Use memoization for expensive operations: + +```rust +pub struct CachedElement { + // Cache key + last_text: Option, + last_width: Option, + + // Cached result + cached_layout: Option, +} + +impl Element for CachedElement { + fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, TextLayout) + { + let current_width = window.bounds().width(); + + // Check if cache is valid + if self.last_text.as_ref() != Some(&self.text) + || self.last_width != Some(current_width) + || self.cached_layout.is_none() + { + // Recompute expensive layout + self.cached_layout = Some(self.compute_text_layout(current_width)); + self.last_text = Some(self.text.clone()); + self.last_width = Some(current_width); + } + + // Use cached layout + let layout = self.cached_layout.as_ref().unwrap(); + (layout_id, layout.clone()) + } +} +``` + +### Lazy Child Rendering + +Only render visible children in scrollable containers: + +```rust +fn paint(&mut self, .., bounds: Bounds, paint_state: &mut Self::PrepaintState, ..) { + for (i, child) in self.children.iter_mut().enumerate() { + let child_bounds = paint_state.child_bounds[i]; + + // Only paint visible children + if self.is_visible(&child_bounds, &bounds) { + child.paint(..); + } + } +} + +fn is_visible(&self, child_bounds: &Bounds, container_bounds: &Bounds) -> bool { + child_bounds.bottom() >= container_bounds.top() && + child_bounds.top() <= container_bounds.bottom() +} +``` + +## Interaction Handling + +### Proper Event Bubbling + +Always check phase and bounds before handling events: + +```rust +fn paint(&mut self, .., window: &mut Window, cx: &mut App) { + window.on_mouse_event({ + let hitbox = self.hitbox.clone(); + move |event: &MouseDownEvent, phase, window, cx| { + // Check phase first + if !phase.bubble() { + return; + } + + // Check if event is within bounds + if !hitbox.is_hovered(window) { + return; + } + + // Handle event + self.handle_click(event); + + // Stop propagation if handled + cx.stop_propagation(); + } + }); +} +``` + +**Don't forget:** +- Check `phase.bubble()` or `phase.capture()` as appropriate +- Check hitbox hover state or bounds +- Call `cx.stop_propagation()` if you handle the event + +### Hitbox Management + +Create hitboxes in `prepaint` phase, not `paint`: + +**Good:** + +```rust +fn prepaint(&mut self, .., bounds: Bounds, window: &mut Window, ..) -> Hitbox { + // Create hitbox in prepaint + window.insert_hitbox(bounds, HitboxBehavior::Normal) +} + +fn paint(&mut self, .., hitbox: &mut Hitbox, window: &mut Window, ..) { + // Use hitbox in paint + window.set_cursor_style(CursorStyle::PointingHand, hitbox); +} +``` + +**Hitbox Behaviors:** + +```rust +// Normal: Blocks events from passing through +HitboxBehavior::Normal + +// Transparent: Allows events to pass through to elements below +HitboxBehavior::Transparent +``` + +### Cursor Style Guidelines + +Set appropriate cursor styles for interactivity cues: + +```rust +// Text selection +window.set_cursor_style(CursorStyle::IBeam, &hitbox); + +// Clickable elements (desktop convention: use default, not pointing hand) +window.set_cursor_style(CursorStyle::Arrow, &hitbox); + +// Links (web convention: use pointing hand) +window.set_cursor_style(CursorStyle::PointingHand, &hitbox); + +// Resizable edges +window.set_cursor_style(CursorStyle::ResizeLeftRight, &hitbox); +``` + +**Desktop vs Web Convention:** +- Desktop apps: Use `Arrow` for buttons +- Web apps: Use `PointingHand` for links only + +## Layout Strategies + +### Fixed Size Elements + +For elements with known, unchanging size: + +```rust +fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) -> (LayoutId, ()) { + let layout_id = window.request_layout( + Style { + size: size(px(200.), px(100.)), + ..default() + }, + vec![], // No children + cx + ); + (layout_id, ()) +} +``` + +### Content-Based Sizing + +For elements sized by their content: + +```rust +fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, Size) +{ + // Measure content + let text_bounds = self.measure_text(window); + let padding = px(16.); + + let layout_id = window.request_layout( + Style { + size: size( + text_bounds.width() + padding * 2., + text_bounds.height() + padding * 2., + ), + ..default() + }, + vec![], + cx + ); + + (layout_id, text_bounds) +} +``` + +### Flexible Layouts + +For elements that adapt to available space: + +```rust +fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, Vec) +{ + let mut child_layout_ids = Vec::new(); + + for child in &mut self.children { + let (layout_id, _) = child.request_layout(window, cx); + child_layout_ids.push(layout_id); + } + + let layout_id = window.request_layout( + Style { + flex_direction: FlexDirection::Row, + gap: px(8.), + size: Size { + width: relative(1.0), // Fill parent width + height: auto(), // Auto height + }, + ..default() + }, + child_layout_ids.clone(), + cx + ); + + (layout_id, child_layout_ids) +} +``` + +## Error Handling + +### Graceful Degradation + +Handle errors gracefully, don't panic: + +```rust +fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, Option) +{ + // Try to create styled text + match StyledText::new(self.text.clone()).request_layout(None, None, window, cx) { + Ok((layout_id, text_layout)) => { + (layout_id, Some(text_layout)) + } + Err(e) => { + // Log error + eprintln!("Failed to layout text: {}", e); + + // Fallback to simple text + let fallback_text = StyledText::new("(Error loading text)".into()); + let (layout_id, _) = fallback_text.request_layout(None, None, window, cx); + (layout_id, None) + } + } +} +``` + +### Defensive Bounds Checking + +Always validate bounds and indices: + +```rust +fn paint_selection(&self, selection: &Selection, text_layout: &TextLayout, ..) { + // Validate selection bounds + let start = selection.start.min(self.text.len()); + let end = selection.end.min(self.text.len()); + + if start >= end { + return; // Invalid selection + } + + let rects = text_layout.rects_for_range(start..end); + // Paint selection... +} +``` + +## Testing Element Implementations + +### Layout Tests + +Test that layout calculations are correct: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[gpui::test] + fn test_element_layout(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut window = cx.open_window(Default::default(), |_, _| ()).unwrap(); + + window.update(cx, |window, cx| { + let mut element = MyElement::new(); + let (layout_id, layout_state) = element.request_layout( + None, + None, + window, + cx + ); + + // Assert layout properties + let bounds = window.layout_bounds(layout_id); + assert_eq!(bounds.size.width, px(200.)); + assert_eq!(bounds.size.height, px(100.)); + }); + }); + } +} +``` + +### Interaction Tests + +Test that interactions work correctly: + +```rust +#[gpui::test] +fn test_element_click(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut window = cx.open_window(Default::default(), |_, cx| { + cx.new(|_| MyElement::new()) + }).unwrap(); + + window.update(cx, |window, cx| { + let view = window.root_view().unwrap(); + + // Simulate click + let position = point(px(10.), px(10.)); + window.dispatch_event(MouseDownEvent { + position, + button: MouseButton::Left, + modifiers: Modifiers::default(), + }); + + // Assert element responded + view.read(cx).assert_clicked(); + }); + }); +} +``` + +## Common Pitfalls + +### ❌ Storing Layout State in Element Struct + +**Bad:** + +```rust +pub struct MyElement { + id: ElementId, + // Bad: This should be in RequestLayoutState + cached_layout: Option, +} +``` + +**Good:** + +```rust +pub struct MyElement { + id: ElementId, + text: SharedString, +} + +type RequestLayoutState = TextLayout; // Good: State in associated type +``` + +### ❌ Mutating Element in Paint Phase + +**Bad:** + +```rust +fn paint(&mut self, ..) { + self.counter += 1; // Bad: Mutating element in paint +} +``` + +**Good:** + +```rust +fn paint(&mut self, .., window: &mut Window, cx: &mut App) { + window.on_mouse_event(move |event, phase, window, cx| { + if phase.bubble() { + self.counter += 1; + cx.notify(); // Schedule re-render + } + }); +} +``` + +### ❌ Creating Hitboxes in Paint Phase + +**Bad:** + +```rust +fn paint(&mut self, .., bounds: Bounds, window: &mut Window, ..) { + // Bad: Creating hitbox in paint + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); +} +``` + +**Good:** + +```rust +fn prepaint(&mut self, .., bounds: Bounds, window: &mut Window, ..) -> Hitbox { + // Good: Creating hitbox in prepaint + window.insert_hitbox(bounds, HitboxBehavior::Normal) +} +``` + +### ❌ Ignoring Event Phase + +**Bad:** + +```rust +window.on_mouse_event(move |event, phase, window, cx| { + // Bad: Not checking phase + self.handle_click(event); +}); +``` + +**Good:** + +```rust +window.on_mouse_event(move |event, phase, window, cx| { + // Good: Checking phase + if !phase.bubble() { + return; + } + self.handle_click(event); +}); +``` + +## Performance Checklist + +Before shipping an element implementation, verify: + +- [ ] No allocations in `paint` phase (except event handlers) +- [ ] Expensive computations are cached/memoized +- [ ] Only visible children are rendered in scrollable containers +- [ ] Hitboxes created in `prepaint`, not `paint` +- [ ] Event handlers check phase and bounds +- [ ] Layout state is passed through associated types, not stored in element +- [ ] Element implements proper error handling with fallbacks +- [ ] Tests cover layout calculations and interactions diff --git a/.agents/skills/gpui-element/references/examples.md b/.agents/skills/gpui-element/references/examples.md new file mode 100644 index 0000000..0e80761 --- /dev/null +++ b/.agents/skills/gpui-element/references/examples.md @@ -0,0 +1,632 @@ +# Element Implementation Examples + +Complete examples of implementing custom elements for various scenarios. + +## Table of Contents + +1. [Simple Text Element](#simple-text-element) +2. [Interactive Element with Selection](#interactive-element-with-selection) +3. [Complex Element with Child Management](#complex-element-with-child-management) + +## Simple Text Element + +A basic text element with syntax highlighting support. + +```rust +pub struct SimpleText { + id: ElementId, + text: SharedString, + highlights: Vec<(Range, HighlightStyle)>, +} + +impl IntoElement for SimpleText { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for SimpleText { + type RequestLayoutState = StyledText; + type PrepaintState = Hitbox; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App + ) -> (LayoutId, Self::RequestLayoutState) { + // Create styled text with highlights + let mut runs = Vec::new(); + let mut ix = 0; + + for (range, highlight) in &self.highlights { + // Add unstyled text before highlight + if ix < range.start { + runs.push(window.text_style().to_run(range.start - ix)); + } + + // Add highlighted text + runs.push( + window.text_style() + .highlight(*highlight) + .to_run(range.len()) + ); + ix = range.end; + } + + // Add remaining unstyled text + if ix < self.text.len() { + runs.push(window.text_style().to_run(self.text.len() - ix)); + } + + let styled_text = StyledText::new(self.text.clone()).with_runs(runs); + let (layout_id, _) = styled_text.request_layout( + global_id, + inspector_id, + window, + cx + ); + + (layout_id, styled_text) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + styled_text: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App + ) -> Self::PrepaintState { + // Prepaint the styled text + styled_text.prepaint( + global_id, + inspector_id, + bounds, + &mut (), + window, + cx + ); + + // Create hitbox for interaction + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + hitbox + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + styled_text: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App + ) { + // Paint the styled text + styled_text.paint( + global_id, + inspector_id, + bounds, + &mut (), + &mut (), + window, + cx + ); + + // Set cursor style for text + window.set_cursor_style(CursorStyle::IBeam, hitbox); + } +} +``` + +## Interactive Element with Selection + +A text element that supports text selection via mouse interaction. + +```rust +#[derive(Clone)] +pub struct Selection { + pub start: usize, + pub end: usize, +} + +pub struct SelectableText { + id: ElementId, + text: SharedString, + selectable: bool, + selection: Option, +} + +impl IntoElement for SelectableText { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for SelectableText { + type RequestLayoutState = TextLayout; + type PrepaintState = Option; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App + ) -> (LayoutId, Self::RequestLayoutState) { + let styled_text = StyledText::new(self.text.clone()); + let (layout_id, _) = styled_text.request_layout( + global_id, + inspector_id, + window, + cx + ); + + // Extract text layout for selection painting + let text_layout = styled_text.layout().clone(); + + (layout_id, text_layout) + } + + fn prepaint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _text_layout: &mut Self::RequestLayoutState, + window: &mut Window, + _cx: &mut App + ) -> Self::PrepaintState { + // Only create hitbox if selectable + if self.selectable { + Some(window.insert_hitbox(bounds, HitboxBehavior::Normal)) + } else { + None + } + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + text_layout: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App + ) { + // Paint text + let styled_text = StyledText::new(self.text.clone()); + styled_text.paint( + global_id, + inspector_id, + bounds, + &mut (), + &mut (), + window, + cx + ); + + // Paint selection if any + if let Some(selection) = &self.selection { + Self::paint_selection(selection, text_layout, &bounds, window, cx); + } + + // Handle mouse events for selection + if let Some(hitbox) = hitbox { + window.set_cursor_style(CursorStyle::IBeam, hitbox); + + // Mouse down to start selection + window.on_mouse_event({ + let bounds = bounds.clone(); + move |event: &MouseDownEvent, phase, window, cx| { + if bounds.contains(&event.position) && phase.bubble() { + // Start selection at mouse position + let char_index = Self::position_to_index( + event.position, + &bounds, + text_layout + ); + self.selection = Some(Selection { + start: char_index, + end: char_index, + }); + cx.notify(); + cx.stop_propagation(); + } + } + }); + + // Mouse drag to extend selection + window.on_mouse_event({ + let bounds = bounds.clone(); + move |event: &MouseMoveEvent, phase, window, cx| { + if let Some(selection) = &mut self.selection { + if phase.bubble() { + let char_index = Self::position_to_index( + event.position, + &bounds, + text_layout + ); + selection.end = char_index; + cx.notify(); + } + } + } + }); + } + } +} + +impl SelectableText { + fn paint_selection( + selection: &Selection, + text_layout: &TextLayout, + bounds: &Bounds, + window: &mut Window, + cx: &mut App + ) { + // Calculate selection bounds from text layout + let selection_rects = text_layout.rects_for_range( + selection.start..selection.end + ); + + // Paint selection background + for rect in selection_rects { + window.paint_quad(paint_quad( + Bounds::new( + point(bounds.left() + rect.origin.x, bounds.top() + rect.origin.y), + rect.size + ), + Corners::default(), + cx.theme().selection_background, + )); + } + } + + fn position_to_index( + position: Point, + bounds: &Bounds, + text_layout: &TextLayout + ) -> usize { + // Convert screen position to character index + let relative_pos = point( + position.x - bounds.left(), + position.y - bounds.top() + ); + text_layout.index_for_position(relative_pos) + } +} +``` + +## Complex Element with Child Management + +A container element that manages multiple children with scrolling support. + +```rust +pub struct ComplexElement { + id: ElementId, + children: Vec>>, + scrollable: bool, + scroll_offset: Point, +} + +struct ComplexLayoutState { + child_layouts: Vec, + total_height: Pixels, +} + +struct ComplexPaintState { + child_bounds: Vec>, + hitbox: Hitbox, +} + +impl IntoElement for ComplexElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ComplexElement { + type RequestLayoutState = ComplexLayoutState; + type PrepaintState = ComplexPaintState; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App + ) -> (LayoutId, Self::RequestLayoutState) { + let mut child_layouts = Vec::new(); + let mut total_height = px(0.); + + // Request layout for all children + for child in &mut self.children { + let (child_layout_id, _) = child.request_layout( + global_id, + inspector_id, + window, + cx + ); + child_layouts.push(child_layout_id); + + // Get child size from layout + let child_size = window.layout_bounds(child_layout_id).size(); + total_height += child_size.height; + } + + // Create container layout + let layout_id = window.request_layout( + Style { + flex_direction: FlexDirection::Column, + gap: px(8.), + size: Size { + width: relative(1.0), + height: if self.scrollable { + // Fixed height for scrollable + px(400.) + } else { + // Auto height for non-scrollable + total_height + }, + }, + ..default() + }, + child_layouts.clone(), + cx + ); + + (layout_id, ComplexLayoutState { + child_layouts, + total_height, + }) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + layout_state: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App + ) -> Self::PrepaintState { + let mut child_bounds = Vec::new(); + let mut y_offset = self.scroll_offset.y; + + // Calculate child bounds and prepaint children + for (child, layout_id) in self.children.iter_mut() + .zip(&layout_state.child_layouts) + { + let child_size = window.layout_bounds(*layout_id).size(); + let child_bound = Bounds::new( + point(bounds.left(), bounds.top() + y_offset), + child_size + ); + + // Only prepaint visible children + if self.is_visible(&child_bound, &bounds) { + child.prepaint( + global_id, + inspector_id, + child_bound, + &mut (), + window, + cx + ); + } + + child_bounds.push(child_bound); + y_offset += child_size.height + px(8.); // gap + } + + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + + ComplexPaintState { + child_bounds, + hitbox, + } + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + layout_state: &mut Self::RequestLayoutState, + paint_state: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App + ) { + // Paint background + window.paint_quad(paint_quad( + bounds, + Corners::all(px(4.)), + cx.theme().background, + )); + + // Paint visible children only + for (i, child) in self.children.iter_mut().enumerate() { + let child_bounds = paint_state.child_bounds[i]; + + if self.is_visible(&child_bounds, &bounds) { + child.paint( + global_id, + inspector_id, + child_bounds, + &mut (), + &mut (), + window, + cx + ); + } + } + + // Paint scrollbar if scrollable + if self.scrollable { + self.paint_scrollbar(bounds, layout_state, window, cx); + } + + // Handle scroll events + if self.scrollable { + window.on_mouse_event({ + let hitbox = paint_state.hitbox.clone(); + let total_height = layout_state.total_height; + let visible_height = bounds.size.height; + + move |event: &ScrollWheelEvent, phase, window, cx| { + if hitbox.is_hovered(window) && phase.bubble() { + // Update scroll offset + self.scroll_offset.y -= event.delta.y; + + // Clamp scroll offset + let max_scroll = (total_height - visible_height).max(px(0.)); + self.scroll_offset.y = self.scroll_offset.y + .max(px(0.)) + .min(max_scroll); + + cx.notify(); + cx.stop_propagation(); + } + } + }); + } + } +} + +impl ComplexElement { + fn is_visible(&self, child_bounds: &Bounds, container_bounds: &Bounds) -> bool { + // Check if child is within visible area + child_bounds.bottom() >= container_bounds.top() && + child_bounds.top() <= container_bounds.bottom() + } + + fn paint_scrollbar( + &self, + bounds: Bounds, + layout_state: &ComplexLayoutState, + window: &mut Window, + cx: &mut App + ) { + let scrollbar_width = px(8.); + let visible_height = bounds.size.height; + let total_height = layout_state.total_height; + + if total_height <= visible_height { + return; // No need for scrollbar + } + + // Calculate scrollbar position and size + let scroll_ratio = self.scroll_offset.y / (total_height - visible_height); + let thumb_height = (visible_height / total_height) * visible_height; + let thumb_y = scroll_ratio * (visible_height - thumb_height); + + // Paint scrollbar track + let track_bounds = Bounds::new( + point(bounds.right() - scrollbar_width, bounds.top()), + size(scrollbar_width, visible_height) + ); + window.paint_quad(paint_quad( + track_bounds, + Corners::default(), + cx.theme().scrollbar_track, + )); + + // Paint scrollbar thumb + let thumb_bounds = Bounds::new( + point(bounds.right() - scrollbar_width, bounds.top() + thumb_y), + size(scrollbar_width, thumb_height) + ); + window.paint_quad(paint_quad( + thumb_bounds, + Corners::all(px(4.)), + cx.theme().scrollbar_thumb, + )); + } +} +``` + +## Usage Examples + +### Using SimpleText + +```rust +fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .child(SimpleText { + id: ElementId::Name("code-text".into()), + text: "fn main() { println!(\"Hello\"); }".into(), + highlights: vec![ + (0..2, HighlightStyle::keyword()), + (3..7, HighlightStyle::function()), + ], + }) +} +``` + +### Using SelectableText + +```rust +fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .child(SelectableText { + id: ElementId::Name("selectable-text".into()), + text: "Select this text with your mouse".into(), + selectable: true, + selection: self.current_selection.clone(), + }) +} +``` + +### Using ComplexElement + +```rust +fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let children: Vec>> = self.items + .iter() + .map(|item| Box::new(div().child(item.name.clone())) as Box<_>) + .collect(); + + div() + .child(ComplexElement { + id: ElementId::Name("scrollable-list".into()), + children, + scrollable: true, + scroll_offset: self.scroll_offset, + }) +} +``` diff --git a/.agents/skills/gpui-element/references/patterns.md b/.agents/skills/gpui-element/references/patterns.md new file mode 100644 index 0000000..1d28fa3 --- /dev/null +++ b/.agents/skills/gpui-element/references/patterns.md @@ -0,0 +1,509 @@ +# Common Element Patterns + +Reusable patterns for implementing common element types in GPUI. + +## Text Rendering Elements + +Elements that display and manipulate text content. + +### Pattern Characteristics + +- Use `StyledText` for text layout and rendering +- Handle text selection in `paint` phase with hitbox interaction +- Create hitboxes for text interaction in `prepaint` +- Support text highlighting and custom styling via runs + +### Implementation Template + +```rust +pub struct TextElement { + id: ElementId, + text: SharedString, + style: TextStyle, +} + +impl Element for TextElement { + type RequestLayoutState = StyledText; + type PrepaintState = Hitbox; + + fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, StyledText) + { + let styled_text = StyledText::new(self.text.clone()) + .with_style(self.style); + let (layout_id, _) = styled_text.request_layout(None, None, window, cx); + (layout_id, styled_text) + } + + fn prepaint(&mut self, .., bounds: Bounds, styled_text: &mut StyledText, + window: &mut Window, cx: &mut App) -> Hitbox + { + styled_text.prepaint(None, None, bounds, &mut (), window, cx); + window.insert_hitbox(bounds, HitboxBehavior::Normal) + } + + fn paint(&mut self, .., bounds: Bounds, styled_text: &mut StyledText, + hitbox: &mut Hitbox, window: &mut Window, cx: &mut App) + { + styled_text.paint(None, None, bounds, &mut (), &mut (), window, cx); + window.set_cursor_style(CursorStyle::IBeam, hitbox); + } +} +``` + +### Use Cases + +- Code editors with syntax highlighting +- Rich text displays +- Labels with custom formatting +- Selectable text areas + +## Container Elements + +Elements that manage and layout child elements. + +### Pattern Characteristics + +- Manage child element layouts and positions +- Handle scrolling and clipping when needed +- Implement flex/grid-like layouts +- Coordinate child interactions and event delegation + +### Implementation Template + +```rust +pub struct ContainerElement { + id: ElementId, + children: Vec, + direction: FlexDirection, + gap: Pixels, +} + +impl Element for ContainerElement { + type RequestLayoutState = Vec; + type PrepaintState = Vec>; + + fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) + -> (LayoutId, Vec) + { + let child_layout_ids: Vec<_> = self.children + .iter_mut() + .map(|child| child.request_layout(window, cx).0) + .collect(); + + let layout_id = window.request_layout( + Style { + flex_direction: self.direction, + gap: self.gap, + ..default() + }, + child_layout_ids.clone(), + cx + ); + + (layout_id, child_layout_ids) + } + + fn prepaint(&mut self, .., bounds: Bounds, layout_ids: &mut Vec, + window: &mut Window, cx: &mut App) -> Vec> + { + let mut child_bounds = Vec::new(); + + for (child, layout_id) in self.children.iter_mut().zip(layout_ids.iter()) { + let child_bound = window.layout_bounds(*layout_id); + child.prepaint(child_bound, window, cx); + child_bounds.push(child_bound); + } + + child_bounds + } + + fn paint(&mut self, .., child_bounds: &mut Vec>, + window: &mut Window, cx: &mut App) + { + for (child, bounds) in self.children.iter_mut().zip(child_bounds.iter()) { + child.paint(*bounds, window, cx); + } + } +} +``` + +### Use Cases + +- Panels and split views +- List containers +- Grid layouts +- Tab containers + +## Interactive Elements + +Elements that respond to user input (mouse, keyboard, touch). + +### Pattern Characteristics + +- Create appropriate hitboxes for interaction areas +- Handle mouse/keyboard/touch events properly +- Manage focus and cursor styles +- Support hover, active, and disabled states + +### Implementation Template + +```rust +pub struct InteractiveElement { + id: ElementId, + content: AnyElement, + on_click: Option>, + hover_style: Option