feat: refactor to use gpui event instead of local state #18
180
.agents/skills/gpui-action/SKILL.md
Normal file
180
.agents/skills/gpui-action/SKILL.md
Normal file
@@ -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<Self>) -> 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<Self>) {
|
||||
// Handle move up
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn move_down(&mut self, _: &MoveDown, cx: &mut Context<Self>) {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn save(&mut self, _: &Save, cx: &mut Context<Self>) {
|
||||
// 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>) {
|
||||
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<Self>) {
|
||||
// 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
|
||||
175
.agents/skills/gpui-async/SKILL.md
Normal file
175
.agents/skills/gpui-async/SKILL.md
Normal file
@@ -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<Self>) {
|
||||
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<Self>) {
|
||||
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>) -> 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
|
||||
161
.agents/skills/gpui-context/SKILL.md
Normal file
161
.agents/skills/gpui-context/SKILL.md
Normal file
@@ -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<T>`**: Entity-specific context for component `T`
|
||||
- **`AsyncApp`**: Async context for foreground tasks
|
||||
- **`AsyncWindowContext`**: Async context with window access
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Context<T> - Component Context
|
||||
|
||||
```rust
|
||||
impl MyComponent {
|
||||
fn update_state(&mut self, cx: &mut Context<Self>) {
|
||||
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<Self>) -> 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<T> (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
|
||||
126
.agents/skills/gpui-element/SKILL.md
Normal file
126
.agents/skills/gpui-element/SKILL.md
Normal file
@@ -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<ElementId> {
|
||||
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<Pixels>, 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<Pixels>, 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
|
||||
705
.agents/skills/gpui-element/references/advanced-patterns.md
Normal file
705
.agents/skills/gpui-element/references/advanced-patterns.md
Normal file
@@ -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<AnyElement>,
|
||||
}
|
||||
|
||||
struct MasonryLayoutState {
|
||||
column_layouts: Vec<Vec<LayoutId>>,
|
||||
column_heights: Vec<Pixels>,
|
||||
}
|
||||
|
||||
struct MasonryPaintState {
|
||||
child_bounds: Vec<Bounds<Pixels>>,
|
||||
}
|
||||
|
||||
impl Element for MasonryLayout {
|
||||
type RequestLayoutState = MasonryLayoutState;
|
||||
type PrepaintState = MasonryPaintState;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
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<LayoutId>> = 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<Pixels>,
|
||||
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<Pixels>,
|
||||
_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<AnyElement>,
|
||||
}
|
||||
|
||||
impl Element for CircularLayout {
|
||||
type RequestLayoutState = Vec<LayoutId>;
|
||||
type PrepaintState = Vec<Bounds<Pixels>>;
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
global_id: Option<&GlobalElementId>,
|
||||
inspector_id: Option<&InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App
|
||||
) -> (LayoutId, Vec<LayoutId>) {
|
||||
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<Pixels>,
|
||||
layout_ids: &mut Vec<LayoutId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App
|
||||
) -> Vec<Bounds<Pixels>> {
|
||||
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<Pixels>,
|
||||
_layout_ids: &mut Vec<LayoutId>,
|
||||
child_bounds: &mut Vec<Bounds<Pixels>>,
|
||||
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<F>(&mut self, f: F) -> &mut Self
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) + 'static;
|
||||
|
||||
fn on_hover_end<F>(&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<Box<dyn Fn(&mut Window, &mut App)>>,
|
||||
hover_end_handlers: Vec<Box<dyn Fn(&mut Window, &mut App)>>,
|
||||
was_hovered: bool,
|
||||
}
|
||||
|
||||
impl Hoverable for HoverableElement {
|
||||
fn on_hover<F>(&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<F>(&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<Pixels>,
|
||||
_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<F>(&mut self, f: F) -> &mut Self
|
||||
where
|
||||
F: Fn(&MouseUpEvent, &mut Window, &mut App) + 'static;
|
||||
|
||||
fn on_double_click<F>(&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<Box<dyn Fn(&MouseUpEvent, &mut Window, &mut App)>>,
|
||||
double_click_handlers: Vec<Box<dyn Fn(&MouseUpEvent, &mut Window, &mut App)>>,
|
||||
last_click_time: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Clickable for ClickableElement {
|
||||
fn on_click<F>(&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<F>(&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<AsyncState>,
|
||||
loading: bool,
|
||||
data: Option<String>,
|
||||
}
|
||||
|
||||
pub struct AsyncState {
|
||||
loading: bool,
|
||||
data: Option<String>,
|
||||
}
|
||||
|
||||
impl Element for AsyncElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = Hitbox;
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_global_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_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<T: PartialEq + Clone + 'static> {
|
||||
id: ElementId,
|
||||
value: T,
|
||||
render_fn: Box<dyn Fn(&T) -> AnyElement>,
|
||||
cached_element: Option<AnyElement>,
|
||||
last_value: Option<T>,
|
||||
}
|
||||
|
||||
impl<T: PartialEq + Clone + 'static> MemoizedElement<T> {
|
||||
pub fn new<F>(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<T: PartialEq + Clone + 'static> Element for MemoizedElement<T> {
|
||||
type RequestLayoutState = LayoutId;
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
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<Pixels>,
|
||||
_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<Pixels>,
|
||||
_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<Self>) -> 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<dyn Fn(usize) -> AnyElement>,
|
||||
}
|
||||
|
||||
struct VirtualListState {
|
||||
visible_range: Range<usize>,
|
||||
visible_item_layouts: Vec<LayoutId>,
|
||||
}
|
||||
|
||||
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<Pixels>,
|
||||
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<Pixels>,
|
||||
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.
|
||||
477
.agents/skills/gpui-element/references/api-reference.md
Normal file
477
.agents/skills/gpui-element/references/api-reference.md
Normal file
@@ -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<ElementId>;
|
||||
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<Pixels>,
|
||||
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<Pixels>,
|
||||
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<ChildLayout>);
|
||||
|
||||
// Complex struct
|
||||
pub struct MyLayoutState {
|
||||
pub styled_text: StyledText,
|
||||
pub child_layouts: Vec<(LayoutId, ChildState)>,
|
||||
pub computed_bounds: Bounds<Pixels>,
|
||||
}
|
||||
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<Hitbox>;
|
||||
|
||||
// Multiple values
|
||||
type PrepaintState = (Hitbox, Vec<Bounds<Pixels>>);
|
||||
|
||||
// Complex struct
|
||||
pub struct MyPaintState {
|
||||
pub hitbox: Hitbox,
|
||||
pub child_bounds: Vec<Bounds<Pixels>>,
|
||||
pub visible_range: Range<usize>,
|
||||
}
|
||||
type PrepaintState = MyPaintState;
|
||||
```
|
||||
|
||||
## Methods
|
||||
|
||||
### id()
|
||||
|
||||
Returns optional unique identifier for debugging and inspection.
|
||||
|
||||
```rust
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
Some(self.id.clone())
|
||||
}
|
||||
|
||||
// Or if no ID needed
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
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<Pixels>,
|
||||
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<Pixels>,
|
||||
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<Pixels>
|
||||
|
||||
Represents rectangular region:
|
||||
|
||||
```rust
|
||||
pub struct Bounds<T> {
|
||||
pub origin: Point<T>,
|
||||
pub size: Size<T>,
|
||||
}
|
||||
|
||||
// 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);
|
||||
```
|
||||
546
.agents/skills/gpui-element/references/best-practices.md
Normal file
546
.agents/skills/gpui-element/references/best-practices.md
Normal file
@@ -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<ChildLayout>);
|
||||
type PrepaintState = (Hitbox, Vec<ChildBounds>);
|
||||
```
|
||||
|
||||
**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<ChildState>,
|
||||
}
|
||||
|
||||
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<StyledText>)
|
||||
{
|
||||
// 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<StyledText>, ..) {
|
||||
// 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<SharedString>,
|
||||
last_width: Option<Pixels>,
|
||||
|
||||
// Cached result
|
||||
cached_layout: Option<TextLayout>,
|
||||
}
|
||||
|
||||
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<Pixels>, 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<Pixels>, container_bounds: &Bounds<Pixels>) -> 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<Pixels>, 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<Pixels>)
|
||||
{
|
||||
// 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<LayoutId>)
|
||||
{
|
||||
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<TextLayout>)
|
||||
{
|
||||
// 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<TextLayout>,
|
||||
}
|
||||
```
|
||||
|
||||
**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<Pixels>, window: &mut Window, ..) {
|
||||
// Bad: Creating hitbox in paint
|
||||
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
|
||||
}
|
||||
```
|
||||
|
||||
**Good:**
|
||||
|
||||
```rust
|
||||
fn prepaint(&mut self, .., bounds: Bounds<Pixels>, 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
|
||||
632
.agents/skills/gpui-element/references/examples.md
Normal file
632
.agents/skills/gpui-element/references/examples.md
Normal file
@@ -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<usize>, 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<ElementId> {
|
||||
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<Pixels>,
|
||||
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<Pixels>,
|
||||
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<Selection>,
|
||||
}
|
||||
|
||||
impl IntoElement for SelectableText {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for SelectableText {
|
||||
type RequestLayoutState = TextLayout;
|
||||
type PrepaintState = Option<Hitbox>;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
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<Pixels>,
|
||||
_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<Pixels>,
|
||||
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<Pixels>,
|
||||
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<Pixels>,
|
||||
bounds: &Bounds<Pixels>,
|
||||
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<Box<dyn Element<RequestLayoutState = (), PrepaintState = ()>>>,
|
||||
scrollable: bool,
|
||||
scroll_offset: Point<Pixels>,
|
||||
}
|
||||
|
||||
struct ComplexLayoutState {
|
||||
child_layouts: Vec<LayoutId>,
|
||||
total_height: Pixels,
|
||||
}
|
||||
|
||||
struct ComplexPaintState {
|
||||
child_bounds: Vec<Bounds<Pixels>>,
|
||||
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<ElementId> {
|
||||
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<Pixels>,
|
||||
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<Pixels>,
|
||||
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<Pixels>, container_bounds: &Bounds<Pixels>) -> 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<Pixels>,
|
||||
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<Self>) -> 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<Self>) -> 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<Self>) -> impl IntoElement {
|
||||
let children: Vec<Box<dyn Element<_, _>>> = 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,
|
||||
})
|
||||
}
|
||||
```
|
||||
509
.agents/skills/gpui-element/references/patterns.md
Normal file
509
.agents/skills/gpui-element/references/patterns.md
Normal file
@@ -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<Pixels>, 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<Pixels>, 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<AnyElement>,
|
||||
direction: FlexDirection,
|
||||
gap: Pixels,
|
||||
}
|
||||
|
||||
impl Element for ContainerElement {
|
||||
type RequestLayoutState = Vec<LayoutId>;
|
||||
type PrepaintState = Vec<Bounds<Pixels>>;
|
||||
|
||||
fn request_layout(&mut self, .., window: &mut Window, cx: &mut App)
|
||||
-> (LayoutId, Vec<LayoutId>)
|
||||
{
|
||||
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<Pixels>, layout_ids: &mut Vec<LayoutId>,
|
||||
window: &mut Window, cx: &mut App) -> Vec<Bounds<Pixels>>
|
||||
{
|
||||
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<Bounds<Pixels>>,
|
||||
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<Box<dyn Fn(&MouseUpEvent, &mut Window, &mut App)>>,
|
||||
hover_style: Option<Style>,
|
||||
}
|
||||
|
||||
impl Element for InteractiveElement {
|
||||
type RequestLayoutState = LayoutId;
|
||||
type PrepaintState = (Hitbox, bool); // hitbox and is_hovered
|
||||
|
||||
fn request_layout(&mut self, .., window: &mut Window, cx: &mut App)
|
||||
-> (LayoutId, LayoutId)
|
||||
{
|
||||
let (content_layout, _) = self.content.request_layout(window, cx);
|
||||
(content_layout, content_layout)
|
||||
}
|
||||
|
||||
fn prepaint(&mut self, .., bounds: Bounds<Pixels>, content_layout: &mut LayoutId,
|
||||
window: &mut Window, cx: &mut App) -> (Hitbox, bool)
|
||||
{
|
||||
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
|
||||
let is_hovered = hitbox.is_hovered(window);
|
||||
|
||||
self.content.prepaint(bounds, window, cx);
|
||||
|
||||
(hitbox, is_hovered)
|
||||
}
|
||||
|
||||
fn paint(&mut self, .., bounds: Bounds<Pixels>, content_layout: &mut LayoutId,
|
||||
prepaint: &mut (Hitbox, bool), window: &mut Window, cx: &mut App)
|
||||
{
|
||||
let (hitbox, is_hovered) = prepaint;
|
||||
|
||||
// Paint hover background if hovered
|
||||
if *is_hovered {
|
||||
if let Some(hover_style) = &self.hover_style {
|
||||
window.paint_quad(paint_quad(
|
||||
bounds,
|
||||
Corners::all(px(4.)),
|
||||
hover_style.background_color.unwrap_or(cx.theme().hover),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Paint content
|
||||
self.content.paint(bounds, window, cx);
|
||||
|
||||
// Handle click
|
||||
if let Some(on_click) = self.on_click.as_ref() {
|
||||
window.on_mouse_event({
|
||||
let on_click = on_click.clone();
|
||||
let hitbox = hitbox.clone();
|
||||
move |event: &MouseUpEvent, phase, window, cx| {
|
||||
if hitbox.is_hovered(window) && phase.bubble() {
|
||||
on_click(event, window, cx);
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set cursor style
|
||||
window.set_cursor_style(CursorStyle::PointingHand, hitbox);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use Cases
|
||||
|
||||
- Buttons
|
||||
- Links
|
||||
- Clickable cards
|
||||
- Drag handles
|
||||
- Menu items
|
||||
|
||||
## Composite Elements
|
||||
|
||||
Elements that combine multiple child elements with complex coordination.
|
||||
|
||||
### Pattern Characteristics
|
||||
|
||||
- Combine multiple child elements with different types
|
||||
- Manage complex state across children
|
||||
- Coordinate animations and transitions
|
||||
- Handle focus delegation between children
|
||||
|
||||
### Implementation Template
|
||||
|
||||
```rust
|
||||
pub struct CompositeElement {
|
||||
id: ElementId,
|
||||
header: AnyElement,
|
||||
content: AnyElement,
|
||||
footer: Option<AnyElement>,
|
||||
}
|
||||
|
||||
struct CompositeLayoutState {
|
||||
header_layout: LayoutId,
|
||||
content_layout: LayoutId,
|
||||
footer_layout: Option<LayoutId>,
|
||||
}
|
||||
|
||||
struct CompositePaintState {
|
||||
header_bounds: Bounds<Pixels>,
|
||||
content_bounds: Bounds<Pixels>,
|
||||
footer_bounds: Option<Bounds<Pixels>>,
|
||||
}
|
||||
|
||||
impl Element for CompositeElement {
|
||||
type RequestLayoutState = CompositeLayoutState;
|
||||
type PrepaintState = CompositePaintState;
|
||||
|
||||
fn request_layout(&mut self, .., window: &mut Window, cx: &mut App)
|
||||
-> (LayoutId, CompositeLayoutState)
|
||||
{
|
||||
let (header_layout, _) = self.header.request_layout(window, cx);
|
||||
let (content_layout, _) = self.content.request_layout(window, cx);
|
||||
let footer_layout = self.footer.as_mut()
|
||||
.map(|f| f.request_layout(window, cx).0);
|
||||
|
||||
let mut children = vec![header_layout, content_layout];
|
||||
if let Some(footer) = footer_layout {
|
||||
children.push(footer);
|
||||
}
|
||||
|
||||
let layout_id = window.request_layout(
|
||||
Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: relative(1.0),
|
||||
height: auto(),
|
||||
},
|
||||
..default()
|
||||
},
|
||||
children,
|
||||
cx
|
||||
);
|
||||
|
||||
(layout_id, CompositeLayoutState {
|
||||
header_layout,
|
||||
content_layout,
|
||||
footer_layout,
|
||||
})
|
||||
}
|
||||
|
||||
fn prepaint(&mut self, .., bounds: Bounds<Pixels>, layout: &mut CompositeLayoutState,
|
||||
window: &mut Window, cx: &mut App) -> CompositePaintState
|
||||
{
|
||||
let header_bounds = window.layout_bounds(layout.header_layout);
|
||||
let content_bounds = window.layout_bounds(layout.content_layout);
|
||||
let footer_bounds = layout.footer_layout
|
||||
.map(|id| window.layout_bounds(id));
|
||||
|
||||
self.header.prepaint(header_bounds, window, cx);
|
||||
self.content.prepaint(content_bounds, window, cx);
|
||||
if let (Some(footer), Some(bounds)) = (&mut self.footer, footer_bounds) {
|
||||
footer.prepaint(bounds, window, cx);
|
||||
}
|
||||
|
||||
CompositePaintState {
|
||||
header_bounds,
|
||||
content_bounds,
|
||||
footer_bounds,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&mut self, .., paint_state: &mut CompositePaintState,
|
||||
window: &mut Window, cx: &mut App)
|
||||
{
|
||||
self.header.paint(paint_state.header_bounds, window, cx);
|
||||
self.content.paint(paint_state.content_bounds, window, cx);
|
||||
if let (Some(footer), Some(bounds)) = (&mut self.footer, paint_state.footer_bounds) {
|
||||
footer.paint(bounds, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use Cases
|
||||
|
||||
- Dialog boxes (header + content + footer)
|
||||
- Cards with multiple sections
|
||||
- Form layouts
|
||||
- Panels with toolbars
|
||||
|
||||
## Scrollable Elements
|
||||
|
||||
Elements with scrollable content areas.
|
||||
|
||||
### Pattern Characteristics
|
||||
|
||||
- Manage scroll state (offset, velocity)
|
||||
- Handle scroll events (wheel, drag, touch)
|
||||
- Paint scrollbars (track and thumb)
|
||||
- Clip content to visible area
|
||||
|
||||
### Implementation Template
|
||||
|
||||
```rust
|
||||
pub struct ScrollableElement {
|
||||
id: ElementId,
|
||||
content: AnyElement,
|
||||
scroll_offset: Point<Pixels>,
|
||||
content_size: Size<Pixels>,
|
||||
}
|
||||
|
||||
struct ScrollPaintState {
|
||||
hitbox: Hitbox,
|
||||
visible_bounds: Bounds<Pixels>,
|
||||
}
|
||||
|
||||
impl Element for ScrollableElement {
|
||||
type RequestLayoutState = (LayoutId, Size<Pixels>);
|
||||
type PrepaintState = ScrollPaintState;
|
||||
|
||||
fn request_layout(&mut self, .., window: &mut Window, cx: &mut App)
|
||||
-> (LayoutId, (LayoutId, Size<Pixels>))
|
||||
{
|
||||
let (content_layout, _) = self.content.request_layout(window, cx);
|
||||
let content_size = window.layout_bounds(content_layout).size;
|
||||
|
||||
let layout_id = window.request_layout(
|
||||
Style {
|
||||
size: Size {
|
||||
width: relative(1.0),
|
||||
height: px(400.), // Fixed viewport height
|
||||
},
|
||||
overflow: Overflow::Hidden,
|
||||
..default()
|
||||
},
|
||||
vec![content_layout],
|
||||
cx
|
||||
);
|
||||
|
||||
(layout_id, (content_layout, content_size))
|
||||
}
|
||||
|
||||
fn prepaint(&mut self, .., bounds: Bounds<Pixels>, layout: &mut (LayoutId, Size<Pixels>),
|
||||
window: &mut Window, cx: &mut App) -> ScrollPaintState
|
||||
{
|
||||
let (content_layout, content_size) = layout;
|
||||
|
||||
// Calculate content bounds with scroll offset
|
||||
let content_bounds = Bounds::new(
|
||||
point(bounds.left(), bounds.top() - self.scroll_offset.y),
|
||||
*content_size
|
||||
);
|
||||
|
||||
self.content.prepaint(content_bounds, window, cx);
|
||||
|
||||
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
|
||||
|
||||
ScrollPaintState {
|
||||
hitbox,
|
||||
visible_bounds: bounds,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&mut self, .., layout: &mut (LayoutId, Size<Pixels>),
|
||||
paint_state: &mut ScrollPaintState, window: &mut Window, cx: &mut App)
|
||||
{
|
||||
let (_, content_size) = layout;
|
||||
|
||||
// Paint content
|
||||
self.content.paint(paint_state.visible_bounds, window, cx);
|
||||
|
||||
// Paint scrollbar
|
||||
self.paint_scrollbar(paint_state.visible_bounds, *content_size, window, cx);
|
||||
|
||||
// Handle scroll events
|
||||
window.on_mouse_event({
|
||||
let hitbox = paint_state.hitbox.clone();
|
||||
let content_height = content_size.height;
|
||||
let visible_height = paint_state.visible_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 to valid range
|
||||
let max_scroll = (content_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 ScrollableElement {
|
||||
fn paint_scrollbar(
|
||||
&self,
|
||||
bounds: Bounds<Pixels>,
|
||||
content_size: Size<Pixels>,
|
||||
window: &mut Window,
|
||||
cx: &mut App
|
||||
) {
|
||||
let visible_height = bounds.size.height;
|
||||
let content_height = content_size.height;
|
||||
|
||||
if content_height <= visible_height {
|
||||
return; // No scrollbar needed
|
||||
}
|
||||
|
||||
let scrollbar_width = px(8.);
|
||||
|
||||
// Calculate thumb position and size
|
||||
let scroll_ratio = self.scroll_offset.y / (content_height - visible_height);
|
||||
let thumb_height = (visible_height / content_height) * visible_height;
|
||||
let thumb_y = scroll_ratio * (visible_height - thumb_height);
|
||||
|
||||
// Paint track
|
||||
window.paint_quad(paint_quad(
|
||||
Bounds::new(
|
||||
point(bounds.right() - scrollbar_width, bounds.top()),
|
||||
size(scrollbar_width, visible_height)
|
||||
),
|
||||
Corners::default(),
|
||||
cx.theme().scrollbar_track,
|
||||
));
|
||||
|
||||
// Paint thumb
|
||||
window.paint_quad(paint_quad(
|
||||
Bounds::new(
|
||||
point(bounds.right() - scrollbar_width, bounds.top() + thumb_y),
|
||||
size(scrollbar_width, thumb_height)
|
||||
),
|
||||
Corners::all(px(4.)),
|
||||
cx.theme().scrollbar_thumb,
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use Cases
|
||||
|
||||
- Scrollable lists
|
||||
- Code editors with large files
|
||||
- Long-form text content
|
||||
- Image galleries
|
||||
|
||||
## Pattern Selection Guide
|
||||
|
||||
| Need | Pattern | Complexity |
|
||||
|------|---------|------------|
|
||||
| Display styled text | Text Rendering | Low |
|
||||
| Layout multiple children | Container | Low-Medium |
|
||||
| Handle clicks/hovers | Interactive | Medium |
|
||||
| Complex multi-part UI | Composite | Medium-High |
|
||||
| Large content with scrolling | Scrollable | High |
|
||||
|
||||
Choose the simplest pattern that meets your requirements, then extend as needed.
|
||||
168
.agents/skills/gpui-entity/SKILL.md
Normal file
168
.agents/skills/gpui-entity/SKILL.md
Normal file
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: gpui-entity
|
||||
description: Entity management and state handling in GPUI. Use when working with entities, managing component state, coordinating between components, handling async operations with state updates, or implementing reactive patterns. Entities provide safe concurrent access to application state.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
An `Entity<T>` is a handle to state of type `T`, providing safe access and updates.
|
||||
|
||||
**Key Methods:**
|
||||
- `entity.read(cx)` → `&T` - Read-only access
|
||||
- `entity.read_with(cx, |state, cx| ...)` → `R` - Read with closure
|
||||
- `entity.update(cx, |state, cx| ...)` → `R` - Mutable update
|
||||
- `entity.downgrade()` → `WeakEntity<T>` - Create weak reference
|
||||
- `entity.entity_id()` → `EntityId` - Unique identifier
|
||||
|
||||
**Entity Types:**
|
||||
- **`Entity<T>`**: Strong reference (increases ref count)
|
||||
- **`WeakEntity<T>`**: Weak reference (doesn't prevent cleanup, returns `Result`)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Creating and Using Entities
|
||||
|
||||
```rust
|
||||
// Create entity
|
||||
let counter = cx.new(|cx| Counter { count: 0 });
|
||||
|
||||
// Read state
|
||||
let count = counter.read(cx).count;
|
||||
|
||||
// Update state
|
||||
counter.update(cx, |state, cx| {
|
||||
state.count += 1;
|
||||
cx.notify(); // Trigger re-render
|
||||
});
|
||||
|
||||
// Weak reference (for closures/callbacks)
|
||||
let weak = counter.downgrade();
|
||||
let _ = weak.update(cx, |state, cx| {
|
||||
state.count += 1;
|
||||
cx.notify();
|
||||
});
|
||||
```
|
||||
|
||||
### In Components
|
||||
|
||||
```rust
|
||||
struct MyComponent {
|
||||
shared_state: Entity<SharedData>,
|
||||
}
|
||||
|
||||
impl MyComponent {
|
||||
fn new(cx: &mut App) -> Entity<Self> {
|
||||
let shared = cx.new(|_| SharedData::default());
|
||||
|
||||
cx.new(|cx| Self {
|
||||
shared_state: shared,
|
||||
})
|
||||
}
|
||||
|
||||
fn update_shared(&mut self, cx: &mut Context<Self>) {
|
||||
self.shared_state.update(cx, |state, cx| {
|
||||
state.value = 42;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Operations
|
||||
|
||||
```rust
|
||||
impl MyComponent {
|
||||
fn fetch_data(&mut self, cx: &mut Context<Self>) {
|
||||
let weak_self = cx.entity().downgrade();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let data = fetch_from_api().await;
|
||||
|
||||
// Update entity safely
|
||||
let _ = weak_self.update(cx, |state, cx| {
|
||||
state.data = Some(data);
|
||||
cx.notify();
|
||||
});
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Core Principles
|
||||
|
||||
### Always Use Weak References in Closures
|
||||
|
||||
```rust
|
||||
// ✅ Good: Weak reference prevents retain cycles
|
||||
let weak = cx.entity().downgrade();
|
||||
callback(move || {
|
||||
let _ = weak.update(cx, |state, cx| cx.notify());
|
||||
});
|
||||
|
||||
// ❌ Bad: Strong reference may cause memory leak
|
||||
let strong = cx.entity();
|
||||
callback(move || {
|
||||
strong.update(cx, |state, cx| cx.notify());
|
||||
});
|
||||
```
|
||||
|
||||
### Use Inner Context
|
||||
|
||||
```rust
|
||||
// ✅ Good: Use inner cx from closure
|
||||
entity.update(cx, |state, inner_cx| {
|
||||
inner_cx.notify(); // Correct
|
||||
});
|
||||
|
||||
// ❌ Bad: Use outer cx (multiple borrow error)
|
||||
entity.update(cx, |state, inner_cx| {
|
||||
cx.notify(); // Wrong!
|
||||
});
|
||||
```
|
||||
|
||||
### Avoid Nested Updates
|
||||
|
||||
```rust
|
||||
// ✅ Good: Sequential updates
|
||||
entity1.update(cx, |state, cx| { /* ... */ });
|
||||
entity2.update(cx, |state, cx| { /* ... */ });
|
||||
|
||||
// ❌ Bad: Nested updates (may panic)
|
||||
entity1.update(cx, |_, cx| {
|
||||
entity2.update(cx, |_, cx| { /* ... */ });
|
||||
});
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
1. **Component State**: Internal state that needs reactivity
|
||||
2. **Shared State**: State shared between multiple components
|
||||
3. **Parent-Child**: Coordinating between related components (use weak refs)
|
||||
4. **Async State**: Managing state that changes from async operations
|
||||
5. **Observations**: Reacting to changes in other entities
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
### Complete API Documentation
|
||||
- **Entity API**: See [api-reference.md](references/api-reference.md)
|
||||
- Entity types, methods, lifecycle
|
||||
- Context methods, async operations
|
||||
- Error handling, type conversions
|
||||
|
||||
### Implementation Guides
|
||||
- **Patterns**: See [patterns.md](references/patterns.md)
|
||||
- Model-view separation, state management
|
||||
- Cross-entity communication, async operations
|
||||
- Observer pattern, event subscription
|
||||
- Pattern selection guide
|
||||
|
||||
- **Best Practices**: See [best-practices.md](references/best-practices.md)
|
||||
- Avoiding common pitfalls, memory leaks
|
||||
- Performance optimization, batching updates
|
||||
- Lifecycle management, cleanup
|
||||
- Async best practices, testing
|
||||
|
||||
- **Advanced Patterns**: See [advanced.md](references/advanced.md)
|
||||
- Entity collections, registry pattern
|
||||
- Debounced/throttled updates, state machines
|
||||
- Entity snapshots, transactions, pools
|
||||
528
.agents/skills/gpui-entity/references/advanced.md
Normal file
528
.agents/skills/gpui-entity/references/advanced.md
Normal file
@@ -0,0 +1,528 @@
|
||||
# Advanced Entity Patterns
|
||||
|
||||
Advanced techniques for sophisticated entity management scenarios.
|
||||
|
||||
## Entity Collections Management
|
||||
|
||||
### Dynamic Collection with Cleanup
|
||||
|
||||
```rust
|
||||
struct EntityCollection<T> {
|
||||
strong_refs: Vec<Entity<T>>,
|
||||
weak_refs: Vec<WeakEntity<T>>,
|
||||
}
|
||||
|
||||
impl<T> EntityCollection<T> {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
strong_refs: Vec::new(),
|
||||
weak_refs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, entity: Entity<T>, cx: &mut App) {
|
||||
self.strong_refs.push(entity.clone());
|
||||
self.weak_refs.push(entity.downgrade());
|
||||
}
|
||||
|
||||
fn remove(&mut self, entity_id: EntityId, cx: &mut App) {
|
||||
self.strong_refs.retain(|e| e.entity_id() != entity_id);
|
||||
self.weak_refs.retain(|w| {
|
||||
w.upgrade()
|
||||
.map(|e| e.entity_id() != entity_id)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
|
||||
fn cleanup_invalid(&mut self, cx: &mut App) {
|
||||
self.weak_refs.retain(|weak| weak.upgrade().is_some());
|
||||
}
|
||||
|
||||
fn for_each<F>(&self, cx: &mut App, mut f: F)
|
||||
where
|
||||
F: FnMut(&Entity<T>, &mut App),
|
||||
{
|
||||
for entity in &self.strong_refs {
|
||||
f(entity, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn for_each_weak<F>(&mut self, cx: &mut App, mut f: F)
|
||||
where
|
||||
F: FnMut(Entity<T>, &mut App),
|
||||
{
|
||||
self.weak_refs.retain(|weak| {
|
||||
if let Some(entity) = weak.upgrade() {
|
||||
f(entity, cx);
|
||||
true
|
||||
} else {
|
||||
false // Remove invalid weak references
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Entity Registry Pattern
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct EntityRegistry<T> {
|
||||
entities: HashMap<EntityId, WeakEntity<T>>,
|
||||
}
|
||||
|
||||
impl<T> EntityRegistry<T> {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
entities: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn register(&mut self, entity: &Entity<T>) {
|
||||
self.entities.insert(entity.entity_id(), entity.downgrade());
|
||||
}
|
||||
|
||||
fn unregister(&mut self, entity_id: EntityId) {
|
||||
self.entities.remove(&entity_id);
|
||||
}
|
||||
|
||||
fn get(&self, entity_id: EntityId) -> Option<Entity<T>> {
|
||||
self.entities.get(&entity_id)?.upgrade()
|
||||
}
|
||||
|
||||
fn cleanup(&mut self) {
|
||||
self.entities.retain(|_, weak| weak.upgrade().is_some());
|
||||
}
|
||||
|
||||
fn count(&self) -> usize {
|
||||
self.entities.len()
|
||||
}
|
||||
|
||||
fn all_entities(&self) -> Vec<Entity<T>> {
|
||||
self.entities
|
||||
.values()
|
||||
.filter_map(|weak| weak.upgrade())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Conditional Update Patterns
|
||||
|
||||
### Debounced Updates
|
||||
|
||||
```rust
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct DebouncedEntity<T> {
|
||||
entity: Entity<T>,
|
||||
last_update: Instant,
|
||||
debounce_duration: Duration,
|
||||
pending_update: Option<Box<dyn FnOnce(&mut T, &mut Context<T>)>>,
|
||||
}
|
||||
|
||||
impl<T: 'static> DebouncedEntity<T> {
|
||||
fn new(entity: Entity<T>, debounce_ms: u64) -> Self {
|
||||
Self {
|
||||
entity,
|
||||
last_update: Instant::now(),
|
||||
debounce_duration: Duration::from_millis(debounce_ms),
|
||||
pending_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update<F>(&mut self, cx: &mut App, update_fn: F)
|
||||
where
|
||||
F: FnOnce(&mut T, &mut Context<T>) + 'static,
|
||||
{
|
||||
let now = Instant::now();
|
||||
let elapsed = now.duration_since(self.last_update);
|
||||
|
||||
if elapsed >= self.debounce_duration {
|
||||
// Execute immediately
|
||||
self.entity.update(cx, update_fn);
|
||||
self.last_update = now;
|
||||
self.pending_update = None;
|
||||
} else {
|
||||
// Store for later
|
||||
self.pending_update = Some(Box::new(update_fn));
|
||||
|
||||
// Schedule execution
|
||||
let entity = self.entity.clone();
|
||||
let delay = self.debounce_duration - elapsed;
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
tokio::time::sleep(delay).await;
|
||||
|
||||
if let Some(update) = self.pending_update.take() {
|
||||
entity.update(cx, |state, inner_cx| {
|
||||
update(state, inner_cx);
|
||||
});
|
||||
}
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Throttled Updates
|
||||
|
||||
```rust
|
||||
struct ThrottledEntity<T> {
|
||||
entity: Entity<T>,
|
||||
last_update: Instant,
|
||||
throttle_duration: Duration,
|
||||
}
|
||||
|
||||
impl<T: 'static> ThrottledEntity<T> {
|
||||
fn new(entity: Entity<T>, throttle_ms: u64) -> Self {
|
||||
Self {
|
||||
entity,
|
||||
last_update: Instant::now(),
|
||||
throttle_duration: Duration::from_millis(throttle_ms),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_update<F>(&mut self, cx: &mut App, update_fn: F) -> bool
|
||||
where
|
||||
F: FnOnce(&mut T, &mut Context<T>),
|
||||
{
|
||||
let now = Instant::now();
|
||||
let elapsed = now.duration_since(self.last_update);
|
||||
|
||||
if elapsed >= self.throttle_duration {
|
||||
self.entity.update(cx, update_fn);
|
||||
self.last_update = now;
|
||||
true
|
||||
} else {
|
||||
false // Update throttled
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Entity State Machine Pattern
|
||||
|
||||
```rust
|
||||
enum AppState {
|
||||
Idle,
|
||||
Loading,
|
||||
Loaded(String),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
struct StateMachine {
|
||||
state: AppState,
|
||||
}
|
||||
|
||||
impl StateMachine {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
state: AppState::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
fn start_loading(&mut self, cx: &mut Context<Self>) {
|
||||
if matches!(self.state, AppState::Idle | AppState::Error(_)) {
|
||||
self.state = AppState::Loading;
|
||||
cx.notify();
|
||||
|
||||
let weak_entity = cx.entity().downgrade();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let result = perform_load().await;
|
||||
|
||||
let _ = weak_entity.update(cx, |state, cx| {
|
||||
match result {
|
||||
Ok(data) => state.on_load_success(data, cx),
|
||||
Err(e) => state.on_load_error(e.to_string(), cx),
|
||||
}
|
||||
});
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_load_success(&mut self, data: String, cx: &mut Context<Self>) {
|
||||
if matches!(self.state, AppState::Loading) {
|
||||
self.state = AppState::Loaded(data);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_load_error(&mut self, error: String, cx: &mut Context<Self>) {
|
||||
if matches!(self.state, AppState::Loading) {
|
||||
self.state = AppState::Error(error);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.state = AppState::Idle;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
async fn perform_load() -> Result<String, anyhow::Error> {
|
||||
// Actual load implementation
|
||||
Ok("Data".to_string())
|
||||
}
|
||||
```
|
||||
|
||||
## Entity Proxy Pattern
|
||||
|
||||
```rust
|
||||
struct EntityProxy<T> {
|
||||
entity: WeakEntity<T>,
|
||||
}
|
||||
|
||||
impl<T> EntityProxy<T> {
|
||||
fn new(entity: &Entity<T>) -> Self {
|
||||
Self {
|
||||
entity: entity.downgrade(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with<F, R>(&self, cx: &mut App, f: F) -> Result<R, anyhow::Error>
|
||||
where
|
||||
F: FnOnce(&T, &App) -> R,
|
||||
{
|
||||
self.entity.read_with(cx, f)
|
||||
}
|
||||
|
||||
fn update<F, R>(&self, cx: &mut App, f: F) -> Result<R, anyhow::Error>
|
||||
where
|
||||
F: FnOnce(&mut T, &mut Context<T>) -> R,
|
||||
{
|
||||
self.entity.update(cx, f)
|
||||
}
|
||||
|
||||
fn is_valid(&self, cx: &App) -> bool {
|
||||
self.entity.upgrade().is_some()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cascading Updates Pattern
|
||||
|
||||
```rust
|
||||
struct CascadingUpdater {
|
||||
entities: Vec<WeakEntity<UpdateTarget>>,
|
||||
}
|
||||
|
||||
impl CascadingUpdater {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
entities: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_target(&mut self, entity: &Entity<UpdateTarget>) {
|
||||
self.entities.push(entity.downgrade());
|
||||
}
|
||||
|
||||
fn cascade_update<F>(&mut self, cx: &mut App, update_fn: F)
|
||||
where
|
||||
F: Fn(&mut UpdateTarget, &mut Context<UpdateTarget>) + Clone,
|
||||
{
|
||||
// Update all entities in sequence
|
||||
self.entities.retain(|weak| {
|
||||
if let Ok(_) = weak.update(cx, |state, inner_cx| {
|
||||
update_fn.clone()(state, inner_cx);
|
||||
}) {
|
||||
true // Keep valid entity
|
||||
} else {
|
||||
false // Remove invalid entity
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateTarget {
|
||||
value: i32,
|
||||
}
|
||||
```
|
||||
|
||||
## Entity Snapshot Pattern
|
||||
|
||||
```rust
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct EntitySnapshot {
|
||||
data: String,
|
||||
timestamp: u64,
|
||||
}
|
||||
|
||||
struct SnapshotableEntity {
|
||||
data: String,
|
||||
snapshots: Vec<EntitySnapshot>,
|
||||
}
|
||||
|
||||
impl SnapshotableEntity {
|
||||
fn new(data: String) -> Self {
|
||||
Self {
|
||||
data,
|
||||
snapshots: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_snapshot(&mut self, cx: &mut Context<Self>) {
|
||||
let snapshot = EntitySnapshot {
|
||||
data: self.data.clone(),
|
||||
timestamp: current_timestamp(),
|
||||
};
|
||||
self.snapshots.push(snapshot);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn restore_snapshot(&mut self, index: usize, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
if let Some(snapshot) = self.snapshots.get(index) {
|
||||
self.data = snapshot.data.clone();
|
||||
cx.notify();
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Invalid snapshot index".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_old_snapshots(&mut self, keep_last: usize, cx: &mut Context<Self>) {
|
||||
if self.snapshots.len() > keep_last {
|
||||
self.snapshots.drain(0..self.snapshots.len() - keep_last);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_timestamp() -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
```
|
||||
|
||||
## Entity Transaction Pattern
|
||||
|
||||
```rust
|
||||
struct Transaction<T> {
|
||||
entity: Entity<T>,
|
||||
original_state: Option<T>,
|
||||
}
|
||||
|
||||
impl<T: Clone> Transaction<T> {
|
||||
fn begin(entity: Entity<T>, cx: &mut App) -> Self {
|
||||
let original_state = entity.read(cx).clone();
|
||||
|
||||
Self {
|
||||
entity,
|
||||
original_state: Some(original_state),
|
||||
}
|
||||
}
|
||||
|
||||
fn update<F>(&mut self, cx: &mut App, update_fn: F)
|
||||
where
|
||||
F: FnOnce(&mut T, &mut Context<T>),
|
||||
{
|
||||
self.entity.update(cx, update_fn);
|
||||
}
|
||||
|
||||
fn commit(mut self, cx: &mut App) {
|
||||
self.original_state = None; // Don't rollback
|
||||
self.entity.update(cx, |_, cx| {
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn rollback(mut self, cx: &mut App) {
|
||||
if let Some(original) = self.original_state.take() {
|
||||
self.entity.update(cx, |state, cx| {
|
||||
*state = original;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Drop for Transaction<T> {
|
||||
fn drop(&mut self) {
|
||||
// Auto-rollback if not committed
|
||||
if self.original_state.is_some() {
|
||||
eprintln!("Warning: Transaction dropped without commit");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
fn perform_transaction(entity: Entity<MyState>, cx: &mut App) -> Result<(), String> {
|
||||
let mut tx = Transaction::begin(entity, cx);
|
||||
|
||||
tx.update(cx, |state, cx| {
|
||||
state.value = 42;
|
||||
});
|
||||
|
||||
if validate_state(&tx.entity, cx)? {
|
||||
tx.commit(cx);
|
||||
Ok(())
|
||||
} else {
|
||||
tx.rollback(cx);
|
||||
Err("Validation failed".to_string())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Entity Pool Pattern
|
||||
|
||||
```rust
|
||||
struct EntityPool<T> {
|
||||
available: Vec<Entity<T>>,
|
||||
in_use: Vec<WeakEntity<T>>,
|
||||
factory: Box<dyn Fn(&mut App) -> Entity<T>>,
|
||||
}
|
||||
|
||||
impl<T: 'static> EntityPool<T> {
|
||||
fn new<F>(factory: F) -> Self
|
||||
where
|
||||
F: Fn(&mut App) -> Entity<T> + 'static,
|
||||
{
|
||||
Self {
|
||||
available: Vec::new(),
|
||||
in_use: Vec::new(),
|
||||
factory: Box::new(factory),
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire(&mut self, cx: &mut App) -> Entity<T> {
|
||||
let entity = if let Some(entity) = self.available.pop() {
|
||||
entity
|
||||
} else {
|
||||
(self.factory)(cx)
|
||||
};
|
||||
|
||||
self.in_use.push(entity.downgrade());
|
||||
entity
|
||||
}
|
||||
|
||||
fn release(&mut self, entity: Entity<T>, cx: &mut App) {
|
||||
// Reset entity state if needed
|
||||
entity.update(cx, |state, cx| {
|
||||
// Reset logic here
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.available.push(entity);
|
||||
self.cleanup_in_use();
|
||||
}
|
||||
|
||||
fn cleanup_in_use(&mut self) {
|
||||
self.in_use.retain(|weak| weak.upgrade().is_some());
|
||||
}
|
||||
|
||||
fn pool_size(&self) -> (usize, usize) {
|
||||
(self.available.len(), self.in_use.len())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These advanced patterns provide powerful abstractions for managing complex entity scenarios while maintaining code quality and performance.
|
||||
382
.agents/skills/gpui-entity/references/api-reference.md
Normal file
382
.agents/skills/gpui-entity/references/api-reference.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# Entity API Reference
|
||||
|
||||
Complete API documentation for GPUI's entity system.
|
||||
|
||||
## Entity Types
|
||||
|
||||
### Entity<T>
|
||||
|
||||
A strong reference to state of type `T`.
|
||||
|
||||
**Methods:**
|
||||
- `entity_id()` → `EntityId` - Returns unique identifier
|
||||
- `downgrade()` → `WeakEntity<T>` - Creates weak reference
|
||||
- `read(cx)` → `&T` - Immutable access to state
|
||||
- `read_with(cx, |state, cx| ...)` → `R` - Read with closure, returns closure result
|
||||
- `update(cx, |state, cx| ...)` → `R` - Mutable update with `Context<T>`, returns closure result
|
||||
- `update_in(cx, |state, window, cx| ...)` → `R` - Update with `Window` access (requires `AsyncWindowContext` or `VisualTestContext`)
|
||||
|
||||
**Important Notes:**
|
||||
- Trying to update an entity while it's already being updated will panic
|
||||
- Within closures, use the inner `cx` provided to avoid multiple borrow issues
|
||||
- With async contexts, return values are wrapped in `anyhow::Result`
|
||||
|
||||
### WeakEntity<T>
|
||||
|
||||
A weak reference to state of type `T`.
|
||||
|
||||
**Methods:**
|
||||
- `upgrade()` → `Option<Entity<T>>` - Convert to strong reference if still alive
|
||||
- `read_with(cx, |state, cx| ...)` → `Result<R>` - Read if entity exists
|
||||
- `update(cx, |state, cx| ...)` → `Result<R>` - Update if entity exists
|
||||
- `update_in(cx, |state, window, cx| ...)` → `Result<R>` - Update with window if entity exists
|
||||
|
||||
**Use Cases:**
|
||||
- Avoid circular dependencies between entities
|
||||
- Store references in closures/callbacks without preventing cleanup
|
||||
- Optional relationships between components
|
||||
|
||||
**Important:** All operations return `Result` since the entity may no longer exist.
|
||||
|
||||
### AnyEntity
|
||||
|
||||
Dynamically-typed entity handle for storing entities of different types.
|
||||
|
||||
### AnyWeakEntity
|
||||
|
||||
Dynamically-typed weak entity handle.
|
||||
|
||||
## Entity Creation
|
||||
|
||||
### cx.new()
|
||||
|
||||
Create new entity with initial state.
|
||||
|
||||
```rust
|
||||
let entity = cx.new(|cx| MyState {
|
||||
count: 0,
|
||||
name: "Default".to_string(),
|
||||
});
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `cx: &mut App` or other context type
|
||||
- Closure receiving `&mut Context<T>` returning initial state `T`
|
||||
|
||||
**Returns:** `Entity<T>`
|
||||
|
||||
## Entity Operations
|
||||
|
||||
### Reading State
|
||||
|
||||
#### read()
|
||||
|
||||
Direct read-only access to state.
|
||||
|
||||
```rust
|
||||
let count = my_entity.read(cx).count;
|
||||
```
|
||||
|
||||
**Use when:** Simple field access, no context operations needed.
|
||||
|
||||
#### read_with()
|
||||
|
||||
Read with context access in closure.
|
||||
|
||||
```rust
|
||||
let count = my_entity.read_with(cx, |state, cx| {
|
||||
// Can access both state and context
|
||||
state.count
|
||||
});
|
||||
|
||||
// Return multiple values
|
||||
let (count, theme) = my_entity.read_with(cx, |state, cx| {
|
||||
(state.count, cx.theme().clone())
|
||||
});
|
||||
```
|
||||
|
||||
**Use when:** Need context operations, multiple return values, complex logic.
|
||||
|
||||
### Updating State
|
||||
|
||||
#### update()
|
||||
|
||||
Mutable update with `Context<T>`.
|
||||
|
||||
```rust
|
||||
my_entity.update(cx, |state, cx| {
|
||||
state.count += 1;
|
||||
cx.notify(); // Trigger re-render
|
||||
});
|
||||
```
|
||||
|
||||
**Available Operations:**
|
||||
- `cx.notify()` - Trigger re-render
|
||||
- `cx.entity()` - Get current entity
|
||||
- `cx.emit(event)` - Emit event
|
||||
- `cx.spawn(task)` - Spawn async task
|
||||
- Other `Context<T>` methods
|
||||
|
||||
#### update_in()
|
||||
|
||||
Update with both `Window` and `Context<T>` access.
|
||||
|
||||
```rust
|
||||
my_entity.update_in(cx, |state, window, cx| {
|
||||
state.focused = window.is_window_focused();
|
||||
cx.notify();
|
||||
});
|
||||
```
|
||||
|
||||
**Requires:** `AsyncWindowContext` or `VisualTestContext`
|
||||
|
||||
**Use when:** Need window-specific operations like focus state, window bounds, etc.
|
||||
|
||||
## Context Methods for Entities
|
||||
|
||||
### cx.entity()
|
||||
|
||||
Get current entity being updated.
|
||||
|
||||
```rust
|
||||
impl MyComponent {
|
||||
fn some_method(&mut self, cx: &mut Context<Self>) {
|
||||
let current_entity = cx.entity(); // Entity<MyComponent>
|
||||
let weak = current_entity.downgrade();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### cx.observe()
|
||||
|
||||
Observe entity for changes.
|
||||
|
||||
```rust
|
||||
cx.observe(&entity, |this, observed_entity, cx| {
|
||||
// Called when observed_entity.update() calls cx.notify()
|
||||
println!("Entity changed");
|
||||
}).detach();
|
||||
```
|
||||
|
||||
**Returns:** `Subscription` - Call `.detach()` to make permanent
|
||||
|
||||
### cx.subscribe()
|
||||
|
||||
Subscribe to events from entity.
|
||||
|
||||
```rust
|
||||
cx.subscribe(&entity, |this, emitter, event: &SomeEvent, cx| {
|
||||
// Called when emitter emits SomeEvent
|
||||
match event {
|
||||
SomeEvent::DataChanged => {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}).detach();
|
||||
```
|
||||
|
||||
**Returns:** `Subscription` - Call `.detach()` to make permanent
|
||||
|
||||
### cx.observe_new_entities()
|
||||
|
||||
Register callback for new entities of a type.
|
||||
|
||||
```rust
|
||||
cx.observe_new_entities::<MyState>(|entity, cx| {
|
||||
println!("New entity created: {:?}", entity.entity_id());
|
||||
}).detach();
|
||||
```
|
||||
|
||||
## Async Operations
|
||||
|
||||
### cx.spawn()
|
||||
|
||||
Spawn foreground task (UI thread).
|
||||
|
||||
```rust
|
||||
cx.spawn(async move |this, cx| {
|
||||
// `this`: WeakEntity<T>
|
||||
// `cx`: &mut AsyncApp
|
||||
|
||||
let result = some_async_work().await;
|
||||
|
||||
// Update entity safely
|
||||
let _ = this.update(cx, |state, cx| {
|
||||
state.data = result;
|
||||
cx.notify();
|
||||
});
|
||||
}).detach();
|
||||
```
|
||||
|
||||
**Note:** Always use weak entity reference in spawned tasks to prevent retain cycles.
|
||||
|
||||
### cx.background_spawn()
|
||||
|
||||
Spawn background task (background thread).
|
||||
|
||||
```rust
|
||||
cx.background_spawn(async move {
|
||||
// Long-running computation
|
||||
let result = heavy_computation().await;
|
||||
// Cannot directly update entities here
|
||||
// Use channels or spawn foreground task to update
|
||||
}).detach();
|
||||
```
|
||||
|
||||
## Entity Lifecycle
|
||||
|
||||
### Creation
|
||||
|
||||
Entities are created via `cx.new()` and immediately registered in the app.
|
||||
|
||||
### Reference Counting
|
||||
|
||||
- `Entity<T>` is a strong reference (increases reference count)
|
||||
- `WeakEntity<T>` is a weak reference (does not increase reference count)
|
||||
- Cloning `Entity<T>` increases reference count
|
||||
|
||||
### Disposal
|
||||
|
||||
Entities are automatically disposed when all strong references are dropped.
|
||||
|
||||
```rust
|
||||
{
|
||||
let entity = cx.new(|cx| MyState::default());
|
||||
// entity exists
|
||||
} // entity dropped here if no other strong references exist
|
||||
```
|
||||
|
||||
**Memory Leak Prevention:**
|
||||
- Use `WeakEntity` in closures/callbacks
|
||||
- Use `WeakEntity` for parent-child relationships
|
||||
- Avoid circular strong references
|
||||
|
||||
## EntityId
|
||||
|
||||
Every entity has a unique identifier.
|
||||
|
||||
```rust
|
||||
let id: EntityId = entity.entity_id();
|
||||
|
||||
// EntityIds can be compared
|
||||
if entity1.entity_id() == entity2.entity_id() {
|
||||
// Same entity
|
||||
}
|
||||
```
|
||||
|
||||
**Use Cases:**
|
||||
- Debugging and logging
|
||||
- Entity comparison without borrowing
|
||||
- Hash maps keyed by entity
|
||||
|
||||
## Error Handling
|
||||
|
||||
### WeakEntity Operations
|
||||
|
||||
All `WeakEntity` operations return `Result`:
|
||||
|
||||
```rust
|
||||
let weak = entity.downgrade();
|
||||
|
||||
// Handle potential failure
|
||||
match weak.read_with(cx, |state, cx| state.count) {
|
||||
Ok(count) => println!("Count: {}", count),
|
||||
Err(e) => eprintln!("Entity no longer exists: {}", e),
|
||||
}
|
||||
|
||||
// Or use Result combinators
|
||||
let _ = weak.update(cx, |state, cx| {
|
||||
state.count += 1;
|
||||
cx.notify();
|
||||
}).ok(); // Ignore errors
|
||||
```
|
||||
|
||||
### Update Panics
|
||||
|
||||
Nested updates on the same entity will panic:
|
||||
|
||||
```rust
|
||||
// ❌ Will panic
|
||||
entity.update(cx, |state1, cx| {
|
||||
entity.update(cx, |state2, cx| {
|
||||
// Panic: entity already borrowed
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Solution:** Perform updates sequentially or use different entities.
|
||||
|
||||
## Type Conversions
|
||||
|
||||
### Entity → WeakEntity
|
||||
|
||||
```rust
|
||||
let entity: Entity<T> = cx.new(|cx| T::default());
|
||||
let weak: WeakEntity<T> = entity.downgrade();
|
||||
```
|
||||
|
||||
### WeakEntity → Entity
|
||||
|
||||
```rust
|
||||
let weak: WeakEntity<T> = entity.downgrade();
|
||||
let strong: Option<Entity<T>> = weak.upgrade();
|
||||
```
|
||||
|
||||
### AnyEntity
|
||||
|
||||
```rust
|
||||
let any: AnyEntity = entity.into();
|
||||
let typed: Option<Entity<T>> = any.downcast::<T>();
|
||||
```
|
||||
|
||||
## Best Practice Guidelines
|
||||
|
||||
### Always Use Inner cx
|
||||
|
||||
```rust
|
||||
// ✅ Good: Use inner cx
|
||||
entity.update(cx, |state, inner_cx| {
|
||||
inner_cx.notify(); // Use inner_cx, not outer cx
|
||||
});
|
||||
|
||||
// ❌ Bad: Use outer cx
|
||||
entity.update(cx, |state, inner_cx| {
|
||||
cx.notify(); // Wrong! Multiple borrow error
|
||||
});
|
||||
```
|
||||
|
||||
### Weak References in Closures
|
||||
|
||||
```rust
|
||||
// ✅ Good: Weak reference
|
||||
let weak = cx.entity().downgrade();
|
||||
callback(move || {
|
||||
let _ = weak.update(cx, |state, cx| {
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
|
||||
// ❌ Bad: Strong reference (retain cycle)
|
||||
let strong = cx.entity();
|
||||
callback(move || {
|
||||
strong.update(cx, |state, cx| {
|
||||
// May never be dropped
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Sequential Updates
|
||||
|
||||
```rust
|
||||
// ✅ Good: Sequential updates
|
||||
entity1.update(cx, |state, cx| { /* ... */ });
|
||||
entity2.update(cx, |state, cx| { /* ... */ });
|
||||
|
||||
// ❌ Bad: Nested updates
|
||||
entity1.update(cx, |_, cx| {
|
||||
entity2.update(cx, |_, cx| {
|
||||
// May panic if entities are related
|
||||
});
|
||||
});
|
||||
```
|
||||
484
.agents/skills/gpui-entity/references/best-practices.md
Normal file
484
.agents/skills/gpui-entity/references/best-practices.md
Normal file
@@ -0,0 +1,484 @@
|
||||
# Entity Best Practices
|
||||
|
||||
Guidelines and best practices for effective entity management in GPUI.
|
||||
|
||||
## Avoiding Common Pitfalls
|
||||
|
||||
### Avoid Entity Borrowing Conflicts
|
||||
|
||||
**Problem:** Nested updates can cause borrow checker panics.
|
||||
|
||||
```rust
|
||||
// ❌ Bad: Nested updates can panic
|
||||
entity1.update(cx, |_, cx| {
|
||||
entity2.update(cx, |_, cx| {
|
||||
// This may panic if entities are related
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Solution:** Perform updates sequentially.
|
||||
|
||||
```rust
|
||||
// ✅ Good: Sequential updates
|
||||
entity1.update(cx, |state1, cx| {
|
||||
// Update entity1
|
||||
state1.value = 42;
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
entity2.update(cx, |state2, cx| {
|
||||
// Update entity2
|
||||
state2.value = 100;
|
||||
cx.notify();
|
||||
});
|
||||
```
|
||||
|
||||
### Use Weak References in Closures
|
||||
|
||||
**Problem:** Strong references in closures can create retain cycles and memory leaks.
|
||||
|
||||
```rust
|
||||
// ❌ Bad: Strong reference creates retain cycle
|
||||
impl MyComponent {
|
||||
fn setup_callback(&mut self, cx: &mut Context<Self>) {
|
||||
let entity = cx.entity(); // Strong reference
|
||||
|
||||
some_callback(move || {
|
||||
entity.update(cx, |state, cx| {
|
||||
// This closure holds a strong reference
|
||||
// If the closure itself is retained by the entity, memory leak!
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Solution:** Use weak references in closures.
|
||||
|
||||
```rust
|
||||
// ✅ Good: Weak reference prevents retain cycle
|
||||
impl MyComponent {
|
||||
fn setup_callback(&mut self, cx: &mut Context<Self>) {
|
||||
let weak_entity = cx.entity().downgrade(); // Weak reference
|
||||
|
||||
some_callback(move || {
|
||||
// Safe: weak reference doesn't prevent cleanup
|
||||
let _ = weak_entity.update(cx, |state, cx| {
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use Inner Context in Closures
|
||||
|
||||
**Problem:** Using outer context causes multiple borrow errors.
|
||||
|
||||
```rust
|
||||
// ❌ Bad: Using outer cx causes borrow issues
|
||||
entity.update(cx, |state, inner_cx| {
|
||||
cx.notify(); // Wrong! Using outer cx
|
||||
cx.spawn(...); // Multiple borrow error
|
||||
});
|
||||
```
|
||||
|
||||
**Solution:** Always use the inner context provided to the closure.
|
||||
|
||||
```rust
|
||||
// ✅ Good: Use inner cx
|
||||
entity.update(cx, |state, inner_cx| {
|
||||
inner_cx.notify(); // Correct
|
||||
inner_cx.spawn(...); // Works fine
|
||||
});
|
||||
```
|
||||
|
||||
### Entity as Props - Use Weak References
|
||||
|
||||
**Problem:** Strong entity references in props can create ownership issues.
|
||||
|
||||
```rust
|
||||
// ❌ Questionable: Strong reference in child
|
||||
struct ChildComponent {
|
||||
parent: Entity<ParentComponent>, // Strong reference
|
||||
}
|
||||
```
|
||||
|
||||
**Better:** Use weak references for parent relationships.
|
||||
|
||||
```rust
|
||||
// ✅ Good: Weak reference prevents issues
|
||||
struct ChildComponent {
|
||||
parent: WeakEntity<ParentComponent>, // Weak reference
|
||||
}
|
||||
|
||||
impl ChildComponent {
|
||||
fn notify_parent(&mut self, cx: &mut Context<Self>) {
|
||||
// Check if parent still exists
|
||||
if let Ok(_) = self.parent.update(cx, |parent_state, cx| {
|
||||
// Update parent
|
||||
cx.notify();
|
||||
}) {
|
||||
// Parent successfully updated
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Minimize cx.notify() Calls
|
||||
|
||||
Each `cx.notify()` triggers a re-render. Batch updates when possible.
|
||||
|
||||
```rust
|
||||
// ❌ Bad: Multiple notifications
|
||||
impl MyComponent {
|
||||
fn update_multiple_fields(&mut self, cx: &mut Context<Self>) {
|
||||
self.field1 = new_value1;
|
||||
cx.notify(); // Unnecessary intermediate notification
|
||||
|
||||
self.field2 = new_value2;
|
||||
cx.notify(); // Unnecessary intermediate notification
|
||||
|
||||
self.field3 = new_value3;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// ✅ Good: Single notification after all updates
|
||||
impl MyComponent {
|
||||
fn update_multiple_fields(&mut self, cx: &mut Context<Self>) {
|
||||
self.field1 = new_value1;
|
||||
self.field2 = new_value2;
|
||||
self.field3 = new_value3;
|
||||
cx.notify(); // Single notification
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Updates
|
||||
|
||||
Only notify when state actually changes.
|
||||
|
||||
```rust
|
||||
impl MyComponent {
|
||||
fn set_value(&mut self, new_value: i32, cx: &mut Context<Self>) {
|
||||
if self.value != new_value {
|
||||
self.value = new_value;
|
||||
cx.notify(); // Only notify if changed
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use read_with for Complex Operations
|
||||
|
||||
Prefer `read_with` over separate `read` calls.
|
||||
|
||||
```rust
|
||||
// ❌ Less efficient: Multiple borrows
|
||||
let state_ref = entity.read(cx);
|
||||
let value1 = state_ref.field1;
|
||||
let value2 = state_ref.field2;
|
||||
// state_ref borrowed for entire scope
|
||||
|
||||
// ✅ More efficient: Single borrow with closure
|
||||
let (value1, value2) = entity.read_with(cx, |state, cx| {
|
||||
(state.field1, state.field2)
|
||||
});
|
||||
```
|
||||
|
||||
### Avoid Excessive Entity Creation
|
||||
|
||||
Creating entities has overhead. Reuse when appropriate.
|
||||
|
||||
```rust
|
||||
// ❌ Bad: Creating entity per item in render
|
||||
impl Render for MyList {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().children(
|
||||
self.items.iter().map(|item| {
|
||||
// Don't create entities in render!
|
||||
let entity = cx.new(|_| item.clone());
|
||||
ItemView { entity }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// ✅ Good: Create entities once, reuse
|
||||
struct MyList {
|
||||
item_entities: Vec<Entity<Item>>,
|
||||
}
|
||||
|
||||
impl MyList {
|
||||
fn add_item(&mut self, item: Item, cx: &mut Context<Self>) {
|
||||
let entity = cx.new(|_| item);
|
||||
self.item_entities.push(entity);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Entity Lifecycle Management
|
||||
|
||||
### Clean Up Weak References
|
||||
|
||||
Periodically clean up invalid weak references from collections.
|
||||
|
||||
```rust
|
||||
struct Container {
|
||||
weak_children: Vec<WeakEntity<Child>>,
|
||||
}
|
||||
|
||||
impl Container {
|
||||
fn cleanup_invalid_children(&mut self, cx: &mut Context<Self>) {
|
||||
// Remove weak references that are no longer valid
|
||||
let before_count = self.weak_children.len();
|
||||
self.weak_children.retain(|weak| weak.upgrade().is_some());
|
||||
let after_count = self.weak_children.len();
|
||||
|
||||
if before_count != after_count {
|
||||
cx.notify(); // Notify if list changed
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Entity Cloning and Sharing
|
||||
|
||||
Understand that cloning `Entity<T>` increases reference count.
|
||||
|
||||
```rust
|
||||
// Each clone increases the reference count
|
||||
let entity1: Entity<MyState> = cx.new(|_| MyState::default());
|
||||
let entity2 = entity1.clone(); // Reference count: 2
|
||||
let entity3 = entity1.clone(); // Reference count: 3
|
||||
|
||||
// Entity is dropped only when all references are dropped
|
||||
drop(entity1); // Reference count: 2
|
||||
drop(entity2); // Reference count: 1
|
||||
drop(entity3); // Reference count: 0, entity is deallocated
|
||||
```
|
||||
|
||||
### Proper Resource Cleanup
|
||||
|
||||
Implement cleanup in `Drop` or explicit cleanup methods.
|
||||
|
||||
```rust
|
||||
struct ManagedResource {
|
||||
handle: Option<FileHandle>,
|
||||
}
|
||||
|
||||
impl ManagedResource {
|
||||
fn close(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(handle) = self.handle.take() {
|
||||
// Explicit cleanup
|
||||
handle.close();
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ManagedResource {
|
||||
fn drop(&mut self) {
|
||||
// Automatic cleanup when entity is dropped
|
||||
if let Some(handle) = self.handle.take() {
|
||||
handle.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Entity Observation Best Practices
|
||||
|
||||
### Detach Subscriptions Appropriately
|
||||
|
||||
Call `.detach()` on subscriptions you want to keep alive.
|
||||
|
||||
```rust
|
||||
impl MyComponent {
|
||||
fn new(other_entity: Entity<OtherComponent>, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
// Observer will live as long as both entities exist
|
||||
cx.observe(&other_entity, |this, observed, cx| {
|
||||
// Handle changes
|
||||
cx.notify();
|
||||
}).detach(); // Important: detach to make permanent
|
||||
|
||||
Self { /* fields */ }
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid Observation Cycles
|
||||
|
||||
Don't create mutual observation between entities.
|
||||
|
||||
```rust
|
||||
// ❌ Bad: Mutual observation can cause infinite loops
|
||||
entity1.update(cx, |_, cx| {
|
||||
cx.observe(&entity2, |_, _, cx| {
|
||||
cx.notify(); // May trigger entity2's observer
|
||||
}).detach();
|
||||
});
|
||||
|
||||
entity2.update(cx, |_, cx| {
|
||||
cx.observe(&entity1, |_, _, cx| {
|
||||
cx.notify(); // May trigger entity1's observer → infinite loop
|
||||
}).detach();
|
||||
});
|
||||
```
|
||||
|
||||
## Async Best Practices
|
||||
|
||||
### Always Use Weak References in Async Tasks
|
||||
|
||||
```rust
|
||||
// ✅ Good: Weak reference in spawned task
|
||||
impl MyComponent {
|
||||
fn fetch_data(&mut self, cx: &mut Context<Self>) {
|
||||
let weak_entity = cx.entity().downgrade();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let data = fetch_from_api().await;
|
||||
|
||||
// Entity may have been dropped during fetch
|
||||
let _ = weak_entity.update(cx, |state, cx| {
|
||||
state.data = Some(data);
|
||||
cx.notify();
|
||||
});
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handle Async Errors Gracefully
|
||||
|
||||
```rust
|
||||
impl MyComponent {
|
||||
fn fetch_data(&mut self, cx: &mut Context<Self>) {
|
||||
let weak_entity = cx.entity().downgrade();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
match fetch_from_api().await {
|
||||
Ok(data) => {
|
||||
let _ = weak_entity.update(cx, |state, cx| {
|
||||
state.data = Some(data);
|
||||
state.error = None;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = weak_entity.update(cx, |state, cx| {
|
||||
state.error = Some(e.to_string());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cancellation Patterns
|
||||
|
||||
Implement cancellation for long-running tasks.
|
||||
|
||||
```rust
|
||||
struct DataFetcher {
|
||||
current_task: Option<Task<()>>,
|
||||
data: Option<String>,
|
||||
}
|
||||
|
||||
impl DataFetcher {
|
||||
fn fetch_data(&mut self, url: String, cx: &mut Context<Self>) {
|
||||
// Cancel previous task
|
||||
self.current_task = None; // Dropping task cancels it
|
||||
|
||||
let weak_entity = cx.entity().downgrade();
|
||||
|
||||
let task = cx.spawn(async move |cx| {
|
||||
let data = fetch_from_url(&url).await?;
|
||||
|
||||
let _ = weak_entity.update(cx, |state, cx| {
|
||||
state.data = Some(data);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
});
|
||||
|
||||
self.current_task = Some(task);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
### Use TestAppContext for Entity Tests
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
#[gpui::test]
|
||||
fn test_entity_update(cx: &mut TestAppContext) {
|
||||
let entity = cx.new(|_| MyState { count: 0 });
|
||||
|
||||
entity.update(cx, |state, cx| {
|
||||
state.count += 1;
|
||||
assert_eq!(state.count, 1);
|
||||
});
|
||||
|
||||
let count = entity.read(cx).count;
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test Entity Observation
|
||||
|
||||
```rust
|
||||
#[gpui::test]
|
||||
fn test_entity_observation(cx: &mut TestAppContext) {
|
||||
let observed = cx.new(|_| MyState { value: 0 });
|
||||
let observer = cx.new(|cx| Observer::new(observed.clone(), cx));
|
||||
|
||||
// Update observed entity
|
||||
observed.update(cx, |state, cx| {
|
||||
state.value = 42;
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Verify observer was notified
|
||||
observer.read(cx).assert_observed();
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Checklist
|
||||
|
||||
Before shipping entity-based code, verify:
|
||||
|
||||
- [ ] No strong references in closures/callbacks (use `WeakEntity`)
|
||||
- [ ] No nested entity updates (use sequential updates)
|
||||
- [ ] Using inner `cx` in update closures
|
||||
- [ ] Batching updates before calling `cx.notify()`
|
||||
- [ ] Cleaning up invalid weak references periodically
|
||||
- [ ] Using `read_with` for complex read operations
|
||||
- [ ] Properly detaching subscriptions and observers
|
||||
- [ ] Using weak references in async tasks
|
||||
- [ ] No observation cycles between entities
|
||||
- [ ] Proper error handling in async operations
|
||||
- [ ] Resource cleanup in `Drop` or explicit methods
|
||||
- [ ] Tests cover entity lifecycle and interactions
|
||||
579
.agents/skills/gpui-entity/references/patterns.md
Normal file
579
.agents/skills/gpui-entity/references/patterns.md
Normal file
@@ -0,0 +1,579 @@
|
||||
# Entity Patterns
|
||||
|
||||
Common patterns and use cases for entity management in GPUI.
|
||||
|
||||
## Application Scenarios
|
||||
|
||||
### Model-View Separation
|
||||
|
||||
Separate business logic (model) from UI (view) using entities.
|
||||
|
||||
```rust
|
||||
struct CounterModel {
|
||||
count: usize,
|
||||
listeners: Vec<Box<dyn Fn(usize)>>,
|
||||
}
|
||||
|
||||
struct CounterView {
|
||||
model: Entity<CounterModel>,
|
||||
}
|
||||
|
||||
impl CounterModel {
|
||||
fn increment(&mut self, cx: &mut Context<Self>) {
|
||||
self.count += 1;
|
||||
|
||||
// Notify listeners
|
||||
for listener in &self.listeners {
|
||||
listener(self.count);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn decrement(&mut self, cx: &mut Context<Self>) {
|
||||
if self.count > 0 {
|
||||
self.count -= 1;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CounterView {
|
||||
fn new(cx: &mut App) -> Entity<Self> {
|
||||
let model = cx.new(|_cx| CounterModel {
|
||||
count: 0,
|
||||
listeners: Vec::new(),
|
||||
});
|
||||
|
||||
cx.new(|cx| Self { model })
|
||||
}
|
||||
|
||||
fn increment_count(&mut self, cx: &mut Context<Self>) {
|
||||
self.model.update(cx, |model, cx| {
|
||||
model.increment(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CounterView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let count = self.model.read(cx).count;
|
||||
|
||||
div()
|
||||
.child(format!("Count: {}", count))
|
||||
.child(
|
||||
Button::new("increment")
|
||||
.label("Increment")
|
||||
.on_click(cx.listener(|this, _, cx| {
|
||||
this.increment_count(cx);
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Component State Management
|
||||
|
||||
Managing complex component state with entities.
|
||||
|
||||
```rust
|
||||
struct TodoList {
|
||||
todos: Vec<Todo>,
|
||||
filter: TodoFilter,
|
||||
next_id: usize,
|
||||
}
|
||||
|
||||
struct Todo {
|
||||
id: usize,
|
||||
text: String,
|
||||
completed: bool,
|
||||
}
|
||||
|
||||
enum TodoFilter {
|
||||
All,
|
||||
Active,
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl TodoList {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
todos: Vec::new(),
|
||||
filter: TodoFilter::All,
|
||||
next_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn add_todo(&mut self, text: String, cx: &mut Context<Self>) {
|
||||
self.todos.push(Todo {
|
||||
id: self.next_id,
|
||||
text,
|
||||
completed: false,
|
||||
});
|
||||
self.next_id += 1;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn toggle_todo(&mut self, id: usize, cx: &mut Context<Self>) {
|
||||
if let Some(todo) = self.todos.iter_mut().find(|t| t.id == id) {
|
||||
todo.completed = !todo.completed;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_todo(&mut self, id: usize, cx: &mut Context<Self>) {
|
||||
self.todos.retain(|t| t.id != id);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_filter(&mut self, filter: TodoFilter, cx: &mut Context<Self>) {
|
||||
self.filter = filter;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn visible_todos(&self) -> impl Iterator<Item = &Todo> {
|
||||
self.todos.iter().filter(move |todo| match self.filter {
|
||||
TodoFilter::All => true,
|
||||
TodoFilter::Active => !todo.completed,
|
||||
TodoFilter::Completed => todo.completed,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cross-Entity Communication
|
||||
|
||||
Coordinating state between parent and child entities.
|
||||
|
||||
```rust
|
||||
struct ParentComponent {
|
||||
child_entities: Vec<Entity<ChildComponent>>,
|
||||
global_message: String,
|
||||
}
|
||||
|
||||
struct ChildComponent {
|
||||
id: usize,
|
||||
message: String,
|
||||
parent: WeakEntity<ParentComponent>,
|
||||
}
|
||||
|
||||
impl ParentComponent {
|
||||
fn new(cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self {
|
||||
child_entities: Vec::new(),
|
||||
global_message: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn add_child(&mut self, cx: &mut Context<Self>) {
|
||||
let parent_weak = cx.entity().downgrade();
|
||||
let child_id = self.child_entities.len();
|
||||
|
||||
let child = cx.new(|cx| ChildComponent {
|
||||
id: child_id,
|
||||
message: String::new(),
|
||||
parent: parent_weak,
|
||||
});
|
||||
|
||||
self.child_entities.push(child);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn broadcast_message(&mut self, message: String, cx: &mut Context<Self>) {
|
||||
self.global_message = message.clone();
|
||||
|
||||
// Update all children
|
||||
for child in &self.child_entities {
|
||||
child.update(cx, |child_state, cx| {
|
||||
child_state.message = message.clone();
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl ChildComponent {
|
||||
fn notify_parent(&mut self, message: String, cx: &mut Context<Self>) {
|
||||
if let Ok(_) = self.parent.update(cx, |parent_state, cx| {
|
||||
parent_state.global_message = format!("Child {}: {}", self.id, message);
|
||||
cx.notify();
|
||||
}) {
|
||||
// Parent successfully notified
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Operations with Entities
|
||||
|
||||
Managing async state updates.
|
||||
|
||||
```rust
|
||||
struct DataLoader {
|
||||
loading: bool,
|
||||
data: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl DataLoader {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
loading: false,
|
||||
data: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_data(&mut self, cx: &mut Context<Self>) {
|
||||
// Set loading state
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
// Get weak reference for async task
|
||||
let entity = cx.entity().downgrade();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
// Simulate async operation
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let result = fetch_data().await;
|
||||
|
||||
// Update entity with result
|
||||
let _ = entity.update(cx, |state, cx| {
|
||||
state.loading = false;
|
||||
match result {
|
||||
Ok(data) => state.data = Some(data),
|
||||
Err(e) => state.error = Some(e.to_string()),
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_data() -> Result<String, anyhow::Error> {
|
||||
// Actual fetch implementation
|
||||
Ok("Fetched data".to_string())
|
||||
}
|
||||
```
|
||||
|
||||
### Background Task Coordination
|
||||
|
||||
Using background tasks with entity updates.
|
||||
|
||||
```rust
|
||||
struct ImageProcessor {
|
||||
images: Vec<ProcessedImage>,
|
||||
processing: bool,
|
||||
}
|
||||
|
||||
struct ProcessedImage {
|
||||
path: PathBuf,
|
||||
thumbnail: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ImageProcessor {
|
||||
fn process_images(&mut self, paths: Vec<PathBuf>, cx: &mut Context<Self>) {
|
||||
self.processing = true;
|
||||
cx.notify();
|
||||
|
||||
let entity = cx.entity().downgrade();
|
||||
|
||||
cx.background_spawn({
|
||||
let paths = paths.clone();
|
||||
async move {
|
||||
let mut processed = Vec::new();
|
||||
|
||||
for path in paths {
|
||||
// Process image on background thread
|
||||
let thumbnail = generate_thumbnail(&path).await;
|
||||
processed.push((path, thumbnail));
|
||||
}
|
||||
|
||||
// Send results back to foreground
|
||||
processed
|
||||
}
|
||||
})
|
||||
.then(cx.spawn(move |processed, cx| {
|
||||
// Update entity on foreground thread
|
||||
let _ = entity.update(cx, |state, cx| {
|
||||
for (path, thumbnail) in processed {
|
||||
state.images.push(ProcessedImage {
|
||||
path,
|
||||
thumbnail: Some(thumbnail),
|
||||
});
|
||||
}
|
||||
state.processing = false;
|
||||
cx.notify();
|
||||
});
|
||||
}))
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### 1. Stateful Components
|
||||
|
||||
Use entities for components that maintain internal state.
|
||||
|
||||
```rust
|
||||
struct StatefulComponent {
|
||||
value: i32,
|
||||
history: Vec<i32>,
|
||||
}
|
||||
|
||||
impl StatefulComponent {
|
||||
fn update_value(&mut self, new_value: i32, cx: &mut Context<Self>) {
|
||||
self.history.push(self.value);
|
||||
self.value = new_value;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn undo(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(prev_value) = self.history.pop() {
|
||||
self.value = prev_value;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Shared State
|
||||
|
||||
Share state between multiple components using entities.
|
||||
|
||||
```rust
|
||||
struct SharedState {
|
||||
theme: Theme,
|
||||
user: Option<User>,
|
||||
}
|
||||
|
||||
struct ComponentA {
|
||||
shared: Entity<SharedState>,
|
||||
}
|
||||
|
||||
struct ComponentB {
|
||||
shared: Entity<SharedState>,
|
||||
}
|
||||
|
||||
// Both components can read/update the same shared state
|
||||
impl ComponentA {
|
||||
fn update_theme(&mut self, theme: Theme, cx: &mut Context<Self>) {
|
||||
self.shared.update(cx, |state, cx| {
|
||||
state.theme = theme;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Event Coordination
|
||||
|
||||
Use entities to coordinate events between components.
|
||||
|
||||
```rust
|
||||
struct EventCoordinator {
|
||||
listeners: Vec<WeakEntity<dyn EventListener>>,
|
||||
}
|
||||
|
||||
trait EventListener {
|
||||
fn on_event(&mut self, event: &AppEvent, cx: &mut App);
|
||||
}
|
||||
|
||||
impl EventCoordinator {
|
||||
fn emit_event(&mut self, event: AppEvent, cx: &mut Context<Self>) {
|
||||
// Notify all listeners
|
||||
self.listeners.retain(|weak_listener| {
|
||||
weak_listener.update(cx, |listener, cx| {
|
||||
listener.on_event(&event, cx);
|
||||
}).is_ok()
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Async State Management
|
||||
|
||||
Manage state that changes based on async operations.
|
||||
|
||||
```rust
|
||||
struct AsyncState<T> {
|
||||
state: AsyncValue<T>,
|
||||
}
|
||||
|
||||
enum AsyncValue<T> {
|
||||
Loading,
|
||||
Loaded(T),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl<T> AsyncState<T> {
|
||||
fn is_loading(&self) -> bool {
|
||||
matches!(self.state, AsyncValue::Loading)
|
||||
}
|
||||
|
||||
fn value(&self) -> Option<&T> {
|
||||
match &self.state {
|
||||
AsyncValue::Loaded(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Parent-Child Relationships
|
||||
|
||||
Manage hierarchical relationships with weak references.
|
||||
|
||||
```rust
|
||||
struct Parent {
|
||||
children: Vec<Entity<Child>>,
|
||||
}
|
||||
|
||||
struct Child {
|
||||
parent: WeakEntity<Parent>,
|
||||
data: String,
|
||||
}
|
||||
|
||||
impl Child {
|
||||
fn notify_parent_of_change(&mut self, cx: &mut Context<Self>) {
|
||||
if let Ok(_) = self.parent.update(cx, |parent, cx| {
|
||||
// Parent can react to child change
|
||||
cx.notify();
|
||||
}) {
|
||||
// Successfully notified
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Observer Pattern
|
||||
|
||||
React to entity state changes using observers.
|
||||
|
||||
```rust
|
||||
struct Observable {
|
||||
value: i32,
|
||||
}
|
||||
|
||||
struct Observer {
|
||||
observed: Entity<Observable>,
|
||||
}
|
||||
|
||||
impl Observer {
|
||||
fn new(observed: Entity<Observable>, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
// Observe the entity
|
||||
cx.observe(&observed, |this, observed_entity, cx| {
|
||||
// React to changes
|
||||
let value = observed_entity.read(cx).value;
|
||||
println!("Value changed to: {}", value);
|
||||
}).detach();
|
||||
|
||||
Self { observed }
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Event Subscription
|
||||
|
||||
Handle events emitted by other entities.
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
enum DataEvent {
|
||||
Updated,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
struct DataSource {
|
||||
data: Vec<String>,
|
||||
}
|
||||
|
||||
impl DataSource {
|
||||
fn update_data(&mut self, cx: &mut Context<Self>) {
|
||||
// Update data
|
||||
cx.emit(DataEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
struct DataConsumer {
|
||||
source: Entity<DataSource>,
|
||||
}
|
||||
|
||||
impl DataConsumer {
|
||||
fn new(source: Entity<DataSource>, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
// Subscribe to events
|
||||
cx.subscribe(&source, |this, source, event: &DataEvent, cx| {
|
||||
match event {
|
||||
DataEvent::Updated => {
|
||||
// Handle update
|
||||
cx.notify();
|
||||
}
|
||||
DataEvent::Deleted => {
|
||||
// Handle deletion
|
||||
}
|
||||
}
|
||||
}).detach();
|
||||
|
||||
Self { source }
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Resource Management
|
||||
|
||||
Manage external resources with proper cleanup.
|
||||
|
||||
```rust
|
||||
struct FileHandle {
|
||||
path: PathBuf,
|
||||
file: Option<File>,
|
||||
}
|
||||
|
||||
impl FileHandle {
|
||||
fn open(&mut self, cx: &mut Context<Self>) -> Result<()> {
|
||||
self.file = Some(File::open(&self.path)?);
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close(&mut self, cx: &mut Context<Self>) {
|
||||
self.file = None;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FileHandle {
|
||||
fn drop(&mut self) {
|
||||
// Cleanup when entity is dropped
|
||||
if let Some(file) = self.file.take() {
|
||||
drop(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern Selection Guide
|
||||
|
||||
| Need | Pattern | Complexity |
|
||||
|------|---------|------------|
|
||||
| Component with internal state | Stateful Components | Low |
|
||||
| State shared by multiple components | Shared State | Low |
|
||||
| Coordinate events between components | Event Coordination | Medium |
|
||||
| Handle async data fetching | Async State Management | Medium |
|
||||
| Parent-child component hierarchy | Parent-Child Relationships | Medium |
|
||||
| React to state changes | Observer Pattern | Medium |
|
||||
| Handle custom events | Event Subscription | Medium-High |
|
||||
| Manage external resources | Resource Management | High |
|
||||
|
||||
Choose the simplest pattern that meets your requirements. Combine patterns as needed for complex scenarios.
|
||||
176
.agents/skills/gpui-event/SKILL.md
Normal file
176
.agents/skills/gpui-event/SKILL.md
Normal file
@@ -0,0 +1,176 @@
|
||||
---
|
||||
name: gpui-event
|
||||
description: Event handling and subscriptions in GPUI. Use when implementing events, observers, or event-driven patterns. Supports custom events, entity observations, and event subscriptions for coordinating between components.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
GPUI provides event system for component coordination:
|
||||
|
||||
**Event Mechanisms:**
|
||||
- **Custom Events**: Define and emit type-safe events
|
||||
- **Observations**: React to entity state changes
|
||||
- **Subscriptions**: Listen to events from other entities
|
||||
- **Global Events**: App-wide event handling
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Define and Emit Events
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
enum MyEvent {
|
||||
DataUpdated(String),
|
||||
ActionTriggered,
|
||||
}
|
||||
|
||||
impl MyComponent {
|
||||
fn update_data(&mut self, data: String, cx: &mut Context<Self>) {
|
||||
self.data = data.clone();
|
||||
|
||||
// Emit event
|
||||
cx.emit(MyEvent::DataUpdated(data));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Subscribe to Events
|
||||
|
||||
```rust
|
||||
impl Listener {
|
||||
fn new(source: Entity<MyComponent>, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
// Subscribe to events
|
||||
cx.subscribe(&source, |this, emitter, event: &MyEvent, cx| {
|
||||
match event {
|
||||
MyEvent::DataUpdated(data) => {
|
||||
this.handle_update(data.clone(), cx);
|
||||
}
|
||||
MyEvent::ActionTriggered => {
|
||||
this.handle_action(cx);
|
||||
}
|
||||
}
|
||||
}).detach();
|
||||
|
||||
Self { source }
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Observe Entity Changes
|
||||
|
||||
```rust
|
||||
impl Observer {
|
||||
fn new(target: Entity<Target>, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
// Observe entity for any changes
|
||||
cx.observe(&target, |this, observed, cx| {
|
||||
// Called when observed.update() calls cx.notify()
|
||||
println!("Target changed");
|
||||
cx.notify();
|
||||
}).detach();
|
||||
|
||||
Self { target }
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### 1. Parent-Child Communication
|
||||
|
||||
```rust
|
||||
// Parent emits events
|
||||
impl Parent {
|
||||
fn notify_children(&mut self, cx: &mut Context<Self>) {
|
||||
cx.emit(ParentEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
// Children subscribe
|
||||
impl Child {
|
||||
fn new(parent: Entity<Parent>, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
cx.subscribe(&parent, |this, parent, event, cx| {
|
||||
this.handle_parent_event(event, cx);
|
||||
}).detach();
|
||||
|
||||
Self { parent }
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Global Event Broadcasting
|
||||
|
||||
```rust
|
||||
struct EventBus {
|
||||
listeners: Vec<WeakEntity<dyn Listener>>,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
fn broadcast(&mut self, event: GlobalEvent, cx: &mut Context<Self>) {
|
||||
self.listeners.retain(|weak| {
|
||||
weak.update(cx, |listener, cx| {
|
||||
listener.on_event(&event, cx);
|
||||
}).is_ok()
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Observer Pattern
|
||||
|
||||
```rust
|
||||
cx.observe(&entity, |this, observed, cx| {
|
||||
// React to any state change
|
||||
let state = observed.read(cx);
|
||||
this.sync_with_state(state, cx);
|
||||
}).detach();
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ Detach Subscriptions
|
||||
|
||||
```rust
|
||||
// ✅ Detach to keep alive
|
||||
cx.subscribe(&entity, |this, source, event, cx| {
|
||||
// Handle event
|
||||
}).detach();
|
||||
```
|
||||
|
||||
### ✅ Clean Event Types
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
enum AppEvent {
|
||||
DataChanged { id: usize, value: String },
|
||||
ActionPerformed(ActionType),
|
||||
Error(String),
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Avoid Event Loops
|
||||
|
||||
```rust
|
||||
// ❌ Don't create mutual subscriptions
|
||||
entity1.subscribe(entity2) → emits event
|
||||
entity2.subscribe(entity1) → emits event → infinite loop!
|
||||
```
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
- **API Reference**: See [api-reference.md](references/api-reference.md)
|
||||
- Event definition, emission, subscriptions
|
||||
- Observations, global events
|
||||
- Subscription lifecycle
|
||||
|
||||
- **Patterns**: See [patterns.md](references/patterns.md)
|
||||
- Event-driven architectures
|
||||
- Communication patterns
|
||||
- Best practices and pitfalls
|
||||
232
.agents/skills/gpui-focus-handle/SKILL.md
Normal file
232
.agents/skills/gpui-focus-handle/SKILL.md
Normal file
@@ -0,0 +1,232 @@
|
||||
---
|
||||
name: gpui-focus-handle
|
||||
description: Focus management and keyboard navigation in GPUI. Use when handling focus, focus handles, or keyboard navigation. Enables keyboard-driven interfaces with proper focus tracking and navigation between focusable elements.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
GPUI's focus system enables keyboard navigation and focus management.
|
||||
|
||||
**Key Concepts:**
|
||||
- **FocusHandle**: Reference to focusable element
|
||||
- **Focus tracking**: Current focused element
|
||||
- **Keyboard navigation**: Tab/Shift-Tab between elements
|
||||
- **Focus events**: on_focus, on_blur
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Creating Focus Handles
|
||||
|
||||
```rust
|
||||
struct FocusableComponent {
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl FocusableComponent {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Making Elements Focusable
|
||||
|
||||
```rust
|
||||
impl Render for FocusableComponent {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::on_enter))
|
||||
.child("Focusable content")
|
||||
}
|
||||
|
||||
fn on_enter(&mut self, _: &Enter, cx: &mut Context<Self>) {
|
||||
// Handle Enter key when focused
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Focus Management
|
||||
|
||||
```rust
|
||||
impl MyComponent {
|
||||
fn focus(&mut self, cx: &mut Context<Self>) {
|
||||
self.focus_handle.focus(cx);
|
||||
}
|
||||
|
||||
fn is_focused(&self, cx: &App) -> bool {
|
||||
self.focus_handle.is_focused(cx)
|
||||
}
|
||||
|
||||
fn blur(&mut self, cx: &mut Context<Self>) {
|
||||
cx.blur();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Focus Events
|
||||
|
||||
### Handling Focus Changes
|
||||
|
||||
```rust
|
||||
impl Render for MyInput {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let is_focused = self.focus_handle.is_focused(cx);
|
||||
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_focus(cx.listener(|this, _event, cx| {
|
||||
this.on_focus(cx);
|
||||
}))
|
||||
.on_blur(cx.listener(|this, _event, cx| {
|
||||
this.on_blur(cx);
|
||||
}))
|
||||
.when(is_focused, |el| {
|
||||
el.bg(cx.theme().focused_background)
|
||||
})
|
||||
.child(self.render_content())
|
||||
}
|
||||
}
|
||||
|
||||
impl MyInput {
|
||||
fn on_focus(&mut self, cx: &mut Context<Self>) {
|
||||
// Handle focus gained
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, cx: &mut Context<Self>) {
|
||||
// Handle focus lost
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Keyboard Navigation
|
||||
|
||||
### Tab Order
|
||||
|
||||
Elements with `track_focus()` automatically participate in Tab navigation.
|
||||
|
||||
```rust
|
||||
div()
|
||||
.child(
|
||||
input1.track_focus(&focus1) // Tab order: 1
|
||||
)
|
||||
.child(
|
||||
input2.track_focus(&focus2) // Tab order: 2
|
||||
)
|
||||
.child(
|
||||
input3.track_focus(&focus3) // Tab order: 3
|
||||
)
|
||||
```
|
||||
|
||||
### Focus Within Containers
|
||||
|
||||
```rust
|
||||
impl Container {
|
||||
fn focus_first(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(first) = self.children.first() {
|
||||
first.update(cx, |child, cx| {
|
||||
child.focus_handle.focus(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_next(&mut self, cx: &mut Context<Self>) {
|
||||
// Custom focus navigation logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### 1. Auto-focus on Mount
|
||||
|
||||
```rust
|
||||
impl MyDialog {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
|
||||
// Focus when created
|
||||
focus_handle.focus(cx);
|
||||
|
||||
Self { focus_handle }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Focus Trap (Modal)
|
||||
|
||||
```rust
|
||||
impl Modal {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_key_down(cx.listener(|this, event: &KeyDownEvent, cx| {
|
||||
if event.key == Key::Tab {
|
||||
// Keep focus within modal
|
||||
this.focus_next_in_modal(cx);
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.child(self.render_content())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Conditional Focus
|
||||
|
||||
```rust
|
||||
impl Searchable {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.when(self.search_active, |el| {
|
||||
el.on_mount(cx.listener(|this, _, cx| {
|
||||
this.focus_handle.focus(cx);
|
||||
}))
|
||||
})
|
||||
.child(self.search_input())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ Track Focus on Interactive Elements
|
||||
|
||||
```rust
|
||||
// ✅ Good: Track focus for keyboard interaction
|
||||
input()
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::on_enter))
|
||||
```
|
||||
|
||||
### ✅ Provide Visual Focus Indicators
|
||||
|
||||
```rust
|
||||
let is_focused = self.focus_handle.is_focused(cx);
|
||||
|
||||
div()
|
||||
.when(is_focused, |el| {
|
||||
el.border_color(cx.theme().focused_border)
|
||||
})
|
||||
```
|
||||
|
||||
### ❌ Don't: Forget to Track Focus
|
||||
|
||||
```rust
|
||||
// ❌ Bad: No track_focus, keyboard navigation won't work
|
||||
div()
|
||||
.on_action(cx.listener(Self::on_enter))
|
||||
```
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
- **API Reference**: See [api-reference.md](references/api-reference.md)
|
||||
- FocusHandle API, focus management
|
||||
- Events, keyboard navigation
|
||||
- Best practices
|
||||
204
.agents/skills/gpui-global/SKILL.md
Normal file
204
.agents/skills/gpui-global/SKILL.md
Normal file
@@ -0,0 +1,204 @@
|
||||
---
|
||||
name: gpui-global
|
||||
description: Global state management in GPUI. Use when implementing global state, app-wide configuration, or shared resources.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Global state in GPUI provides app-wide shared data accessible from any context.
|
||||
|
||||
**Key Trait**: `Global` - Implement on types to make them globally accessible
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Define Global State
|
||||
|
||||
```rust
|
||||
use gpui::Global;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppSettings {
|
||||
theme: Theme,
|
||||
language: String,
|
||||
}
|
||||
|
||||
impl Global for AppSettings {}
|
||||
```
|
||||
|
||||
### Set and Access Globals
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
let app = Application::new();
|
||||
app.run(|cx: &mut App| {
|
||||
// Set global
|
||||
cx.set_global(AppSettings {
|
||||
theme: Theme::Dark,
|
||||
language: "en".to_string(),
|
||||
});
|
||||
|
||||
// Access global (read-only)
|
||||
let settings = cx.global::<AppSettings>();
|
||||
println!("Theme: {:?}", settings.theme);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Update Globals
|
||||
|
||||
```rust
|
||||
impl MyComponent {
|
||||
fn change_theme(&mut self, new_theme: Theme, cx: &mut Context<Self>) {
|
||||
cx.update_global::<AppSettings, _>(|settings, cx| {
|
||||
settings.theme = new_theme;
|
||||
// Global updates don't trigger automatic notifications
|
||||
// Manually notify components that care
|
||||
});
|
||||
|
||||
cx.notify(); // Re-render this component
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### 1. App Configuration
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
struct AppConfig {
|
||||
api_endpoint: String,
|
||||
max_retries: u32,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl Global for AppConfig {}
|
||||
|
||||
// Set once at startup
|
||||
cx.set_global(AppConfig {
|
||||
api_endpoint: "https://api.example.com".to_string(),
|
||||
max_retries: 3,
|
||||
timeout: Duration::from_secs(30),
|
||||
});
|
||||
|
||||
// Access anywhere
|
||||
let config = cx.global::<AppConfig>();
|
||||
```
|
||||
|
||||
### 2. Feature Flags
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
struct FeatureFlags {
|
||||
enable_beta_features: bool,
|
||||
enable_analytics: bool,
|
||||
}
|
||||
|
||||
impl Global for FeatureFlags {}
|
||||
|
||||
impl MyComponent {
|
||||
fn render_beta_feature(&self, cx: &App) -> Option<impl IntoElement> {
|
||||
let flags = cx.global::<FeatureFlags>();
|
||||
|
||||
if flags.enable_beta_features {
|
||||
Some(div().child("Beta feature"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Shared Services
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
struct ServiceRegistry {
|
||||
http_client: Arc<HttpClient>,
|
||||
logger: Arc<Logger>,
|
||||
}
|
||||
|
||||
impl Global for ServiceRegistry {}
|
||||
|
||||
impl MyComponent {
|
||||
fn fetch_data(&mut self, cx: &mut Context<Self>) {
|
||||
let registry = cx.global::<ServiceRegistry>();
|
||||
let client = registry.http_client.clone();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let data = client.get("api/data").await?;
|
||||
// Process data...
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ Use Arc for Shared Resources
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
struct GlobalState {
|
||||
database: Arc<Database>, // Cheap to clone
|
||||
cache: Arc<RwLock<Cache>>,
|
||||
}
|
||||
|
||||
impl Global for GlobalState {}
|
||||
```
|
||||
|
||||
### ✅ Immutable by Default
|
||||
|
||||
Globals are read-only by default. Use interior mutability when needed:
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
struct Counter {
|
||||
count: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Global for Counter {}
|
||||
|
||||
impl Counter {
|
||||
fn increment(&self) {
|
||||
self.count.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn get(&self) -> usize {
|
||||
self.count.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Don't: Overuse Globals
|
||||
|
||||
```rust
|
||||
// ❌ Bad: Too many globals
|
||||
cx.set_global(UserState { ... });
|
||||
cx.set_global(CartState { ... });
|
||||
cx.set_global(CheckoutState { ... });
|
||||
|
||||
// ✅ Good: Use entities for component state
|
||||
let user_entity = cx.new(|_| UserState { ... });
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
**Use Globals for:**
|
||||
- App-wide configuration
|
||||
- Feature flags
|
||||
- Shared services (HTTP client, logger)
|
||||
- Read-only reference data
|
||||
|
||||
**Use Entities for:**
|
||||
- Component-specific state
|
||||
- State that changes frequently
|
||||
- State that needs notifications
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
- **API Reference**: See [api-reference.md](references/api-reference.md)
|
||||
- Global trait, set_global, update_global
|
||||
- Interior mutability patterns
|
||||
- Best practices and anti-patterns
|
||||
177
.agents/skills/gpui-layout-and-style/SKILL.md
Normal file
177
.agents/skills/gpui-layout-and-style/SKILL.md
Normal file
@@ -0,0 +1,177 @@
|
||||
---
|
||||
name: gpui-layout-and-style
|
||||
description: Layout and styling in GPUI. Use when styling components, layout systems, or CSS-like properties.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
GPUI provides CSS-like styling with Rust type safety.
|
||||
|
||||
**Key Concepts:**
|
||||
- Flexbox layout system
|
||||
- Styled trait for chaining styles
|
||||
- Size units: `px()`, `rems()`, `relative()`
|
||||
- Colors, borders, shadows
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Styling
|
||||
|
||||
```rust
|
||||
use gpui::*;
|
||||
|
||||
div()
|
||||
.w(px(200.))
|
||||
.h(px(100.))
|
||||
.bg(rgb(0x2196F3))
|
||||
.text_color(rgb(0xFFFFFF))
|
||||
.rounded(px(8.))
|
||||
.p(px(16.))
|
||||
.child("Styled content")
|
||||
```
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
```rust
|
||||
div()
|
||||
.flex()
|
||||
.flex_row() // or flex_col() for column
|
||||
.gap(px(8.))
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.children([
|
||||
div().child("Item 1"),
|
||||
div().child("Item 2"),
|
||||
div().child("Item 3"),
|
||||
])
|
||||
```
|
||||
|
||||
### Size Units
|
||||
|
||||
```rust
|
||||
div()
|
||||
.w(px(200.)) // Pixels
|
||||
.h(rems(10.)) // Relative to font size
|
||||
.w(relative(0.5)) // 50% of parent
|
||||
.min_w(px(100.))
|
||||
.max_w(px(400.))
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Centered Content
|
||||
|
||||
```rust
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size_full()
|
||||
.child("Centered")
|
||||
```
|
||||
|
||||
### Card Layout
|
||||
|
||||
```rust
|
||||
div()
|
||||
.w(px(300.))
|
||||
.bg(cx.theme().surface)
|
||||
.rounded(px(8.))
|
||||
.shadow_md()
|
||||
.p(px(16.))
|
||||
.gap(px(12.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(heading())
|
||||
.child(content())
|
||||
```
|
||||
|
||||
### Responsive Spacing
|
||||
|
||||
```rust
|
||||
div()
|
||||
.p(px(16.)) // Padding all sides
|
||||
.px(px(20.)) // Padding horizontal
|
||||
.py(px(12.)) // Padding vertical
|
||||
.pt(px(8.)) // Padding top
|
||||
.gap(px(8.)) // Gap between children
|
||||
```
|
||||
|
||||
## Styling Methods
|
||||
|
||||
### Dimensions
|
||||
|
||||
```rust
|
||||
.w(px(200.)) // Width
|
||||
.h(px(100.)) // Height
|
||||
.size(px(200.)) // Width and height
|
||||
.min_w(px(100.)) // Min width
|
||||
.max_w(px(400.)) // Max width
|
||||
```
|
||||
|
||||
### Colors
|
||||
|
||||
```rust
|
||||
.bg(rgb(0x2196F3)) // Background
|
||||
.text_color(rgb(0xFFFFFF)) // Text color
|
||||
.border_color(rgb(0x000000)) // Border color
|
||||
```
|
||||
|
||||
### Borders
|
||||
|
||||
```rust
|
||||
.border(px(1.)) // Border width
|
||||
.rounded(px(8.)) // Border radius
|
||||
.rounded_t(px(8.)) // Top corners
|
||||
.border_color(rgb(0x000000))
|
||||
```
|
||||
|
||||
### Spacing
|
||||
|
||||
```rust
|
||||
.p(px(16.)) // Padding
|
||||
.m(px(8.)) // Margin
|
||||
.gap(px(8.)) // Gap between flex children
|
||||
```
|
||||
|
||||
### Flexbox
|
||||
|
||||
```rust
|
||||
.flex() // Enable flexbox
|
||||
.flex_row() // Row direction
|
||||
.flex_col() // Column direction
|
||||
.items_center() // Align items center
|
||||
.justify_between() // Space between items
|
||||
.flex_grow() // Grow to fill space
|
||||
```
|
||||
|
||||
## Theme Integration
|
||||
|
||||
```rust
|
||||
div()
|
||||
.bg(cx.theme().surface)
|
||||
.text_color(cx.theme().foreground)
|
||||
.border_color(cx.theme().border)
|
||||
.when(is_hovered, |el| {
|
||||
el.bg(cx.theme().hover)
|
||||
})
|
||||
```
|
||||
|
||||
## Conditional Styling
|
||||
|
||||
```rust
|
||||
div()
|
||||
.when(is_active, |el| {
|
||||
el.bg(cx.theme().primary)
|
||||
})
|
||||
.when_some(optional_color, |el, color| {
|
||||
el.bg(color)
|
||||
})
|
||||
```
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
- **Complete Guide**: See [reference.md](references/reference.md)
|
||||
- All styling methods
|
||||
- Layout strategies
|
||||
- Theming, responsive design
|
||||
94
.agents/skills/gpui-test/SKILL.md
Normal file
94
.agents/skills/gpui-test/SKILL.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: gpui-test
|
||||
description: Writing tests for GPUI applications. Use when testing components, async operations, or UI behavior.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
GPUI provides a comprehensive testing framework that allows you to test UI components, async operations, and distributed systems. Tests run on a single-threaded executor that provides deterministic execution and the ability to test complex async scenarios. GPUI tests use the `#[gpui::test]` attribute and work with `TestAppContext` for basic testing and `VisualTestContext` for window-dependent tests.
|
||||
|
||||
### Rules
|
||||
|
||||
- If test does not require windows or rendering, we can avoid use `[gpui::test]` and `TestAppContext`, just write simple rust test.
|
||||
|
||||
## Core Testing Infrastructure
|
||||
|
||||
### Test Attributes
|
||||
|
||||
#### Basic Test
|
||||
|
||||
```rust
|
||||
#[gpui::test]
|
||||
fn my_test(cx: &mut TestAppContext) {
|
||||
// Test implementation
|
||||
}
|
||||
```
|
||||
|
||||
#### Async Test
|
||||
|
||||
```rust
|
||||
#[gpui::test]
|
||||
async fn my_async_test(cx: &mut TestAppContext) {
|
||||
// Async test implementation
|
||||
}
|
||||
```
|
||||
|
||||
#### Property Test with Iterations
|
||||
|
||||
```rust
|
||||
#[gpui::test(iterations = 10)]
|
||||
fn my_property_test(cx: &mut TestAppContext, mut rng: StdRng) {
|
||||
// Property testing with random data
|
||||
}
|
||||
```
|
||||
|
||||
### Test Contexts
|
||||
|
||||
#### TestAppContext
|
||||
|
||||
`TestAppContext` provides access to GPUI's core functionality without windows:
|
||||
|
||||
```rust
|
||||
#[gpui::test]
|
||||
fn test_entity_operations(cx: &mut TestAppContext) {
|
||||
// Create entities
|
||||
let entity = cx.new(|cx| MyComponent::new(cx));
|
||||
|
||||
// Update entities
|
||||
entity.update(cx, |component, cx| {
|
||||
component.value = 42;
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Read entities
|
||||
let value = entity.read_with(cx, |component, _| component.value);
|
||||
assert_eq!(value, 42);
|
||||
}
|
||||
```
|
||||
|
||||
#### VisualTestContext
|
||||
|
||||
`VisualTestContext` extends `TestAppContext` with window support:
|
||||
|
||||
```rust
|
||||
#[gpui::test]
|
||||
fn test_with_window(cx: &mut TestAppContext) {
|
||||
// Create window with component
|
||||
let window = cx.update(|cx| {
|
||||
cx.open_window(Default::default(), |_, cx| {
|
||||
cx.new(|cx| MyComponent::new(cx))
|
||||
}).unwrap()
|
||||
});
|
||||
|
||||
// Convert to visual context
|
||||
let mut cx = VisualTestContext::from_window(window.into(), cx);
|
||||
|
||||
// Access window and component
|
||||
let component = window.root(&mut cx).unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- For detailed testing patterns and examples, see [reference.md](reference.md)
|
||||
- For best practices and running tests, see [examples.md](examples.md)
|
||||
172
.agents/skills/gpui-test/examples.md
Normal file
172
.agents/skills/gpui-test/examples.md
Normal file
@@ -0,0 +1,172 @@
|
||||
## Testing Best Practices
|
||||
|
||||
### Test Organization
|
||||
|
||||
Group related tests in modules:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod entity_tests {
|
||||
use super::*;
|
||||
|
||||
#[gpui::test]
|
||||
fn test_creation() { /* ... */ }
|
||||
|
||||
#[gpui::test]
|
||||
fn test_updates() { /* ... */ }
|
||||
}
|
||||
|
||||
mod async_tests {
|
||||
use super::*;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_async_ops() { /* ... */ }
|
||||
}
|
||||
|
||||
mod distributed_tests {
|
||||
use super::*;
|
||||
|
||||
#[gpui::test]
|
||||
fn test_multi_app() { /* ... */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Setup and Teardown
|
||||
|
||||
Use helper functions for common setup:
|
||||
|
||||
```rust
|
||||
fn create_test_counter(cx: &mut TestAppContext) -> Entity<Counter> {
|
||||
cx.new(|cx| Counter::new(cx))
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_counter_operations(cx: &mut TestAppContext) {
|
||||
let counter = create_test_counter(cx);
|
||||
|
||||
// Test operations
|
||||
}
|
||||
```
|
||||
|
||||
### Assertions
|
||||
|
||||
Use descriptive assertions:
|
||||
|
||||
```rust
|
||||
#[gpui::test]
|
||||
fn test_counter_bounds(cx: &mut TestAppContext) {
|
||||
let counter = create_test_counter(cx);
|
||||
|
||||
// Test upper bound
|
||||
for _ in 0..100 {
|
||||
counter.update(cx, |counter, cx| {
|
||||
counter.increment(cx);
|
||||
});
|
||||
}
|
||||
|
||||
let count = counter.read_with(cx, |counter, _| counter.count);
|
||||
assert!(count <= 100, "Counter should not exceed maximum");
|
||||
|
||||
// Test lower bound
|
||||
for _ in 0..200 {
|
||||
counter.update(cx, |counter, cx| {
|
||||
counter.decrement(cx);
|
||||
});
|
||||
}
|
||||
|
||||
let count = counter.read_with(cx, |counter, _| counter.count);
|
||||
assert!(count >= 0, "Counter should not go below minimum");
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Testing
|
||||
|
||||
Test performance characteristics:
|
||||
|
||||
```rust
|
||||
#[gpui::test]
|
||||
fn test_operation_performance(cx: &mut TestAppContext) {
|
||||
let component = cx.new(|cx| MyComponent::new(cx));
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Perform many operations
|
||||
for i in 0..1000 {
|
||||
component.update(cx, |comp, cx| {
|
||||
comp.perform_operation(i, cx);
|
||||
});
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
assert!(elapsed < Duration::from_millis(100), "Operations should complete quickly");
|
||||
}
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Basic Test Execution
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
cargo test
|
||||
|
||||
# Run specific test
|
||||
cargo test test_counter_operations
|
||||
|
||||
# Run tests in a specific module
|
||||
cargo test entity_tests::
|
||||
|
||||
# Run with output
|
||||
cargo test -- --nocapture
|
||||
```
|
||||
|
||||
### Test Configuration
|
||||
|
||||
Enable test-support feature for GPUI tests:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
test-support = ["gpui/test-support"]
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo test --features test-support
|
||||
```
|
||||
|
||||
### Advanced Test Execution
|
||||
|
||||
```bash
|
||||
# Run tests with iterations for property testing
|
||||
cargo test -- --test-threads=1
|
||||
|
||||
# Run tests matching a pattern
|
||||
cargo test test_async
|
||||
|
||||
# Run tests with backtrace on failure
|
||||
RUST_BACKTRACE=1 cargo test
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
For continuous integration:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Tests
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Run tests
|
||||
run: cargo test --features test-support
|
||||
```
|
||||
|
||||
GPUI's testing framework provides deterministic, fast, and comprehensive testing capabilities that mirror real application behavior while providing the control needed for thorough testing of complex UI and async scenarios.
|
||||
350
.agents/skills/gpui-test/reference.md
Normal file
350
.agents/skills/gpui-test/reference.md
Normal file
@@ -0,0 +1,350 @@
|
||||
## Testing Patterns
|
||||
|
||||
### Basic Entity Testing
|
||||
|
||||
Test entity creation, updates, and reads:
|
||||
|
||||
```rust
|
||||
#[gpui::test]
|
||||
fn test_counter_entity(cx: &mut TestAppContext) {
|
||||
let counter = cx.new(|cx| Counter::new(cx));
|
||||
|
||||
// Test initial state
|
||||
let initial_count = counter.read_with(cx, |counter, _| counter.count);
|
||||
assert_eq!(initial_count, 0);
|
||||
|
||||
// Test updates
|
||||
counter.update(cx, |counter, cx| {
|
||||
counter.count = 42;
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let updated_count = counter.read_with(cx, |counter, _| counter.count);
|
||||
assert_eq!(updated_count, 42);
|
||||
}
|
||||
```
|
||||
|
||||
### Event Testing
|
||||
|
||||
Test event emission and handling:
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
struct ValueChanged {
|
||||
new_value: i32,
|
||||
}
|
||||
|
||||
impl EventEmitter<ValueChanged> for MyComponent {}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_event_emission(cx: &mut TestAppContext) {
|
||||
let component = cx.new(|cx| {
|
||||
let mut comp = MyComponent::default();
|
||||
|
||||
// Subscribe to self
|
||||
cx.subscribe_self(|this, event: &ValueChanged, cx| {
|
||||
this.received_value = event.new_value;
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
comp
|
||||
});
|
||||
|
||||
// Emit event
|
||||
component.update(cx, |_, cx| {
|
||||
cx.emit(ValueChanged { new_value: 123 });
|
||||
});
|
||||
|
||||
// Verify event was handled
|
||||
let received = component.read_with(cx, |comp, _| comp.received_value);
|
||||
assert_eq!(received, 123);
|
||||
}
|
||||
```
|
||||
|
||||
### Action Testing
|
||||
|
||||
Test action dispatching and handling:
|
||||
|
||||
```rust
|
||||
actions!(my_app, [Increment, Decrement]);
|
||||
|
||||
#[gpui::test]
|
||||
fn test_action_dispatch(cx: &mut TestAppContext) {
|
||||
let window = cx.update(|cx| {
|
||||
cx.open_window(Default::default(), |_, cx| {
|
||||
cx.new(|cx| MyComponent::new(cx))
|
||||
}).unwrap()
|
||||
});
|
||||
|
||||
let mut cx = VisualTestContext::from_window(window.into(), cx);
|
||||
let counter = window.root(&mut cx).unwrap();
|
||||
|
||||
// Dispatch action via focus handle
|
||||
let focus_handle = counter.read_with(&cx, |counter, _| counter.focus_handle.clone());
|
||||
cx.update(|window, cx| {
|
||||
focus_handle.dispatch_action(&Increment, window, cx);
|
||||
});
|
||||
|
||||
let count = counter.read_with(&cx, |counter, _| counter.count);
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
```
|
||||
|
||||
### Async Testing
|
||||
|
||||
Test async operations and background tasks:
|
||||
|
||||
```rust
|
||||
impl MyComponent {
|
||||
fn load_data(&self, cx: &mut Context<Self>) -> Task<i32> {
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Simulate async work
|
||||
this.update(cx, |comp, _| comp.loading = true).await;
|
||||
// Return result
|
||||
42
|
||||
})
|
||||
}
|
||||
|
||||
fn background_update(&self, cx: &mut Context<Self>) {
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Background work
|
||||
this.update(cx, |comp, _| {
|
||||
comp.value += 10;
|
||||
}).await;
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_async_operations(cx: &mut TestAppContext) {
|
||||
let component = cx.new(|cx| MyComponent::new(cx));
|
||||
|
||||
// Test awaited task
|
||||
let result = component.update(cx, |comp, cx| comp.load_data(cx)).await;
|
||||
assert_eq!(result, 42);
|
||||
|
||||
// Test detached task
|
||||
component.update(cx, |comp, cx| comp.background_update(cx));
|
||||
|
||||
// Detached tasks don't run until you yield
|
||||
let value_before = component.read_with(cx, |comp, _| comp.value);
|
||||
assert_eq!(value_before, 0);
|
||||
|
||||
// Run pending tasks
|
||||
cx.run_until_parked();
|
||||
|
||||
let value_after = component.read_with(cx, |comp, _| comp.value);
|
||||
assert_eq!(value_after, 10);
|
||||
}
|
||||
```
|
||||
|
||||
### Timer Testing
|
||||
|
||||
Test timer-based operations:
|
||||
|
||||
```rust
|
||||
impl MyComponent {
|
||||
fn delayed_action(&self, cx: &mut Context<Self>) {
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(100))
|
||||
.await;
|
||||
|
||||
this.update(cx, |comp, cx| {
|
||||
comp.action_performed = true;
|
||||
cx.notify();
|
||||
}).await;
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_timers(cx: &mut TestAppContext) {
|
||||
let component = cx.new(|cx| MyComponent::new(cx));
|
||||
|
||||
component.update(cx, |comp, cx| comp.delayed_action(cx));
|
||||
|
||||
// Action shouldn't have completed yet
|
||||
let performed = component.read_with(cx, |comp, _| comp.action_performed);
|
||||
assert!(!performed);
|
||||
|
||||
// Run until parked (timers complete)
|
||||
cx.run_until_parked();
|
||||
|
||||
let performed = component.read_with(cx, |comp, _| comp.action_performed);
|
||||
assert!(performed);
|
||||
}
|
||||
```
|
||||
|
||||
### External I/O Testing
|
||||
|
||||
For tests involving external systems, use `allow_parking()`:
|
||||
|
||||
```rust
|
||||
#[gpui::test]
|
||||
async fn test_external_io(cx: &mut TestAppContext) {
|
||||
// Allow parking for external I/O
|
||||
cx.executor().allow_parking();
|
||||
|
||||
// Simulate external operation
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
tx.send(42).ok();
|
||||
});
|
||||
|
||||
let result = rx.await.unwrap();
|
||||
assert_eq!(result, 42);
|
||||
}
|
||||
```
|
||||
|
||||
## Property Testing
|
||||
|
||||
Use random data to test edge cases:
|
||||
|
||||
```rust
|
||||
#[gpui::test(iterations = 10)]
|
||||
fn test_counter_random_operations(cx: &mut TestAppContext, mut rng: StdRng) {
|
||||
let counter = cx.new(|cx| Counter::new(cx));
|
||||
|
||||
let mut expected = 0i32;
|
||||
for _ in 0..100 {
|
||||
let delta = rng.random_range(-10..=10);
|
||||
expected += delta;
|
||||
|
||||
counter.update(cx, |counter, cx| {
|
||||
counter.count += delta;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
let actual = counter.read_with(cx, |counter, _| counter.count);
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
```
|
||||
|
||||
## Distributed Systems Testing
|
||||
|
||||
Test multiple app contexts communicating:
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
struct NetworkMessage {
|
||||
from: String,
|
||||
to: String,
|
||||
data: i32,
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_distributed_apps(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
|
||||
// Create components in different app contexts
|
||||
let comp_a = cx_a.new(|_| MyComponent::new("A".to_string()));
|
||||
let comp_b = cx_b.new(|_| MyComponent::new("B".to_string()));
|
||||
|
||||
// Simulate message passing
|
||||
comp_a.update(cx_a, |comp, cx| {
|
||||
comp.send_message("B", 42, cx);
|
||||
});
|
||||
|
||||
// Run async operations
|
||||
cx_a.run_until_parked();
|
||||
|
||||
// Verify message received in other context
|
||||
comp_b.update(cx_b, |comp, _| {
|
||||
comp.receive_messages();
|
||||
});
|
||||
|
||||
let messages = comp_b.read_with(cx_b, |comp, _| comp.messages.clone());
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].data, 42);
|
||||
}
|
||||
```
|
||||
|
||||
### Interleaving Testing
|
||||
|
||||
Test concurrent operations with random execution order:
|
||||
|
||||
```rust
|
||||
#[gpui::test(iterations = 10)]
|
||||
fn test_concurrent_operations(
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
mut rng: StdRng,
|
||||
) {
|
||||
let comp_a = cx_a.new(|_| MyComponent::new());
|
||||
let comp_b = cx_b.new(|_| MyComponent::new());
|
||||
|
||||
// Perform random operations across contexts
|
||||
for i in 0..20 {
|
||||
if rng.random_bool(0.5) {
|
||||
comp_a.update(cx_a, |comp, cx| {
|
||||
comp.perform_operation(i, cx);
|
||||
});
|
||||
} else {
|
||||
comp_b.update(cx_b, |comp, cx| {
|
||||
comp.perform_operation(i, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run all pending operations
|
||||
cx_a.run_until_parked();
|
||||
|
||||
// Verify final state
|
||||
let state_a = comp_a.read_with(cx_a, |comp, _| comp.state);
|
||||
let state_b = comp_b.read_with(cx_b, |comp, _| comp.state);
|
||||
|
||||
// Assert invariants hold despite execution order
|
||||
assert!(state_a.is_consistent());
|
||||
assert!(state_b.is_consistent());
|
||||
}
|
||||
```
|
||||
|
||||
## Mocking and Isolation
|
||||
|
||||
### Network Mocking
|
||||
|
||||
Create mock networks for testing distributed features:
|
||||
|
||||
```rust
|
||||
struct MockNetwork {
|
||||
messages: Arc<Mutex<Vec<NetworkMessage>>>,
|
||||
}
|
||||
|
||||
impl MockNetwork {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
messages: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn send(&self, message: NetworkMessage) {
|
||||
self.messages.lock().unwrap().push(message);
|
||||
}
|
||||
|
||||
fn receive_all(&self) -> Vec<NetworkMessage> {
|
||||
self.messages.lock().unwrap().drain(..).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_networked_components(cx: &mut TestAppContext) {
|
||||
let network = Arc::new(MockNetwork::new());
|
||||
|
||||
let sender = cx.new(|_| MessageSender::new(network.clone()));
|
||||
let receiver = cx.new(|_| MessageReceiver::new(network));
|
||||
|
||||
// Send message
|
||||
sender.update(cx, |sender, _| {
|
||||
sender.send("Hello");
|
||||
});
|
||||
|
||||
// Receive message
|
||||
receiver.update(cx, |receiver, _| {
|
||||
receiver.receive_all();
|
||||
});
|
||||
|
||||
let received = receiver.read_with(cx, |receiver, _| receiver.messages.clone());
|
||||
assert_eq!(received, vec!["Hello"]);
|
||||
}
|
||||
```
|
||||
111
Cargo.lock
generated
111
Cargo.lock
generated
@@ -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"
|
||||
@@ -4264,6 +4299,17 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nostr-memory"
|
||||
version = "0.44.0"
|
||||
source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9"
|
||||
dependencies = [
|
||||
"btreecap",
|
||||
"nostr",
|
||||
"nostr-database",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nostr-sdk"
|
||||
version = "0.44.1"
|
||||
@@ -4299,7 +4345,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]]
|
||||
@@ -4724,6 +4770,7 @@ dependencies = [
|
||||
"smallvec",
|
||||
"smol",
|
||||
"state",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5160,7 +5207,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5585,6 +5632,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 +5751,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6307,6 +6378,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,7 +6444,9 @@ dependencies = [
|
||||
"nostr",
|
||||
"nostr-blossom",
|
||||
"nostr-connect",
|
||||
"nostr-gossip-sqlite",
|
||||
"nostr-lmdb",
|
||||
"nostr-memory",
|
||||
"nostr-sdk",
|
||||
"petname",
|
||||
"rustls",
|
||||
@@ -6661,7 +6746,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 +7274,7 @@ checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7331,6 +7416,12 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "usvg"
|
||||
version = "0.45.1"
|
||||
@@ -7491,6 +7582,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 +8151,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]]
|
||||
|
||||
@@ -20,8 +20,10 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-memory = { 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" ] }
|
||||
|
||||
|
||||
@@ -10,11 +10,12 @@ use common::EventUtils;
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
||||
WeakEntity, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, RelayState, TIMEOUT, USER_GIFTWRAP};
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, StateEvent, TIMEOUT, USER_GIFTWRAP};
|
||||
|
||||
mod message;
|
||||
mod room;
|
||||
@@ -39,6 +40,10 @@ pub enum ChatEvent {
|
||||
CloseRoom(u64),
|
||||
/// An event to notify UI about a new chat request
|
||||
Ping,
|
||||
/// An event to notify UI that the chat registry has subscribed to messaging relays
|
||||
Subscribed,
|
||||
/// An error occurred
|
||||
Error(SharedString),
|
||||
}
|
||||
|
||||
/// Channel signal.
|
||||
@@ -48,41 +53,25 @@ enum Signal {
|
||||
Message(NewMessage),
|
||||
/// Eose received from relay pool
|
||||
Eose,
|
||||
}
|
||||
|
||||
/// Inbox state.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum InboxState {
|
||||
#[default]
|
||||
Idle,
|
||||
Checking,
|
||||
RelayNotAvailable,
|
||||
RelayConfigured(Box<Event>),
|
||||
Subscribing,
|
||||
}
|
||||
|
||||
impl InboxState {
|
||||
pub fn not_configured(&self) -> bool {
|
||||
matches!(self, InboxState::RelayNotAvailable)
|
||||
}
|
||||
|
||||
pub fn subscribing(&self) -> bool {
|
||||
matches!(self, InboxState::Subscribing)
|
||||
}
|
||||
/// An error occurred
|
||||
Error(SharedString),
|
||||
}
|
||||
|
||||
/// Chat Registry
|
||||
#[derive(Debug)]
|
||||
pub struct ChatRegistry {
|
||||
/// Relay state for messaging relay list
|
||||
state: Entity<InboxState>,
|
||||
|
||||
/// Collection of all chat rooms
|
||||
rooms: Vec<Entity<Room>>,
|
||||
|
||||
/// Tracking the status of unwrapping gift wrap events.
|
||||
tracking_flag: Arc<AtomicBool>,
|
||||
|
||||
/// Channel for sending signals to the UI.
|
||||
signal_tx: flume::Sender<Signal>,
|
||||
|
||||
/// Channel for receiving signals from the UI.
|
||||
signal_rx: flume::Receiver<Signal>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
|
||||
|
||||
@@ -105,36 +94,18 @@ impl ChatRegistry {
|
||||
|
||||
/// Create a new chat registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let state = cx.new(|_| InboxState::default());
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let (tx, rx) = flume::unbounded::<Signal>();
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the nip65 state and load chat rooms on every state change
|
||||
cx.observe(&nostr, |this, state, cx| {
|
||||
match state.read(cx).relay_list_state {
|
||||
RelayState::Idle => {
|
||||
this.reset(cx);
|
||||
}
|
||||
RelayState::Configured => {
|
||||
this.get_contact_list(cx);
|
||||
this.ensure_messaging_relays(cx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Load rooms on every state change
|
||||
this.get_rooms(cx);
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the nip17 state and load chat rooms on every state change
|
||||
cx.observe(&state, |this, state, cx| {
|
||||
if let InboxState::RelayConfigured(event) = state.read(cx) {
|
||||
let relay_urls: Vec<_> = nip17::extract_relay_list(event).cloned().collect();
|
||||
this.get_messages(relay_urls, cx);
|
||||
// Subscribe to the signer event
|
||||
cx.subscribe(&nostr, |this, _state, event, cx| {
|
||||
if let StateEvent::SignerSet = event {
|
||||
this.reset(cx);
|
||||
this.get_rooms(cx);
|
||||
this.get_contact_list(cx);
|
||||
this.get_messages(cx)
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -147,9 +118,10 @@ impl ChatRegistry {
|
||||
});
|
||||
|
||||
Self {
|
||||
state,
|
||||
rooms: vec![],
|
||||
tracking_flag: Arc::new(AtomicBool::new(false)),
|
||||
signal_rx: rx,
|
||||
signal_tx: tx,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -167,7 +139,8 @@ impl ChatRegistry {
|
||||
let sub_id2 = SubscriptionId::new(USER_GIFTWRAP);
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<Signal>(1024);
|
||||
let tx = self.signal_tx.clone();
|
||||
let rx = self.signal_rx.clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let device_signer = signer.get_encryption_signer().await;
|
||||
@@ -194,19 +167,29 @@ impl ChatRegistry {
|
||||
|
||||
// Extract the rumor from the gift wrap event
|
||||
match extract_rumor(&client, &device_signer, event.as_ref()).await {
|
||||
Ok(rumor) => match rumor.created_at >= initialized_at {
|
||||
true => {
|
||||
let new_message = NewMessage::new(event.id, rumor);
|
||||
let signal = Signal::Message(new_message);
|
||||
Ok(rumor) => {
|
||||
if rumor.tags.is_empty() {
|
||||
let error: SharedString =
|
||||
"Message doesn't belong to any rooms".into();
|
||||
tx.send_async(Signal::Error(error)).await?;
|
||||
}
|
||||
|
||||
tx.send_async(signal).await?;
|
||||
match rumor.created_at >= initialized_at {
|
||||
true => {
|
||||
let new_message = NewMessage::new(event.id, rumor);
|
||||
let signal = Signal::Message(new_message);
|
||||
|
||||
tx.send_async(signal).await?;
|
||||
}
|
||||
false => {
|
||||
status.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
false => {
|
||||
status.store(true, Ordering::Release);
|
||||
}
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to unwrap the gift wrap event: {e}");
|
||||
let error: SharedString =
|
||||
format!("Failed to unwrap the gift wrap event: {e}").into();
|
||||
tx.send_async(Signal::Error(error)).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,6 +218,11 @@ impl ChatRegistry {
|
||||
this.get_rooms(cx);
|
||||
})?;
|
||||
}
|
||||
Signal::Error(error) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::Error(error));
|
||||
})?;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -245,6 +233,7 @@ impl ChatRegistry {
|
||||
/// Tracking the status of unwrapping gift wrap events.
|
||||
fn tracking(&mut self, cx: &mut Context<Self>) {
|
||||
let status = self.tracking_flag.clone();
|
||||
let tx = self.signal_tx.clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let loop_duration = Duration::from_secs(15);
|
||||
@@ -252,6 +241,9 @@ impl ChatRegistry {
|
||||
loop {
|
||||
if status.load(Ordering::Acquire) {
|
||||
_ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed);
|
||||
_ = tx.send_async(Signal::Eose).await;
|
||||
} else {
|
||||
_ = tx.send_async(Signal::Eose).await;
|
||||
}
|
||||
smol::Timer::after(loop_duration).await;
|
||||
}
|
||||
@@ -268,29 +260,20 @@ impl ChatRegistry {
|
||||
return;
|
||||
};
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
let task: Task<Result<(), Error>> = 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(())
|
||||
});
|
||||
@@ -298,39 +281,35 @@ impl ChatRegistry {
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Ensure messaging relays are set up for the current user.
|
||||
pub fn ensure_messaging_relays(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.verify_relays(cx);
|
||||
|
||||
// Set state to checking
|
||||
self.set_state(InboxState::Checking, cx);
|
||||
/// Get all messages for current user
|
||||
pub fn get_messages(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.subscribe(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await?;
|
||||
|
||||
// Update state
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_state(result, cx);
|
||||
})?;
|
||||
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::Subscribed);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::Error(SharedString::from(e.to_string())));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
// Verify messaging relay list for current user
|
||||
fn verify_relays(&mut self, cx: &mut Context<Self>) -> Task<Result<InboxState, Error>> {
|
||||
// Get messaging relay list for current user
|
||||
fn get_messaging_relays(&self, cx: &App) -> Task<Result<Vec<RelayUrl>, Error>> {
|
||||
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")));
|
||||
};
|
||||
|
||||
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,61 +317,32 @@ 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?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
return Ok(InboxState::RelayConfigured(Box::new(event)));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to receive relay list event: {e}");
|
||||
}
|
||||
if let Ok(event) = res {
|
||||
let urls: Vec<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
|
||||
return Ok(urls);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(InboxState::RelayNotAvailable)
|
||||
Err(anyhow!("Messaging Relays not found"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all messages for current user
|
||||
fn get_messages<I>(&mut self, relay_urls: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
let task = self.subscribe(relay_urls, cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
task.await?;
|
||||
|
||||
// Update state
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_state(InboxState::Subscribing, cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Continuously get gift wrap events for the current user in their messaging relays
|
||||
fn subscribe<I>(&mut self, urls: I, cx: &mut Context<Self>) -> Task<Result<(), Error>>
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
fn subscribe(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let urls = urls.into_iter().collect::<Vec<_>>();
|
||||
let urls = self.get_messaging_relays(cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let urls = urls.await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
let id = SubscriptionId::new(USER_GIFTWRAP);
|
||||
@@ -419,19 +369,6 @@ impl ChatRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the state of the inbox
|
||||
fn set_state(&mut self, state: InboxState, cx: &mut Context<Self>) {
|
||||
self.state.update(cx, |this, cx| {
|
||||
*this = state;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Get the relay state
|
||||
pub fn state(&self, cx: &App) -> InboxState {
|
||||
self.state.read(cx).clone()
|
||||
}
|
||||
|
||||
/// Get the loading status of the chat registry
|
||||
pub fn loading(&self) -> bool {
|
||||
self.tracking_flag.load(Ordering::Acquire)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -10,7 +9,7 @@ use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use settings::{RoomConfig, SignerKind};
|
||||
use state::{NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
||||
use state::{NostrRegistry, TIMEOUT};
|
||||
|
||||
use crate::NewMessage;
|
||||
|
||||
@@ -333,9 +332,6 @@ impl Room {
|
||||
let signer = nostr.read(cx).signer();
|
||||
let sender = signer.public_key();
|
||||
|
||||
// Get room's id
|
||||
let id = self.id;
|
||||
|
||||
// Get all members, excluding the sender
|
||||
let members: Vec<PublicKey> = self
|
||||
.members
|
||||
@@ -345,30 +341,27 @@ impl Room {
|
||||
.collect();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let id = SubscriptionId::new(format!("room-{id}"));
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(Duration::from_secs(TIMEOUT)));
|
||||
|
||||
// Construct filters for each member
|
||||
let filters: Vec<Filter> = members
|
||||
.into_iter()
|
||||
.map(|public_key| {
|
||||
Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::RelayList)
|
||||
.limit(1)
|
||||
})
|
||||
.collect();
|
||||
for public_key in members.into_iter() {
|
||||
let inbox = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::InboxRelays)
|
||||
.limit(1);
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
||||
.into_iter()
|
||||
.map(|relay| (relay, filters.clone()))
|
||||
.collect();
|
||||
let announcement = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::Custom(10044))
|
||||
.limit(1);
|
||||
|
||||
// Subscribe to the target
|
||||
client.subscribe(target).close_on(opts).with_id(id).await?;
|
||||
// Subscribe to the target
|
||||
client
|
||||
.subscribe(vec![inbox, announcement])
|
||||
.close_on(opts)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -491,15 +484,9 @@ impl Room {
|
||||
|
||||
// Process each member
|
||||
for member in members {
|
||||
let relays = member.messaging_relays();
|
||||
let announcement = member.announcement();
|
||||
let public_key = member.public_key();
|
||||
|
||||
if relays.is_empty() {
|
||||
reports.push(SendReport::new(public_key).error("No messaging relays"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle encryption signer requirements
|
||||
if signer_kind.encryption() {
|
||||
if announcement.is_none() {
|
||||
@@ -535,8 +522,7 @@ impl Room {
|
||||
SignerKind::User => (member.public_key(), user_signer.clone()),
|
||||
};
|
||||
|
||||
match send_gift_wrap(&client, &signer, &receiver, &rumor, relays, public_key).await
|
||||
{
|
||||
match send_gift_wrap(&client, &signer, &receiver, &rumor, public_key).await {
|
||||
Ok((report, _)) => {
|
||||
reports.push(report);
|
||||
sents += 1;
|
||||
@@ -549,12 +535,10 @@ impl Room {
|
||||
|
||||
// Send backup to current user if needed
|
||||
if backup && sents >= 1 {
|
||||
let relays = sender.messaging_relays();
|
||||
let public_key = sender.public_key();
|
||||
let signer = encryption_signer.as_ref().unwrap_or(&user_signer);
|
||||
|
||||
match send_gift_wrap(&client, signer, &public_key, &rumor, relays, public_key).await
|
||||
{
|
||||
match send_gift_wrap(&client, signer, &public_key, &rumor, public_key).await {
|
||||
Ok((report, _)) => reports.push(report),
|
||||
Err(report) => reports.push(report),
|
||||
}
|
||||
@@ -571,22 +555,16 @@ async fn send_gift_wrap<T>(
|
||||
signer: &T,
|
||||
receiver: &PublicKey,
|
||||
rumor: &UnsignedEvent,
|
||||
relays: &[RelayUrl],
|
||||
public_key: PublicKey,
|
||||
) -> Result<(SendReport, bool), SendReport>
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
// Ensure relay connections
|
||||
for url in relays {
|
||||
client.add_relay(url).and_connect().await.ok();
|
||||
}
|
||||
|
||||
match EventBuilder::gift_wrap(signer, receiver, rumor.clone(), []).await {
|
||||
Ok(event) => {
|
||||
match client
|
||||
.send_event(&event)
|
||||
.to(relays)
|
||||
.to_nip17()
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -604,7 +604,10 @@ impl ChatPanel {
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_uploading(false, cx);
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
window.push_notification(
|
||||
Notification::error(e.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
@@ -652,7 +655,10 @@ impl ChatPanel {
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
window.push_notification(Notification::error("Failed to change subject"), cx);
|
||||
window.push_notification(
|
||||
Notification::error("Failed to change subject").autohide(false),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
Command::ChangeSigner(kind) => {
|
||||
@@ -663,7 +669,10 @@ impl ChatPanel {
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
window.push_notification(Notification::error("Failed to change signer"), cx);
|
||||
window.push_notification(
|
||||
Notification::error("Failed to change signer").autohide(false),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
Command::ToggleBackup => {
|
||||
@@ -674,7 +683,10 @@ impl ChatPanel {
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
window.push_notification(Notification::error("Failed to toggle backup"), cx);
|
||||
window.push_notification(
|
||||
Notification::error("Failed to toggle backup").autohide(false),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
Command::Subject => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use anyhow::{Error, anyhow};
|
||||
use chrono::{Local, TimeZone};
|
||||
use gpui::{Image, ImageFormat, SharedString};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window,
|
||||
App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, Render,
|
||||
SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, div, px,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use state::{NostrRegistry, SignerEvent};
|
||||
use state::{NostrRegistry, StateEvent};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::{h_flex, v_flex, Disableable, Icon, IconName, Sizable, WindowExtension};
|
||||
use ui::{Disableable, Icon, IconName, Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::dialogs::connect::ConnectSigner;
|
||||
use crate::dialogs::import::ImportKey;
|
||||
@@ -44,13 +44,14 @@ impl AccountSelector {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, window, cx| {
|
||||
match event {
|
||||
SignerEvent::Set => {
|
||||
StateEvent::SignerSet => {
|
||||
window.close_all_modals(cx);
|
||||
window.refresh();
|
||||
}
|
||||
SignerEvent::Error(e) => {
|
||||
StateEvent::Error(e) => {
|
||||
this.set_error(e.to_string(), cx);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ use std::time::Duration;
|
||||
use common::TextUtils;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, px, AppContext, Context, Entity, Image, IntoElement, ParentElement, Render,
|
||||
SharedString, Styled, Subscription, Window,
|
||||
AppContext, Context, Entity, Image, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Subscription, Window, div, img, px,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use state::{
|
||||
CoopAuthUrlHandler, NostrRegistry, SignerEvent, CLIENT_NAME, NOSTR_CONNECT_RELAY,
|
||||
NOSTR_CONNECT_TIMEOUT,
|
||||
CLIENT_NAME, CoopAuthUrlHandler, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT, NostrRegistry,
|
||||
StateEvent,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::v_flex;
|
||||
@@ -31,7 +31,7 @@ impl ConnectSigner {
|
||||
let error = cx.new(|_| None);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let app_keys = nostr.read(cx).app_keys.clone();
|
||||
let app_keys = nostr.read(cx).keys();
|
||||
|
||||
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
||||
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
|
||||
@@ -55,7 +55,7 @@ impl ConnectSigner {
|
||||
|
||||
// Subscribe to the signer event
|
||||
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
|
||||
if let SignerEvent::Error(e) = event {
|
||||
if let StateEvent::Error(e) = event {
|
||||
this.set_error(e, cx);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use anyhow::{Error, anyhow};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Subscription, Task, Window,
|
||||
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Subscription, Task, Window, div,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{CoopAuthUrlHandler, NostrRegistry, SignerEvent};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{CoopAuthUrlHandler, NostrRegistry, StateEvent};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::{v_flex, Disableable};
|
||||
use ui::{Disableable, v_flex};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportKey {
|
||||
@@ -60,7 +60,7 @@ impl ImportKey {
|
||||
subscriptions.push(
|
||||
// Subscribe to the nostr signer event
|
||||
cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
|
||||
if let SignerEvent::Error(e) = event {
|
||||
if let StateEvent::Error(e) = event {
|
||||
this.set_error(e, cx);
|
||||
}
|
||||
}),
|
||||
@@ -117,7 +117,7 @@ impl ImportKey {
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let app_keys = nostr.read(cx).app_keys.clone();
|
||||
let app_keys = nostr.read(cx).keys();
|
||||
let timeout = Duration::from_secs(30);
|
||||
|
||||
// Construct the nostr connect signer
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use gpui::http_client::Url;
|
||||
use gpui::{
|
||||
div, px, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Window,
|
||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Window, div, px,
|
||||
};
|
||||
use settings::{AppSettings, AuthMode};
|
||||
use theme::{ActiveTheme, ThemeMode};
|
||||
@@ -11,7 +11,7 @@ use ui::input::{InputState, TextInput};
|
||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||
use ui::notification::Notification;
|
||||
use ui::switch::Switch;
|
||||
use ui::{h_flex, v_flex, IconName, Sizable, WindowExtension};
|
||||
use ui::{IconName, Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
||||
cx.new(|cx| Preferences::new(window, cx))
|
||||
@@ -41,7 +41,7 @@ impl Preferences {
|
||||
AppSettings::update_file_server(url, cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
window.push_notification(Notification::error(e.to_string()).autohide(false), cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use assets::Assets;
|
||||
use gpui::{
|
||||
actions, point, px, size, App, AppContext, Bounds, KeyBinding, Menu, MenuItem, SharedString,
|
||||
TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind,
|
||||
WindowOptions,
|
||||
App, AppContext, Bounds, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions,
|
||||
WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions,
|
||||
actions, point, px, size,
|
||||
};
|
||||
use gpui_platform::application;
|
||||
use state::{APP_ID, CLIENT_NAME};
|
||||
@@ -86,7 +86,7 @@ fn main() {
|
||||
state::init(window, cx);
|
||||
|
||||
// Initialize person registry
|
||||
person::init(cx);
|
||||
person::init(window, cx);
|
||||
|
||||
// Initialize relay auth registry
|
||||
relay_auth::init(window, cx);
|
||||
|
||||
@@ -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<ContactListPanel> {
|
||||
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<Contact> = self
|
||||
@@ -177,14 +168,12 @@ impl ContactListPanel {
|
||||
self.set_updating(true, cx);
|
||||
|
||||
let task: Task<Result<(), Error>> = 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(())
|
||||
});
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
use chat::{ChatRegistry, InboxState};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window, div, svg,
|
||||
};
|
||||
use state::{NostrRegistry, RelayState};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::dock::DockPlacement;
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
||||
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||
|
||||
use crate::panels::{messaging_relays, profile, relay_list};
|
||||
use crate::panels::profile;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
|
||||
@@ -82,15 +80,6 @@ impl Render for GreeterPanel {
|
||||
const TITLE: &str = "Welcome to Coop!";
|
||||
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
|
||||
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let nip17 = chat.read(cx).state(cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let nip65 = nostr.read(cx).relay_list_state.clone();
|
||||
|
||||
let required_actions =
|
||||
nip65 == RelayState::NotConfigured || nip17 == InboxState::RelayNotAvailable;
|
||||
|
||||
h_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
@@ -130,64 +119,6 @@ impl Render for GreeterPanel {
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(required_actions, |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Required Actions"))
|
||||
.child(div().flex_1().h_px().bg(cx.theme().border)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.when(nip65.not_configured(), |this| {
|
||||
this.child(
|
||||
Button::new("relaylist")
|
||||
.icon(Icon::new(IconName::Relay))
|
||||
.label("Set up relay list")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
Workspace::add_panel(
|
||||
relay_list::init(window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(nip17.not_configured(), |this| {
|
||||
this.child(
|
||||
Button::new("import")
|
||||
.icon(Icon::new(IconName::Relay))
|
||||
.label("Set up messaging relays")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
Workspace::add_panel(
|
||||
messaging_relays::init(window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
|
||||
@@ -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<Tag> = self
|
||||
@@ -191,14 +182,12 @@ impl MessagingRelayPanel {
|
||||
self.set_updating(true, cx);
|
||||
|
||||
let task: Task<Result<(), Error>> = 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(())
|
||||
});
|
||||
|
||||
@@ -3,21 +3,21 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use gpui::{
|
||||
div, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
||||
Window,
|
||||
Window, div,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{shorten_pubkey, Person, PersonRegistry};
|
||||
use person::{Person, PersonRegistry, shorten_pubkey};
|
||||
use settings::AppSettings;
|
||||
use state::{upload, NostrRegistry};
|
||||
use state::{NostrRegistry, upload};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::notification::Notification;
|
||||
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(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfilePanel> {
|
||||
cx.new(|cx| ProfilePanel::new(public_key, window, cx))
|
||||
@@ -186,7 +186,10 @@ impl ProfilePanel {
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_uploading(false, cx);
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
window.push_notification(
|
||||
Notification::error(e.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
@@ -269,7 +272,10 @@ impl ProfilePanel {
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
window.push_notification(
|
||||
Notification::error(e.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -180,7 +180,10 @@ impl Sidebar {
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
window.push_notification(
|
||||
Notification::error(e.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ::settings::AppSettings;
|
||||
use chat::{ChatEvent, ChatRegistry, InboxState};
|
||||
use device::DeviceRegistry;
|
||||
use chat::{ChatEvent, ChatRegistry};
|
||||
use device::{DeviceEvent, DeviceRegistry};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
@@ -11,7 +13,7 @@ use gpui::{
|
||||
use person::PersonRegistry;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, RelayState, SignerEvent};
|
||||
use state::{NostrRegistry, StateEvent};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||
use title_bar::TitleBar;
|
||||
use ui::avatar::Avatar;
|
||||
@@ -20,8 +22,8 @@ use ui::dock_area::dock::DockPlacement;
|
||||
use ui::dock_area::panel::PanelView;
|
||||
use ui::dock_area::{ClosePanel, DockArea, DockItem};
|
||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||
use ui::notification::Notification;
|
||||
use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
|
||||
use ui::notification::{Notification, NotificationKind};
|
||||
use ui::{Disableable, IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::dialogs::{accounts, settings};
|
||||
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list};
|
||||
@@ -37,6 +39,8 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
|
||||
cx.new(|cx| Workspace::new(window, cx))
|
||||
}
|
||||
|
||||
struct RelayNotifcation;
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = workspace, no_json)]
|
||||
enum Command {
|
||||
@@ -63,15 +67,23 @@ pub struct Workspace {
|
||||
/// App's Dock Area
|
||||
dock: Entity<DockArea>,
|
||||
|
||||
/// Whether a user's relay list is connected
|
||||
relay_connected: bool,
|
||||
|
||||
/// Whether the inbox is connected
|
||||
inbox_connected: bool,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||
_subscriptions: SmallVec<[Subscription; 6]>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let npubs = nostr.read(cx).npubs();
|
||||
let chat = ChatRegistry::global(cx);
|
||||
|
||||
let titlebar = cx.new(|_| TitleBar::new());
|
||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||
|
||||
@@ -96,9 +108,56 @@ impl Workspace {
|
||||
subscriptions.push(
|
||||
// Subscribe to the signer events
|
||||
cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| {
|
||||
if let SignerEvent::Set = event {
|
||||
this.set_center_layout(window, cx);
|
||||
}
|
||||
match event {
|
||||
StateEvent::Connecting => {
|
||||
let note = Notification::new()
|
||||
.id::<RelayNotifcation>()
|
||||
.message("Connecting to the bootstrap relay...")
|
||||
.with_kind(NotificationKind::Info)
|
||||
.icon(IconName::Relay);
|
||||
|
||||
window.push_notification(note, cx);
|
||||
}
|
||||
StateEvent::Connected => {
|
||||
let note = Notification::new()
|
||||
.id::<RelayNotifcation>()
|
||||
.message("Connected to the bootstrap relay")
|
||||
.with_kind(NotificationKind::Success)
|
||||
.icon(IconName::Relay);
|
||||
|
||||
window.push_notification(note, cx);
|
||||
}
|
||||
StateEvent::RelayNotConfigured => {
|
||||
this.relay_notification(window, cx);
|
||||
}
|
||||
StateEvent::RelayConnected => {
|
||||
window.clear_notification::<RelayNotifcation>(cx);
|
||||
this.set_relay_connected(true, cx);
|
||||
}
|
||||
StateEvent::SignerSet => {
|
||||
this.set_center_layout(window, cx);
|
||||
this.set_relay_connected(false, cx);
|
||||
this.set_inbox_connected(false, cx);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe all events emitted by the device registry
|
||||
cx.subscribe_in(&device, window, |_this, _device, ev, window, cx| {
|
||||
match ev {
|
||||
DeviceEvent::Set => {
|
||||
window.push_notification(
|
||||
Notification::success("Encryption Key has been set"),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
DeviceEvent::Error(error) => {
|
||||
window.push_notification(Notification::error(error).autohide(false), cx);
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -130,6 +189,12 @@ impl Workspace {
|
||||
});
|
||||
});
|
||||
}
|
||||
ChatEvent::Subscribed => {
|
||||
this.set_inbox_connected(true, cx);
|
||||
}
|
||||
ChatEvent::Error(error) => {
|
||||
window.push_notification(Notification::error(error).autohide(false), cx);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
@@ -154,6 +219,8 @@ impl Workspace {
|
||||
Self {
|
||||
titlebar,
|
||||
dock,
|
||||
relay_connected: false,
|
||||
inbox_connected: false,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
@@ -185,6 +252,18 @@ impl Workspace {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Set whether the relay list is connected
|
||||
fn set_relay_connected(&mut self, connected: bool, cx: &mut Context<Self>) {
|
||||
self.relay_connected = connected;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Set whether the inbox is connected
|
||||
fn set_inbox_connected(&mut self, connected: bool, cx: &mut Context<Self>) {
|
||||
self.inbox_connected = connected;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Set the dock layout
|
||||
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
|
||||
@@ -267,6 +346,12 @@ impl Workspace {
|
||||
);
|
||||
});
|
||||
}
|
||||
Command::RefreshMessagingRelays => {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
chat.update(cx, |this, cx| {
|
||||
this.get_messages(cx);
|
||||
});
|
||||
}
|
||||
Command::ShowRelayList => {
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.add_panel(
|
||||
@@ -277,27 +362,25 @@ impl Workspace {
|
||||
);
|
||||
});
|
||||
}
|
||||
Command::RefreshRelayList => {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
if let Some(public_key) = signer.public_key() {
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.ensure_relay_list(&public_key, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
Command::RefreshEncryption => {
|
||||
let device = DeviceRegistry::global(cx);
|
||||
device.update(cx, |this, cx| {
|
||||
this.get_announcement(cx);
|
||||
});
|
||||
}
|
||||
Command::RefreshRelayList => {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.ensure_relay_list(cx);
|
||||
});
|
||||
}
|
||||
Command::ResetEncryption => {
|
||||
self.confirm_reset_encryption(window, cx);
|
||||
}
|
||||
Command::RefreshMessagingRelays => {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
chat.update(cx, |this, cx| {
|
||||
this.ensure_messaging_relays(cx);
|
||||
});
|
||||
}
|
||||
Command::ToggleTheme => {
|
||||
self.theme_selector(window, cx);
|
||||
}
|
||||
@@ -341,8 +424,10 @@ impl Workspace {
|
||||
window.close_modal(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window
|
||||
.push_notification(Notification::error(e.to_string()), cx);
|
||||
window.push_notification(
|
||||
Notification::error(e.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
@@ -450,7 +535,56 @@ impl Workspace {
|
||||
});
|
||||
}
|
||||
|
||||
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
||||
fn relay_notification(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
const BODY: &str = "Coop cannot found your gossip relay list. \
|
||||
Maybe you haven't set it yet or relay not responsed";
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let Some(public_key) = signer.public_key() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let entity = nostr.downgrade();
|
||||
let loading = Rc::new(Cell::new(false));
|
||||
|
||||
let note = Notification::new()
|
||||
.autohide(false)
|
||||
.id::<RelayNotifcation>()
|
||||
.icon(IconName::Relay)
|
||||
.title("Gossip Relays are required")
|
||||
.message(BODY)
|
||||
.action(move |_this, _window, _cx| {
|
||||
let entity = entity.clone();
|
||||
let public_key = public_key.to_owned();
|
||||
|
||||
Button::new("retry")
|
||||
.label("Retry")
|
||||
.small()
|
||||
.primary()
|
||||
.loading(loading.get())
|
||||
.disabled(loading.get())
|
||||
.on_click({
|
||||
let loading = Rc::clone(&loading);
|
||||
|
||||
move |_ev, _window, cx| {
|
||||
// Set loading state to true
|
||||
loading.set(true);
|
||||
// Retry
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.ensure_relay_list(&public_key, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
window.push_notification(note, cx);
|
||||
}
|
||||
|
||||
fn titlebar_left(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
let current_user = signer.public_key();
|
||||
@@ -529,14 +663,14 @@ impl Workspace {
|
||||
})
|
||||
}
|
||||
|
||||
fn titlebar_right(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
||||
fn titlebar_right(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let relay_connected = self.relay_connected;
|
||||
let inbox_connected = self.inbox_connected;
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let inbox_state = chat.read(cx).state(cx);
|
||||
|
||||
let Some(pkey) = signer.public_key() else {
|
||||
let Some(public_key) = signer.public_key() else {
|
||||
return div();
|
||||
};
|
||||
|
||||
@@ -554,7 +688,7 @@ impl Workspace {
|
||||
let state = device.read(cx).state();
|
||||
|
||||
this.min_w(px(260.))
|
||||
.item(PopupMenuItem::element(move |_window, _cx| {
|
||||
.item(PopupMenuItem::element(move |_window, cx| {
|
||||
h_flex()
|
||||
.px_1()
|
||||
.w_full()
|
||||
@@ -566,7 +700,7 @@ impl Workspace {
|
||||
.rounded_full()
|
||||
.when(state.set(), |this| this.bg(gpui::green()))
|
||||
.when(state.requesting(), |this| {
|
||||
this.bg(gpui::yellow())
|
||||
this.bg(cx.theme().icon_accent)
|
||||
}),
|
||||
)
|
||||
.child(SharedString::from(state.to_string()))
|
||||
@@ -585,140 +719,81 @@ impl Workspace {
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.map(|this| match inbox_state {
|
||||
InboxState::Checking => this.child(div().child(
|
||||
SharedString::from("Fetching user's messaging relay list..."),
|
||||
)),
|
||||
InboxState::RelayNotAvailable => {
|
||||
this.child(div().text_color(cx.theme().warning_active).child(
|
||||
SharedString::from(
|
||||
"User hasn't configured a messaging relay list",
|
||||
),
|
||||
))
|
||||
}
|
||||
_ => this,
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("inbox")
|
||||
.icon(IconName::Inbox)
|
||||
.tooltip("Inbox")
|
||||
.small()
|
||||
.ghost()
|
||||
.when(inbox_state.subscribing(), |this| this.indicator())
|
||||
.dropdown_menu(move |this, _window, cx| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&pkey, cx);
|
||||
let urls: Vec<SharedString> = profile
|
||||
.messaging_relays()
|
||||
.iter()
|
||||
.map(|url| SharedString::from(url.to_string()))
|
||||
.collect();
|
||||
Button::new("inbox")
|
||||
.icon(IconName::Inbox)
|
||||
.small()
|
||||
.ghost()
|
||||
.loading(!inbox_connected)
|
||||
.disabled(!inbox_connected)
|
||||
.when(!inbox_connected, |this| {
|
||||
this.tooltip("Connecting to user's messaging relays...")
|
||||
})
|
||||
.when(inbox_connected, |this| this.indicator())
|
||||
.dropdown_menu(move |this, _window, cx| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
|
||||
// Header
|
||||
let menu = this.min_w(px(260.)).label("Messaging Relays");
|
||||
let urls: Vec<SharedString> = profile
|
||||
.messaging_relays()
|
||||
.iter()
|
||||
.map(|url| SharedString::from(url.to_string()))
|
||||
.collect();
|
||||
|
||||
// Content
|
||||
let menu = urls.into_iter().fold(menu, |this, url| {
|
||||
this.item(PopupMenuItem::element(move |_window, _cx| {
|
||||
h_flex()
|
||||
.px_1()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
div().size_1p5().rounded_full().bg(gpui::green()),
|
||||
)
|
||||
.child(url.clone())
|
||||
}))
|
||||
});
|
||||
// Header
|
||||
let menu = this.min_w(px(260.)).label("Messaging Relays");
|
||||
|
||||
// Footer
|
||||
menu.separator()
|
||||
.menu_with_icon(
|
||||
"Reload",
|
||||
IconName::Refresh,
|
||||
Box::new(Command::RefreshMessagingRelays),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Update relays",
|
||||
IconName::Settings,
|
||||
Box::new(Command::ShowMessaging),
|
||||
)
|
||||
}),
|
||||
),
|
||||
// Content
|
||||
let menu = urls.into_iter().fold(menu, |this, url| {
|
||||
this.item(PopupMenuItem::element(move |_window, _cx| {
|
||||
h_flex()
|
||||
.px_1()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(div().size_1p5().rounded_full().bg(gpui::green()))
|
||||
.child(url.clone())
|
||||
}))
|
||||
});
|
||||
|
||||
// Footer
|
||||
menu.separator()
|
||||
.menu_with_icon(
|
||||
"Reload",
|
||||
IconName::Refresh,
|
||||
Box::new(Command::RefreshMessagingRelays),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Update relays",
|
||||
IconName::Settings,
|
||||
Box::new(Command::ShowMessaging),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.map(|this| match nostr.read(cx).relay_list_state {
|
||||
RelayState::Checking => this
|
||||
.child(div().child(SharedString::from(
|
||||
"Fetching user's relay list...",
|
||||
))),
|
||||
RelayState::NotConfigured => {
|
||||
this.child(div().text_color(cx.theme().warning_active).child(
|
||||
SharedString::from("User hasn't configured a relay list"),
|
||||
))
|
||||
}
|
||||
_ => this,
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("relay-list")
|
||||
.icon(IconName::Relay)
|
||||
.tooltip("User's relay list")
|
||||
.small()
|
||||
.ghost()
|
||||
.when(nostr.read(cx).relay_list_state.configured(), |this| {
|
||||
this.indicator()
|
||||
})
|
||||
.dropdown_menu(move |this, _window, cx| {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let urls = nostr.read(cx).read_only_relays(&pkey, cx);
|
||||
|
||||
// Header
|
||||
let menu = this.min_w(px(260.)).label("Relays");
|
||||
|
||||
// Content
|
||||
let menu = urls.into_iter().fold(menu, |this, url| {
|
||||
this.item(PopupMenuItem::element(move |_window, _cx| {
|
||||
h_flex()
|
||||
.px_1()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
div().size_1p5().rounded_full().bg(gpui::green()),
|
||||
)
|
||||
.child(url.clone())
|
||||
}))
|
||||
});
|
||||
|
||||
// Footer
|
||||
menu.separator()
|
||||
.menu_with_icon(
|
||||
"Reload",
|
||||
IconName::Refresh,
|
||||
Box::new(Command::RefreshRelayList),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Update relay list",
|
||||
IconName::Settings,
|
||||
Box::new(Command::ShowRelayList),
|
||||
)
|
||||
}),
|
||||
),
|
||||
Button::new("relay-list")
|
||||
.icon(IconName::Relay)
|
||||
.small()
|
||||
.ghost()
|
||||
.loading(!relay_connected)
|
||||
.disabled(!relay_connected)
|
||||
.when(!relay_connected, |this| {
|
||||
this.tooltip("Connecting to user's relay list...")
|
||||
})
|
||||
.when(relay_connected, |this| this.indicator())
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
this.label("User's Relay List")
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"Reload",
|
||||
IconName::Refresh,
|
||||
Box::new(Command::RefreshRelayList),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Update",
|
||||
IconName::Settings,
|
||||
Box::new(Command::ShowRelayList),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,22 +3,19 @@ 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, EventEmitter, Global, IntoElement, ParentElement,
|
||||
SharedString, Styled, Task, Window, div, relative,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{
|
||||
app_name, Announcement, DeviceState, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT,
|
||||
};
|
||||
use state::{Announcement, DEVICE_GIFTWRAP, DeviceState, NostrRegistry, 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. \
|
||||
@@ -32,6 +29,15 @@ struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
|
||||
|
||||
impl Global for GlobalDeviceRegistry {}
|
||||
|
||||
/// Device event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum DeviceEvent {
|
||||
/// A new encryption signer has been set
|
||||
Set,
|
||||
/// An error occurred
|
||||
Error(SharedString),
|
||||
}
|
||||
|
||||
/// Device Registry
|
||||
///
|
||||
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
@@ -42,11 +48,10 @@ pub struct DeviceRegistry {
|
||||
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
/// Subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl EventEmitter<DeviceEvent> for DeviceRegistry {}
|
||||
|
||||
impl DeviceRegistry {
|
||||
/// Retrieve the global device registry state
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
@@ -60,27 +65,16 @@ impl DeviceRegistry {
|
||||
|
||||
/// Create a new device registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let mut subscriptions = smallvec![];
|
||||
let state = DeviceState::default();
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the NIP-65 state
|
||||
cx.observe(&nostr, |this, state, cx| {
|
||||
if state.read(cx).relay_list_state == RelayState::Configured {
|
||||
this.get_announcement(cx);
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Run at the end of current cycle
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.handle_notifications(window, cx);
|
||||
this.get_announcement(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
state: DeviceState::default(),
|
||||
state,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,30 +117,27 @@ impl DeviceRegistry {
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.tasks.push(
|
||||
// Update GPUI states
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
match event.kind {
|
||||
// New request event
|
||||
Kind::Custom(4454) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.ask_for_approval(event, window, cx);
|
||||
})?;
|
||||
}
|
||||
// New response event
|
||||
Kind::Custom(4455) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.extract_encryption(event, cx);
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
match event.kind {
|
||||
// New request event
|
||||
Kind::Custom(4454) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.ask_for_approval(event, window, cx);
|
||||
})?;
|
||||
}
|
||||
// New response event
|
||||
Kind::Custom(4455) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.extract_encryption(event, cx);
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get the device state
|
||||
@@ -191,45 +182,68 @@ impl DeviceRegistry {
|
||||
fn get_messages(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.subscribe_to_giftwrap_events(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
||||
task.await?;
|
||||
|
||||
// Update state
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::Error(SharedString::from(e.to_string())));
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Continuously get gift wrap events for the current user in their messaging relays
|
||||
fn subscribe_to_giftwrap_events(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
|
||||
/// Get the messaging relays for the current user
|
||||
fn get_user_messaging_relays(&self, cx: &App) -> Task<Result<Vec<RelayUrl>, Error>> {
|
||||
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")));
|
||||
};
|
||||
cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
let relay_urls = profile.messaging_relays().clone();
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
// Extract relay URLs from the event
|
||||
let urls: Vec<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
|
||||
|
||||
// Ensure all relays are connected
|
||||
for url in urls.iter() {
|
||||
client.add_relay(url).and_connect().await?;
|
||||
}
|
||||
|
||||
Ok(urls)
|
||||
} else {
|
||||
Err(anyhow!("Relays not found"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Continuously get gift wrap events for the current user in their messaging relays
|
||||
fn subscribe_to_giftwrap_events(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let urls = self.get_user_messaging_relays(cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let urls = urls.await?;
|
||||
let encryption = signer.get_encryption_signer().await.context("not found")?;
|
||||
let public_key = encryption.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
let id = SubscriptionId::new(DEVICE_GIFTWRAP);
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<RelayUrl, Filter> = relay_urls
|
||||
let target: HashMap<RelayUrl, Filter> = urls
|
||||
.into_iter()
|
||||
.map(|relay| (relay, filter.clone()))
|
||||
.collect();
|
||||
|
||||
let output = client.subscribe(target).with_id(id).await?;
|
||||
|
||||
log::info!(
|
||||
"Successfully subscribed to encryption gift-wrap messages on: {:?}",
|
||||
output.success
|
||||
);
|
||||
// Subscribe
|
||||
client.subscribe(target).with_id(id).await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -239,20 +253,13 @@ impl DeviceRegistry {
|
||||
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
||||
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;
|
||||
};
|
||||
|
||||
// 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<Result<Event, Error>> = cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Construct the filter for the device announcement event
|
||||
let filter = Filter::new()
|
||||
@@ -260,29 +267,19 @@ impl DeviceRegistry {
|
||||
.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?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
log::info!("Received device announcement event: {event:?}");
|
||||
return Ok(event);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to receive device announcement event: {e}");
|
||||
}
|
||||
if let Ok(event) = res {
|
||||
return Ok(event);
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Device announcement not found"))
|
||||
Err(anyhow!("Announcement not found"))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
@@ -307,22 +304,12 @@ impl DeviceRegistry {
|
||||
pub fn create_encryption(&self, cx: &App) -> Task<Result<Keys, Error>> {
|
||||
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 +319,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 +396,15 @@ impl DeviceRegistry {
|
||||
return;
|
||||
};
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
let task: Task<Result<(), Error>> = 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 +422,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,13 +442,7 @@ 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_keys = nostr.read(cx).keys();
|
||||
let app_pubkey = app_keys.public_key();
|
||||
|
||||
let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move {
|
||||
@@ -507,8 +472,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 +481,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)
|
||||
}
|
||||
@@ -549,7 +512,7 @@ impl DeviceRegistry {
|
||||
/// Parse the response event for device keys from other devices
|
||||
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let app_keys = nostr.read(cx).app_keys.clone();
|
||||
let app_keys = nostr.read(cx).keys();
|
||||
|
||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||
let root_device = event
|
||||
@@ -586,18 +549,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<Result<(), Error>> = 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 +582,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(())
|
||||
});
|
||||
@@ -635,13 +591,16 @@ impl DeviceRegistry {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
cx.update(|window, cx| {
|
||||
window.clear_notification(id, cx);
|
||||
window.clear_notification_by_id::<DeviceNotification>(id, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
window.push_notification(
|
||||
Notification::error(e.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -671,17 +630,23 @@ impl DeviceRegistry {
|
||||
|
||||
let entity = cx.entity().downgrade();
|
||||
let loading = Rc::new(Cell::new(false));
|
||||
let key = SharedString::from(event.id.to_hex());
|
||||
|
||||
Notification::new()
|
||||
.custom_id(SharedString::from(event.id.to_hex()))
|
||||
.type_id::<DeviceNotification>(key)
|
||||
.autohide(false)
|
||||
.icon(IconName::UserKey)
|
||||
.title(SharedString::from("New request"))
|
||||
.content(move |_window, cx| {
|
||||
.content(move |_this, _window, cx| {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(SharedString::from(MSG))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(MSG)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
@@ -733,7 +698,7 @@ impl DeviceRegistry {
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.action(move |_window, _cx| {
|
||||
.action(move |_this, _window, _cx| {
|
||||
let view = entity.clone();
|
||||
let event = event.clone();
|
||||
|
||||
@@ -759,6 +724,8 @@ impl DeviceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
struct DeviceNotification;
|
||||
|
||||
/// Verify the author of an event
|
||||
async fn verify_author(client: &Client, event: &Event) -> bool {
|
||||
if let Some(signer) = client.signer() {
|
||||
|
||||
@@ -15,3 +15,4 @@ smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
urlencoding = "2.1.3"
|
||||
|
||||
@@ -3,19 +3,19 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventUtils;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{Announcement, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
|
||||
|
||||
mod person;
|
||||
|
||||
pub use person::*;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx);
|
||||
}
|
||||
|
||||
struct GlobalPersonRegistry(Entity<PersonRegistry>);
|
||||
@@ -36,13 +36,13 @@ pub struct PersonRegistry {
|
||||
persons: HashMap<PublicKey, Entity<Person>>,
|
||||
|
||||
/// Set of public keys that have been seen
|
||||
seen: Rc<RefCell<HashSet<PublicKey>>>,
|
||||
seens: Rc<RefCell<HashSet<PublicKey>>>,
|
||||
|
||||
/// Sender for requesting metadata
|
||||
sender: flume::Sender<PublicKey>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
_tasks: SmallVec<[Task<()>; 4]>,
|
||||
tasks: SmallVec<[Task<()>; 4]>,
|
||||
}
|
||||
|
||||
impl PersonRegistry {
|
||||
@@ -57,13 +57,13 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
/// Create a new person registry instance
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<Dispatch>(100);
|
||||
let (mta_tx, mta_rx) = flume::bounded::<PublicKey>(100);
|
||||
let (mta_tx, mta_rx) = flume::unbounded::<PublicKey>();
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
@@ -111,33 +111,16 @@ impl PersonRegistry {
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Load all user profiles from the database
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_executor()
|
||||
.await_on_background(async move { load_persons(&client).await })
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(persons) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.bulk_inserts(persons, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to load all persons from the database: {e}");
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
// Load all user profiles from the database
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.load(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
persons: HashMap::new(),
|
||||
seen: Rc::new(RefCell::new(HashSet::new())),
|
||||
seens: Rc::new(RefCell::new(HashSet::new())),
|
||||
sender: mta_tx,
|
||||
_tasks: tasks,
|
||||
tasks,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,25 +146,21 @@ impl PersonRegistry {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
let person = Person::new(event.pubkey, metadata);
|
||||
let val = Box::new(person);
|
||||
|
||||
// Send
|
||||
tx.send_async(Dispatch::Person(val)).await.ok();
|
||||
}
|
||||
Kind::ContactList => {
|
||||
let public_keys = event.extract_public_keys();
|
||||
|
||||
// Get metadata for all public keys
|
||||
get_metadata(client, public_keys).await.ok();
|
||||
}
|
||||
Kind::InboxRelays => {
|
||||
let val = Box::new(event.into_owned());
|
||||
|
||||
// Send
|
||||
tx.send_async(Dispatch::Relays(val)).await.ok();
|
||||
}
|
||||
Kind::Custom(10044) => {
|
||||
let val = Box::new(event.into_owned());
|
||||
|
||||
// Send
|
||||
tx.send_async(Dispatch::Announcement(val)).await.ok();
|
||||
}
|
||||
@@ -198,7 +177,7 @@ impl PersonRegistry {
|
||||
loop {
|
||||
match flume::Selector::new()
|
||||
.recv(rx, |result| result.ok())
|
||||
.wait_timeout(Duration::from_secs(2))
|
||||
.wait_timeout(Duration::from_secs(TIMEOUT))
|
||||
{
|
||||
Ok(Some(public_key)) => {
|
||||
batch.insert(public_key);
|
||||
@@ -208,40 +187,81 @@ impl PersonRegistry {
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
||||
if !batch.is_empty() {
|
||||
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all user profiles from the database
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let task: Task<Result<Vec<Person>, Error>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let events = client.database().query(filter).await?;
|
||||
let persons = events
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
Person::new(event.pubkey, metadata)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(persons)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Ok(persons) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.bulk_inserts(persons, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
let announcement = Announcement::from(event);
|
||||
let announcement = Announcement::from(event);
|
||||
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
person.update(cx, |person, cx| {
|
||||
person.set_announcement(announcement);
|
||||
cx.notify();
|
||||
});
|
||||
} else {
|
||||
let person =
|
||||
Person::new(event.pubkey, Metadata::default()).with_announcement(announcement);
|
||||
self.insert(person, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set messaging relays for a person
|
||||
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) {
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).cloned().collect();
|
||||
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).cloned().collect();
|
||||
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
person.update(cx, |person, cx| {
|
||||
person.set_messaging_relays(urls);
|
||||
cx.notify();
|
||||
});
|
||||
} else {
|
||||
let person = Person::new(event.pubkey, Metadata::default()).with_messaging_relays(urls);
|
||||
self.insert(person, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert batch of persons
|
||||
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
||||
for person in persons.into_iter() {
|
||||
self.persons.insert(person.public_key(), cx.new(|_| person));
|
||||
let public_key = person.public_key();
|
||||
self.persons
|
||||
.entry(public_key)
|
||||
.or_insert_with(|| cx.new(|_| person));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
@@ -270,7 +290,7 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
let public_key = *public_key;
|
||||
let mut seen = self.seen.borrow_mut();
|
||||
let mut seen = self.seens.borrow_mut();
|
||||
|
||||
if seen.insert(public_key) {
|
||||
let sender = self.sender.clone();
|
||||
@@ -322,19 +342,3 @@ where
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load all user profiles from the database
|
||||
async fn load_persons(client: &Client) -> Result<Vec<Person>, Error> {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let mut persons = vec![];
|
||||
|
||||
for event in events.into_iter() {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
let person = Person::new(event.pubkey, metadata);
|
||||
persons.push(person);
|
||||
}
|
||||
|
||||
Ok(persons)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,21 @@ impl Person {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build profile encryption keys announcement
|
||||
pub fn with_announcement(mut self, announcement: Announcement) -> Self {
|
||||
self.announcement = Some(announcement);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build profile messaging relays
|
||||
pub fn with_messaging_relays<I>(mut self, relays: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
self.messaging_relays = relays.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Get profile public key
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
@@ -75,21 +90,11 @@ impl Person {
|
||||
self.metadata.clone()
|
||||
}
|
||||
|
||||
/// Set profile metadata
|
||||
pub fn set_metadata(&mut self, metadata: Metadata) {
|
||||
self.metadata = metadata;
|
||||
}
|
||||
|
||||
/// Get profile encryption keys announcement
|
||||
pub fn announcement(&self) -> Option<Announcement> {
|
||||
self.announcement.clone()
|
||||
}
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
pub fn set_announcement(&mut self, announcement: Announcement) {
|
||||
self.announcement = Some(announcement);
|
||||
}
|
||||
|
||||
/// Get profile messaging relays
|
||||
pub fn messaging_relays(&self) -> &Vec<RelayUrl> {
|
||||
&self.messaging_relays
|
||||
@@ -100,14 +105,6 @@ impl Person {
|
||||
self.messaging_relays.first().cloned()
|
||||
}
|
||||
|
||||
/// Set profile messaging relays
|
||||
pub fn set_messaging_relays<I>(&mut self, relays: I)
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
self.messaging_relays = relays.into_iter().collect();
|
||||
}
|
||||
|
||||
/// Get profile avatar
|
||||
pub fn avatar(&self) -> SharedString {
|
||||
self.metadata()
|
||||
@@ -115,8 +112,9 @@ impl Person {
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| {
|
||||
let encoded_picture = urlencoding::encode(picture);
|
||||
let url = format!(
|
||||
"{IMAGE_RESIZER}/?url={picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
|
||||
"{IMAGE_RESIZER}/?url={encoded_picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
|
||||
);
|
||||
url.into()
|
||||
})
|
||||
@@ -139,6 +137,24 @@ impl Person {
|
||||
|
||||
SharedString::from(shorten_pubkey(self.public_key(), 4))
|
||||
}
|
||||
|
||||
/// Set profile metadata
|
||||
pub fn set_metadata(&mut self, metadata: Metadata) {
|
||||
self.metadata = metadata;
|
||||
}
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
pub fn set_announcement(&mut self, announcement: Announcement) {
|
||||
self.announcement = Some(announcement);
|
||||
}
|
||||
|
||||
/// Set profile messaging relays
|
||||
pub fn set_messaging_relays<I>(&mut self, relays: I)
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
self.messaging_relays = relays.into_iter().collect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten a [`PublicKey`] to a string with the first and last `len` characters
|
||||
@@ -148,7 +164,7 @@ pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
|
||||
let Ok(pubkey) = public_key.to_bech32();
|
||||
|
||||
format!(
|
||||
"{}:{}",
|
||||
"{}...{}",
|
||||
&pubkey[0..(len + 1)],
|
||||
&pubkey[pubkey.len() - len..]
|
||||
)
|
||||
|
||||
@@ -5,19 +5,19 @@ use std::hash::Hash;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
|
||||
Task, Window,
|
||||
Task, Window, div, relative,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::{AppSettings, AuthMode};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::notification::Notification;
|
||||
use ui::{v_flex, Disableable, IconName, Sizable, WindowExtension};
|
||||
use ui::{Disableable, IconName, Sizable, WindowExtension, v_flex};
|
||||
|
||||
const AUTH_MESSAGE: &str =
|
||||
"Approve the authentication request to allow Coop to continue sending or receiving events.";
|
||||
@@ -34,7 +34,10 @@ struct AuthRequest {
|
||||
}
|
||||
|
||||
impl AuthRequest {
|
||||
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
|
||||
pub fn new<S>(challenge: S, url: RelayUrl) -> Self
|
||||
where
|
||||
S: Into<String>,
|
||||
{
|
||||
Self {
|
||||
challenge: challenge.into(),
|
||||
url,
|
||||
@@ -106,22 +109,6 @@ impl RelayAuth {
|
||||
tx.send_async(signal).await.ok();
|
||||
}
|
||||
}
|
||||
RelayMessage::Closed {
|
||||
subscription_id,
|
||||
message,
|
||||
} => {
|
||||
let msg = MachineReadablePrefix::parse(&message);
|
||||
|
||||
if let Some(MachineReadablePrefix::AuthRequired) = msg {
|
||||
if let Ok(Some(relay)) = client.relay(&relay_url).await {
|
||||
// Send close message to relay
|
||||
relay
|
||||
.send_msg(ClientMessage::Close(subscription_id))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
RelayMessage::Ok {
|
||||
event_id, message, ..
|
||||
} => {
|
||||
@@ -273,7 +260,7 @@ impl RelayAuth {
|
||||
fn response(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
|
||||
let settings = AppSettings::global(cx);
|
||||
let req = req.clone();
|
||||
let challenge = req.challenge().to_string();
|
||||
let challenge = SharedString::from(req.challenge().to_string());
|
||||
|
||||
// Create a task for authentication
|
||||
let task = self.auth(&req, cx);
|
||||
@@ -283,7 +270,7 @@ impl RelayAuth {
|
||||
let url = req.url();
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
window.clear_notification(challenge, cx);
|
||||
window.clear_notification_by_id::<AuthNotification>(challenge, cx);
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
@@ -295,10 +282,19 @@ impl RelayAuth {
|
||||
this.add_trusted_relay(url, cx);
|
||||
});
|
||||
|
||||
window.push_notification(format!("{} has been authenticated", url), cx);
|
||||
window.push_notification(
|
||||
Notification::success(format!(
|
||||
"Relay {} has been authenticated",
|
||||
url.domain().unwrap_or_default()
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
window.push_notification(
|
||||
Notification::error(e.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -323,20 +319,25 @@ impl RelayAuth {
|
||||
/// Build a notification for the authentication request.
|
||||
fn notification(&self, req: &Arc<AuthRequest>, cx: &Context<Self>) -> Notification {
|
||||
let req = req.clone();
|
||||
let challenge = SharedString::from(req.challenge.clone());
|
||||
let url = SharedString::from(req.url().to_string());
|
||||
let entity = cx.entity().downgrade();
|
||||
let loading = Rc::new(Cell::new(false));
|
||||
|
||||
Notification::new()
|
||||
.custom_id(SharedString::from(&req.challenge))
|
||||
.type_id::<AuthNotification>(challenge)
|
||||
.autohide(false)
|
||||
.icon(IconName::Info)
|
||||
.icon(IconName::Warning)
|
||||
.title(SharedString::from("Authentication Required"))
|
||||
.content(move |_window, cx| {
|
||||
.content(move |_this, _window, cx| {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(SharedString::from(AUTH_MESSAGE))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(AUTH_MESSAGE)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.py_1()
|
||||
@@ -349,7 +350,7 @@ impl RelayAuth {
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.action(move |_window, _cx| {
|
||||
.action(move |_this, _window, _cx| {
|
||||
let view = entity.clone();
|
||||
let req = req.clone();
|
||||
|
||||
@@ -374,3 +375,5 @@ impl RelayAuth {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthNotification;
|
||||
|
||||
@@ -10,6 +10,8 @@ common = { path = "../common" }
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
nostr-lmdb.workspace = true
|
||||
nostr-memory.workspace = true
|
||||
nostr-gossip-sqlite.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
nostr-blossom.workspace = true
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
||||
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
||||
|
||||
/// Default bootstrap relays
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://indexer.coracle.social",
|
||||
"wss://user.kindpag.es",
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// Gossip
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Gossip {
|
||||
relays: HashMap<PublicKey, HashSet<(RelayUrl, Option<RelayMetadata>)>>,
|
||||
}
|
||||
|
||||
impl Gossip {
|
||||
pub fn read_only_relays(&self, public_key: &PublicKey) -> Vec<SharedString> {
|
||||
self.relays
|
||||
.get(public_key)
|
||||
.map(|relays| {
|
||||
relays
|
||||
.iter()
|
||||
.map(|(url, _)| url.to_string().into())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get read relays for a given public key
|
||||
pub fn read_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
|
||||
self.relays
|
||||
.get(public_key)
|
||||
.map(|relays| {
|
||||
relays
|
||||
.iter()
|
||||
.filter_map(|(url, metadata)| {
|
||||
if metadata.is_none() || metadata == &Some(RelayMetadata::Read) {
|
||||
Some(url.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get write relays for a given public key
|
||||
pub fn write_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
|
||||
self.relays
|
||||
.get(public_key)
|
||||
.map(|relays| {
|
||||
relays
|
||||
.iter()
|
||||
.filter_map(|(url, metadata)| {
|
||||
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
|
||||
Some(url.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Insert gossip relays for a public key
|
||||
pub fn insert_relays(&mut self, event: &Event) {
|
||||
self.relays.entry(event.pubkey).or_default().extend(
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|tag| {
|
||||
if let Some(TagStandard::RelayMetadata {
|
||||
relay_url,
|
||||
metadata,
|
||||
}) = tag.clone().to_standardized()
|
||||
{
|
||||
Some((relay_url, metadata))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.take(3),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -6,20 +6,20 @@ use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Task, Window};
|
||||
use nostr_connect::prelude::*;
|
||||
use nostr_gossip_sqlite::prelude::*;
|
||||
use nostr_lmdb::prelude::*;
|
||||
use nostr_memory::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
mod blossom;
|
||||
mod constants;
|
||||
mod device;
|
||||
mod gossip;
|
||||
mod nip05;
|
||||
mod signer;
|
||||
|
||||
pub use blossom::*;
|
||||
pub use constants::*;
|
||||
pub use device::*;
|
||||
pub use gossip::*;
|
||||
pub use nip05::*;
|
||||
pub use signer::*;
|
||||
|
||||
@@ -41,6 +41,23 @@ struct GlobalNostrRegistry(Entity<NostrRegistry>);
|
||||
|
||||
impl Global for GlobalNostrRegistry {}
|
||||
|
||||
/// Signer event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum StateEvent {
|
||||
/// Connecting to the bootstrapping relay
|
||||
Connecting,
|
||||
/// Connected to the bootstrapping relay
|
||||
Connected,
|
||||
/// User has not set up NIP-65 relays
|
||||
RelayNotConfigured,
|
||||
/// Connected to NIP-65 relays
|
||||
RelayConnected,
|
||||
/// A new signer has been set
|
||||
SignerSet,
|
||||
/// An error occurred
|
||||
Error(SharedString),
|
||||
}
|
||||
|
||||
/// Nostr Registry
|
||||
#[derive(Debug)]
|
||||
pub struct NostrRegistry {
|
||||
@@ -53,21 +70,17 @@ pub struct NostrRegistry {
|
||||
/// Local public keys
|
||||
npubs: Entity<Vec<PublicKey>>,
|
||||
|
||||
/// Custom gossip implementation
|
||||
gossip: Entity<Gossip>,
|
||||
|
||||
/// App keys
|
||||
///
|
||||
/// Used for Nostr Connect and NIP-4e operations
|
||||
pub app_keys: Keys,
|
||||
|
||||
/// Relay list state
|
||||
pub relay_list_state: RelayState,
|
||||
app_keys: Keys,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
tasks: Vec<Task<()>>,
|
||||
}
|
||||
|
||||
impl EventEmitter<StateEvent> for NostrRegistry {}
|
||||
|
||||
impl NostrRegistry {
|
||||
/// Retrieve the global nostr state
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
@@ -88,32 +101,43 @@ 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 lmdb instance
|
||||
let lmdb = cx.foreground_executor().block_on(async move {
|
||||
NostrLmdb::open(config_dir().join("nostr"))
|
||||
// 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 database")
|
||||
.expect("Failed to initialize gossip instance")
|
||||
});
|
||||
|
||||
// Construct the nostr client
|
||||
let client = ClientBuilder::default()
|
||||
// Construct the nostr client builder
|
||||
let mut builder = ClientBuilder::default()
|
||||
.signer(signer.clone())
|
||||
.database(lmdb)
|
||||
.gossip(gossip)
|
||||
.automatic_authentication(false)
|
||||
.verify_subscriptions(false)
|
||||
.connect_timeout(Duration::from_secs(TIMEOUT))
|
||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||
timeout: Duration::from_secs(600),
|
||||
})
|
||||
.build();
|
||||
});
|
||||
|
||||
// Add database if not in debug mode
|
||||
if !cfg!(debug_assertions) {
|
||||
// Construct the nostr lmdb instance
|
||||
let lmdb = cx.foreground_executor().block_on(async move {
|
||||
NostrLmdb::open(config_dir().join("nostr"))
|
||||
.await
|
||||
.expect("Failed to initialize database")
|
||||
});
|
||||
builder = builder.database(lmdb);
|
||||
} else {
|
||||
builder = builder.database(MemoryDatabase::unbounded())
|
||||
}
|
||||
|
||||
// Build the nostr client
|
||||
let client = builder.build();
|
||||
|
||||
// Run at the end of current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.connect(cx);
|
||||
this.handle_notifications(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -121,8 +145,6 @@ impl NostrRegistry {
|
||||
signer,
|
||||
npubs,
|
||||
app_keys,
|
||||
gossip,
|
||||
relay_list_state: RelayState::Idle,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
@@ -142,94 +164,57 @@ impl NostrRegistry {
|
||||
self.npubs.clone()
|
||||
}
|
||||
|
||||
/// Get the app keys
|
||||
pub fn keys(&self) -> Keys {
|
||||
self.app_keys.clone()
|
||||
}
|
||||
|
||||
/// Connect to the bootstrapping relays
|
||||
fn connect(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor()
|
||||
.await_on_background(async move {
|
||||
// Add search relay to the relay pool
|
||||
for url in SEARCH_RELAYS.into_iter() {
|
||||
client.add_relay(url).await.ok();
|
||||
}
|
||||
|
||||
// Add bootstrap relay to the relay pool
|
||||
for url in BOOTSTRAP_RELAYS.into_iter() {
|
||||
client.add_relay(url).await.ok();
|
||||
}
|
||||
|
||||
// Connect to all added relays
|
||||
client.connect().and_wait(Duration::from_secs(2)).await;
|
||||
})
|
||||
.await;
|
||||
|
||||
// Update the state
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_npubs(cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Handle nostr notifications
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client();
|
||||
let gossip = self.gossip.downgrade();
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<Event>(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?;
|
||||
}
|
||||
}
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
// Add search relay to the relay pool
|
||||
for url in SEARCH_RELAYS.into_iter() {
|
||||
client.add_relay(url).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();
|
||||
})?;
|
||||
}
|
||||
// Add bootstrap relay to the relay pool
|
||||
for url in BOOTSTRAP_RELAYS.into_iter() {
|
||||
client.add_relay(url).await?;
|
||||
}
|
||||
|
||||
// Connect to all added relays
|
||||
client.connect().await;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
});
|
||||
|
||||
// Emit connecting event
|
||||
cx.emit(StateEvent::Connecting);
|
||||
|
||||
self.tasks
|
||||
.push(cx.spawn(async move |this, cx| match task.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
cx.emit(StateEvent::Connected);
|
||||
this.get_npubs(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get all used npubs
|
||||
fn get_npubs(&mut self, cx: &mut Context<Self>) {
|
||||
let npubs = self.npubs.downgrade();
|
||||
|
||||
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||
let dir = config_dir().join("keys");
|
||||
// Ensure keys directory exists
|
||||
@@ -269,25 +254,26 @@ impl NostrRegistry {
|
||||
true => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.create_identity(cx);
|
||||
})?;
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
false => {
|
||||
// TODO: auto login
|
||||
npubs.update(cx, |this, cx| {
|
||||
this.extend(public_keys);
|
||||
cx.notify();
|
||||
})?;
|
||||
npubs
|
||||
.update(cx, |this, cx| {
|
||||
this.extend(public_keys);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to get npubs: {e}");
|
||||
this.update(cx, |this, cx| {
|
||||
this.create_identity(cx);
|
||||
})?;
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -307,74 +293,49 @@ impl NostrRegistry {
|
||||
let task: Task<Result<(), Error>> = 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<RelayUrl> = 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?;
|
||||
|
||||
@@ -385,15 +346,20 @@ impl NostrRegistry {
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
// Wait for the task to complete
|
||||
task.await?;
|
||||
|
||||
// Set signer
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -472,6 +438,7 @@ impl NostrRegistry {
|
||||
Ok(public_key) => {
|
||||
// Update states
|
||||
this.update(cx, |this, cx| {
|
||||
this.ensure_relay_list(&public_key, cx);
|
||||
// Add public key to npubs if not already present
|
||||
this.npubs.update(cx, |this, cx| {
|
||||
if !this.contains(&public_key) {
|
||||
@@ -479,22 +446,18 @@ impl NostrRegistry {
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure relay list for the user
|
||||
this.ensure_relay_list(cx);
|
||||
|
||||
// Emit signer changed event
|
||||
cx.emit(SignerEvent::Set);
|
||||
})?;
|
||||
cx.emit(StateEvent::SignerSet);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(SignerEvent::Error(e.to_string()));
|
||||
})?;
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -506,16 +469,15 @@ impl NostrRegistry {
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let key_path = keys_dir.join(format!("{}.npub", npub));
|
||||
smol::fs::remove_file(key_path).await?;
|
||||
smol::fs::remove_file(key_path).await.ok();
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.npubs().update(cx, |this, cx| {
|
||||
this.retain(|k| k != &public_key);
|
||||
cx.notify();
|
||||
});
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -533,16 +495,16 @@ impl NostrRegistry {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(SignerEvent::Error(e.to_string()));
|
||||
})?;
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -564,192 +526,90 @@ impl NostrRegistry {
|
||||
match task.await {
|
||||
Ok((public_key, uri)) => {
|
||||
let username = public_key.to_bech32().unwrap();
|
||||
let write_credential = this.read_with(cx, |_this, cx| {
|
||||
cx.write_credentials(&username, "nostrconnect", uri.to_string().as_bytes())
|
||||
})?;
|
||||
let write_credential = this
|
||||
.read_with(cx, |_this, cx| {
|
||||
cx.write_credentials(
|
||||
&username,
|
||||
"nostrconnect",
|
||||
uri.to_string().as_bytes(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
match write_credential.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(nip46, cx);
|
||||
})?;
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(SignerEvent::Error(e.to_string()));
|
||||
})?;
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(SignerEvent::Error(e.to_string()));
|
||||
})?;
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set the state of the relay list
|
||||
fn set_relay_state(&mut self, state: RelayState, cx: &mut Context<Self>) {
|
||||
self.relay_list_state = state;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn ensure_relay_list(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.verify_relay_list(cx);
|
||||
|
||||
// Set the state to idle before starting the task
|
||||
self.set_relay_state(RelayState::default(), cx);
|
||||
pub fn ensure_relay_list(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
|
||||
let task = self.get_event(public_key, Kind::RelayList, 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(())
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::RelayConnected);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(StateEvent::RelayNotConfigured);
|
||||
cx.emit(StateEvent::Error(SharedString::from(e.to_string())));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
// Verify relay list for current user
|
||||
fn verify_relay_list(&mut self, cx: &mut Context<Self>) -> Task<Result<RelayState, Error>> {
|
||||
/// Get an event with the given author and kind.
|
||||
pub fn get_event(
|
||||
&self,
|
||||
author: &PublicKey,
|
||||
kind: Kind,
|
||||
cx: &App,
|
||||
) -> Task<Result<Event, Error>> {
|
||||
let client = self.client();
|
||||
let public_key = *author;
|
||||
|
||||
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<Filter>> = BOOTSTRAP_RELAYS
|
||||
.into_iter()
|
||||
.map(|relay| (relay, vec![filter.clone()]))
|
||||
.collect();
|
||||
|
||||
// Stream events from the bootstrap relays
|
||||
let filter = Filter::new().kind(kind).author(public_key).limit(1);
|
||||
let mut stream = client
|
||||
.stream_events(target)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.stream_events(filter)
|
||||
.timeout(Duration::from_millis(800))
|
||||
.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}");
|
||||
}
|
||||
if let Ok(event) = res {
|
||||
return Ok(event);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RelayState::NotConfigured)
|
||||
Err(anyhow!("No event found"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensure write relays for a given public key
|
||||
pub fn ensure_write_relays(&self, public_key: &PublicKey, cx: &App) -> Task<Vec<RelayUrl>> {
|
||||
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<Filter>> = 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<Vec<RelayUrl>> {
|
||||
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<Vec<RelayUrl>> {
|
||||
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<SharedString> {
|
||||
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<Result<PublicKey, Error>> {
|
||||
let client = self.client();
|
||||
@@ -905,8 +765,6 @@ impl NostrRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<SignerEvent> for NostrRegistry {}
|
||||
|
||||
/// Get or create a new app keys
|
||||
fn get_or_init_app_keys() -> Result<Keys, Error> {
|
||||
let dir = config_dir().join(".app_keys");
|
||||
@@ -932,52 +790,6 @@ fn get_or_init_app_keys() -> Result<Keys, Error> {
|
||||
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<Filter>> = 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<RelayMetadata>)> {
|
||||
vec![
|
||||
(
|
||||
@@ -1011,43 +823,6 @@ fn default_messaging_relays() -> Vec<RelayUrl> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Signer event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum SignerEvent {
|
||||
/// A new signer has been set
|
||||
Set,
|
||||
|
||||
/// An error occurred
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum RelayState {
|
||||
#[default]
|
||||
Idle,
|
||||
Checking,
|
||||
NotConfigured,
|
||||
Configured,
|
||||
}
|
||||
|
||||
impl RelayState {
|
||||
pub fn idle(&self) -> bool {
|
||||
matches!(self, RelayState::Idle)
|
||||
}
|
||||
|
||||
pub fn checking(&self) -> bool {
|
||||
matches!(self, RelayState::Checking)
|
||||
}
|
||||
|
||||
pub fn not_configured(&self) -> bool {
|
||||
matches!(self, RelayState::NotConfigured)
|
||||
}
|
||||
|
||||
pub fn configured(&self) -> bool {
|
||||
matches!(self, RelayState::Configured)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoopAuthUrlHandler;
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ impl Anchor {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn other_side_corner_along(&self, axis: Axis) -> Anchor {
|
||||
pub fn other_side_corner_along(&self, axis: Axis) -> Anchor {
|
||||
match axis {
|
||||
Axis::Vertical => match self {
|
||||
Self::TopLeft => Self::BottomLeft,
|
||||
@@ -4,6 +4,8 @@ use std::rc::Rc;
|
||||
use gpui::{App, Global, Pixels, SharedString, Window, px};
|
||||
|
||||
mod colors;
|
||||
mod geometry;
|
||||
mod notification;
|
||||
mod platform_kind;
|
||||
mod registry;
|
||||
mod scale;
|
||||
@@ -11,6 +13,8 @@ mod scrollbar_mode;
|
||||
mod theme;
|
||||
|
||||
pub use colors::*;
|
||||
pub use geometry::*;
|
||||
pub use notification::*;
|
||||
pub use platform_kind::PlatformKind;
|
||||
pub use registry::*;
|
||||
pub use scale::*;
|
||||
@@ -82,6 +86,9 @@ pub struct Theme {
|
||||
/// Show the scrollbar mode, default: scrolling
|
||||
pub scrollbar_mode: ScrollbarMode,
|
||||
|
||||
/// Notification settings
|
||||
pub notification: NotificationSettings,
|
||||
|
||||
/// Platform kind
|
||||
pub platform: PlatformKind,
|
||||
}
|
||||
@@ -204,6 +211,7 @@ impl From<ThemeFamily> for Theme {
|
||||
radius_lg: px(10.),
|
||||
shadow: true,
|
||||
scrollbar_mode: ScrollbarMode::default(),
|
||||
notification: NotificationSettings::default(),
|
||||
mode,
|
||||
colors: *colors,
|
||||
theme: Rc::new(family),
|
||||
|
||||
31
crates/theme/src/notification.rs
Normal file
31
crates/theme/src/notification.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use gpui::{Pixels, px};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Anchor, Edges, TITLEBAR_HEIGHT};
|
||||
|
||||
/// The settings for notifications.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationSettings {
|
||||
/// The placement of the notification, default: [`Anchor::TopRight`]
|
||||
pub placement: Anchor,
|
||||
/// The margins of the notification with respect to the window edges.
|
||||
pub margins: Edges<Pixels>,
|
||||
/// The maximum number of notifications to show at once, default: 10
|
||||
pub max_items: usize,
|
||||
}
|
||||
|
||||
impl Default for NotificationSettings {
|
||||
fn default() -> Self {
|
||||
let offset = px(16.);
|
||||
Self {
|
||||
placement: Anchor::TopRight,
|
||||
margins: Edges {
|
||||
top: TITLEBAR_HEIGHT + offset, // avoid overlap with title bar
|
||||
right: offset,
|
||||
bottom: offset,
|
||||
left: offset,
|
||||
},
|
||||
max_items: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
//! This is a fork of gpui's anchored element that adds support for offsetting
|
||||
//! https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/elements/anchored.rs
|
||||
use gpui::{
|
||||
point, px, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, Half,
|
||||
AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, Half,
|
||||
InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style,
|
||||
Window,
|
||||
Window, point, px,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::Anchor;
|
||||
use theme::Anchor;
|
||||
|
||||
/// The state that the anchored element element uses to track its children.
|
||||
pub struct AnchoredState {
|
||||
|
||||
@@ -7,16 +7,16 @@ use gpui::{
|
||||
Window,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING};
|
||||
use theme::{ActiveTheme, AxisExt as _, CLIENT_SIDE_DECORATION_ROUNDING, Placement};
|
||||
|
||||
use super::{DockArea, PanelEvent};
|
||||
use crate::dock_area::panel::{Panel, PanelView};
|
||||
use crate::dock_area::tab_panel::TabPanel;
|
||||
use crate::h_flex;
|
||||
use crate::resizable::{
|
||||
resizable_panel, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
|
||||
PANEL_MIN_SIZE,
|
||||
PANEL_MIN_SIZE, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
|
||||
resizable_panel,
|
||||
};
|
||||
use crate::{h_flex, AxisExt as _, Placement};
|
||||
|
||||
pub struct StackPanel {
|
||||
pub(super) parent: Option<WeakEntity<StackPanel>>,
|
||||
|
||||
@@ -2,12 +2,12 @@ use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, rems, App, AppContext, Context, Corner, DefiniteLength, DismissEvent, DragMoveEvent,
|
||||
Empty, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement,
|
||||
MouseButton, ParentElement, Pixels, Render, ScrollHandle, SharedString,
|
||||
StatefulInteractiveElement, Styled, WeakEntity, Window,
|
||||
App, AppContext, Context, Corner, DefiniteLength, DismissEvent, DragMoveEvent, Empty, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, MouseButton,
|
||||
ParentElement, Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled,
|
||||
WeakEntity, Window, div, px, rems,
|
||||
};
|
||||
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT};
|
||||
use theme::{ActiveTheme, AxisExt, CLIENT_SIDE_DECORATION_ROUNDING, Placement, TABBAR_HEIGHT};
|
||||
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::dock_area::dock::DockPlacement;
|
||||
@@ -15,9 +15,9 @@ use crate::dock_area::panel::{Panel, PanelView};
|
||||
use crate::dock_area::stack_panel::StackPanel;
|
||||
use crate::dock_area::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom};
|
||||
use crate::menu::{DropdownMenu, PopupMenu};
|
||||
use crate::tab::tab_bar::TabBar;
|
||||
use crate::tab::Tab;
|
||||
use crate::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt};
|
||||
use crate::tab::tab_bar::TabBar;
|
||||
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TabState {
|
||||
|
||||
@@ -2,11 +2,10 @@ pub use anchored::*;
|
||||
pub use element_ext::ElementExt;
|
||||
pub use event::InteractiveElementExt;
|
||||
pub use focusable::FocusableCycle;
|
||||
pub use geometry::*;
|
||||
pub use icon::*;
|
||||
pub use index_path::IndexPath;
|
||||
pub use kbd::*;
|
||||
pub use root::{window_paddings, Root};
|
||||
pub use root::{Root, window_paddings};
|
||||
pub use styled::*;
|
||||
pub use window_ext::*;
|
||||
|
||||
@@ -39,7 +38,6 @@ mod anchored;
|
||||
mod element_ext;
|
||||
mod event;
|
||||
mod focusable;
|
||||
mod geometry;
|
||||
mod icon;
|
||||
mod index_path;
|
||||
mod kbd;
|
||||
|
||||
@@ -2,19 +2,19 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
anchored, div, px, rems, Action, AnyElement, App, AppContext, Axis, Bounds, ClickEvent,
|
||||
Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, Half,
|
||||
InteractiveElement, IntoElement, KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement,
|
||||
Pixels, Point, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled,
|
||||
Subscription, WeakEntity, Window,
|
||||
Action, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, Context, Corner, DismissEvent,
|
||||
Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement, IntoElement,
|
||||
KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, Pixels, Point, Render, ScrollHandle,
|
||||
SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, anchored,
|
||||
div, px, rems,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, Side};
|
||||
|
||||
use crate::actions::{Cancel, Confirm, SelectDown, SelectLeft, SelectRight, SelectUp};
|
||||
use crate::kbd::Kbd;
|
||||
use crate::menu::menu_item::MenuItemElement;
|
||||
use crate::scroll::ScrollableElement;
|
||||
use crate::{h_flex, v_flex, ElementExt, Icon, IconName, Side, Sizable as _, Size, StyledExt};
|
||||
use crate::{ElementExt, Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
|
||||
|
||||
const CONTEXT: &str = "PopupMenu";
|
||||
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
use std::any::TypeId;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context,
|
||||
DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
|
||||
Subscription, Window,
|
||||
Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context, DismissEvent,
|
||||
ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||
Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription,
|
||||
Window, div, px, relative,
|
||||
};
|
||||
use smol::Timer;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, Anchor};
|
||||
|
||||
use crate::animation::cubic_bezier;
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::{h_flex, v_flex, Icon, IconName, Sizable as _, StyledExt};
|
||||
use crate::{Icon, IconName, Sizable as _, StyledExt, h_flex, v_flex};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub enum NotificationType {
|
||||
pub enum NotificationKind {
|
||||
#[default]
|
||||
Info,
|
||||
Success,
|
||||
@@ -27,13 +25,17 @@ pub enum NotificationType {
|
||||
Error,
|
||||
}
|
||||
|
||||
impl NotificationType {
|
||||
impl NotificationKind {
|
||||
fn icon(&self, cx: &App) -> Icon {
|
||||
match self {
|
||||
Self::Info => Icon::new(IconName::Info).text_color(cx.theme().element_foreground),
|
||||
Self::Success => Icon::new(IconName::Info).text_color(cx.theme().secondary_foreground),
|
||||
Self::Info => Icon::new(IconName::Info).text_color(cx.theme().icon),
|
||||
Self::Warning => Icon::new(IconName::Warning).text_color(cx.theme().warning_foreground),
|
||||
Self::Error => Icon::new(IconName::Warning).text_color(cx.theme().danger_foreground),
|
||||
Self::Success => {
|
||||
Icon::new(IconName::CheckCircle).text_color(cx.theme().secondary_foreground)
|
||||
}
|
||||
Self::Error => {
|
||||
Icon::new(IconName::CloseCircle).text_color(cx.theme().danger_foreground)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +58,7 @@ impl From<(TypeId, ElementId)> for NotificationId {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
/// A notification element.
|
||||
pub struct Notification {
|
||||
/// The id is used make the notification unique.
|
||||
@@ -64,16 +67,13 @@ pub struct Notification {
|
||||
/// None means the notification will be added to the end of the list.
|
||||
id: NotificationId,
|
||||
style: StyleRefinement,
|
||||
type_: Option<NotificationType>,
|
||||
kind: Option<NotificationKind>,
|
||||
title: Option<SharedString>,
|
||||
message: Option<SharedString>,
|
||||
icon: Option<Icon>,
|
||||
autohide: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
action_builder: Option<Rc<dyn Fn(&mut Window, &mut Context<Self>) -> Button>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
content_builder: Option<Rc<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
action_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button>>,
|
||||
content_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement>>,
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
closing: bool,
|
||||
}
|
||||
@@ -84,12 +84,6 @@ impl From<String> for Notification {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Cow<'static, str>> for Notification {
|
||||
fn from(s: Cow<'static, str>) -> Self {
|
||||
Self::new().message(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SharedString> for Notification {
|
||||
fn from(s: SharedString) -> Self {
|
||||
Self::new().message(s)
|
||||
@@ -102,24 +96,24 @@ impl From<&'static str> for Notification {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(NotificationType, &'static str)> for Notification {
|
||||
fn from((type_, content): (NotificationType, &'static str)) -> Self {
|
||||
Self::new().message(content).with_type(type_)
|
||||
impl From<(NotificationKind, &'static str)> for Notification {
|
||||
fn from((kind, content): (NotificationKind, &'static str)) -> Self {
|
||||
Self::new().message(content).with_kind(kind)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(NotificationType, SharedString)> for Notification {
|
||||
fn from((type_, content): (NotificationType, SharedString)) -> Self {
|
||||
Self::new().message(content).with_type(type_)
|
||||
impl From<(NotificationKind, SharedString)> for Notification {
|
||||
fn from((kind, content): (NotificationKind, SharedString)) -> Self {
|
||||
Self::new().message(content).with_kind(kind)
|
||||
}
|
||||
}
|
||||
|
||||
struct DefaultIdType;
|
||||
|
||||
impl Notification {
|
||||
/// Create a new notification with the given content.
|
||||
/// Create a new notification.
|
||||
///
|
||||
/// default width is 320px.
|
||||
/// The default id is a random UUID.
|
||||
pub fn new() -> Self {
|
||||
let id: SharedString = uuid::Uuid::new_v4().to_string().into();
|
||||
let id = (TypeId::of::<DefaultIdType>(), id.into());
|
||||
@@ -129,7 +123,7 @@ impl Notification {
|
||||
style: StyleRefinement::default(),
|
||||
title: None,
|
||||
message: None,
|
||||
type_: None,
|
||||
kind: None,
|
||||
icon: None,
|
||||
autohide: true,
|
||||
action_builder: None,
|
||||
@@ -139,33 +133,38 @@ impl Notification {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the message of the notification, default is None.
|
||||
pub fn message(mut self, message: impl Into<SharedString>) -> Self {
|
||||
self.message = Some(message.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Create an info notification with the given message.
|
||||
pub fn info(message: impl Into<SharedString>) -> Self {
|
||||
Self::new()
|
||||
.message(message)
|
||||
.with_type(NotificationType::Info)
|
||||
.with_kind(NotificationKind::Info)
|
||||
}
|
||||
|
||||
/// Create a success notification with the given message.
|
||||
pub fn success(message: impl Into<SharedString>) -> Self {
|
||||
Self::new()
|
||||
.message(message)
|
||||
.with_type(NotificationType::Success)
|
||||
.with_kind(NotificationKind::Success)
|
||||
}
|
||||
|
||||
/// Create a warning notification with the given message.
|
||||
pub fn warning(message: impl Into<SharedString>) -> Self {
|
||||
Self::new()
|
||||
.message(message)
|
||||
.with_type(NotificationType::Warning)
|
||||
.with_kind(NotificationKind::Warning)
|
||||
}
|
||||
|
||||
/// Create an error notification with the given message.
|
||||
pub fn error(message: impl Into<SharedString>) -> Self {
|
||||
Self::new()
|
||||
.message(message)
|
||||
.with_type(NotificationType::Error)
|
||||
.with_kind(NotificationKind::Error)
|
||||
}
|
||||
|
||||
/// Set the type for unique identification of the notification.
|
||||
@@ -180,8 +179,8 @@ impl Notification {
|
||||
}
|
||||
|
||||
/// Set the type and id of the notification, used to uniquely identify the notification.
|
||||
pub fn custom_id(mut self, key: impl Into<ElementId>) -> Self {
|
||||
self.id = (TypeId::of::<DefaultIdType>(), key.into()).into();
|
||||
pub fn type_id<T: Sized + 'static>(mut self, key: impl Into<ElementId>) -> Self {
|
||||
self.id = (TypeId::of::<T>(), key.into()).into();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -202,8 +201,8 @@ impl Notification {
|
||||
}
|
||||
|
||||
/// Set the type of the notification, default is NotificationType::Info.
|
||||
pub fn with_type(mut self, type_: NotificationType) -> Self {
|
||||
self.type_ = Some(type_);
|
||||
pub fn with_kind(mut self, kind: NotificationKind) -> Self {
|
||||
self.kind = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -223,22 +222,31 @@ impl Notification {
|
||||
}
|
||||
|
||||
/// Set the action button of the notification.
|
||||
///
|
||||
/// When an action is set, the notification will not autohide.
|
||||
pub fn action<F>(mut self, action: F) -> Self
|
||||
where
|
||||
F: Fn(&mut Window, &mut Context<Self>) -> Button + 'static,
|
||||
F: Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button + 'static,
|
||||
{
|
||||
self.action_builder = Some(Rc::new(action));
|
||||
self.autohide = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Dismiss the notification.
|
||||
pub fn dismiss(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.closing {
|
||||
return;
|
||||
}
|
||||
self.closing = true;
|
||||
cx.notify();
|
||||
|
||||
// Dismiss the notification after 0.15s to show the animation.
|
||||
cx.spawn(async move |view, cx| {
|
||||
Timer::after(Duration::from_secs_f32(0.15)).await;
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_secs_f32(0.15))
|
||||
.await;
|
||||
|
||||
cx.update(|cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |view, cx| {
|
||||
@@ -248,13 +256,13 @@ impl Notification {
|
||||
}
|
||||
})
|
||||
})
|
||||
.detach()
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Set the content of the notification.
|
||||
pub fn content(
|
||||
mut self,
|
||||
content: impl Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static,
|
||||
content: impl Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement + 'static,
|
||||
) -> Self {
|
||||
self.content_builder = Some(Rc::new(content));
|
||||
self
|
||||
@@ -276,57 +284,76 @@ impl Styled for Notification {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Notification {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let closing = self.closing;
|
||||
let icon = match self.type_ {
|
||||
let placement = cx.theme().notification.placement;
|
||||
|
||||
let content = self
|
||||
.content_builder
|
||||
.clone()
|
||||
.map(|builder| builder(self, window, cx));
|
||||
|
||||
let action = self
|
||||
.action_builder
|
||||
.clone()
|
||||
.map(|builder| builder(self, window, cx).small().mr_3p5());
|
||||
|
||||
let icon = match self.kind {
|
||||
None => self.icon.clone(),
|
||||
Some(type_) => Some(type_.icon(cx)),
|
||||
Some(kind) => Some(kind.icon(cx)),
|
||||
};
|
||||
|
||||
let background = match self.kind {
|
||||
Some(NotificationKind::Error) => cx.theme().danger_background,
|
||||
_ => cx.theme().surface_background,
|
||||
};
|
||||
|
||||
let text_color = match self.kind {
|
||||
Some(NotificationKind::Error) => cx.theme().danger_foreground,
|
||||
_ => cx.theme().text,
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id("notification")
|
||||
.refine_style(&self.style)
|
||||
.group("")
|
||||
.occlude()
|
||||
.relative()
|
||||
.w_96()
|
||||
.w_112()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().surface_background)
|
||||
.bg(background)
|
||||
.text_color(text_color)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.when(cx.theme().shadow, |this| this.shadow_md())
|
||||
.p_2()
|
||||
.gap_3()
|
||||
.gap_2()
|
||||
.justify_start()
|
||||
.items_start()
|
||||
.refine_style(&self.style)
|
||||
.when_some(icon, |this, icon| {
|
||||
this.child(div().flex_shrink_0().pt_1().child(icon))
|
||||
this.child(div().flex_shrink_0().child(icon))
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.overflow_hidden()
|
||||
.when_some(self.title.clone(), |this, title| {
|
||||
this.child(div().text_sm().font_semibold().child(title))
|
||||
})
|
||||
.when_some(self.message.clone(), |this, message| {
|
||||
this.child(div().text_sm().child(message))
|
||||
this.child(div().text_sm().line_height(relative(1.25)).child(message))
|
||||
})
|
||||
.when_some(self.content_builder.clone(), |this, child_builder| {
|
||||
this.child(child_builder(window, cx))
|
||||
})
|
||||
.when_some(self.action_builder.clone(), |this, action_builder| {
|
||||
this.child(action_builder(window, cx).small().w_full().my_2())
|
||||
.when_some(content, |this, content| this.child(content))
|
||||
.when_some(action, |this, action| {
|
||||
this.child(h_flex().flex_1().gap_1().justify_end().child(action))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_2p5()
|
||||
.right_2p5()
|
||||
.top_2()
|
||||
.right_2()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.child(
|
||||
@@ -334,7 +361,7 @@ impl Render for Notification {
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.dismiss(window, cx);
|
||||
})),
|
||||
),
|
||||
@@ -345,21 +372,47 @@ impl Render for Notification {
|
||||
on_click(event, window, cx);
|
||||
}))
|
||||
})
|
||||
.on_aux_click(cx.listener(move |view, event: &ClickEvent, window, cx| {
|
||||
if event.is_middle_click() {
|
||||
view.dismiss(window, cx);
|
||||
}
|
||||
}))
|
||||
.with_animation(
|
||||
ElementId::NamedInteger("slide-down".into(), closing as u64),
|
||||
Animation::new(Duration::from_secs_f64(0.25))
|
||||
.with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
|
||||
move |this, delta| {
|
||||
if closing {
|
||||
let x_offset = px(0.) + delta * px(45.);
|
||||
let opacity = 1. - delta;
|
||||
this.left(px(0.) + x_offset)
|
||||
let that = this
|
||||
.shadow_none()
|
||||
.opacity(opacity)
|
||||
.when(opacity < 0.85, |this| this.shadow_none())
|
||||
.when(opacity < 0.85, |this| this.shadow_none());
|
||||
match placement {
|
||||
Anchor::TopRight | Anchor::BottomRight => {
|
||||
let x_offset = px(0.) + delta * px(45.);
|
||||
that.left(px(0.) + x_offset)
|
||||
}
|
||||
Anchor::TopLeft | Anchor::BottomLeft => {
|
||||
let x_offset = px(0.) - delta * px(45.);
|
||||
that.left(px(0.) + x_offset)
|
||||
}
|
||||
Anchor::TopCenter => {
|
||||
let y_offset = px(0.) - delta * px(45.);
|
||||
that.top(px(0.) + y_offset)
|
||||
}
|
||||
Anchor::BottomCenter => {
|
||||
let y_offset = px(0.) + delta * px(45.);
|
||||
that.top(px(0.) + y_offset)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let y_offset = px(-45.) + delta * px(45.);
|
||||
let opacity = delta;
|
||||
let y_offset = match placement {
|
||||
placement if placement.is_top() => px(-45.) + delta * px(45.),
|
||||
placement if placement.is_bottom() => px(45.) - delta * px(45.),
|
||||
_ => px(0.),
|
||||
};
|
||||
this.top(px(0.) + y_offset)
|
||||
.opacity(opacity)
|
||||
.when(opacity < 0.85, |this| this.shadow_none())
|
||||
@@ -373,7 +426,11 @@ impl Render for Notification {
|
||||
pub struct NotificationList {
|
||||
/// Notifications that will be auto hidden.
|
||||
pub(crate) notifications: VecDeque<Entity<Notification>>,
|
||||
|
||||
/// Whether the notification list is expanded.
|
||||
expanded: bool,
|
||||
|
||||
/// Subscriptions
|
||||
_subscriptions: HashMap<NotificationId, Subscription>,
|
||||
}
|
||||
|
||||
@@ -386,10 +443,12 @@ impl NotificationList {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push<T>(&mut self, notification: T, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<Notification>,
|
||||
{
|
||||
pub fn push(
|
||||
&mut self,
|
||||
notification: impl Into<Notification>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let notification = notification.into();
|
||||
let id = notification.id.clone();
|
||||
let autohide = notification.autohide;
|
||||
@@ -411,36 +470,35 @@ impl NotificationList {
|
||||
|
||||
if autohide {
|
||||
// Sleep for 5 seconds to autohide the notification
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
Timer::after(Duration::from_secs(5)).await;
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
||||
|
||||
if let Err(error) =
|
||||
if let Err(err) =
|
||||
notification.update_in(cx, |note, window, cx| note.dismiss(window, cx))
|
||||
{
|
||||
log::error!("Failed to auto hide notification: {error}");
|
||||
log::error!("failed to auto hide notification: {:?}", err);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn close<T>(&mut self, key: T, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<ElementId>,
|
||||
{
|
||||
let id = (TypeId::of::<DefaultIdType>(), key.into()).into();
|
||||
|
||||
pub(crate) fn close(
|
||||
&mut self,
|
||||
id: impl Into<NotificationId>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id: NotificationId = id.into();
|
||||
if let Some(n) = self.notifications.iter().find(|n| n.read(cx).id == id) {
|
||||
n.update(cx, |note, cx| {
|
||||
note.dismiss(window, cx);
|
||||
});
|
||||
n.update(cx, |note, cx| note.dismiss(window, cx))
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn clear(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
pub fn clear(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.notifications.clear();
|
||||
cx.notify();
|
||||
}
|
||||
@@ -451,25 +509,46 @@ impl NotificationList {
|
||||
}
|
||||
|
||||
impl Render for NotificationList {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(
|
||||
&mut self,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let size = window.viewport_size();
|
||||
let items = self.notifications.iter().rev().take(10).rev().cloned();
|
||||
|
||||
div()
|
||||
.id("notification-wrapper")
|
||||
.absolute()
|
||||
.top_4()
|
||||
.right_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.id("notification-list")
|
||||
.h(size.height - px(8.))
|
||||
.gap_3()
|
||||
.children(items)
|
||||
.on_hover(cx.listener(|view, hovered, _, cx| {
|
||||
view.expanded = *hovered;
|
||||
cx.notify()
|
||||
})),
|
||||
let placement = cx.theme().notification.placement;
|
||||
let margins = &cx.theme().notification.margins;
|
||||
|
||||
v_flex()
|
||||
.id("notification-list")
|
||||
.max_h(size.height)
|
||||
.pt(margins.top)
|
||||
.pb(margins.bottom)
|
||||
.gap_3()
|
||||
.when(
|
||||
matches!(placement, Anchor::TopRight),
|
||||
|this| this.pr(margins.right), // ignore left
|
||||
)
|
||||
.when(
|
||||
matches!(placement, Anchor::TopLeft),
|
||||
|this| this.pl(margins.left), // ignore right
|
||||
)
|
||||
.when(
|
||||
matches!(placement, Anchor::BottomLeft),
|
||||
|this| this.flex_col_reverse().pl(margins.left), // ignore right
|
||||
)
|
||||
.when(
|
||||
matches!(placement, Anchor::BottomRight),
|
||||
|this| this.flex_col_reverse().pr(margins.right), // ignore left
|
||||
)
|
||||
.when(matches!(placement, Anchor::BottomCenter), |this| {
|
||||
this.flex_col_reverse()
|
||||
})
|
||||
.on_hover(cx.listener(|view, hovered, _, cx| {
|
||||
view.expanded = *hovered;
|
||||
cx.notify()
|
||||
}))
|
||||
.children(items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,15 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
deferred, div, px, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId,
|
||||
EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding,
|
||||
MouseButton, ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement,
|
||||
Styled, Subscription, Window,
|
||||
AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, EventEmitter,
|
||||
FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, MouseButton,
|
||||
ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, Styled,
|
||||
Subscription, Window, deferred, div, px,
|
||||
};
|
||||
use theme::Anchor;
|
||||
|
||||
use crate::actions::Cancel;
|
||||
use crate::{anchored, v_flex, Anchor, ElementExt, Selectable, StyledExt as _};
|
||||
use crate::{ElementExt, Selectable, StyledExt as _, anchored, v_flex};
|
||||
|
||||
const CONTEXT: &str = "Popover";
|
||||
|
||||
|
||||
@@ -3,14 +3,15 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty,
|
||||
Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent,
|
||||
MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window,
|
||||
Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty, Entity,
|
||||
EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent, MouseUpEvent,
|
||||
ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window, div,
|
||||
};
|
||||
use theme::AxisExt;
|
||||
|
||||
use super::{resizable_panel, resize_handle, ResizableState};
|
||||
use super::{ResizableState, resizable_panel, resize_handle};
|
||||
use crate::resizable::PANEL_MIN_SIZE;
|
||||
use crate::{h_flex, v_flex, AxisExt, ElementExt};
|
||||
use crate::{ElementExt, h_flex, v_flex};
|
||||
|
||||
pub enum ResizablePanelEvent {
|
||||
Resized,
|
||||
|
||||
@@ -3,14 +3,13 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, px, AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId,
|
||||
InteractiveElement, IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels,
|
||||
Point, Render, StatefulInteractiveElement, Styled as _, Window,
|
||||
AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement,
|
||||
IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
|
||||
StatefulInteractiveElement, Styled as _, Window, div, px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, AxisExt};
|
||||
|
||||
use crate::dock_area::dock::DockPlacement;
|
||||
use crate::AxisExt;
|
||||
|
||||
pub(crate) const HANDLE_PADDING: Pixels = px(4.);
|
||||
pub(crate) const HANDLE_SIZE: Pixels = px(1.);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use std::any::TypeId;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyView, App, AppContext, Bounds, Context, CursorStyle, Decorations, Edges, Entity,
|
||||
AnyView, App, AppContext, Bounds, Context, CursorStyle, Decorations, Edges, ElementId, Entity,
|
||||
FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement as _, Pixels, Point, Render, ResizeEdge, SharedString, Size, Styled, Tiling,
|
||||
WeakFocusHandle, Window, canvas, div, point, px, size,
|
||||
ParentElement as _, Pixels, Point, Render, ResizeEdge, Size, Styled, Tiling, WeakFocusHandle,
|
||||
Window, canvas, div, point, px, size,
|
||||
};
|
||||
use theme::{
|
||||
ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING,
|
||||
@@ -213,13 +214,30 @@ impl Root {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Clear a notification by its ID.
|
||||
pub fn clear_notification<T>(&mut self, id: T, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
self.notification
|
||||
.update(cx, |view, cx| view.close(id.into(), window, cx));
|
||||
/// Clear a notification by its type.
|
||||
pub fn clear_notification<T: Sized + 'static>(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<'_, Root>,
|
||||
) {
|
||||
self.notification.update(cx, |view, cx| {
|
||||
let id = TypeId::of::<T>();
|
||||
view.close(id, window, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Clear a notification by its type.
|
||||
pub fn clear_notification_by_id<T: Sized + 'static>(
|
||||
&mut self,
|
||||
key: impl Into<ElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<'_, Root>,
|
||||
) {
|
||||
self.notification.update(cx, |view, cx| {
|
||||
let id = (TypeId::of::<T>(), key.into());
|
||||
view.close(id, window, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use gpui::{
|
||||
px, relative, App, Axis, BorderStyle, Bounds, ContentMask, Corners, Edges, Element, ElementId,
|
||||
EntityId, GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels,
|
||||
Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window,
|
||||
App, Axis, BorderStyle, Bounds, ContentMask, Corners, Edges, Element, ElementId, EntityId,
|
||||
GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels, Point,
|
||||
Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window, px, relative,
|
||||
};
|
||||
|
||||
use crate::AxisExt;
|
||||
use theme::AxisExt;
|
||||
|
||||
/// Make a scrollable mask element to cover the parent view with the mouse wheel event listening.
|
||||
///
|
||||
|
||||
@@ -11,9 +11,7 @@ use gpui::{
|
||||
Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill,
|
||||
point, px, relative, size,
|
||||
};
|
||||
use theme::{ActiveTheme, ScrollbarMode};
|
||||
|
||||
use crate::AxisExt;
|
||||
use theme::{ActiveTheme, AxisExt, ScrollbarMode};
|
||||
|
||||
/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH)
|
||||
const WIDTH: Pixels = px(1. * 2. + 8.);
|
||||
|
||||
@@ -4,13 +4,13 @@ use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, px, white, Animation, AnimationExt as _, AnyElement, App, Element, ElementId,
|
||||
GlobalElementId, InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString,
|
||||
Styled as _, Window,
|
||||
Animation, AnimationExt as _, AnyElement, App, Element, ElementId, GlobalElementId,
|
||||
InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString, Styled as _,
|
||||
Window, div, px, white,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, Side};
|
||||
|
||||
use crate::{Disableable, Side, Sizable, Size};
|
||||
use crate::{Disableable, Sizable, Size};
|
||||
|
||||
type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>;
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, Entity, SharedString, Window};
|
||||
use gpui::{App, ElementId, Entity, Window};
|
||||
|
||||
use crate::Root;
|
||||
use crate::input::InputState;
|
||||
use crate::modal::Modal;
|
||||
use crate::notification::Notification;
|
||||
use crate::Root;
|
||||
|
||||
/// Extension trait for [`Window`] to add modal, notification .. functionality.
|
||||
pub trait WindowExtension: Sized {
|
||||
@@ -31,10 +31,15 @@ pub trait WindowExtension: Sized {
|
||||
where
|
||||
T: Into<Notification>;
|
||||
|
||||
/// Clears a notification by its ID.
|
||||
fn clear_notification<T>(&mut self, id: T, cx: &mut App)
|
||||
where
|
||||
T: Into<SharedString>;
|
||||
/// Clear the unique notification.
|
||||
fn clear_notification<T: Sized + 'static>(&mut self, cx: &mut App);
|
||||
|
||||
/// Clear the unique notification with the given id.
|
||||
fn clear_notification_by_id<T: Sized + 'static>(
|
||||
&mut self,
|
||||
key: impl Into<ElementId>,
|
||||
cx: &mut App,
|
||||
);
|
||||
|
||||
/// Clear all notifications
|
||||
fn clear_notifications(&mut self, cx: &mut App);
|
||||
@@ -88,13 +93,21 @@ impl WindowExtension for Window {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clear_notification<T>(&mut self, id: T, cx: &mut App)
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
let id = id.into();
|
||||
Root::update(self, cx, move |root, window, cx| {
|
||||
root.clear_notification(id, window, cx);
|
||||
fn clear_notification<T: Sized + 'static>(&mut self, cx: &mut App) {
|
||||
Root::update(self, cx, |root, window, cx| {
|
||||
root.clear_notification::<T>(window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clear_notification_by_id<T: Sized + 'static>(
|
||||
&mut self,
|
||||
key: impl Into<ElementId>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let key: ElementId = key.into();
|
||||
Root::update(self, cx, |root, window, cx| {
|
||||
root.clear_notification_by_id::<T>(key, window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user