Merge branch 'main' into instrument-keyboard-events

This commit is contained in:
Joseph T. Lyons 2024-01-10 16:03:01 -05:00
commit b26a468820
188 changed files with 3391 additions and 1997 deletions

83
Cargo.lock generated
View file

@ -292,6 +292,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "assets"
version = "0.1.0"
dependencies = [
"anyhow",
"gpui",
"rust-embed",
]
[[package]] [[package]]
name = "assistant" name = "assistant"
version = "0.1.0" version = "0.1.0"
@ -1443,7 +1452,7 @@ dependencies = [
[[package]] [[package]]
name = "collab" name = "collab"
version = "0.34.0" version = "0.36.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -1464,6 +1473,7 @@ dependencies = [
"editor", "editor",
"env_logger", "env_logger",
"envy", "envy",
"file_finder",
"fs", "fs",
"futures 0.3.28", "futures 0.3.28",
"git", "git",
@ -1477,6 +1487,7 @@ dependencies = [
"live_kit_server", "live_kit_server",
"log", "log",
"lsp", "lsp",
"menu",
"nanoid", "nanoid",
"node_runtime", "node_runtime",
"notifications", "notifications",
@ -1550,6 +1561,7 @@ dependencies = [
"serde_json", "serde_json",
"settings", "settings",
"smallvec", "smallvec",
"story",
"theme", "theme",
"theme_selector", "theme_selector",
"time", "time",
@ -3428,6 +3440,40 @@ dependencies = [
"tiff", "tiff",
] ]
[[package]]
name = "include-flate"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2e11569346406931d20276cc460215ee2826e7cad43aa986999cb244dd7adb0"
dependencies = [
"include-flate-codegen-exports",
"lazy_static",
"libflate",
]
[[package]]
name = "include-flate-codegen"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a7d6e1419fa3129eb0802b4c99603c0d425c79fb5d76191d5a20d0ab0d664e8"
dependencies = [
"libflate",
"proc-macro-hack",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "include-flate-codegen-exports"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75657043ffe3d8280f1cb8aef0f505532b392ed7758e0baeac22edadcee31a03"
dependencies = [
"include-flate-codegen",
"proc-macro-hack",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@ -3819,6 +3865,26 @@ version = "0.2.148"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b" checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b"
[[package]]
name = "libflate"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ff4ae71b685bbad2f2f391fe74f6b7659a34871c08b210fdc039e43bee07d18"
dependencies = [
"adler32",
"crc32fast",
"libflate_lz77",
]
[[package]]
name = "libflate_lz77"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a52d3a8bfc85f250440e4424db7d857e241a3aebbbe301f3eb606ab15c39acbf"
dependencies = [
"rle-decode-fast",
]
[[package]] [[package]]
name = "libgit2-sys" name = "libgit2-sys"
version = "0.14.2+1.5.1" version = "0.14.2+1.5.1"
@ -5396,6 +5462,12 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "proc-macro-hack"
version = "0.5.20+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.67" version = "1.0.67"
@ -6090,6 +6162,12 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "rle-decode-fast"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422"
[[package]] [[package]]
name = "rmp" name = "rmp"
version = "0.8.12" version = "0.8.12"
@ -6237,6 +6315,7 @@ version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e7d90385b59f0a6bf3d3b757f3ca4ece2048265d70db20a2016043d4509a40" checksum = "b1e7d90385b59f0a6bf3d3b757f3ca4ece2048265d70db20a2016043d4509a40"
dependencies = [ dependencies = [
"include-flate",
"rust-embed-impl", "rust-embed-impl",
"rust-embed-utils", "rust-embed-utils",
"walkdir", "walkdir",
@ -7438,6 +7517,7 @@ dependencies = [
"backtrace-on-stack-overflow", "backtrace-on-stack-overflow",
"chrono", "chrono",
"clap 4.4.4", "clap 4.4.4",
"collab_ui",
"dialoguer", "dialoguer",
"editor", "editor",
"fuzzy", "fuzzy",
@ -9529,6 +9609,7 @@ dependencies = [
"activity_indicator", "activity_indicator",
"ai", "ai",
"anyhow", "anyhow",
"assets",
"assistant", "assistant",
"async-compression", "async-compression",
"async-recursion 0.3.2", "async-recursion 0.3.2",

View file

@ -1,5 +1,6 @@
[workspace] [workspace]
members = [ members = [
"crates/assets",
"crates/activity_indicator", "crates/activity_indicator",
"crates/ai", "crates/ai",
"crates/assistant", "crates/assistant",
@ -109,7 +110,7 @@ prost = { version = "0.8" }
rand = { version = "0.8.5" } rand = { version = "0.8.5" }
refineable = { path = "./crates/refineable" } refineable = { path = "./crates/refineable" }
regex = { version = "1.5" } regex = { version = "1.5" }
rust-embed = { version = "8.0", features = ["include-exclude"] } rust-embed = { version = "8.0", features = ["include-exclude", "compression"] }
rusqlite = { version = "0.29.0", features = ["blob", "array", "modern_sqlite"] } rusqlite = { version = "0.29.0", features = ["blob", "array", "modern_sqlite"] }
schemars = { version = "0.8" } schemars = { version = "0.8" }
serde = { version = "1.0", features = ["derive", "rc"] } serde = { version = "1.0", features = ["derive", "rc"] }

View file

@ -77,9 +77,6 @@ impl ActivityIndicator {
cx.observe(auto_updater, |_, _, cx| cx.notify()).detach(); cx.observe(auto_updater, |_, _, cx| cx.notify()).detach();
} }
// cx.observe_active_labeled_tasks(|_, cx| cx.notify())
// .detach();
Self { Self {
statuses: Default::default(), statuses: Default::default(),
project: project.clone(), project: project.clone(),
@ -288,15 +285,6 @@ impl ActivityIndicator {
}; };
} }
// todo!(show active tasks)
// if let Some(most_recent_active_task) = cx.active_labeled_tasks().last() {
// return Content {
// icon: None,
// message: most_recent_active_task.to_string(),
// on_click: None,
// };
// }
Default::default() Default::default()
} }
} }

12
crates/assets/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "assets"
version = "0.1.0"
edition = "2021"
publish = false
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
gpui = {path = "../gpui"}
rust-embed.workspace = true
anyhow.workspace = true

View file

@ -1,3 +1,4 @@
// This crate was essentially pulled out verbatim from main `zed` crate to avoid having to run RustEmbed macro whenever zed has to be rebuilt. It saves a second or two on an incremental build.
use anyhow::anyhow; use anyhow::anyhow;
use gpui::{AssetSource, Result, SharedString}; use gpui::{AssetSource, Result, SharedString};

View file

@ -933,7 +933,7 @@ impl AssistantPanel {
} }
fn render_hamburger_button(cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_hamburger_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
IconButton::new("hamburger_button", Icon::Menu) IconButton::new("hamburger_button", IconName::Menu)
.on_click(cx.listener(|this, _event, cx| { .on_click(cx.listener(|this, _event, cx| {
if this.active_editor().is_some() { if this.active_editor().is_some() {
this.set_active_editor_index(None, cx); this.set_active_editor_index(None, cx);
@ -957,7 +957,7 @@ impl AssistantPanel {
} }
fn render_split_button(cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_split_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
IconButton::new("split_button", Icon::Snip) IconButton::new("split_button", IconName::Snip)
.on_click(cx.listener(|this, _event, cx| { .on_click(cx.listener(|this, _event, cx| {
if let Some(active_editor) = this.active_editor() { if let Some(active_editor) = this.active_editor() {
active_editor.update(cx, |editor, cx| editor.split(&Default::default(), cx)); active_editor.update(cx, |editor, cx| editor.split(&Default::default(), cx));
@ -968,7 +968,7 @@ impl AssistantPanel {
} }
fn render_assist_button(cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_assist_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
IconButton::new("assist_button", Icon::MagicWand) IconButton::new("assist_button", IconName::MagicWand)
.on_click(cx.listener(|this, _event, cx| { .on_click(cx.listener(|this, _event, cx| {
if let Some(active_editor) = this.active_editor() { if let Some(active_editor) = this.active_editor() {
active_editor.update(cx, |editor, cx| editor.assist(&Default::default(), cx)); active_editor.update(cx, |editor, cx| editor.assist(&Default::default(), cx));
@ -979,7 +979,7 @@ impl AssistantPanel {
} }
fn render_quote_button(cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_quote_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
IconButton::new("quote_button", Icon::Quote) IconButton::new("quote_button", IconName::Quote)
.on_click(cx.listener(|this, _event, cx| { .on_click(cx.listener(|this, _event, cx| {
if let Some(workspace) = this.workspace.upgrade() { if let Some(workspace) = this.workspace.upgrade() {
cx.window_context().defer(move |cx| { cx.window_context().defer(move |cx| {
@ -994,7 +994,7 @@ impl AssistantPanel {
} }
fn render_plus_button(cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_plus_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
IconButton::new("plus_button", Icon::Plus) IconButton::new("plus_button", IconName::Plus)
.on_click(cx.listener(|this, _event, cx| { .on_click(cx.listener(|this, _event, cx| {
this.new_conversation(cx); this.new_conversation(cx);
})) }))
@ -1004,12 +1004,12 @@ impl AssistantPanel {
fn render_zoom_button(&self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_zoom_button(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let zoomed = self.zoomed; let zoomed = self.zoomed;
IconButton::new("zoom_button", Icon::Maximize) IconButton::new("zoom_button", IconName::Maximize)
.on_click(cx.listener(|this, _event, cx| { .on_click(cx.listener(|this, _event, cx| {
this.toggle_zoom(&ToggleZoom, cx); this.toggle_zoom(&ToggleZoom, cx);
})) }))
.selected(zoomed) .selected(zoomed)
.selected_icon(Icon::Minimize) .selected_icon(IconName::Minimize)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.tooltip(move |cx| { .tooltip(move |cx| {
Tooltip::for_action(if zoomed { "Zoom Out" } else { "Zoom In" }, &ToggleZoom, cx) Tooltip::for_action(if zoomed { "Zoom Out" } else { "Zoom In" }, &ToggleZoom, cx)
@ -1286,8 +1286,8 @@ impl Panel for AssistantPanel {
} }
} }
fn icon(&self, cx: &WindowContext) -> Option<Icon> { fn icon(&self, cx: &WindowContext) -> Option<IconName> {
Some(Icon::Ai).filter(|_| AssistantSettings::get_global(cx).button) Some(IconName::Ai).filter(|_| AssistantSettings::get_global(cx).button)
} }
fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
@ -2349,7 +2349,7 @@ impl ConversationEditor {
div() div()
.id("error") .id("error")
.tooltip(move |cx| Tooltip::text(error.clone(), cx)) .tooltip(move |cx| Tooltip::text(error.clone(), cx))
.child(IconElement::new(Icon::XCircle)), .child(Icon::new(IconName::XCircle)),
) )
} else { } else {
None None
@ -2645,7 +2645,7 @@ impl Render for InlineAssistant {
.justify_center() .justify_center()
.w(measurements.gutter_width) .w(measurements.gutter_width)
.child( .child(
IconButton::new("include_conversation", Icon::Ai) IconButton::new("include_conversation", IconName::Ai)
.on_click(cx.listener(|this, _, cx| { .on_click(cx.listener(|this, _, cx| {
this.toggle_include_conversation(&ToggleIncludeConversation, cx) this.toggle_include_conversation(&ToggleIncludeConversation, cx)
})) }))
@ -2660,7 +2660,7 @@ impl Render for InlineAssistant {
) )
.children(if SemanticIndex::enabled(cx) { .children(if SemanticIndex::enabled(cx) {
Some( Some(
IconButton::new("retrieve_context", Icon::MagnifyingGlass) IconButton::new("retrieve_context", IconName::MagnifyingGlass)
.on_click(cx.listener(|this, _, cx| { .on_click(cx.listener(|this, _, cx| {
this.toggle_retrieve_context(&ToggleRetrieveContext, cx) this.toggle_retrieve_context(&ToggleRetrieveContext, cx)
})) }))
@ -2682,7 +2682,7 @@ impl Render for InlineAssistant {
div() div()
.id("error") .id("error")
.tooltip(move |cx| Tooltip::text(error_message.clone(), cx)) .tooltip(move |cx| Tooltip::text(error_message.clone(), cx))
.child(IconElement::new(Icon::XCircle).color(Color::Error)), .child(Icon::new(IconName::XCircle).color(Color::Error)),
) )
} else { } else {
None None
@ -2957,7 +2957,7 @@ impl InlineAssistant {
div() div()
.id("error") .id("error")
.tooltip(|cx| Tooltip::text("Not Authenticated. Please ensure you have a valid 'OPENAI_API_KEY' in your environment variables.", cx)) .tooltip(|cx| Tooltip::text("Not Authenticated. Please ensure you have a valid 'OPENAI_API_KEY' in your environment variables.", cx))
.child(IconElement::new(Icon::XCircle)) .child(Icon::new(IconName::XCircle))
.into_any_element() .into_any_element()
), ),
@ -2965,7 +2965,7 @@ impl InlineAssistant {
div() div()
.id("error") .id("error")
.tooltip(|cx| Tooltip::text("Not Indexed", cx)) .tooltip(|cx| Tooltip::text("Not Indexed", cx))
.child(IconElement::new(Icon::XCircle)) .child(Icon::new(IconName::XCircle))
.into_any_element() .into_any_element()
), ),
@ -2996,7 +2996,7 @@ impl InlineAssistant {
div() div()
.id("update") .id("update")
.tooltip(move |cx| Tooltip::text(status_text.clone(), cx)) .tooltip(move |cx| Tooltip::text(status_text.clone(), cx))
.child(IconElement::new(Icon::Update).color(Color::Info)) .child(Icon::new(IconName::Update).color(Color::Info))
.into_any_element() .into_any_element()
) )
} }
@ -3005,7 +3005,7 @@ impl InlineAssistant {
div() div()
.id("check") .id("check")
.tooltip(|cx| Tooltip::text("Index up to date", cx)) .tooltip(|cx| Tooltip::text("Index up to date", cx))
.child(IconElement::new(Icon::Check).color(Color::Success)) .child(Icon::new(IconName::Check).color(Color::Success))
.into_any_element() .into_any_element()
), ),
} }

View file

@ -4,7 +4,7 @@ use gpui::{
}; };
use menu::Cancel; use menu::Cancel;
use util::channel::ReleaseChannel; use util::channel::ReleaseChannel;
use workspace::ui::{h_stack, v_stack, Icon, IconElement, Label, StyledExt}; use workspace::ui::{h_stack, v_stack, Icon, IconName, Label, StyledExt};
pub struct UpdateNotification { pub struct UpdateNotification {
version: SemanticVersion, version: SemanticVersion,
@ -30,7 +30,7 @@ impl Render for UpdateNotification {
.child( .child(
div() div()
.id("cancel") .id("cancel")
.child(IconElement::new(Icon::Close)) .child(Icon::new(IconName::Close))
.cursor_pointer() .cursor_pointer()
.on_click(cx.listener(|this, _, cx| this.dismiss(&menu::Cancel, cx))), .on_click(cx.listener(|this, _, cx| this.dismiss(&menu::Cancel, cx))),
), ),

View file

@ -9,9 +9,12 @@ pub struct CallSettings {
pub mute_on_join: bool, pub mute_on_join: bool,
} }
/// Configuration of voice calls in Zed.
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
pub struct CallSettingsContent { pub struct CallSettingsContent {
/// Whether the microphone should be muted when joining a channel or a call. /// Whether the microphone should be muted when joining a channel or a call.
///
/// Default: false
pub mute_on_join: Option<bool>, pub mute_on_join: Option<bool>,
} }

View file

@ -173,7 +173,11 @@ impl Room {
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
connect.await?; connect.await?;
if !cx.update(|cx| Self::mute_on_join(cx))? { let is_read_only = this
.update(&mut cx, |room, _| room.read_only())
.unwrap_or(true);
if !cx.update(|cx| Self::mute_on_join(cx))? && !is_read_only {
this.update(&mut cx, |this, cx| this.share_microphone(cx))? this.update(&mut cx, |this, cx| this.share_microphone(cx))?
.await?; .await?;
} }
@ -620,6 +624,27 @@ impl Room {
self.local_participant.role == proto::ChannelRole::Admin self.local_participant.role == proto::ChannelRole::Admin
} }
pub fn set_participant_role(
&mut self,
user_id: u64,
role: proto::ChannelRole,
cx: &ModelContext<Self>,
) -> Task<Result<()>> {
let client = self.client.clone();
let room_id = self.id;
let role = role.into();
cx.spawn(|_, _| async move {
client
.request(proto::SetRoomParticipantRole {
room_id,
user_id,
role,
})
.await
.map(|_| ())
})
}
pub fn pending_participants(&self) -> &[Arc<User>] { pub fn pending_participants(&self) -> &[Arc<User>] {
&self.pending_participants &self.pending_participants
} }
@ -729,9 +754,21 @@ impl Room {
if this.local_participant.role != role { if this.local_participant.role != role {
this.local_participant.role = role; this.local_participant.role = role;
if role == proto::ChannelRole::Guest {
for project in mem::take(&mut this.shared_projects) {
if let Some(project) = project.upgrade() {
this.unshare_project(project, cx).log_err();
}
}
this.local_participant.projects.clear();
if let Some(live_kit_room) = &mut this.live_kit {
live_kit_room.stop_publishing(cx);
}
}
this.joined_projects.retain(|project| { this.joined_projects.retain(|project| {
if let Some(project) = project.upgrade() { if let Some(project) = project.upgrade() {
project.update(cx, |project, _| project.set_role(role)); project.update(cx, |project, cx| project.set_role(role, cx));
true true
} else { } else {
false false
@ -1607,6 +1644,24 @@ impl LiveKitRoom {
Ok((result, old_muted)) Ok((result, old_muted))
} }
fn stop_publishing(&mut self, cx: &mut ModelContext<Room>) {
if let LocalTrack::Published {
track_publication, ..
} = mem::replace(&mut self.microphone_track, LocalTrack::None)
{
self.room.unpublish_track(track_publication);
cx.notify();
}
if let LocalTrack::Published {
track_publication, ..
} = mem::replace(&mut self.screen_track, LocalTrack::None)
{
self.room.unpublish_track(track_publication);
cx.notify();
}
}
} }
enum LocalTrack { enum LocalTrack {

View file

@ -3,7 +3,7 @@ use anyhow::{anyhow, Context, Result};
use collections::{hash_map::Entry, HashMap, HashSet}; use collections::{hash_map::Entry, HashMap, HashSet};
use feature_flags::FeatureFlagAppExt; use feature_flags::FeatureFlagAppExt;
use futures::{channel::mpsc, Future, StreamExt}; use futures::{channel::mpsc, Future, StreamExt};
use gpui::{AsyncAppContext, EventEmitter, Model, ModelContext, SharedString, Task}; use gpui::{AsyncAppContext, EventEmitter, Model, ModelContext, SharedUrl, Task};
use postage::{sink::Sink, watch}; use postage::{sink::Sink, watch};
use rpc::proto::{RequestMessage, UsersResponse}; use rpc::proto::{RequestMessage, UsersResponse};
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
@ -19,7 +19,7 @@ pub struct ParticipantIndex(pub u32);
pub struct User { pub struct User {
pub id: UserId, pub id: UserId,
pub github_login: String, pub github_login: String,
pub avatar_uri: SharedString, pub avatar_uri: SharedUrl,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]

View file

@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathan@zed.dev>"]
default-run = "collab" default-run = "collab"
edition = "2021" edition = "2021"
name = "collab" name = "collab"
version = "0.34.0" version = "0.36.0"
publish = false publish = false
[[bin]] [[bin]]
@ -74,6 +74,8 @@ live_kit_client = { path = "../live_kit_client", features = ["test-support"] }
lsp = { path = "../lsp", features = ["test-support"] } lsp = { path = "../lsp", features = ["test-support"] }
node_runtime = { path = "../node_runtime" } node_runtime = { path = "../node_runtime" }
notifications = { path = "../notifications", features = ["test-support"] } notifications = { path = "../notifications", features = ["test-support"] }
file_finder = { path = "../file_finder"}
menu = { path = "../menu"}
project = { path = "../project", features = ["test-support"] } project = { path = "../project", features = ["test-support"] }
rpc = { path = "../rpc", features = ["test-support"] } rpc = { path = "../rpc", features = ["test-support"] }

View file

@ -133,13 +133,29 @@ impl ChannelRole {
} }
} }
pub fn can_share_projects(&self) -> bool { pub fn can_publish_to_rooms(&self) -> bool {
use ChannelRole::*; use ChannelRole::*;
match self { match self {
Admin | Member => true, Admin | Member => true,
Guest | Banned => false, Guest | Banned => false,
} }
} }
pub fn can_edit_projects(&self) -> bool {
use ChannelRole::*;
match self {
Admin | Member => true,
Guest | Banned => false,
}
}
pub fn can_read_projects(&self) -> bool {
use ChannelRole::*;
match self {
Admin | Member | Guest => true,
Banned => false,
}
}
} }
impl From<proto::ChannelRole> for ChannelRole { impl From<proto::ChannelRole> for ChannelRole {

View file

@ -49,7 +49,7 @@ impl Database {
if !participant if !participant
.role .role
.unwrap_or(ChannelRole::Member) .unwrap_or(ChannelRole::Member)
.can_share_projects() .can_publish_to_rooms()
{ {
return Err(anyhow!("guests cannot share projects"))?; return Err(anyhow!("guests cannot share projects"))?;
} }
@ -777,13 +777,131 @@ impl Database {
.await .await
} }
pub async fn project_collaborators( pub async fn check_user_is_project_host(
&self, &self,
project_id: ProjectId, project_id: ProjectId,
connection_id: ConnectionId, connection_id: ConnectionId,
) -> Result<()> {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
project_collaborator::Entity::find()
.filter(
Condition::all()
.add(project_collaborator::Column::ProjectId.eq(project_id))
.add(project_collaborator::Column::IsHost.eq(true))
.add(project_collaborator::Column::ConnectionId.eq(connection_id.id))
.add(
project_collaborator::Column::ConnectionServerId
.eq(connection_id.owner_id),
),
)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("failed to read project host"))?;
Ok(())
})
.await
.map(|guard| guard.into_inner())
}
pub async fn host_for_read_only_project_request(
&self,
project_id: ProjectId,
connection_id: ConnectionId,
) -> Result<ConnectionId> {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let current_participant = room_participant::Entity::find()
.filter(room_participant::Column::RoomId.eq(room_id))
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.id))
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such room"))?;
if !current_participant
.role
.map_or(false, |role| role.can_read_projects())
{
Err(anyhow!("not authorized to read projects"))?;
}
let host = project_collaborator::Entity::find()
.filter(
project_collaborator::Column::ProjectId
.eq(project_id)
.and(project_collaborator::Column::IsHost.eq(true)),
)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("failed to read project host"))?;
Ok(host.connection())
})
.await
.map(|guard| guard.into_inner())
}
pub async fn host_for_mutating_project_request(
&self,
project_id: ProjectId,
connection_id: ConnectionId,
) -> Result<ConnectionId> {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let current_participant = room_participant::Entity::find()
.filter(room_participant::Column::RoomId.eq(room_id))
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.id))
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such room"))?;
if !current_participant
.role
.map_or(false, |role| role.can_edit_projects())
{
Err(anyhow!("not authorized to edit projects"))?;
}
let host = project_collaborator::Entity::find()
.filter(
project_collaborator::Column::ProjectId
.eq(project_id)
.and(project_collaborator::Column::IsHost.eq(true)),
)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("failed to read project host"))?;
Ok(host.connection())
})
.await
.map(|guard| guard.into_inner())
}
pub async fn project_collaborators_for_buffer_update(
&self,
project_id: ProjectId,
connection_id: ConnectionId,
requires_write: bool,
) -> Result<RoomGuard<Vec<ProjectCollaborator>>> { ) -> Result<RoomGuard<Vec<ProjectCollaborator>>> {
let room_id = self.room_id_for_project(project_id).await?; let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move { self.room_transaction(room_id, |tx| async move {
let current_participant = room_participant::Entity::find()
.filter(room_participant::Column::RoomId.eq(room_id))
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.id))
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such room"))?;
if requires_write
&& !current_participant
.role
.map_or(false, |role| role.can_edit_projects())
{
Err(anyhow!("not authorized to edit projects"))?;
}
let collaborators = project_collaborator::Entity::find() let collaborators = project_collaborator::Entity::find()
.filter(project_collaborator::Column::ProjectId.eq(project_id)) .filter(project_collaborator::Column::ProjectId.eq(project_id))
.all(&*tx) .all(&*tx)

View file

@ -1004,6 +1004,46 @@ impl Database {
.await .await
} }
pub async fn set_room_participant_role(
&self,
admin_id: UserId,
room_id: RoomId,
user_id: UserId,
role: ChannelRole,
) -> Result<RoomGuard<proto::Room>> {
self.room_transaction(room_id, |tx| async move {
room_participant::Entity::find()
.filter(
Condition::all()
.add(room_participant::Column::RoomId.eq(room_id))
.add(room_participant::Column::UserId.eq(admin_id))
.add(room_participant::Column::Role.eq(ChannelRole::Admin)),
)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("only admins can set participant role"))?;
let result = room_participant::Entity::update_many()
.filter(
Condition::all()
.add(room_participant::Column::RoomId.eq(room_id))
.add(room_participant::Column::UserId.eq(user_id)),
)
.set(room_participant::ActiveModel {
role: ActiveValue::set(Some(ChannelRole::from(role))),
..Default::default()
})
.exec(&*tx)
.await?;
if result.rows_affected != 1 {
Err(anyhow!("could not update room participant role"))?;
}
Ok(self.get_room(room_id, &tx).await?)
})
.await
}
pub async fn connection_lost(&self, connection: ConnectionId) -> Result<()> { pub async fn connection_lost(&self, connection: ConnectionId) -> Result<()> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
self.room_connection_lost(connection, &*tx).await?; self.room_connection_lost(connection, &*tx).await?;

View file

@ -103,6 +103,12 @@ pub struct Config {
pub zed_environment: Arc<str>, pub zed_environment: Arc<str>,
} }
impl Config {
pub fn is_development(&self) -> bool {
self.zed_environment == "development".into()
}
}
#[derive(Default, Deserialize)] #[derive(Default, Deserialize)]
pub struct MigrateConfig { pub struct MigrateConfig {
pub database_url: String, pub database_url: String,

View file

@ -53,6 +53,25 @@ async fn main() -> Result<()> {
let config = envy::from_env::<Config>().expect("error loading config"); let config = envy::from_env::<Config>().expect("error loading config");
init_tracing(&config); init_tracing(&config);
if config.is_development() {
// sanity check database url so even if we deploy a busted ZED_ENVIRONMENT to production
// we do not run
if config.database_url != "postgres://postgres@localhost/zed" {
panic!("about to run development migrations on a non-development database?")
}
let migrations_path = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"));
let db_options = db::ConnectOptions::new(config.database_url.clone());
let db = Database::new(db_options, Executor::Production).await?;
let migrations = db.migrate(&migrations_path, false).await?;
for (migration, duration) in migrations {
println!(
"Ran {} {} {:?}",
migration.version, migration.description, duration
);
}
}
let state = AppState::new(config).await?; let state = AppState::new(config).await?;
let listener = TcpListener::bind(&format!("0.0.0.0:{}", state.config.http_port)) let listener = TcpListener::bind(&format!("0.0.0.0:{}", state.config.http_port))

View file

@ -42,7 +42,7 @@ use prometheus::{register_int_gauge, IntGauge};
use rpc::{ use rpc::{
proto::{ proto::{
self, Ack, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, LiveKitConnectionInfo, self, Ack, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, LiveKitConnectionInfo,
RequestMessage, UpdateChannelBufferCollaborators, RequestMessage, ShareProject, UpdateChannelBufferCollaborators,
}, },
Connection, ConnectionId, Peer, Receipt, TypedEnvelope, Connection, ConnectionId, Peer, Receipt, TypedEnvelope,
}; };
@ -202,6 +202,7 @@ impl Server {
.add_request_handler(join_room) .add_request_handler(join_room)
.add_request_handler(rejoin_room) .add_request_handler(rejoin_room)
.add_request_handler(leave_room) .add_request_handler(leave_room)
.add_request_handler(set_room_participant_role)
.add_request_handler(call) .add_request_handler(call)
.add_request_handler(cancel_call) .add_request_handler(cancel_call)
.add_message_handler(decline_call) .add_message_handler(decline_call)
@ -216,40 +217,45 @@ impl Server {
.add_message_handler(update_language_server) .add_message_handler(update_language_server)
.add_message_handler(update_diagnostic_summary) .add_message_handler(update_diagnostic_summary)
.add_message_handler(update_worktree_settings) .add_message_handler(update_worktree_settings)
.add_message_handler(refresh_inlay_hints) .add_request_handler(forward_read_only_project_request::<proto::GetHover>)
.add_request_handler(forward_project_request::<proto::GetHover>) .add_request_handler(forward_read_only_project_request::<proto::GetDefinition>)
.add_request_handler(forward_project_request::<proto::GetDefinition>) .add_request_handler(forward_read_only_project_request::<proto::GetTypeDefinition>)
.add_request_handler(forward_project_request::<proto::GetTypeDefinition>) .add_request_handler(forward_read_only_project_request::<proto::GetReferences>)
.add_request_handler(forward_project_request::<proto::GetReferences>) .add_request_handler(forward_read_only_project_request::<proto::SearchProject>)
.add_request_handler(forward_project_request::<proto::SearchProject>) .add_request_handler(forward_read_only_project_request::<proto::GetDocumentHighlights>)
.add_request_handler(forward_project_request::<proto::GetDocumentHighlights>) .add_request_handler(forward_read_only_project_request::<proto::GetProjectSymbols>)
.add_request_handler(forward_project_request::<proto::GetProjectSymbols>) .add_request_handler(forward_read_only_project_request::<proto::OpenBufferForSymbol>)
.add_request_handler(forward_project_request::<proto::OpenBufferForSymbol>) .add_request_handler(forward_read_only_project_request::<proto::OpenBufferById>)
.add_request_handler(forward_project_request::<proto::OpenBufferById>) .add_request_handler(forward_read_only_project_request::<proto::SynchronizeBuffers>)
.add_request_handler(forward_project_request::<proto::OpenBufferByPath>) .add_request_handler(forward_read_only_project_request::<proto::InlayHints>)
.add_request_handler(forward_project_request::<proto::GetCompletions>) .add_request_handler(forward_read_only_project_request::<proto::OpenBufferByPath>)
.add_request_handler(forward_project_request::<proto::ApplyCompletionAdditionalEdits>) .add_request_handler(forward_mutating_project_request::<proto::GetCompletions>)
.add_request_handler(forward_project_request::<proto::ResolveCompletionDocumentation>) .add_request_handler(
.add_request_handler(forward_project_request::<proto::GetCodeActions>) forward_mutating_project_request::<proto::ApplyCompletionAdditionalEdits>,
.add_request_handler(forward_project_request::<proto::ApplyCodeAction>) )
.add_request_handler(forward_project_request::<proto::PrepareRename>) .add_request_handler(
.add_request_handler(forward_project_request::<proto::PerformRename>) forward_mutating_project_request::<proto::ResolveCompletionDocumentation>,
.add_request_handler(forward_project_request::<proto::ReloadBuffers>) )
.add_request_handler(forward_project_request::<proto::SynchronizeBuffers>) .add_request_handler(forward_mutating_project_request::<proto::GetCodeActions>)
.add_request_handler(forward_project_request::<proto::FormatBuffers>) .add_request_handler(forward_mutating_project_request::<proto::ApplyCodeAction>)
.add_request_handler(forward_project_request::<proto::CreateProjectEntry>) .add_request_handler(forward_mutating_project_request::<proto::PrepareRename>)
.add_request_handler(forward_project_request::<proto::RenameProjectEntry>) .add_request_handler(forward_mutating_project_request::<proto::PerformRename>)
.add_request_handler(forward_project_request::<proto::CopyProjectEntry>) .add_request_handler(forward_mutating_project_request::<proto::ReloadBuffers>)
.add_request_handler(forward_project_request::<proto::DeleteProjectEntry>) .add_request_handler(forward_mutating_project_request::<proto::FormatBuffers>)
.add_request_handler(forward_project_request::<proto::ExpandProjectEntry>) .add_request_handler(forward_mutating_project_request::<proto::CreateProjectEntry>)
.add_request_handler(forward_project_request::<proto::OnTypeFormatting>) .add_request_handler(forward_mutating_project_request::<proto::RenameProjectEntry>)
.add_request_handler(forward_project_request::<proto::InlayHints>) .add_request_handler(forward_mutating_project_request::<proto::CopyProjectEntry>)
.add_request_handler(forward_mutating_project_request::<proto::DeleteProjectEntry>)
.add_request_handler(forward_mutating_project_request::<proto::ExpandProjectEntry>)
.add_request_handler(forward_mutating_project_request::<proto::OnTypeFormatting>)
.add_request_handler(forward_mutating_project_request::<proto::SaveBuffer>)
.add_message_handler(create_buffer_for_peer) .add_message_handler(create_buffer_for_peer)
.add_request_handler(update_buffer) .add_request_handler(update_buffer)
.add_message_handler(update_buffer_file) .add_message_handler(broadcast_project_message_from_host::<proto::RefreshInlayHints>)
.add_message_handler(buffer_reloaded) .add_message_handler(broadcast_project_message_from_host::<proto::UpdateBufferFile>)
.add_message_handler(buffer_saved) .add_message_handler(broadcast_project_message_from_host::<proto::BufferReloaded>)
.add_request_handler(forward_project_request::<proto::SaveBuffer>) .add_message_handler(broadcast_project_message_from_host::<proto::BufferSaved>)
.add_message_handler(broadcast_project_message_from_host::<proto::UpdateDiffBase>)
.add_request_handler(get_users) .add_request_handler(get_users)
.add_request_handler(fuzzy_search_users) .add_request_handler(fuzzy_search_users)
.add_request_handler(request_contact) .add_request_handler(request_contact)
@ -281,7 +287,6 @@ impl Server {
.add_request_handler(follow) .add_request_handler(follow)
.add_message_handler(unfollow) .add_message_handler(unfollow)
.add_message_handler(update_followers) .add_message_handler(update_followers)
.add_message_handler(update_diff_base)
.add_request_handler(get_private_user_info) .add_request_handler(get_private_user_info)
.add_message_handler(acknowledge_channel_message) .add_message_handler(acknowledge_channel_message)
.add_message_handler(acknowledge_buffer_version); .add_message_handler(acknowledge_buffer_version);
@ -1254,6 +1259,50 @@ async fn leave_room(
Ok(()) Ok(())
} }
async fn set_room_participant_role(
request: proto::SetRoomParticipantRole,
response: Response<proto::SetRoomParticipantRole>,
session: Session,
) -> Result<()> {
let (live_kit_room, can_publish) = {
let room = session
.db()
.await
.set_room_participant_role(
session.user_id,
RoomId::from_proto(request.room_id),
UserId::from_proto(request.user_id),
ChannelRole::from(request.role()),
)
.await?;
let live_kit_room = room.live_kit_room.clone();
let can_publish = ChannelRole::from(request.role()).can_publish_to_rooms();
room_updated(&room, &session.peer);
(live_kit_room, can_publish)
};
if let Some(live_kit) = session.live_kit_client.as_ref() {
live_kit
.update_participant(
live_kit_room.clone(),
request.user_id.to_string(),
live_kit_server::proto::ParticipantPermission {
can_subscribe: true,
can_publish,
can_publish_data: can_publish,
hidden: false,
recorder: false,
},
)
.await
.trace_err();
}
response.send(proto::Ack {})?;
Ok(())
}
async fn call( async fn call(
request: proto::Call, request: proto::Call,
response: Response<proto::Call>, response: Response<proto::Call>,
@ -1694,10 +1743,6 @@ async fn update_worktree_settings(
Ok(()) Ok(())
} }
async fn refresh_inlay_hints(request: proto::RefreshInlayHints, session: Session) -> Result<()> {
broadcast_project_message(request.project_id, request, session).await
}
async fn start_language_server( async fn start_language_server(
request: proto::StartLanguageServer, request: proto::StartLanguageServer,
session: Session, session: Session,
@ -1742,7 +1787,7 @@ async fn update_language_server(
Ok(()) Ok(())
} }
async fn forward_project_request<T>( async fn forward_read_only_project_request<T>(
request: T, request: T,
response: Response<T>, response: Response<T>,
session: Session, session: Session,
@ -1751,24 +1796,37 @@ where
T: EntityMessage + RequestMessage, T: EntityMessage + RequestMessage,
{ {
let project_id = ProjectId::from_proto(request.remote_entity_id()); let project_id = ProjectId::from_proto(request.remote_entity_id());
let host_connection_id = { let host_connection_id = session
let collaborators = session .db()
.db() .await
.await .host_for_read_only_project_request(project_id, session.connection_id)
.project_collaborators(project_id, session.connection_id) .await?;
.await?;
collaborators
.iter()
.find(|collaborator| collaborator.is_host)
.ok_or_else(|| anyhow!("host not found"))?
.connection_id
};
let payload = session let payload = session
.peer .peer
.forward_request(session.connection_id, host_connection_id, request) .forward_request(session.connection_id, host_connection_id, request)
.await?; .await?;
response.send(payload)?;
Ok(())
}
async fn forward_mutating_project_request<T>(
request: T,
response: Response<T>,
session: Session,
) -> Result<()>
where
T: EntityMessage + RequestMessage,
{
let project_id = ProjectId::from_proto(request.remote_entity_id());
let host_connection_id = session
.db()
.await
.host_for_mutating_project_request(project_id, session.connection_id)
.await?;
let payload = session
.peer
.forward_request(session.connection_id, host_connection_id, request)
.await?;
response.send(payload)?; response.send(payload)?;
Ok(()) Ok(())
} }
@ -1777,6 +1835,14 @@ async fn create_buffer_for_peer(
request: proto::CreateBufferForPeer, request: proto::CreateBufferForPeer,
session: Session, session: Session,
) -> Result<()> { ) -> Result<()> {
session
.db()
.await
.check_user_is_project_host(
ProjectId::from_proto(request.project_id),
session.connection_id,
)
.await?;
let peer_id = request.peer_id.ok_or_else(|| anyhow!("invalid peer id"))?; let peer_id = request.peer_id.ok_or_else(|| anyhow!("invalid peer id"))?;
session session
.peer .peer
@ -1792,11 +1858,25 @@ async fn update_buffer(
let project_id = ProjectId::from_proto(request.project_id); let project_id = ProjectId::from_proto(request.project_id);
let mut guest_connection_ids; let mut guest_connection_ids;
let mut host_connection_id = None; let mut host_connection_id = None;
let mut requires_write_permission = false;
for op in request.operations.iter() {
match op.variant {
None | Some(proto::operation::Variant::UpdateSelections(_)) => {}
Some(_) => requires_write_permission = true,
}
}
{ {
let collaborators = session let collaborators = session
.db() .db()
.await .await
.project_collaborators(project_id, session.connection_id) .project_collaborators_for_buffer_update(
project_id,
session.connection_id,
requires_write_permission,
)
.await?; .await?;
guest_connection_ids = Vec::with_capacity(collaborators.len() - 1); guest_connection_ids = Vec::with_capacity(collaborators.len() - 1);
for collaborator in collaborators.iter() { for collaborator in collaborators.iter() {
@ -1829,60 +1909,17 @@ async fn update_buffer(
Ok(()) Ok(())
} }
async fn update_buffer_file(request: proto::UpdateBufferFile, session: Session) -> Result<()> { async fn broadcast_project_message_from_host<T: EntityMessage<Entity = ShareProject>>(
let project_id = ProjectId::from_proto(request.project_id);
let project_connection_ids = session
.db()
.await
.project_connection_ids(project_id, session.connection_id)
.await?;
broadcast(
Some(session.connection_id),
project_connection_ids.iter().copied(),
|connection_id| {
session
.peer
.forward_send(session.connection_id, connection_id, request.clone())
},
);
Ok(())
}
async fn buffer_reloaded(request: proto::BufferReloaded, session: Session) -> Result<()> {
let project_id = ProjectId::from_proto(request.project_id);
let project_connection_ids = session
.db()
.await
.project_connection_ids(project_id, session.connection_id)
.await?;
broadcast(
Some(session.connection_id),
project_connection_ids.iter().copied(),
|connection_id| {
session
.peer
.forward_send(session.connection_id, connection_id, request.clone())
},
);
Ok(())
}
async fn buffer_saved(request: proto::BufferSaved, session: Session) -> Result<()> {
broadcast_project_message(request.project_id, request, session).await
}
async fn broadcast_project_message<T: EnvelopedMessage>(
project_id: u64,
request: T, request: T,
session: Session, session: Session,
) -> Result<()> { ) -> Result<()> {
let project_id = ProjectId::from_proto(project_id); let project_id = ProjectId::from_proto(request.remote_entity_id());
let project_connection_ids = session let project_connection_ids = session
.db() .db()
.await .await
.project_connection_ids(project_id, session.connection_id) .project_connection_ids(project_id, session.connection_id)
.await?; .await?;
broadcast( broadcast(
Some(session.connection_id), Some(session.connection_id),
project_connection_ids.iter().copied(), project_connection_ids.iter().copied(),
@ -3111,25 +3148,6 @@ async fn mark_notification_as_read(
Ok(()) Ok(())
} }
async fn update_diff_base(request: proto::UpdateDiffBase, session: Session) -> Result<()> {
let project_id = ProjectId::from_proto(request.project_id);
let project_connection_ids = session
.db()
.await
.project_connection_ids(project_id, session.connection_id)
.await?;
broadcast(
Some(session.connection_id),
project_connection_ids.iter().copied(),
|connection_id| {
session
.peer
.forward_send(session.connection_id, connection_id, request.clone())
},
);
Ok(())
}
async fn get_private_user_info( async fn get_private_user_info(
_request: proto::GetPrivateUserInfo, _request: proto::GetPrivateUserInfo,
response: Response<proto::GetPrivateUserInfo>, response: Response<proto::GetPrivateUserInfo>,

View file

@ -1,8 +1,8 @@
use crate::tests::TestServer; use crate::tests::TestServer;
use call::ActiveCall; use call::ActiveCall;
use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; use editor::Editor;
use gpui::{BackgroundExecutor, TestAppContext};
use rpc::proto; use rpc::proto;
use workspace::Workspace;
#[gpui::test] #[gpui::test]
async fn test_channel_guests( async fn test_channel_guests(
@ -13,37 +13,18 @@ async fn test_channel_guests(
let mut server = TestServer::start(executor.clone()).await; let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await; let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await; let client_b = server.create_client(cx_b, "user_b").await;
let channel_id = server
.make_channel("the-channel", None, (&client_a, cx_a), &mut [])
.await;
client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
channel_store.set_channel_visibility(channel_id, proto::ChannelVisibility::Public, cx)
})
.await
.unwrap();
client_a
.fs()
.insert_tree(
"/a",
serde_json::json!({
"a.txt": "a-contents",
}),
)
.await;
let active_call_a = cx_a.read(ActiveCall::global); let active_call_a = cx_a.read(ActiveCall::global);
let channel_id = server
.make_public_channel("the-channel", &client_a, cx_a)
.await;
// Client A shares a project in the channel // Client A shares a project in the channel
let project_a = client_a.build_test_project(cx_a).await;
active_call_a active_call_a
.update(cx_a, |call, cx| call.join_channel(channel_id, cx)) .update(cx_a, |call, cx| call.join_channel(channel_id, cx))
.await .await
.unwrap(); .unwrap();
let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
let project_id = active_call_a let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await .await
@ -57,30 +38,122 @@ async fn test_channel_guests(
// b should be following a in the shared project. // b should be following a in the shared project.
// B is a guest, // B is a guest,
cx_a.executor().run_until_parked(); executor.run_until_parked();
// todo!() the test window does not call activation handlers let active_call_b = cx_b.read(ActiveCall::global);
// correctly yet, so this API does not work. let project_b =
// let project_b = active_call_b.read_with(cx_b, |call, _| { active_call_b.read_with(cx_b, |call, _| call.location().unwrap().upgrade().unwrap());
// call.location() let room_b = active_call_b.update(cx_b, |call, _| call.room().unwrap().clone());
// .unwrap()
// .upgrade()
// .expect("should not be weak")
// });
let window_b = cx_b.update(|cx| cx.active_window().unwrap());
let cx_b = &mut VisualTestContext::from_window(window_b, cx_b);
let workspace_b = window_b
.downcast::<Workspace>()
.unwrap()
.root_view(cx_b)
.unwrap();
let project_b = workspace_b.update(cx_b, |workspace, _| workspace.project().clone());
assert_eq!( assert_eq!(
project_b.read_with(cx_b, |project, _| project.remote_id()), project_b.read_with(cx_b, |project, _| project.remote_id()),
Some(project_id), Some(project_id),
); );
assert!(project_b.read_with(cx_b, |project, _| project.is_read_only())) assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
assert!(project_b
.update(cx_b, |project, cx| {
let worktree_id = project.worktrees().next().unwrap().read(cx).id();
project.create_entry((worktree_id, "b.txt"), false, cx)
})
.await
.is_err());
assert!(room_b.read_with(cx_b, |room, _| !room.is_sharing_mic()));
}
#[gpui::test]
async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let active_call_a = cx_a.read(ActiveCall::global);
let channel_id = server
.make_public_channel("the-channel", &client_a, cx_a)
.await;
let project_a = client_a.build_test_project(cx_a).await;
cx_a.update(|cx| workspace::join_channel(channel_id, client_a.app_state.clone(), None, cx))
.await
.unwrap();
// Client A shares a project in the channel
active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
cx_a.run_until_parked();
// Client B joins channel A as a guest
cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx))
.await
.unwrap();
cx_a.run_until_parked();
// client B opens 1.txt as a guest
let (workspace_b, cx_b) = client_b.active_workspace(cx_b);
let room_b = cx_b
.read(ActiveCall::global)
.update(cx_b, |call, _| call.room().unwrap().clone());
cx_b.simulate_keystrokes("cmd-p 1 enter");
let (project_b, editor_b) = workspace_b.update(cx_b, |workspace, cx| {
(
workspace.project().clone(),
workspace.active_item_as::<Editor>(cx).unwrap(),
)
});
assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
assert!(editor_b.update(cx_b, |e, cx| e.read_only(cx)));
assert!(dbg!(
room_b
.update(cx_b, |room, cx| room.share_microphone(cx))
.await
)
.is_err());
// B is promoted
active_call_a
.update(cx_a, |call, cx| {
call.room().unwrap().update(cx, |room, cx| {
room.set_participant_role(
client_b.user_id().unwrap(),
proto::ChannelRole::Member,
cx,
)
})
})
.await
.unwrap();
cx_a.run_until_parked();
// project and buffers are now editable
assert!(project_b.read_with(cx_b, |project, _| !project.is_read_only()));
assert!(editor_b.update(cx_b, |editor, cx| !editor.read_only(cx)));
room_b
.update(cx_b, |room, cx| room.share_microphone(cx))
.await
.unwrap();
// B is demoted
active_call_a
.update(cx_a, |call, cx| {
call.room().unwrap().update(cx, |room, cx| {
room.set_participant_role(
client_b.user_id().unwrap(),
proto::ChannelRole::Guest,
cx,
)
})
})
.await
.unwrap();
cx_a.run_until_parked();
// project and buffers are no longer editable
assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
assert!(editor_b.update(cx_b, |editor, cx| editor.read_only(cx)));
assert!(room_b
.update(cx_b, |room, cx| room.share_microphone(cx))
.await
.is_err());
} }

View file

@ -262,7 +262,6 @@ async fn test_remove_channel_message(
#[track_caller] #[track_caller]
fn assert_messages(chat: &Model<ChannelChat>, messages: &[&str], cx: &mut TestAppContext) { fn assert_messages(chat: &Model<ChannelChat>, messages: &[&str], cx: &mut TestAppContext) {
// todo!(don't directly borrow here)
assert_eq!( assert_eq!(
chat.read_with(cx, |chat, _| { chat.read_with(cx, |chat, _| {
chat.messages() chat.messages()

View file

@ -1337,6 +1337,7 @@ async fn test_guest_access(
}) })
.await .await
.unwrap(); .unwrap();
executor.run_until_parked();
assert_channels_list_shape(client_b.channel_store(), cx_b, &[]); assert_channels_list_shape(client_b.channel_store(), cx_b, &[]);

View file

@ -71,6 +71,7 @@ async fn test_host_disconnect(
let workspace_b = let workspace_b =
cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx)); cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx));
let cx_b = &mut VisualTestContext::from_window(*workspace_b, cx_b); let cx_b = &mut VisualTestContext::from_window(*workspace_b, cx_b);
let workspace_b_view = workspace_b.root_view(cx_b).unwrap();
let editor_b = workspace_b let editor_b = workspace_b
.update(cx_b, |workspace, cx| { .update(cx_b, |workspace, cx| {
@ -85,8 +86,10 @@ async fn test_host_disconnect(
//TODO: focus //TODO: focus
assert!(cx_b.update_view(&editor_b, |editor, cx| editor.is_focused(cx))); assert!(cx_b.update_view(&editor_b, |editor, cx| editor.is_focused(cx)));
editor_b.update(cx_b, |editor, cx| editor.insert("X", cx)); editor_b.update(cx_b, |editor, cx| editor.insert("X", cx));
//todo(is_edited)
// assert!(workspace_b.is_edited(cx_b)); cx_b.update(|cx| {
assert!(workspace_b_view.read(cx).is_edited());
});
// Drop client A's connection. Collaborators should disappear and the project should not be shown as shared. // Drop client A's connection. Collaborators should disappear and the project should not be shown as shared.
server.forbid_connections(); server.forbid_connections();
@ -105,11 +108,11 @@ async fn test_host_disconnect(
// Ensure client B's edited state is reset and that the whole window is blurred. // Ensure client B's edited state is reset and that the whole window is blurred.
workspace_b workspace_b
.update(cx_b, |_, cx| { .update(cx_b, |workspace, cx| {
assert_eq!(cx.focused(), None); assert_eq!(cx.focused(), None);
assert!(!workspace.is_edited())
}) })
.unwrap(); .unwrap();
// assert!(!workspace_b.is_edited(cx_b));
// Ensure client B is not prompted to save edits when closing window after disconnecting. // Ensure client B is not prompted to save edits when closing window after disconnecting.
let can_close = workspace_b let can_close = workspace_b

View file

@ -76,6 +76,10 @@ async fn test_basic_following(
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
cx_b.update(|cx| {
assert!(cx.is_window_active());
});
// Client A opens some editors. // Client A opens some editors.
let pane_a = workspace_a.update(cx_a, |workspace, _| workspace.active_pane().clone()); let pane_a = workspace_a.update(cx_a, |workspace, _| workspace.active_pane().clone());
let editor_a1 = workspace_a let editor_a1 = workspace_a
@ -157,7 +161,6 @@ async fn test_basic_following(
.update(cx_c, |call, cx| call.set_location(Some(&project_c), cx)) .update(cx_c, |call, cx| call.set_location(Some(&project_c), cx))
.await .await
.unwrap(); .unwrap();
let weak_project_c = project_c.downgrade();
drop(project_c); drop(project_c);
// Client C also follows client A. // Client C also follows client A.
@ -234,17 +237,16 @@ async fn test_basic_following(
workspace_c.update(cx_c, |workspace, cx| { workspace_c.update(cx_c, |workspace, cx| {
workspace.close_window(&Default::default(), cx); workspace.close_window(&Default::default(), cx);
}); });
cx_c.update(|_| { executor.run_until_parked();
drop(workspace_c);
});
cx_b.executor().run_until_parked();
// are you sure you want to leave the call? // are you sure you want to leave the call?
cx_c.simulate_prompt_answer(0); cx_c.simulate_prompt_answer(0);
cx_b.executor().run_until_parked(); cx_c.cx.update(|_| {
drop(workspace_c);
});
executor.run_until_parked(); executor.run_until_parked();
cx_c.cx.update(|_| {});
weak_workspace_c.assert_dropped(); weak_workspace_c.assert_dropped();
weak_project_c.assert_dropped();
// Clients A and B see that client B is following A, and client C is not present in the followers. // Clients A and B see that client B is following A, and client C is not present in the followers.
executor.run_until_parked(); executor.run_until_parked();
@ -1363,8 +1365,6 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut
let mut server = TestServer::start(executor.clone()).await; let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await; let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await; let client_b = server.create_client(cx_b, "user_b").await;
cx_a.update(editor::init);
cx_b.update(editor::init);
client_a client_a
.fs() .fs()
@ -1400,9 +1400,6 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
cx_a.update(|cx| collab_ui::init(&client_a.app_state, cx));
cx_b.update(|cx| collab_ui::init(&client_b.app_state, cx));
active_call_a active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await .await

View file

@ -3065,6 +3065,7 @@ async fn test_local_settings(
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await .await
.unwrap(); .unwrap();
executor.run_until_parked();
// As client B, join that project and observe the local settings. // As client B, join that project and observe the local settings.
let project_b = client_b.build_remote_project(project_id, cx_b).await; let project_b = client_b.build_remote_project(project_id, cx_b).await;
@ -4936,10 +4937,10 @@ async fn test_project_symbols(
.await .await
.unwrap(); .unwrap();
buffer_b_2.read_with(cx_b, |buffer, _| { buffer_b_2.read_with(cx_b, |buffer, cx| {
assert_eq!( assert_eq!(
buffer.file().unwrap().path().as_ref(), buffer.file().unwrap().full_path(cx),
Path::new("../crate-2/two.rs") Path::new("/code/crate-2/two.rs")
); );
}); });

View file

@ -20,7 +20,11 @@ use node_runtime::FakeNodeRuntime;
use notifications::NotificationStore; use notifications::NotificationStore;
use parking_lot::Mutex; use parking_lot::Mutex;
use project::{Project, WorktreeId}; use project::{Project, WorktreeId};
use rpc::{proto::ChannelRole, RECEIVE_TIMEOUT}; use rpc::{
proto::{self, ChannelRole},
RECEIVE_TIMEOUT,
};
use serde_json::json;
use settings::SettingsStore; use settings::SettingsStore;
use std::{ use std::{
cell::{Ref, RefCell, RefMut}, cell::{Ref, RefCell, RefMut},
@ -228,12 +232,16 @@ impl TestServer {
Project::init(&client, cx); Project::init(&client, cx);
client::init(&client, cx); client::init(&client, cx);
language::init(cx); language::init(cx);
editor::init_settings(cx); editor::init(cx);
workspace::init(app_state.clone(), cx); workspace::init(app_state.clone(), cx);
audio::init((), cx); audio::init((), cx);
call::init(client.clone(), user_store.clone(), cx); call::init(client.clone(), user_store.clone(), cx);
channel::init(&client, user_store.clone(), cx); channel::init(&client, user_store.clone(), cx);
notifications::init(client.clone(), user_store, cx); notifications::init(client.clone(), user_store, cx);
collab_ui::init(&app_state, cx);
file_finder::init(cx);
menu::init();
settings::KeymapFile::load_asset("keymaps/default.json", cx).unwrap();
}); });
client client
@ -351,6 +359,31 @@ impl TestServer {
channel_id channel_id
} }
pub async fn make_public_channel(
&self,
channel: &str,
client: &TestClient,
cx: &mut TestAppContext,
) -> u64 {
let channel_id = self
.make_channel(channel, None, (client, cx), &mut [])
.await;
client
.channel_store()
.update(cx, |channel_store, cx| {
channel_store.set_channel_visibility(
channel_id,
proto::ChannelVisibility::Public,
cx,
)
})
.await
.unwrap();
channel_id
}
pub async fn make_channel_tree( pub async fn make_channel_tree(
&self, &self,
channels: &[(&str, Option<&str>)], channels: &[(&str, Option<&str>)],
@ -580,6 +613,20 @@ impl TestClient {
(project, worktree.read_with(cx, |tree, _| tree.id())) (project, worktree.read_with(cx, |tree, _| tree.id()))
} }
pub async fn build_test_project(&self, cx: &mut TestAppContext) -> Model<Project> {
self.fs()
.insert_tree(
"/a",
json!({
"1.txt": "one\none\none",
"2.txt": "two\ntwo\ntwo",
"3.txt": "three\nthree\nthree",
}),
)
.await;
self.build_local_project("/a", cx).await.0
}
pub fn build_empty_local_project(&self, cx: &mut TestAppContext) -> Model<Project> { pub fn build_empty_local_project(&self, cx: &mut TestAppContext) -> Model<Project> {
cx.update(|cx| { cx.update(|cx| {
Project::local( Project::local(
@ -617,7 +664,22 @@ impl TestClient {
project: &Model<Project>, project: &Model<Project>,
cx: &'a mut TestAppContext, cx: &'a mut TestAppContext,
) -> (View<Workspace>, &'a mut VisualTestContext) { ) -> (View<Workspace>, &'a mut VisualTestContext) {
cx.add_window_view(|cx| Workspace::new(0, project.clone(), self.app_state.clone(), cx)) cx.add_window_view(|cx| {
cx.activate_window();
Workspace::new(0, project.clone(), self.app_state.clone(), cx)
})
}
pub fn active_workspace<'a>(
&'a self,
cx: &'a mut TestAppContext,
) -> (View<Workspace>, &'a mut VisualTestContext) {
let window = cx.update(|cx| cx.active_window().unwrap().downcast::<Workspace>().unwrap());
let view = window.root_view(cx).unwrap();
let cx = Box::new(VisualTestContext::from_window(*window.deref(), cx));
// it might be nice to try and cleanup these at the end of each test.
(view, Box::leak(cx))
} }
} }

View file

@ -9,6 +9,8 @@ path = "src/collab_ui.rs"
doctest = false doctest = false
[features] [features]
default = []
stories = ["dep:story"]
test-support = [ test-support = [
"call/test-support", "call/test-support",
"client/test-support", "client/test-support",
@ -44,6 +46,7 @@ project = { path = "../project" }
recent_projects = { path = "../recent_projects" } recent_projects = { path = "../recent_projects" }
rpc = { path = "../rpc" } rpc = { path = "../rpc" }
settings = { path = "../settings" } settings = { path = "../settings" }
story = { path = "../story", optional = true }
feature_flags = { path = "../feature_flags"} feature_flags = { path = "../feature_flags"}
theme = { path = "../theme" } theme = { path = "../theme" }
theme_selector = { path = "../theme_selector" } theme_selector = { path = "../theme_selector" }

View file

@ -19,9 +19,8 @@ use rich_text::RichText;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore}; use settings::{Settings, SettingsStore};
use std::sync::Arc; use std::sync::Arc;
use theme::ActiveTheme as _;
use time::{OffsetDateTime, UtcOffset}; use time::{OffsetDateTime, UtcOffset};
use ui::{prelude::*, Avatar, Button, Icon, IconButton, Label, TabBar, Tooltip}; use ui::{prelude::*, Avatar, Button, IconButton, IconName, Label, TabBar, Tooltip};
use util::{ResultExt, TryFutureExt}; use util::{ResultExt, TryFutureExt};
use workspace::{ use workspace::{
dock::{DockPosition, Panel, PanelEvent}, dock::{DockPosition, Panel, PanelEvent},
@ -48,7 +47,7 @@ pub struct ChatPanel {
languages: Arc<LanguageRegistry>, languages: Arc<LanguageRegistry>,
message_list: ListState, message_list: ListState,
active_chat: Option<(Model<ChannelChat>, Subscription)>, active_chat: Option<(Model<ChannelChat>, Subscription)>,
input_editor: View<MessageEditor>, message_editor: View<MessageEditor>,
local_timezone: UtcOffset, local_timezone: UtcOffset,
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
width: Option<Pixels>, width: Option<Pixels>,
@ -120,7 +119,7 @@ impl ChatPanel {
message_list, message_list,
active_chat: Default::default(), active_chat: Default::default(),
pending_serialization: Task::ready(None), pending_serialization: Task::ready(None),
input_editor, message_editor: input_editor,
local_timezone: cx.local_timezone(), local_timezone: cx.local_timezone(),
subscriptions: Vec::new(), subscriptions: Vec::new(),
workspace: workspace_handle, workspace: workspace_handle,
@ -209,7 +208,7 @@ impl ChatPanel {
self.message_list.reset(chat.message_count()); self.message_list.reset(chat.message_count());
let channel_name = chat.channel(cx).map(|channel| channel.name.clone()); let channel_name = chat.channel(cx).map(|channel| channel.name.clone());
self.input_editor.update(cx, |editor, cx| { self.message_editor.update(cx, |editor, cx| {
editor.set_channel(channel_id, channel_name, cx); editor.set_channel(channel_id, channel_name, cx);
}); });
}; };
@ -282,12 +281,12 @@ impl ChatPanel {
)), )),
) )
.end_child( .end_child(
IconButton::new("notes", Icon::File) IconButton::new("notes", IconName::File)
.on_click(cx.listener(Self::open_notes)) .on_click(cx.listener(Self::open_notes))
.tooltip(|cx| Tooltip::text("Open notes", cx)), .tooltip(|cx| Tooltip::text("Open notes", cx)),
) )
.end_child( .end_child(
IconButton::new("call", Icon::AudioOn) IconButton::new("call", IconName::AudioOn)
.on_click(cx.listener(Self::join_call)) .on_click(cx.listener(Self::join_call))
.tooltip(|cx| Tooltip::text("Join call", cx)), .tooltip(|cx| Tooltip::text("Join call", cx)),
), ),
@ -300,13 +299,7 @@ impl ChatPanel {
this this
} }
})) }))
.child( .child(h_stack().p_2().child(self.message_editor.clone()))
div()
.z_index(1)
.p_2()
.bg(cx.theme().colors().background)
.child(self.input_editor.clone()),
)
.into_any() .into_any()
} }
@ -402,7 +395,7 @@ impl ChatPanel {
.w_8() .w_8()
.visible_on_hover("") .visible_on_hover("")
.children(message_id_to_remove.map(|message_id| { .children(message_id_to_remove.map(|message_id| {
IconButton::new(("remove", message_id), Icon::XCircle).on_click( IconButton::new(("remove", message_id), IconName::XCircle).on_click(
cx.listener(move |this, _, cx| { cx.listener(move |this, _, cx| {
this.remove_message(message_id, cx); this.remove_message(message_id, cx);
}), }),
@ -436,7 +429,7 @@ impl ChatPanel {
Button::new("sign-in", "Sign in") Button::new("sign-in", "Sign in")
.style(ButtonStyle::Filled) .style(ButtonStyle::Filled)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.icon(Icon::Github) .icon(IconName::Github)
.icon_position(IconPosition::Start) .icon_position(IconPosition::Start)
.full_width() .full_width()
.on_click(cx.listener(move |this, _, cx| { .on_click(cx.listener(move |this, _, cx| {
@ -469,7 +462,7 @@ impl ChatPanel {
fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) { fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
if let Some((chat, _)) = self.active_chat.as_ref() { if let Some((chat, _)) = self.active_chat.as_ref() {
let message = self let message = self
.input_editor .message_editor
.update(cx, |editor, cx| editor.take_message(cx)); .update(cx, |editor, cx| editor.take_message(cx));
if let Some(task) = chat if let Some(task) = chat
@ -585,7 +578,7 @@ impl Render for ChatPanel {
impl FocusableView for ChatPanel { impl FocusableView for ChatPanel {
fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle { fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
self.input_editor.read(cx).focus_handle(cx) self.message_editor.read(cx).focus_handle(cx)
} }
} }
@ -629,12 +622,12 @@ impl Panel for ChatPanel {
"ChatPanel" "ChatPanel"
} }
fn icon(&self, cx: &WindowContext) -> Option<ui::Icon> { fn icon(&self, cx: &WindowContext) -> Option<ui::IconName> {
if !is_channels_feature_enabled(cx) { if !is_channels_feature_enabled(cx) {
return None; return None;
} }
Some(ui::Icon::MessageBubbles).filter(|_| ChatPanelSettings::get_global(cx).button) Some(ui::IconName::MessageBubbles).filter(|_| ChatPanelSettings::get_global(cx).button)
} }
fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {

View file

@ -1,16 +1,19 @@
use std::{sync::Arc, time::Duration};
use channel::{ChannelId, ChannelMembership, ChannelStore, MessageParams}; use channel::{ChannelId, ChannelMembership, ChannelStore, MessageParams};
use client::UserId; use client::UserId;
use collections::HashMap; use collections::HashMap;
use editor::{AnchorRangeExt, Editor}; use editor::{AnchorRangeExt, Editor, EditorElement, EditorStyle};
use gpui::{ use gpui::{
AsyncWindowContext, FocusableView, IntoElement, Model, Render, SharedString, Task, View, AsyncWindowContext, FocusableView, FontStyle, FontWeight, HighlightStyle, IntoElement, Model,
ViewContext, WeakView, Render, SharedString, Task, TextStyle, View, ViewContext, WeakView, WhiteSpace,
}; };
use language::{language_settings::SoftWrap, Buffer, BufferSnapshot, LanguageRegistry}; use language::{language_settings::SoftWrap, Buffer, BufferSnapshot, LanguageRegistry};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use project::search::SearchQuery; use project::search::SearchQuery;
use std::{sync::Arc, time::Duration}; use settings::Settings;
use workspace::item::ItemHandle; use theme::ThemeSettings;
use ui::prelude::*;
const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50); const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
@ -181,7 +184,14 @@ impl MessageEditor {
} }
editor.clear_highlights::<Self>(cx); editor.clear_highlights::<Self>(cx);
editor.highlight_text::<Self>(anchor_ranges, gpui::red().into(), cx) editor.highlight_text::<Self>(
anchor_ranges,
HighlightStyle {
font_weight: Some(FontWeight::BOLD),
..Default::default()
},
cx,
)
}); });
this.mentions = mentioned_user_ids; this.mentions = mentioned_user_ids;
@ -196,8 +206,39 @@ impl MessageEditor {
} }
impl Render for MessageEditor { impl Render for MessageEditor {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
self.editor.to_any() let settings = ThemeSettings::get_global(cx);
let text_style = TextStyle {
color: if self.editor.read(cx).read_only(cx) {
cx.theme().colors().text_disabled
} else {
cx.theme().colors().text
},
font_family: settings.ui_font.family.clone(),
font_features: settings.ui_font.features,
font_size: rems(0.875).into(),
font_weight: FontWeight::NORMAL,
font_style: FontStyle::Normal,
line_height: relative(1.3).into(),
background_color: None,
underline: None,
white_space: WhiteSpace::Normal,
};
div()
.w_full()
.px_2()
.py_1()
.bg(cx.theme().colors().editor_background)
.rounded_md()
.child(EditorElement::new(
&self.editor,
EditorStyle {
local_player: cx.theme().players().local(),
text: text_style,
..Default::default()
},
))
} }
} }
@ -205,7 +246,7 @@ impl Render for MessageEditor {
mod tests { mod tests {
use super::*; use super::*;
use client::{Client, User, UserStore}; use client::{Client, User, UserStore};
use gpui::{Context as _, TestAppContext, VisualContext as _}; use gpui::TestAppContext;
use language::{Language, LanguageConfig}; use language::{Language, LanguageConfig};
use rpc::proto; use rpc::proto;
use settings::SettingsStore; use settings::SettingsStore;

View file

@ -31,13 +31,13 @@ use smallvec::SmallVec;
use std::{mem, sync::Arc}; use std::{mem, sync::Arc};
use theme::{ActiveTheme, ThemeSettings}; use theme::{ActiveTheme, ThemeSettings};
use ui::{ use ui::{
prelude::*, Avatar, Button, Color, ContextMenu, Icon, IconButton, IconElement, IconSize, Label, prelude::*, Avatar, Button, Color, ContextMenu, Icon, IconButton, IconName, IconSize, Label,
ListHeader, ListItem, Tooltip, ListHeader, ListItem, Tooltip,
}; };
use util::{maybe, ResultExt, TryFutureExt}; use util::{maybe, ResultExt, TryFutureExt};
use workspace::{ use workspace::{
dock::{DockPosition, Panel, PanelEvent}, dock::{DockPosition, Panel, PanelEvent},
notifications::NotifyResultExt, notifications::{NotifyResultExt, NotifyTaskExt},
Workspace, Workspace,
}; };
@ -140,6 +140,7 @@ enum ListEntry {
user: Arc<User>, user: Arc<User>,
peer_id: Option<PeerId>, peer_id: Option<PeerId>,
is_pending: bool, is_pending: bool,
role: proto::ChannelRole,
}, },
ParticipantProject { ParticipantProject {
project_id: u64, project_id: u64,
@ -151,10 +152,6 @@ enum ListEntry {
peer_id: Option<PeerId>, peer_id: Option<PeerId>,
is_last: bool, is_last: bool,
}, },
GuestCount {
count: usize,
has_visible_participants: bool,
},
IncomingRequest(Arc<User>), IncomingRequest(Arc<User>),
OutgoingRequest(Arc<User>), OutgoingRequest(Arc<User>),
ChannelInvite(Arc<Channel>), ChannelInvite(Arc<Channel>),
@ -384,14 +381,10 @@ impl CollabPanel {
if !self.collapsed_sections.contains(&Section::ActiveCall) { if !self.collapsed_sections.contains(&Section::ActiveCall) {
let room = room.read(cx); let room = room.read(cx);
let mut guest_count_ix = 0;
let mut guest_count = if room.read_only() { 1 } else { 0 };
let mut non_guest_count = if room.read_only() { 0 } else { 1 };
if let Some(channel_id) = room.channel_id() { if let Some(channel_id) = room.channel_id() {
self.entries.push(ListEntry::ChannelNotes { channel_id }); self.entries.push(ListEntry::ChannelNotes { channel_id });
self.entries.push(ListEntry::ChannelChat { channel_id }); self.entries.push(ListEntry::ChannelChat { channel_id });
guest_count_ix = self.entries.len();
} }
// Populate the active user. // Populate the active user.
@ -410,12 +403,13 @@ impl CollabPanel {
&Default::default(), &Default::default(),
executor.clone(), executor.clone(),
)); ));
if !matches.is_empty() && !room.read_only() { if !matches.is_empty() {
let user_id = user.id; let user_id = user.id;
self.entries.push(ListEntry::CallParticipant { self.entries.push(ListEntry::CallParticipant {
user, user,
peer_id: None, peer_id: None,
is_pending: false, is_pending: false,
role: room.local_participant().role,
}); });
let mut projects = room.local_participant().projects.iter().peekable(); let mut projects = room.local_participant().projects.iter().peekable();
while let Some(project) = projects.next() { while let Some(project) = projects.next() {
@ -442,12 +436,6 @@ impl CollabPanel {
room.remote_participants() room.remote_participants()
.iter() .iter()
.filter_map(|(_, participant)| { .filter_map(|(_, participant)| {
if participant.role == proto::ChannelRole::Guest {
guest_count += 1;
return None;
} else {
non_guest_count += 1;
}
Some(StringMatchCandidate { Some(StringMatchCandidate {
id: participant.user.id as usize, id: participant.user.id as usize,
string: participant.user.github_login.clone(), string: participant.user.github_login.clone(),
@ -455,7 +443,7 @@ impl CollabPanel {
}) })
}), }),
); );
let matches = executor.block(match_strings( let mut matches = executor.block(match_strings(
&self.match_candidates, &self.match_candidates,
&query, &query,
true, true,
@ -463,6 +451,15 @@ impl CollabPanel {
&Default::default(), &Default::default(),
executor.clone(), executor.clone(),
)); ));
matches.sort_by(|a, b| {
let a_is_guest = room.role_for_user(a.candidate_id as u64)
== Some(proto::ChannelRole::Guest);
let b_is_guest = room.role_for_user(b.candidate_id as u64)
== Some(proto::ChannelRole::Guest);
a_is_guest
.cmp(&b_is_guest)
.then_with(|| a.string.cmp(&b.string))
});
for mat in matches { for mat in matches {
let user_id = mat.candidate_id as u64; let user_id = mat.candidate_id as u64;
let participant = &room.remote_participants()[&user_id]; let participant = &room.remote_participants()[&user_id];
@ -470,6 +467,7 @@ impl CollabPanel {
user: participant.user.clone(), user: participant.user.clone(),
peer_id: Some(participant.peer_id), peer_id: Some(participant.peer_id),
is_pending: false, is_pending: false,
role: participant.role,
}); });
let mut projects = participant.projects.iter().peekable(); let mut projects = participant.projects.iter().peekable();
while let Some(project) = projects.next() { while let Some(project) = projects.next() {
@ -488,15 +486,6 @@ impl CollabPanel {
}); });
} }
} }
if guest_count > 0 {
self.entries.insert(
guest_count_ix,
ListEntry::GuestCount {
count: guest_count,
has_visible_participants: non_guest_count > 0,
},
);
}
// Populate pending participants. // Populate pending participants.
self.match_candidates.clear(); self.match_candidates.clear();
@ -521,6 +510,7 @@ impl CollabPanel {
user: room.pending_participants()[mat.candidate_id].clone(), user: room.pending_participants()[mat.candidate_id].clone(),
peer_id: None, peer_id: None,
is_pending: true, is_pending: true,
role: proto::ChannelRole::Member,
})); }));
} }
} }
@ -834,13 +824,19 @@ impl CollabPanel {
user: &Arc<User>, user: &Arc<User>,
peer_id: Option<PeerId>, peer_id: Option<PeerId>,
is_pending: bool, is_pending: bool,
role: proto::ChannelRole,
is_selected: bool, is_selected: bool,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) -> ListItem { ) -> ListItem {
let user_id = user.id;
let is_current_user = let is_current_user =
self.user_store.read(cx).current_user().map(|user| user.id) == Some(user.id); self.user_store.read(cx).current_user().map(|user| user.id) == Some(user_id);
let tooltip = format!("Follow {}", user.github_login); let tooltip = format!("Follow {}", user.github_login);
let is_call_admin = ActiveCall::global(cx).read(cx).room().is_some_and(|room| {
room.read(cx).local_participant().role == proto::ChannelRole::Admin
});
ListItem::new(SharedString::from(user.github_login.clone())) ListItem::new(SharedString::from(user.github_login.clone()))
.start_slot(Avatar::new(user.avatar_uri.clone())) .start_slot(Avatar::new(user.avatar_uri.clone()))
.child(Label::new(user.github_login.clone())) .child(Label::new(user.github_login.clone()))
@ -848,22 +844,32 @@ impl CollabPanel {
.end_slot(if is_pending { .end_slot(if is_pending {
Label::new("Calling").color(Color::Muted).into_any_element() Label::new("Calling").color(Color::Muted).into_any_element()
} else if is_current_user { } else if is_current_user {
IconButton::new("leave-call", Icon::Exit) IconButton::new("leave-call", IconName::Exit)
.style(ButtonStyle::Subtle) .style(ButtonStyle::Subtle)
.on_click(move |_, cx| Self::leave_call(cx)) .on_click(move |_, cx| Self::leave_call(cx))
.tooltip(|cx| Tooltip::text("Leave Call", cx)) .tooltip(|cx| Tooltip::text("Leave Call", cx))
.into_any_element() .into_any_element()
} else if role == proto::ChannelRole::Guest {
Label::new("Guest").color(Color::Muted).into_any_element()
} else { } else {
div().into_any_element() div().into_any_element()
}) })
.when_some(peer_id, |this, peer_id| { .when_some(peer_id, |el, peer_id| {
this.tooltip(move |cx| Tooltip::text(tooltip.clone(), cx)) if role == proto::ChannelRole::Guest {
return el;
}
el.tooltip(move |cx| Tooltip::text(tooltip.clone(), cx))
.on_click(cx.listener(move |this, _, cx| { .on_click(cx.listener(move |this, _, cx| {
this.workspace this.workspace
.update(cx, |workspace, cx| workspace.follow(peer_id, cx)) .update(cx, |workspace, cx| workspace.follow(peer_id, cx))
.ok(); .ok();
})) }))
}) })
.when(is_call_admin, |el| {
el.on_secondary_mouse_down(cx.listener(move |this, event: &MouseDownEvent, cx| {
this.deploy_participant_context_menu(event.position, user_id, role, cx)
}))
})
} }
fn render_participant_project( fn render_participant_project(
@ -897,7 +903,7 @@ impl CollabPanel {
h_stack() h_stack()
.gap_1() .gap_1()
.child(render_tree_branch(is_last, false, cx)) .child(render_tree_branch(is_last, false, cx))
.child(IconButton::new(0, Icon::Folder)), .child(IconButton::new(0, IconName::Folder)),
) )
.child(Label::new(project_name.clone())) .child(Label::new(project_name.clone()))
.tooltip(move |cx| Tooltip::text(format!("Open {}", project_name), cx)) .tooltip(move |cx| Tooltip::text(format!("Open {}", project_name), cx))
@ -918,7 +924,7 @@ impl CollabPanel {
h_stack() h_stack()
.gap_1() .gap_1()
.child(render_tree_branch(is_last, false, cx)) .child(render_tree_branch(is_last, false, cx))
.child(IconButton::new(0, Icon::Screen)), .child(IconButton::new(0, IconName::Screen)),
) )
.child(Label::new("Screen")) .child(Label::new("Screen"))
.when_some(peer_id, |this, _| { .when_some(peer_id, |this, _| {
@ -959,7 +965,7 @@ impl CollabPanel {
h_stack() h_stack()
.gap_1() .gap_1()
.child(render_tree_branch(false, true, cx)) .child(render_tree_branch(false, true, cx))
.child(IconButton::new(0, Icon::File)), .child(IconButton::new(0, IconName::File)),
) )
.child(div().h_7().w_full().child(Label::new("notes"))) .child(div().h_7().w_full().child(Label::new("notes")))
.tooltip(move |cx| Tooltip::text("Open Channel Notes", cx)) .tooltip(move |cx| Tooltip::text("Open Channel Notes", cx))
@ -980,47 +986,12 @@ impl CollabPanel {
h_stack() h_stack()
.gap_1() .gap_1()
.child(render_tree_branch(false, false, cx)) .child(render_tree_branch(false, false, cx))
.child(IconButton::new(0, Icon::MessageBubbles)), .child(IconButton::new(0, IconName::MessageBubbles)),
) )
.child(Label::new("chat")) .child(Label::new("chat"))
.tooltip(move |cx| Tooltip::text("Open Chat", cx)) .tooltip(move |cx| Tooltip::text("Open Chat", cx))
} }
fn render_guest_count(
&self,
count: usize,
has_visible_participants: bool,
is_selected: bool,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
let manageable_channel_id = ActiveCall::global(cx).read(cx).room().and_then(|room| {
let room = room.read(cx);
if room.local_participant_is_admin() {
room.channel_id()
} else {
None
}
});
ListItem::new("guest_count")
.selected(is_selected)
.start_slot(
h_stack()
.gap_1()
.child(render_tree_branch(!has_visible_participants, false, cx))
.child(""),
)
.child(Label::new(if count == 1 {
format!("{} guest", count)
} else {
format!("{} guests", count)
}))
.when_some(manageable_channel_id, |el, channel_id| {
el.tooltip(move |cx| Tooltip::text("Manage Members", cx))
.on_click(cx.listener(move |this, _, cx| this.manage_members(channel_id, cx)))
})
}
fn has_subchannels(&self, ix: usize) -> bool { fn has_subchannels(&self, ix: usize) -> bool {
self.entries.get(ix).map_or(false, |entry| { self.entries.get(ix).map_or(false, |entry| {
if let ListEntry::Channel { has_children, .. } = entry { if let ListEntry::Channel { has_children, .. } = entry {
@ -1031,6 +1002,80 @@ impl CollabPanel {
}) })
} }
fn deploy_participant_context_menu(
&mut self,
position: Point<Pixels>,
user_id: u64,
role: proto::ChannelRole,
cx: &mut ViewContext<Self>,
) {
let this = cx.view().clone();
if !(role == proto::ChannelRole::Guest || role == proto::ChannelRole::Member) {
return;
}
let context_menu = ContextMenu::build(cx, |context_menu, cx| {
if role == proto::ChannelRole::Guest {
context_menu.entry(
"Grant Write Access",
None,
cx.handler_for(&this, move |_, cx| {
ActiveCall::global(cx)
.update(cx, |call, cx| {
let Some(room) = call.room() else {
return Task::ready(Ok(()));
};
room.update(cx, |room, cx| {
room.set_participant_role(
user_id,
proto::ChannelRole::Member,
cx,
)
})
})
.detach_and_notify_err(cx)
}),
)
} else if role == proto::ChannelRole::Member {
context_menu.entry(
"Revoke Write Access",
None,
cx.handler_for(&this, move |_, cx| {
ActiveCall::global(cx)
.update(cx, |call, cx| {
let Some(room) = call.room() else {
return Task::ready(Ok(()));
};
room.update(cx, |room, cx| {
room.set_participant_role(
user_id,
proto::ChannelRole::Guest,
cx,
)
})
})
.detach_and_notify_err(cx)
}),
)
} else {
unreachable!()
}
});
cx.focus_view(&context_menu);
let subscription =
cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
if this.context_menu.as_ref().is_some_and(|context_menu| {
context_menu.0.focus_handle(cx).contains_focused(cx)
}) {
cx.focus_self();
}
this.context_menu.take();
cx.notify();
});
self.context_menu = Some((context_menu, position, subscription));
}
fn deploy_channel_context_menu( fn deploy_channel_context_menu(
&mut self, &mut self,
position: Point<Pixels>, position: Point<Pixels>,
@ -1242,18 +1287,6 @@ impl CollabPanel {
}); });
} }
} }
ListEntry::GuestCount { .. } => {
let Some(room) = ActiveCall::global(cx).read(cx).room() else {
return;
};
let room = room.read(cx);
let Some(channel_id) = room.channel_id() else {
return;
};
if room.local_participant_is_admin() {
self.manage_members(channel_id, cx)
}
}
ListEntry::Channel { channel, .. } => { ListEntry::Channel { channel, .. } => {
let is_active = maybe!({ let is_active = maybe!({
let call_channel = ActiveCall::global(cx) let call_channel = ActiveCall::global(cx)
@ -1724,7 +1757,7 @@ impl CollabPanel {
.child( .child(
Button::new("sign_in", "Sign in") Button::new("sign_in", "Sign in")
.icon_color(Color::Muted) .icon_color(Color::Muted)
.icon(Icon::Github) .icon(IconName::Github)
.icon_position(IconPosition::Start) .icon_position(IconPosition::Start)
.style(ButtonStyle::Filled) .style(ButtonStyle::Filled)
.full_width() .full_width()
@ -1788,8 +1821,9 @@ impl CollabPanel {
user, user,
peer_id, peer_id,
is_pending, is_pending,
role,
} => self } => self
.render_call_participant(user, *peer_id, *is_pending, is_selected, cx) .render_call_participant(user, *peer_id, *is_pending, *role, is_selected, cx)
.into_any_element(), .into_any_element(),
ListEntry::ParticipantProject { ListEntry::ParticipantProject {
project_id, project_id,
@ -1809,12 +1843,6 @@ impl CollabPanel {
ListEntry::ParticipantScreen { peer_id, is_last } => self ListEntry::ParticipantScreen { peer_id, is_last } => self
.render_participant_screen(*peer_id, *is_last, is_selected, cx) .render_participant_screen(*peer_id, *is_last, is_selected, cx)
.into_any_element(), .into_any_element(),
ListEntry::GuestCount {
count,
has_visible_participants,
} => self
.render_guest_count(*count, *has_visible_participants, is_selected, cx)
.into_any_element(),
ListEntry::ChannelNotes { channel_id } => self ListEntry::ChannelNotes { channel_id } => self
.render_channel_notes(*channel_id, is_selected, cx) .render_channel_notes(*channel_id, is_selected, cx)
.into_any_element(), .into_any_element(),
@ -1921,7 +1949,7 @@ impl CollabPanel {
let button = match section { let button = match section {
Section::ActiveCall => channel_link.map(|channel_link| { Section::ActiveCall => channel_link.map(|channel_link| {
let channel_link_copy = channel_link.clone(); let channel_link_copy = channel_link.clone();
IconButton::new("channel-link", Icon::Copy) IconButton::new("channel-link", IconName::Copy)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.size(ButtonSize::None) .size(ButtonSize::None)
.visible_on_hover("section-header") .visible_on_hover("section-header")
@ -1933,13 +1961,13 @@ impl CollabPanel {
.into_any_element() .into_any_element()
}), }),
Section::Contacts => Some( Section::Contacts => Some(
IconButton::new("add-contact", Icon::Plus) IconButton::new("add-contact", IconName::Plus)
.on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx))) .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
.tooltip(|cx| Tooltip::text("Search for new contact", cx)) .tooltip(|cx| Tooltip::text("Search for new contact", cx))
.into_any_element(), .into_any_element(),
), ),
Section::Channels => Some( Section::Channels => Some(
IconButton::new("add-channel", Icon::Plus) IconButton::new("add-channel", IconName::Plus)
.on_click(cx.listener(|this, _, cx| this.new_root_channel(cx))) .on_click(cx.listener(|this, _, cx| this.new_root_channel(cx)))
.tooltip(|cx| Tooltip::text("Create a channel", cx)) .tooltip(|cx| Tooltip::text("Create a channel", cx))
.into_any_element(), .into_any_element(),
@ -2010,7 +2038,7 @@ impl CollabPanel {
}) })
.when(!calling, |el| { .when(!calling, |el| {
el.child( el.child(
IconButton::new("remove_contact", Icon::Close) IconButton::new("remove_contact", IconName::Close)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.visible_on_hover("") .visible_on_hover("")
.tooltip(|cx| Tooltip::text("Remove Contact", cx)) .tooltip(|cx| Tooltip::text("Remove Contact", cx))
@ -2071,13 +2099,13 @@ impl CollabPanel {
let controls = if is_incoming { let controls = if is_incoming {
vec![ vec![
IconButton::new("decline-contact", Icon::Close) IconButton::new("decline-contact", IconName::Close)
.on_click(cx.listener(move |this, _, cx| { .on_click(cx.listener(move |this, _, cx| {
this.respond_to_contact_request(user_id, false, cx); this.respond_to_contact_request(user_id, false, cx);
})) }))
.icon_color(color) .icon_color(color)
.tooltip(|cx| Tooltip::text("Decline invite", cx)), .tooltip(|cx| Tooltip::text("Decline invite", cx)),
IconButton::new("accept-contact", Icon::Check) IconButton::new("accept-contact", IconName::Check)
.on_click(cx.listener(move |this, _, cx| { .on_click(cx.listener(move |this, _, cx| {
this.respond_to_contact_request(user_id, true, cx); this.respond_to_contact_request(user_id, true, cx);
})) }))
@ -2086,7 +2114,7 @@ impl CollabPanel {
] ]
} else { } else {
let github_login = github_login.clone(); let github_login = github_login.clone();
vec![IconButton::new("remove_contact", Icon::Close) vec![IconButton::new("remove_contact", IconName::Close)
.on_click(cx.listener(move |this, _, cx| { .on_click(cx.listener(move |this, _, cx| {
this.remove_contact(user_id, &github_login, cx); this.remove_contact(user_id, &github_login, cx);
})) }))
@ -2126,13 +2154,13 @@ impl CollabPanel {
}; };
let controls = [ let controls = [
IconButton::new("reject-invite", Icon::Close) IconButton::new("reject-invite", IconName::Close)
.on_click(cx.listener(move |this, _, cx| { .on_click(cx.listener(move |this, _, cx| {
this.respond_to_channel_invite(channel_id, false, cx); this.respond_to_channel_invite(channel_id, false, cx);
})) }))
.icon_color(color) .icon_color(color)
.tooltip(|cx| Tooltip::text("Decline invite", cx)), .tooltip(|cx| Tooltip::text("Decline invite", cx)),
IconButton::new("accept-invite", Icon::Check) IconButton::new("accept-invite", IconName::Check)
.on_click(cx.listener(move |this, _, cx| { .on_click(cx.listener(move |this, _, cx| {
this.respond_to_channel_invite(channel_id, true, cx); this.respond_to_channel_invite(channel_id, true, cx);
})) }))
@ -2150,7 +2178,7 @@ impl CollabPanel {
.child(h_stack().children(controls)), .child(h_stack().children(controls)),
) )
.start_slot( .start_slot(
IconElement::new(Icon::Hash) Icon::new(IconName::Hash)
.size(IconSize::Small) .size(IconSize::Small)
.color(Color::Muted), .color(Color::Muted),
) )
@ -2162,7 +2190,7 @@ impl CollabPanel {
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) -> ListItem { ) -> ListItem {
ListItem::new("contact-placeholder") ListItem::new("contact-placeholder")
.child(IconElement::new(Icon::Plus)) .child(Icon::new(IconName::Plus))
.child(Label::new("Add a Contact")) .child(Label::new("Add a Contact"))
.selected(is_selected) .selected(is_selected)
.on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx))) .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx)))
@ -2228,47 +2256,6 @@ impl CollabPanel {
None None
}; };
let button_container = |cx: &mut ViewContext<Self>| {
h_stack()
.absolute()
// We're using a negative coordinate for the right anchor to
// counteract the padding of the `ListItem`.
//
// This prevents a gap from showing up between the background
// of this element and the edge of the collab panel.
.right(rems(-0.5))
// HACK: Without this the channel name clips on top of the icons, but I'm not sure why.
.z_index(10)
.bg(cx.theme().colors().panel_background)
.when(is_selected || is_active, |this| {
this.bg(cx.theme().colors().ghost_element_selected)
})
};
let messages_button = |cx: &mut ViewContext<Self>| {
IconButton::new("channel_chat", Icon::MessageBubbles)
.icon_size(IconSize::Small)
.icon_color(if has_messages_notification {
Color::Default
} else {
Color::Muted
})
.on_click(cx.listener(move |this, _, cx| this.join_channel_chat(channel_id, cx)))
.tooltip(|cx| Tooltip::text("Open channel chat", cx))
};
let notes_button = |cx: &mut ViewContext<Self>| {
IconButton::new("channel_notes", Icon::File)
.icon_size(IconSize::Small)
.icon_color(if has_notes_notification {
Color::Default
} else {
Color::Muted
})
.on_click(cx.listener(move |this, _, cx| this.open_channel_notes(channel_id, cx)))
.tooltip(|cx| Tooltip::text("Open channel notes", cx))
};
let width = self.width.unwrap_or(px(240.)); let width = self.width.unwrap_or(px(240.));
div() div()
@ -2315,65 +2302,69 @@ impl CollabPanel {
}, },
)) ))
.start_slot( .start_slot(
IconElement::new(if is_public { Icon::Public } else { Icon::Hash }) Icon::new(if is_public {
.size(IconSize::Small) IconName::Public
.color(Color::Muted), } else {
IconName::Hash
})
.size(IconSize::Small)
.color(Color::Muted),
) )
.child( .child(
h_stack() h_stack()
.id(channel_id as usize) .id(channel_id as usize)
// HACK: This is a dirty hack to help with the positioning of the button container.
//
// We're using a pixel width for the elements but then allowing the contents to
// overflow. This means that the label and facepile will be shown, but will not
// push the button container off the edge of the panel.
.w_px()
.child(Label::new(channel.name.clone())) .child(Label::new(channel.name.clone()))
.children(face_pile.map(|face_pile| face_pile.render(cx))), .children(face_pile.map(|face_pile| face_pile.render(cx))),
) ),
.end_slot::<Div>( )
// If we have a notification for either button, we want to show the corresponding .child(
// button(s) as indicators. h_stack()
if has_messages_notification || has_notes_notification { .absolute()
Some( .right(rems(0.))
button_container(cx).child( .h_full()
h_stack() // HACK: Without this the channel name clips on top of the icons, but I'm not sure why.
.px_1() .z_index(10)
.children( .child(
// We only want to render the messages button if there are unseen messages. h_stack()
// This way we don't take up any space that might overlap the channel name .h_full()
// when there are no notifications. .gap_1()
has_messages_notification.then(|| messages_button(cx)), .px_1()
) .child(
.child( IconButton::new("channel_chat", IconName::MessageBubbles)
// We always want the notes button to take up space to prevent layout .style(ButtonStyle::Filled)
// shift when hovering over the channel. .size(ButtonSize::Compact)
// However, if there are is no notes notification we just show an empty slot. .icon_size(IconSize::Small)
notes_button(cx) .icon_color(if has_messages_notification {
.when(!has_notes_notification, |this| { Color::Default
this.visible_on_hover("") } else {
}), Color::Muted
), })
), .on_click(cx.listener(move |this, _, cx| {
this.join_channel_chat(channel_id, cx)
}))
.tooltip(|cx| Tooltip::text("Open channel chat", cx))
.when(!has_messages_notification, |this| {
this.visible_on_hover("")
}),
) )
} else { .child(
None IconButton::new("channel_notes", IconName::File)
}, .style(ButtonStyle::Filled)
) .size(ButtonSize::Compact)
.end_hover_slot( .icon_size(IconSize::Small)
// When we hover the channel entry we want to always show both buttons. .icon_color(if has_notes_notification {
button_container(cx).child( Color::Default
h_stack() } else {
.px_1() Color::Muted
// The element hover background has a slight transparency to it, so we })
// need to apply it to the inner element so that it blends with the solid .on_click(cx.listener(move |this, _, cx| {
// background color of the absolutely-positioned element. this.open_channel_notes(channel_id, cx)
.group_hover("", |style| { }))
style.bg(cx.theme().colors().ghost_element_hover) .tooltip(|cx| Tooltip::text("Open channel notes", cx))
}) .when(!has_notes_notification, |this| {
.child(messages_button(cx)) this.visible_on_hover("")
.child(notes_button(cx)), }),
), ),
), ),
) )
.tooltip(|cx| Tooltip::text("Join channel", cx)) .tooltip(|cx| Tooltip::text("Join channel", cx))
@ -2386,7 +2377,7 @@ impl CollabPanel {
.indent_level(depth + 1) .indent_level(depth + 1)
.indent_step_size(px(20.)) .indent_step_size(px(20.))
.start_slot( .start_slot(
IconElement::new(Icon::Hash) Icon::new(IconName::Hash)
.size(IconSize::Small) .size(IconSize::Small)
.color(Color::Muted), .color(Color::Muted),
); );
@ -2500,10 +2491,10 @@ impl Panel for CollabPanel {
cx.notify(); cx.notify();
} }
fn icon(&self, cx: &gpui::WindowContext) -> Option<ui::Icon> { fn icon(&self, cx: &gpui::WindowContext) -> Option<ui::IconName> {
CollaborationPanelSettings::get_global(cx) CollaborationPanelSettings::get_global(cx)
.button .button
.then(|| ui::Icon::Collab) .then(|| ui::IconName::Collab)
} }
fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
@ -2621,11 +2612,6 @@ impl PartialEq for ListEntry {
return true; return true;
} }
} }
ListEntry::GuestCount { .. } => {
if let ListEntry::GuestCount { .. } = other {
return true;
}
}
} }
false false
} }
@ -2646,11 +2632,11 @@ impl Render for DraggedChannelView {
.p_1() .p_1()
.gap_1() .gap_1()
.child( .child(
IconElement::new( Icon::new(
if self.channel.visibility == proto::ChannelVisibility::Public { if self.channel.visibility == proto::ChannelVisibility::Public {
Icon::Public IconName::Public
} else { } else {
Icon::Hash IconName::Hash
}, },
) )
.size(IconSize::Small) .size(IconSize::Small)

View file

@ -168,7 +168,7 @@ impl Render for ChannelModal {
.w_px() .w_px()
.flex_1() .flex_1()
.gap_1() .gap_1()
.child(IconElement::new(Icon::Hash).size(IconSize::Medium)) .child(Icon::new(IconName::Hash).size(IconSize::Medium))
.child(Label::new(channel_name)), .child(Label::new(channel_name)),
) )
.child( .child(
@ -406,7 +406,7 @@ impl PickerDelegate for ChannelModalDelegate {
Some(ChannelRole::Guest) => Some(Label::new("Guest")), Some(ChannelRole::Guest) => Some(Label::new("Guest")),
_ => None, _ => None,
}) })
.child(IconButton::new("ellipsis", Icon::Ellipsis)) .child(IconButton::new("ellipsis", IconName::Ellipsis))
.children( .children(
if let (Some((menu, _)), true) = (&self.context_menu, selected) { if let (Some((menu, _)), true) = (&self.context_menu, selected) {
Some( Some(

View file

@ -155,9 +155,7 @@ impl PickerDelegate for ContactFinderDelegate {
.selected(selected) .selected(selected)
.start_slot(Avatar::new(user.avatar_uri.clone())) .start_slot(Avatar::new(user.avatar_uri.clone()))
.child(Label::new(user.github_login.clone())) .child(Label::new(user.github_login.clone()))
.end_slot::<IconElement>( .end_slot::<Icon>(icon_path.map(|icon_path| Icon::from_path(icon_path))),
icon_path.map(|icon_path| IconElement::from_path(icon_path)),
),
) )
} }
} }

View file

@ -15,7 +15,7 @@ use std::sync::Arc;
use theme::{ActiveTheme, PlayerColors}; use theme::{ActiveTheme, PlayerColors};
use ui::{ use ui::{
h_stack, popover_menu, prelude::*, Avatar, Button, ButtonLike, ButtonStyle, ContextMenu, Icon, h_stack, popover_menu, prelude::*, Avatar, Button, ButtonLike, ButtonStyle, ContextMenu, Icon,
IconButton, IconElement, TintColor, Tooltip, IconButton, IconName, TintColor, Tooltip,
}; };
use util::ResultExt; use util::ResultExt;
use vcs_menu::{build_branch_list, BranchList, OpenRecent as ToggleVcsMenu}; use vcs_menu::{build_branch_list, BranchList, OpenRecent as ToggleVcsMenu};
@ -41,12 +41,6 @@ pub fn init(cx: &mut AppContext) {
workspace.set_titlebar_item(titlebar_item.into(), cx) workspace.set_titlebar_item(titlebar_item.into(), cx)
}) })
.detach(); .detach();
// todo!()
// cx.add_action(CollabTitlebarItem::share_project);
// cx.add_action(CollabTitlebarItem::unshare_project);
// cx.add_action(CollabTitlebarItem::toggle_user_menu);
// cx.add_action(CollabTitlebarItem::toggle_vcs_menu);
// cx.add_action(CollabTitlebarItem::toggle_project_menu);
} }
pub struct CollabTitlebarItem { pub struct CollabTitlebarItem {
@ -213,7 +207,7 @@ impl Render for CollabTitlebarItem {
.child( .child(
div() div()
.child( .child(
IconButton::new("leave-call", ui::Icon::Exit) IconButton::new("leave-call", ui::IconName::Exit)
.style(ButtonStyle::Subtle) .style(ButtonStyle::Subtle)
.tooltip(|cx| Tooltip::text("Leave call", cx)) .tooltip(|cx| Tooltip::text("Leave call", cx))
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
@ -230,9 +224,9 @@ impl Render for CollabTitlebarItem {
IconButton::new( IconButton::new(
"mute-microphone", "mute-microphone",
if is_muted { if is_muted {
ui::Icon::MicMute ui::IconName::MicMute
} else { } else {
ui::Icon::Mic ui::IconName::Mic
}, },
) )
.tooltip(move |cx| { .tooltip(move |cx| {
@ -256,9 +250,9 @@ impl Render for CollabTitlebarItem {
IconButton::new( IconButton::new(
"mute-sound", "mute-sound",
if is_deafened { if is_deafened {
ui::Icon::AudioOff ui::IconName::AudioOff
} else { } else {
ui::Icon::AudioOn ui::IconName::AudioOn
}, },
) )
.style(ButtonStyle::Subtle) .style(ButtonStyle::Subtle)
@ -281,7 +275,7 @@ impl Render for CollabTitlebarItem {
) )
.when(!read_only, |this| { .when(!read_only, |this| {
this.child( this.child(
IconButton::new("screen-share", ui::Icon::Screen) IconButton::new("screen-share", ui::IconName::Screen)
.style(ButtonStyle::Subtle) .style(ButtonStyle::Subtle)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.selected(is_screen_sharing) .selected(is_screen_sharing)
@ -573,7 +567,7 @@ impl CollabTitlebarItem {
| client::Status::ReconnectionError { .. } => Some( | client::Status::ReconnectionError { .. } => Some(
div() div()
.id("disconnected") .id("disconnected")
.child(IconElement::new(Icon::Disconnected).size(IconSize::Small)) .child(Icon::new(IconName::Disconnected).size(IconSize::Small))
.tooltip(|cx| Tooltip::text("Disconnected", cx)) .tooltip(|cx| Tooltip::text("Disconnected", cx))
.into_any_element(), .into_any_element(),
), ),
@ -643,7 +637,7 @@ impl CollabTitlebarItem {
h_stack() h_stack()
.gap_0p5() .gap_0p5()
.child(Avatar::new(user.avatar_uri.clone())) .child(Avatar::new(user.avatar_uri.clone()))
.child(IconElement::new(Icon::ChevronDown).color(Color::Muted)), .child(Icon::new(IconName::ChevronDown).color(Color::Muted)),
) )
.style(ButtonStyle::Subtle) .style(ButtonStyle::Subtle)
.tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)), .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)),
@ -665,7 +659,7 @@ impl CollabTitlebarItem {
.child( .child(
h_stack() h_stack()
.gap_0p5() .gap_0p5()
.child(IconElement::new(Icon::ChevronDown).color(Color::Muted)), .child(Icon::new(IconName::ChevronDown).color(Color::Muted)),
) )
.style(ButtonStyle::Subtle) .style(ButtonStyle::Subtle)
.tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)), .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)),

View file

@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore}; use settings::{Settings, SettingsStore};
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use time::{OffsetDateTime, UtcOffset}; use time::{OffsetDateTime, UtcOffset};
use ui::{h_stack, prelude::*, v_stack, Avatar, Button, Icon, IconButton, IconElement, Label}; use ui::{h_stack, prelude::*, v_stack, Avatar, Button, Icon, IconButton, IconName, Label};
use util::{ResultExt, TryFutureExt}; use util::{ResultExt, TryFutureExt};
use workspace::{ use workspace::{
dock::{DockPosition, Panel, PanelEvent}, dock::{DockPosition, Panel, PanelEvent},
@ -553,7 +553,7 @@ impl Render for NotificationPanel {
.border_b_1() .border_b_1()
.border_color(cx.theme().colors().border) .border_color(cx.theme().colors().border)
.child(Label::new("Notifications")) .child(Label::new("Notifications"))
.child(IconElement::new(Icon::Envelope)), .child(Icon::new(IconName::Envelope)),
) )
.map(|this| { .map(|this| {
if self.client.user_id().is_none() { if self.client.user_id().is_none() {
@ -564,7 +564,7 @@ impl Render for NotificationPanel {
.child( .child(
Button::new("sign_in_prompt_button", "Sign in") Button::new("sign_in_prompt_button", "Sign in")
.icon_color(Color::Muted) .icon_color(Color::Muted)
.icon(Icon::Github) .icon(IconName::Github)
.icon_position(IconPosition::Start) .icon_position(IconPosition::Start)
.style(ButtonStyle::Filled) .style(ButtonStyle::Filled)
.full_width() .full_width()
@ -655,10 +655,10 @@ impl Panel for NotificationPanel {
} }
} }
fn icon(&self, cx: &gpui::WindowContext) -> Option<Icon> { fn icon(&self, cx: &gpui::WindowContext) -> Option<IconName> {
(NotificationPanelSettings::get_global(cx).button (NotificationPanelSettings::get_global(cx).button
&& self.notification_store.read(cx).notification_count() > 0) && self.notification_store.read(cx).notification_count() > 0)
.then(|| Icon::Bell) .then(|| IconName::Bell)
} }
fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
@ -716,7 +716,7 @@ impl Render for NotificationToast {
.children(user.map(|user| Avatar::new(user.avatar_uri.clone()))) .children(user.map(|user| Avatar::new(user.avatar_uri.clone())))
.child(Label::new(self.text.clone())) .child(Label::new(self.text.clone()))
.child( .child(
IconButton::new("close", Icon::Close) IconButton::new("close", IconName::Close)
.on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))), .on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))),
) )
.on_click(cx.listener(|this, _, cx| { .on_click(cx.listener(|this, _, cx| {

View file

@ -1,9 +1,16 @@
mod collab_notification;
pub mod incoming_call_notification;
pub mod project_shared_notification;
#[cfg(feature = "stories")]
mod stories;
use gpui::AppContext; use gpui::AppContext;
use std::sync::Arc; use std::sync::Arc;
use workspace::AppState; use workspace::AppState;
pub mod incoming_call_notification; #[cfg(feature = "stories")]
pub mod project_shared_notification; pub use stories::*;
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) { pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
incoming_call_notification::init(app_state, cx); incoming_call_notification::init(app_state, cx);

View file

@ -0,0 +1,52 @@
use gpui::{img, prelude::*, AnyElement, SharedUrl};
use smallvec::SmallVec;
use ui::prelude::*;
#[derive(IntoElement)]
pub struct CollabNotification {
avatar_uri: SharedUrl,
accept_button: Button,
dismiss_button: Button,
children: SmallVec<[AnyElement; 2]>,
}
impl CollabNotification {
pub fn new(
avatar_uri: impl Into<SharedUrl>,
accept_button: Button,
dismiss_button: Button,
) -> Self {
Self {
avatar_uri: avatar_uri.into(),
accept_button,
dismiss_button,
children: SmallVec::new(),
}
}
}
impl ParentElement for CollabNotification {
fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
&mut self.children
}
}
impl RenderOnce for CollabNotification {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
h_stack()
.text_ui()
.justify_between()
.size_full()
.overflow_hidden()
.elevation_3(cx)
.p_2()
.gap_2()
.child(img(self.avatar_uri).w_12().h_12().rounded_full())
.child(v_stack().overflow_hidden().children(self.children))
.child(
v_stack()
.child(self.accept_button)
.child(self.dismiss_button),
)
}
}

View file

@ -1,15 +1,12 @@
use crate::notification_window_options; use crate::notification_window_options;
use crate::notifications::collab_notification::CollabNotification;
use call::{ActiveCall, IncomingCall}; use call::{ActiveCall, IncomingCall};
use futures::StreamExt; use futures::StreamExt;
use gpui::{ use gpui::{prelude::*, AppContext, WindowHandle};
img, px, AppContext, ParentElement, Render, RenderOnce, Styled, ViewContext,
VisualContext as _, WindowHandle,
};
use settings::Settings; use settings::Settings;
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
use theme::ThemeSettings; use theme::ThemeSettings;
use ui::prelude::*; use ui::{prelude::*, Button, Label};
use ui::{h_stack, v_stack, Button, Label};
use util::ResultExt; use util::ResultExt;
use workspace::AppState; use workspace::AppState;
@ -22,7 +19,6 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
for window in notification_windows.drain(..) { for window in notification_windows.drain(..) {
window window
.update(&mut cx, |_, cx| { .update(&mut cx, |_, cx| {
// todo!()
cx.remove_window(); cx.remove_window();
}) })
.log_err(); .log_err();
@ -31,8 +27,8 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
if let Some(incoming_call) = incoming_call { if let Some(incoming_call) = incoming_call {
let unique_screens = cx.update(|cx| cx.displays()).unwrap(); let unique_screens = cx.update(|cx| cx.displays()).unwrap();
let window_size = gpui::Size { let window_size = gpui::Size {
width: px(380.), width: px(400.),
height: px(64.), height: px(72.),
}; };
for screen in unique_screens { for screen in unique_screens {
@ -129,35 +125,22 @@ impl Render for IncomingCallNotification {
cx.set_rem_size(ui_font_size); cx.set_rem_size(ui_font_size);
h_stack() div().size_full().font(ui_font).child(
.font(ui_font) CollabNotification::new(
.text_ui() self.state.call.calling_user.avatar_uri.clone(),
.justify_between() Button::new("accept", "Accept").on_click({
.size_full() let state = self.state.clone();
.overflow_hidden() move |_, cx| state.respond(true, cx)
.elevation_3(cx) }),
.p_2() Button::new("decline", "Decline").on_click({
.gap_2() let state = self.state.clone();
.child( move |_, cx| state.respond(false, cx)
img(self.state.call.calling_user.avatar_uri.clone()) }),
.w_12()
.h_12()
.rounded_full(),
) )
.child(v_stack().overflow_hidden().child(Label::new(format!( .child(v_stack().overflow_hidden().child(Label::new(format!(
"{} is sharing a project in Zed", "{} is sharing a project in Zed",
self.state.call.calling_user.github_login self.state.call.calling_user.github_login
)))) )))),
.child( )
v_stack()
.child(Button::new("accept", "Accept").render(cx).on_click({
let state = self.state.clone();
move |_, cx| state.respond(true, cx)
}))
.child(Button::new("decline", "Decline").render(cx).on_click({
let state = self.state.clone();
move |_, cx| state.respond(false, cx)
})),
)
} }
} }

View file

@ -1,12 +1,13 @@
use crate::notification_window_options; use crate::notification_window_options;
use crate::notifications::collab_notification::CollabNotification;
use call::{room, ActiveCall}; use call::{room, ActiveCall};
use client::User; use client::User;
use collections::HashMap; use collections::HashMap;
use gpui::{img, px, AppContext, ParentElement, Render, Size, Styled, ViewContext, VisualContext}; use gpui::{AppContext, Size};
use settings::Settings; use settings::Settings;
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
use theme::ThemeSettings; use theme::ThemeSettings;
use ui::{h_stack, prelude::*, v_stack, Button, Label}; use ui::{prelude::*, Button, Label};
use workspace::AppState; use workspace::AppState;
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) { pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
@ -50,7 +51,6 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
for window in windows { for window in windows {
window window
.update(cx, |_, cx| { .update(cx, |_, cx| {
// todo!()
cx.remove_window(); cx.remove_window();
}) })
.ok(); .ok();
@ -63,7 +63,6 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
for window in windows { for window in windows {
window window
.update(cx, |_, cx| { .update(cx, |_, cx| {
// todo!()
cx.remove_window(); cx.remove_window();
}) })
.ok(); .ok();
@ -130,51 +129,30 @@ impl Render for ProjectSharedNotification {
cx.set_rem_size(ui_font_size); cx.set_rem_size(ui_font_size);
h_stack() div().size_full().font(ui_font).child(
.font(ui_font) CollabNotification::new(
.text_ui() self.owner.avatar_uri.clone(),
.justify_between() Button::new("open", "Open").on_click(cx.listener(move |this, _event, cx| {
.size_full() this.join(cx);
.overflow_hidden() })),
.elevation_3(cx) Button::new("dismiss", "Dismiss").on_click(cx.listener(move |this, _event, cx| {
.p_2() this.dismiss(cx);
.gap_2() })),
.child(
img(self.owner.avatar_uri.clone())
.w_12()
.h_12()
.rounded_full(),
)
.child(
v_stack()
.overflow_hidden()
.child(Label::new(self.owner.github_login.clone()))
.child(Label::new(format!(
"is sharing a project in Zed{}",
if self.worktree_root_names.is_empty() {
""
} else {
":"
}
)))
.children(if self.worktree_root_names.is_empty() {
None
} else {
Some(Label::new(self.worktree_root_names.join(", ")))
}),
)
.child(
v_stack()
.child(Button::new("open", "Open").on_click(cx.listener(
move |this, _event, cx| {
this.join(cx);
},
)))
.child(Button::new("dismiss", "Dismiss").on_click(cx.listener(
move |this, _event, cx| {
this.dismiss(cx);
},
))),
) )
.child(Label::new(self.owner.github_login.clone()))
.child(Label::new(format!(
"is sharing a project in Zed{}",
if self.worktree_root_names.is_empty() {
""
} else {
":"
}
)))
.children(if self.worktree_root_names.is_empty() {
None
} else {
Some(Label::new(self.worktree_root_names.join(", ")))
}),
)
} }
} }

View file

@ -0,0 +1,3 @@
mod collab_notification;
pub use collab_notification::*;

View file

@ -0,0 +1,50 @@
use gpui::prelude::*;
use story::{StoryContainer, StoryItem, StorySection};
use ui::prelude::*;
use crate::notifications::collab_notification::CollabNotification;
pub struct CollabNotificationStory;
impl Render for CollabNotificationStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
let window_container = |width, height| div().w(px(width)).h(px(height));
StoryContainer::new(
"CollabNotification Story",
"crates/collab_ui/src/notifications/stories/collab_notification.rs",
)
.child(
StorySection::new().child(StoryItem::new(
"Incoming Call Notification",
window_container(400., 72.).child(
CollabNotification::new(
"https://avatars.githubusercontent.com/u/1486634?v=4",
Button::new("accept", "Accept"),
Button::new("decline", "Decline"),
)
.child(
v_stack()
.overflow_hidden()
.child(Label::new("maxdeviant is sharing a project in Zed")),
),
),
)),
)
.child(
StorySection::new().child(StoryItem::new(
"Project Shared Notification",
window_container(400., 72.).child(
CollabNotification::new(
"https://avatars.githubusercontent.com/u/1714999?v=4",
Button::new("open", "Open"),
Button::new("dismiss", "Dismiss"),
)
.child(Label::new("iamnbutler"))
.child(Label::new("is sharing a project in Zed:"))
.child(Label::new("zed")),
),
)),
)
}
}

View file

@ -17,7 +17,9 @@ use util::{paths, ResultExt};
use workspace::{ use workspace::{
create_and_open_local_file, create_and_open_local_file,
item::ItemHandle, item::ItemHandle,
ui::{popover_menu, ButtonCommon, Clickable, ContextMenu, Icon, IconButton, IconSize, Tooltip}, ui::{
popover_menu, ButtonCommon, Clickable, ContextMenu, IconButton, IconName, IconSize, Tooltip,
},
StatusItemView, Toast, Workspace, StatusItemView, Toast, Workspace,
}; };
use zed_actions::OpenBrowser; use zed_actions::OpenBrowser;
@ -51,15 +53,15 @@ impl Render for CopilotButton {
.unwrap_or_else(|| all_language_settings.copilot_enabled(None, None)); .unwrap_or_else(|| all_language_settings.copilot_enabled(None, None));
let icon = match status { let icon = match status {
Status::Error(_) => Icon::CopilotError, Status::Error(_) => IconName::CopilotError,
Status::Authorized => { Status::Authorized => {
if enabled { if enabled {
Icon::Copilot IconName::Copilot
} else { } else {
Icon::CopilotDisabled IconName::CopilotDisabled
} }
} }
_ => Icon::CopilotInit, _ => IconName::CopilotInit,
}; };
if let Status::Error(e) = status { if let Status::Error(e) = status {

View file

@ -4,7 +4,7 @@ use gpui::{
FocusableView, InteractiveElement, IntoElement, Model, ParentElement, Render, Styled, FocusableView, InteractiveElement, IntoElement, Model, ParentElement, Render, Styled,
Subscription, ViewContext, Subscription, ViewContext,
}; };
use ui::{prelude::*, Button, Icon, Label}; use ui::{prelude::*, Button, IconName, Label};
use workspace::ModalView; use workspace::ModalView;
const COPILOT_SIGN_UP_URL: &'static str = "https://github.com/features/copilot"; const COPILOT_SIGN_UP_URL: &'static str = "https://github.com/features/copilot";
@ -175,7 +175,7 @@ impl Render for CopilotCodeVerification {
.w_32() .w_32()
.h_16() .h_16()
.flex_none() .flex_none()
.path(Icon::ZedXCopilot.path()) .path(IconName::ZedXCopilot.path())
.text_color(cx.theme().colors().icon), .text_color(cx.theme().colors().icon),
) )
.child(prompt) .child(prompt)

View file

@ -36,7 +36,7 @@ use std::{
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
pub use toolbar_controls::ToolbarControls; pub use toolbar_controls::ToolbarControls;
use ui::{h_stack, prelude::*, Icon, IconElement, Label}; use ui::{h_stack, prelude::*, Icon, IconName, Label};
use util::TryFutureExt; use util::TryFutureExt;
use workspace::{ use workspace::{
item::{BreadcrumbText, Item, ItemEvent, ItemHandle}, item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
@ -660,7 +660,7 @@ impl Item for ProjectDiagnosticsEditor {
then.child( then.child(
h_stack() h_stack()
.gap_1() .gap_1()
.child(IconElement::new(Icon::XCircle).color(Color::Error)) .child(Icon::new(IconName::XCircle).color(Color::Error))
.child(Label::new(self.summary.error_count.to_string()).color( .child(Label::new(self.summary.error_count.to_string()).color(
if selected { if selected {
Color::Default Color::Default
@ -674,9 +674,7 @@ impl Item for ProjectDiagnosticsEditor {
then.child( then.child(
h_stack() h_stack()
.gap_1() .gap_1()
.child( .child(Icon::new(IconName::ExclamationTriangle).color(Color::Warning))
IconElement::new(Icon::ExclamationTriangle).color(Color::Warning),
)
.child(Label::new(self.summary.warning_count.to_string()).color( .child(Label::new(self.summary.warning_count.to_string()).color(
if selected { if selected {
Color::Default Color::Default
@ -816,10 +814,10 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
.flex_none() .flex_none()
.map(|icon| { .map(|icon| {
if diagnostic.severity == DiagnosticSeverity::ERROR { if diagnostic.severity == DiagnosticSeverity::ERROR {
icon.path(Icon::XCircle.path()) icon.path(IconName::XCircle.path())
.text_color(Color::Error.color(cx)) .text_color(Color::Error.color(cx))
} else { } else {
icon.path(Icon::ExclamationTriangle.path()) icon.path(IconName::ExclamationTriangle.path())
.text_color(Color::Warning.color(cx)) .text_color(Color::Warning.color(cx))
} }
}), }),

View file

@ -6,7 +6,7 @@ use gpui::{
}; };
use language::Diagnostic; use language::Diagnostic;
use lsp::LanguageServerId; use lsp::LanguageServerId;
use ui::{h_stack, prelude::*, Button, ButtonLike, Color, Icon, IconElement, Label, Tooltip}; use ui::{h_stack, prelude::*, Button, ButtonLike, Color, Icon, IconName, Label, Tooltip};
use workspace::{item::ItemHandle, StatusItemView, ToolbarItemEvent, Workspace}; use workspace::{item::ItemHandle, StatusItemView, ToolbarItemEvent, Workspace};
use crate::{Deploy, ProjectDiagnosticsEditor}; use crate::{Deploy, ProjectDiagnosticsEditor};
@ -25,7 +25,7 @@ impl Render for DiagnosticIndicator {
let diagnostic_indicator = match (self.summary.error_count, self.summary.warning_count) { let diagnostic_indicator = match (self.summary.error_count, self.summary.warning_count) {
(0, 0) => h_stack().map(|this| { (0, 0) => h_stack().map(|this| {
this.child( this.child(
IconElement::new(Icon::Check) Icon::new(IconName::Check)
.size(IconSize::Small) .size(IconSize::Small)
.color(Color::Default), .color(Color::Default),
) )
@ -33,7 +33,7 @@ impl Render for DiagnosticIndicator {
(0, warning_count) => h_stack() (0, warning_count) => h_stack()
.gap_1() .gap_1()
.child( .child(
IconElement::new(Icon::ExclamationTriangle) Icon::new(IconName::ExclamationTriangle)
.size(IconSize::Small) .size(IconSize::Small)
.color(Color::Warning), .color(Color::Warning),
) )
@ -41,7 +41,7 @@ impl Render for DiagnosticIndicator {
(error_count, 0) => h_stack() (error_count, 0) => h_stack()
.gap_1() .gap_1()
.child( .child(
IconElement::new(Icon::XCircle) Icon::new(IconName::XCircle)
.size(IconSize::Small) .size(IconSize::Small)
.color(Color::Error), .color(Color::Error),
) )
@ -49,13 +49,13 @@ impl Render for DiagnosticIndicator {
(error_count, warning_count) => h_stack() (error_count, warning_count) => h_stack()
.gap_1() .gap_1()
.child( .child(
IconElement::new(Icon::XCircle) Icon::new(IconName::XCircle)
.size(IconSize::Small) .size(IconSize::Small)
.color(Color::Error), .color(Color::Error),
) )
.child(Label::new(error_count.to_string()).size(LabelSize::Small)) .child(Label::new(error_count.to_string()).size(LabelSize::Small))
.child( .child(
IconElement::new(Icon::ExclamationTriangle) Icon::new(IconName::ExclamationTriangle)
.size(IconSize::Small) .size(IconSize::Small)
.color(Color::Warning), .color(Color::Warning),
) )
@ -66,7 +66,7 @@ impl Render for DiagnosticIndicator {
Some( Some(
h_stack() h_stack()
.gap_2() .gap_2()
.child(IconElement::new(Icon::ArrowCircle).size(IconSize::Small)) .child(Icon::new(IconName::ArrowCircle).size(IconSize::Small))
.child( .child(
Label::new("Checking…") Label::new("Checking…")
.size(LabelSize::Small) .size(LabelSize::Small)

View file

@ -1,7 +1,7 @@
use crate::ProjectDiagnosticsEditor; use crate::ProjectDiagnosticsEditor;
use gpui::{div, EventEmitter, ParentElement, Render, ViewContext, WeakView}; use gpui::{div, EventEmitter, ParentElement, Render, ViewContext, WeakView};
use ui::prelude::*; use ui::prelude::*;
use ui::{Icon, IconButton, Tooltip}; use ui::{IconButton, IconName, Tooltip};
use workspace::{item::ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView}; use workspace::{item::ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView};
pub struct ToolbarControls { pub struct ToolbarControls {
@ -24,7 +24,7 @@ impl Render for ToolbarControls {
}; };
div().child( div().child(
IconButton::new("toggle-warnings", Icon::ExclamationTriangle) IconButton::new("toggle-warnings", IconName::ExclamationTriangle)
.tooltip(move |cx| Tooltip::text(tooltip, cx)) .tooltip(move |cx| Tooltip::text(tooltip, cx))
.on_click(cx.listener(|this, _, cx| { .on_click(cx.listener(|this, _, cx| {
if let Some(editor) = this.editor.as_ref().and_then(|editor| editor.upgrade()) { if let Some(editor) = this.editor.as_ref().and_then(|editor| editor.upgrade()) {

View file

@ -1015,7 +1015,6 @@ pub mod tests {
.map(|i| i.parse().expect("invalid `OPERATIONS` variable")) .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
.unwrap_or(10); .unwrap_or(10);
let _test_platform = &cx.test_platform;
let mut tab_size = rng.gen_range(1..=4); let mut tab_size = rng.gen_range(1..=4);
let buffer_start_excerpt_header_height = rng.gen_range(1..=5); let buffer_start_excerpt_header_height = rng.gen_range(1..=5);
let excerpt_header_height = rng.gen_range(1..=5); let excerpt_header_height = rng.gen_range(1..=5);

View file

@ -99,8 +99,8 @@ use sum_tree::TreeMap;
use text::{OffsetUtf16, Rope}; use text::{OffsetUtf16, Rope};
use theme::{ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, ThemeColors, ThemeSettings}; use theme::{ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, ThemeColors, ThemeSettings};
use ui::{ use ui::{
h_stack, prelude::*, ButtonSize, ButtonStyle, Icon, IconButton, IconSize, ListItem, Popover, h_stack, prelude::*, ButtonSize, ButtonStyle, IconButton, IconName, IconSize, ListItem,
Tooltip, Popover, Tooltip,
}; };
use util::{post_inc, RangeExt, ResultExt, TryFutureExt}; use util::{post_inc, RangeExt, ResultExt, TryFutureExt};
use workspace::{searchable::SearchEvent, ItemNavHistory, Pane, SplitDirection, ViewId, Workspace}; use workspace::{searchable::SearchEvent, ItemNavHistory, Pane, SplitDirection, ViewId, Workspace};
@ -507,7 +507,7 @@ pub enum SoftWrap {
Column(u32), Column(u32),
} }
#[derive(Clone, Default)] #[derive(Clone)]
pub struct EditorStyle { pub struct EditorStyle {
pub background: Hsla, pub background: Hsla,
pub local_player: PlayerColor, pub local_player: PlayerColor,
@ -519,6 +519,24 @@ pub struct EditorStyle {
pub suggestions_style: HighlightStyle, pub suggestions_style: HighlightStyle,
} }
impl Default for EditorStyle {
fn default() -> Self {
Self {
background: Hsla::default(),
local_player: PlayerColor::default(),
text: TextStyle::default(),
scrollbar_width: Pixels::default(),
syntax: Default::default(),
// HACK: Status colors don't have a real default.
// We should look into removing the status colors from the editor
// style and retrieve them directly from the theme.
status: StatusColors::dark(),
inlays_style: HighlightStyle::default(),
suggestions_style: HighlightStyle::default(),
}
}
}
type CompletionId = usize; type CompletionId = usize;
// type GetFieldEditorTheme = dyn Fn(&theme::Theme) -> theme::FieldEditor; // type GetFieldEditorTheme = dyn Fn(&theme::Theme) -> theme::FieldEditor;
@ -1811,10 +1829,6 @@ impl Editor {
this.end_selection(cx); this.end_selection(cx);
this.scroll_manager.show_scrollbar(cx); this.scroll_manager.show_scrollbar(cx);
// todo!("use a different mechanism")
// let editor_created_event = EditorCreated(cx.handle());
// cx.emit_global(editor_created_event);
if mode == EditorMode::Full { if mode == EditorMode::Full {
let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars(); let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars();
cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars)); cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars));
@ -4223,7 +4237,7 @@ impl Editor {
) -> Option<IconButton> { ) -> Option<IconButton> {
if self.available_code_actions.is_some() { if self.available_code_actions.is_some() {
Some( Some(
IconButton::new("code_actions_indicator", ui::Icon::Bolt) IconButton::new("code_actions_indicator", ui::IconName::Bolt)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.selected(is_active) .selected(is_active)
@ -4257,7 +4271,7 @@ impl Editor {
fold_data fold_data
.map(|(fold_status, buffer_row, active)| { .map(|(fold_status, buffer_row, active)| {
(active || gutter_hovered || fold_status == FoldStatus::Folded).then(|| { (active || gutter_hovered || fold_status == FoldStatus::Folded).then(|| {
IconButton::new(ix as usize, ui::Icon::ChevronDown) IconButton::new(ix as usize, ui::IconName::ChevronDown)
.on_click(cx.listener(move |editor, _e, cx| match fold_status { .on_click(cx.listener(move |editor, _e, cx| match fold_status {
FoldStatus::Folded => { FoldStatus::Folded => {
editor.unfold_at(&UnfoldAt { buffer_row }, cx); editor.unfold_at(&UnfoldAt { buffer_row }, cx);
@ -4269,7 +4283,7 @@ impl Editor {
.icon_color(ui::Color::Muted) .icon_color(ui::Color::Muted)
.icon_size(ui::IconSize::Small) .icon_size(ui::IconSize::Small)
.selected(fold_status == FoldStatus::Folded) .selected(fold_status == FoldStatus::Folded)
.selected_icon(ui::Icon::ChevronRight) .selected_icon(ui::IconName::ChevronRight)
.size(ui::ButtonSize::None) .size(ui::ButtonSize::None)
}) })
}) })
@ -7036,7 +7050,7 @@ impl Editor {
let buffer = self.buffer.read(cx).snapshot(cx); let buffer = self.buffer.read(cx).snapshot(cx);
let selection = self.selections.newest::<usize>(cx); let selection = self.selections.newest::<usize>(cx);
// If there is an active Diagnostic Popover. Jump to it's diagnostic instead. // If there is an active Diagnostic Popover jump to its diagnostic instead.
if direction == Direction::Next { if direction == Direction::Next {
if let Some(popover) = self.hover_state.diagnostic_popover.as_ref() { if let Some(popover) = self.hover_state.diagnostic_popover.as_ref() {
let (group_id, jump_to) = popover.activation_info(); let (group_id, jump_to) = popover.activation_info();
@ -9743,7 +9757,7 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> Ren
), ),
) )
.child( .child(
IconButton::new(("copy-block", cx.block_id), Icon::Copy) IconButton::new(("copy-block", cx.block_id), IconName::Copy)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.size(ButtonSize::Compact) .size(ButtonSize::Compact)
.style(ButtonStyle::Transparent) .style(ButtonStyle::Transparent)

View file

@ -539,7 +539,6 @@ fn test_clone(cx: &mut TestAppContext) {
); );
} }
//todo!(editor navigate)
#[gpui::test] #[gpui::test]
async fn test_navigation_history(cx: &mut TestAppContext) { async fn test_navigation_history(cx: &mut TestAppContext) {
init_test(cx, |_| {}); init_test(cx, |_| {});
@ -993,7 +992,6 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) {
}); });
} }
//todo!(finish editor tests)
#[gpui::test] #[gpui::test]
fn test_move_cursor_different_line_lengths(cx: &mut TestAppContext) { fn test_move_cursor_different_line_lengths(cx: &mut TestAppContext) {
init_test(cx, |_| {}); init_test(cx, |_| {});
@ -1259,7 +1257,6 @@ fn test_prev_next_word_boundary(cx: &mut TestAppContext) {
}); });
} }
//todo!(finish editor tests)
#[gpui::test] #[gpui::test]
fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) { fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) {
init_test(cx, |_| {}); init_test(cx, |_| {});
@ -1318,7 +1315,6 @@ fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) {
}); });
} }
//todo!(simulate_resize)
#[gpui::test] #[gpui::test]
async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut gpui::TestAppContext) { async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {}); init_test(cx, |_| {});
@ -2546,7 +2542,6 @@ fn test_delete_line(cx: &mut TestAppContext) {
}); });
} }
//todo!(select_anchor_ranges)
#[gpui::test] #[gpui::test]
fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { fn test_join_lines_with_single_selection(cx: &mut TestAppContext) {
init_test(cx, |_| {}); init_test(cx, |_| {});
@ -3114,7 +3109,6 @@ fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) {
}); });
} }
//todo!(test_transpose)
#[gpui::test] #[gpui::test]
fn test_transpose(cx: &mut TestAppContext) { fn test_transpose(cx: &mut TestAppContext) {
init_test(cx, |_| {}); init_test(cx, |_| {});
@ -4860,7 +4854,6 @@ async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) {
}); });
} }
// todo!(select_anchor_ranges)
#[gpui::test] #[gpui::test]
async fn test_snippets(cx: &mut gpui::TestAppContext) { async fn test_snippets(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {}); init_test(cx, |_| {});
@ -6455,7 +6448,6 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) {
}); });
} }
// todo!(following)
#[gpui::test] #[gpui::test]
async fn test_following(cx: &mut gpui::TestAppContext) { async fn test_following(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {}); init_test(cx, |_| {});
@ -7094,7 +7086,6 @@ async fn test_move_to_enclosing_bracket(cx: &mut gpui::TestAppContext) {
); );
} }
// todo!(completions)
#[gpui::test(iterations = 10)] #[gpui::test(iterations = 10)]
async fn test_copilot(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) { async fn test_copilot(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) {
// flaky // flaky

View file

@ -28,7 +28,7 @@ use gpui::{
AnchorCorner, AnyElement, AvailableSpace, BorrowWindow, Bounds, ContentMask, Corners, AnchorCorner, AnyElement, AvailableSpace, BorrowWindow, Bounds, ContentMask, Corners,
CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Hsla, InteractiveBounds, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Hsla, InteractiveBounds,
InteractiveElement, IntoElement, ModifiersChangedEvent, MouseButton, MouseDownEvent, InteractiveElement, IntoElement, ModifiersChangedEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, ScrollWheelEvent, ShapedLine, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine,
SharedString, Size, StackingOrder, StatefulInteractiveElement, Style, Styled, TextRun, SharedString, Size, StackingOrder, StatefulInteractiveElement, Style, Styled, TextRun,
TextStyle, View, ViewContext, WindowContext, TextStyle, View, ViewContext, WindowContext,
}; };
@ -581,41 +581,6 @@ impl EditorElement {
} }
} }
fn scroll(
editor: &mut Editor,
event: &ScrollWheelEvent,
position_map: &PositionMap,
bounds: &InteractiveBounds,
cx: &mut ViewContext<Editor>,
) {
if !bounds.visibly_contains(&event.position, cx) {
return;
}
let line_height = position_map.line_height;
let max_glyph_width = position_map.em_width;
let (delta, axis) = match event.delta {
gpui::ScrollDelta::Pixels(mut pixels) => {
//Trackpad
let axis = position_map.snapshot.ongoing_scroll.filter(&mut pixels);
(pixels, axis)
}
gpui::ScrollDelta::Lines(lines) => {
//Not trackpad
let pixels = point(lines.x * max_glyph_width, lines.y * line_height);
(pixels, None)
}
};
let scroll_position = position_map.snapshot.scroll_position();
let x = f32::from((scroll_position.x * max_glyph_width - delta.x) / max_glyph_width);
let y = f32::from((scroll_position.y * line_height - delta.y) / line_height);
let scroll_position = point(x, y).clamp(&point(0., 0.), &position_map.scroll_max);
editor.scroll(scroll_position, axis, cx);
cx.stop_propagation();
}
fn paint_background( fn paint_background(
&self, &self,
gutter_bounds: Bounds<Pixels>, gutter_bounds: Bounds<Pixels>,
@ -839,9 +804,22 @@ impl EditorElement {
let start_row = display_row_range.start; let start_row = display_row_range.start;
let end_row = display_row_range.end; let end_row = display_row_range.end;
// If we're in a multibuffer, row range span might include an
// excerpt header, so if we were to draw the marker straight away,
// the hunk might include the rows of that header.
// Making the range inclusive doesn't quite cut it, as we rely on the exclusivity for the soft wrap.
// Instead, we simply check whether the range we're dealing with includes
// any custom elements and if so, we stop painting the diff hunk on the first row of that custom element.
let end_row_in_current_excerpt = layout
.position_map
.snapshot
.blocks_in_range(start_row..end_row)
.next()
.map(|(start_row, _)| start_row)
.unwrap_or(end_row);
let start_y = start_row as f32 * line_height - scroll_top; let start_y = start_row as f32 * line_height - scroll_top;
let end_y = end_row as f32 * line_height - scroll_top; let end_y = end_row_in_current_excerpt as f32 * line_height - scroll_top;
let width = 0.275 * line_height; let width = 0.275 * line_height;
let highlight_origin = bounds.origin + point(-width, start_y); let highlight_origin = bounds.origin + point(-width, start_y);
@ -2450,6 +2428,64 @@ impl EditorElement {
) )
} }
fn paint_scroll_wheel_listener(
&mut self,
interactive_bounds: &InteractiveBounds,
layout: &LayoutState,
cx: &mut WindowContext,
) {
cx.on_mouse_event({
let position_map = layout.position_map.clone();
let editor = self.editor.clone();
let interactive_bounds = interactive_bounds.clone();
let mut delta = ScrollDelta::default();
move |event: &ScrollWheelEvent, phase, cx| {
if phase == DispatchPhase::Bubble
&& interactive_bounds.visibly_contains(&event.position, cx)
{
delta = delta.coalesce(event.delta);
editor.update(cx, |editor, cx| {
let position = event.position;
let position_map: &PositionMap = &position_map;
let bounds = &interactive_bounds;
if !bounds.visibly_contains(&position, cx) {
return;
}
let line_height = position_map.line_height;
let max_glyph_width = position_map.em_width;
let (delta, axis) = match delta {
gpui::ScrollDelta::Pixels(mut pixels) => {
//Trackpad
let axis = position_map.snapshot.ongoing_scroll.filter(&mut pixels);
(pixels, axis)
}
gpui::ScrollDelta::Lines(lines) => {
//Not trackpad
let pixels =
point(lines.x * max_glyph_width, lines.y * line_height);
(pixels, None)
}
};
let scroll_position = position_map.snapshot.scroll_position();
let x = f32::from(
(scroll_position.x * max_glyph_width - delta.x) / max_glyph_width,
);
let y =
f32::from((scroll_position.y * line_height - delta.y) / line_height);
let scroll_position =
point(x, y).clamp(&point(0., 0.), &position_map.scroll_max);
editor.scroll(scroll_position, axis, cx);
cx.stop_propagation();
});
}
}
});
}
fn paint_mouse_listeners( fn paint_mouse_listeners(
&mut self, &mut self,
bounds: Bounds<Pixels>, bounds: Bounds<Pixels>,
@ -2463,21 +2499,7 @@ impl EditorElement {
stacking_order: cx.stacking_order().clone(), stacking_order: cx.stacking_order().clone(),
}; };
cx.on_mouse_event({ self.paint_scroll_wheel_listener(&interactive_bounds, layout, cx);
let position_map = layout.position_map.clone();
let editor = self.editor.clone();
let interactive_bounds = interactive_bounds.clone();
move |event: &ScrollWheelEvent, phase, cx| {
if phase == DispatchPhase::Bubble
&& interactive_bounds.visibly_contains(&event.position, cx)
{
editor.update(cx, |editor, cx| {
Self::scroll(editor, event, &position_map, &interactive_bounds, cx)
});
}
}
});
cx.on_mouse_event({ cx.on_mouse_event({
let position_map = layout.position_map.clone(); let position_map = layout.position_map.clone();

View file

@ -16,7 +16,7 @@ use lsp::DiagnosticSeverity;
use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart, Project}; use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart, Project};
use settings::Settings; use settings::Settings;
use std::{ops::Range, sync::Arc, time::Duration}; use std::{ops::Range, sync::Arc, time::Duration};
use ui::{StyledExt, Tooltip}; use ui::{prelude::*, Tooltip};
use util::TryFutureExt; use util::TryFutureExt;
use workspace::Workspace; use workspace::Workspace;
@ -514,6 +514,8 @@ impl DiagnosticPopover {
None => self.local_diagnostic.diagnostic.message.clone(), None => self.local_diagnostic.diagnostic.message.clone(),
}; };
let status_colors = cx.theme().status();
struct DiagnosticColors { struct DiagnosticColors {
pub background: Hsla, pub background: Hsla,
pub border: Hsla, pub border: Hsla,
@ -521,24 +523,24 @@ impl DiagnosticPopover {
let diagnostic_colors = match self.local_diagnostic.diagnostic.severity { let diagnostic_colors = match self.local_diagnostic.diagnostic.severity {
DiagnosticSeverity::ERROR => DiagnosticColors { DiagnosticSeverity::ERROR => DiagnosticColors {
background: style.status.error_background, background: status_colors.error_background,
border: style.status.error_border, border: status_colors.error_border,
}, },
DiagnosticSeverity::WARNING => DiagnosticColors { DiagnosticSeverity::WARNING => DiagnosticColors {
background: style.status.warning_background, background: status_colors.warning_background,
border: style.status.warning_border, border: status_colors.warning_border,
}, },
DiagnosticSeverity::INFORMATION => DiagnosticColors { DiagnosticSeverity::INFORMATION => DiagnosticColors {
background: style.status.info_background, background: status_colors.info_background,
border: style.status.info_border, border: status_colors.info_border,
}, },
DiagnosticSeverity::HINT => DiagnosticColors { DiagnosticSeverity::HINT => DiagnosticColors {
background: style.status.hint_background, background: status_colors.hint_background,
border: style.status.hint_border, border: status_colors.hint_border,
}, },
_ => DiagnosticColors { _ => DiagnosticColors {
background: style.status.ignored_background, background: status_colors.ignored_background,
border: style.status.ignored_border, border: status_colors.ignored_border,
}, },
}; };

View file

@ -95,7 +95,7 @@ pub fn up_by_rows(
text_layout_details: &TextLayoutDetails, text_layout_details: &TextLayoutDetails,
) -> (DisplayPoint, SelectionGoal) { ) -> (DisplayPoint, SelectionGoal) {
let mut goal_x = match goal { let mut goal_x = match goal {
SelectionGoal::HorizontalPosition(x) => x.into(), // todo!("Can the fields in SelectionGoal by Pixels? We should extract a geometry crate and depend on that.") SelectionGoal::HorizontalPosition(x) => x.into(),
SelectionGoal::WrappedHorizontalPosition((_, x)) => x.into(), SelectionGoal::WrappedHorizontalPosition((_, x)) => x.into(),
SelectionGoal::HorizontalRange { end, .. } => end.into(), SelectionGoal::HorizontalRange { end, .. } => end.into(),
_ => map.x_for_display_point(start, text_layout_details), _ => map.x_for_display_point(start, text_layout_details),

View file

@ -384,10 +384,12 @@ impl Editor {
) { ) {
hide_hover(self, cx); hide_hover(self, cx);
let workspace_id = self.workspace.as_ref().map(|workspace| workspace.1); let workspace_id = self.workspace.as_ref().map(|workspace| workspace.1);
let top_row = scroll_anchor let snapshot = &self.buffer().read(cx).snapshot(cx);
.anchor if !scroll_anchor.anchor.is_valid(snapshot) {
.to_point(&self.buffer().read(cx).snapshot(cx)) log::warn!("Invalid scroll anchor: {:?}", scroll_anchor);
.row; return;
}
let top_row = scroll_anchor.anchor.to_point(snapshot).row;
self.scroll_manager self.scroll_manager
.set_anchor(scroll_anchor, top_row, false, false, workspace_id, cx); .set_anchor(scroll_anchor, top_row, false, false, workspace_id, cx);
} }

View file

@ -11,10 +11,9 @@ impl Editor {
return; return;
} }
// todo!() if self.mouse_context_menu.is_some() {
// if self.mouse_context_menu.read(cx).visible() { return;
// return None; }
// }
if matches!(self.mode, EditorMode::SingleLine) { if matches!(self.mode, EditorMode::SingleLine) {
cx.propagate(); cx.propagate();

View file

@ -60,8 +60,7 @@ pub fn assert_text_with_selections(
#[allow(dead_code)] #[allow(dead_code)]
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub(crate) fn build_editor(buffer: Model<MultiBuffer>, cx: &mut ViewContext<Editor>) -> Editor { pub(crate) fn build_editor(buffer: Model<MultiBuffer>, cx: &mut ViewContext<Editor>) -> Editor {
// todo!() Editor::new(EditorMode::Full, buffer, None, cx)
Editor::new(EditorMode::Full, buffer, None, /*None,*/ cx)
} }
pub(crate) fn build_editor_with_project( pub(crate) fn build_editor_with_project(
@ -69,6 +68,5 @@ pub(crate) fn build_editor_with_project(
buffer: Model<MultiBuffer>, buffer: Model<MultiBuffer>,
cx: &mut ViewContext<Editor>, cx: &mut ViewContext<Editor>,
) -> Editor { ) -> Editor {
// todo!() Editor::new(EditorMode::Full, buffer, Some(project), cx)
Editor::new(EditorMode::Full, buffer, Some(project), /*None,*/ cx)
} }

View file

@ -1,5 +1,5 @@
use gpui::{Render, ViewContext, WeakView}; use gpui::{Render, ViewContext, WeakView};
use ui::{prelude::*, ButtonCommon, Icon, IconButton, Tooltip}; use ui::{prelude::*, ButtonCommon, IconButton, IconName, Tooltip};
use workspace::{item::ItemHandle, StatusItemView, Workspace}; use workspace::{item::ItemHandle, StatusItemView, Workspace};
use crate::{feedback_modal::FeedbackModal, GiveFeedback}; use crate::{feedback_modal::FeedbackModal, GiveFeedback};
@ -27,7 +27,7 @@ impl Render for DeployFeedbackButton {
}) })
}) })
.is_some(); .is_some();
IconButton::new("give-feedback", Icon::Envelope) IconButton::new("give-feedback", IconName::Envelope)
.style(ui::ButtonStyle::Subtle) .style(ui::ButtonStyle::Subtle)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.selected(is_open) .selected(is_open)

View file

@ -7,7 +7,7 @@ use db::kvp::KEY_VALUE_STORE;
use editor::{Editor, EditorEvent}; use editor::{Editor, EditorEvent};
use futures::AsyncReadExt; use futures::AsyncReadExt;
use gpui::{ use gpui::{
div, red, rems, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model, div, rems, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model,
PromptLevel, Render, Task, View, ViewContext, PromptLevel, Render, Task, View, ViewContext,
}; };
use isahc::Request; use isahc::Request;
@ -179,14 +179,13 @@ impl FeedbackModal {
editor editor
}); });
// Moved here because providing it inline breaks rustfmt
let placeholder_text =
"You can use markdown to organize your feedback with code and links.";
let feedback_editor = cx.new_view(|cx| { let feedback_editor = cx.new_view(|cx| {
let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx); let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx);
editor.set_placeholder_text(placeholder_text, cx); editor.set_placeholder_text(
// editor.set_show_gutter(false, cx); "You can use markdown to organize your feedback with code and links.",
cx,
);
editor.set_show_gutter(false, cx);
editor.set_vertical_scroll_margin(5, cx); editor.set_vertical_scroll_margin(5, cx);
editor editor
}); });
@ -422,10 +421,6 @@ impl Render for FeedbackModal {
let open_community_repo = let open_community_repo =
cx.listener(|_, _, cx| cx.dispatch_action(Box::new(OpenZedCommunityRepo))); cx.listener(|_, _, cx| cx.dispatch_action(Box::new(OpenZedCommunityRepo)));
// Moved this here because providing it inline breaks rustfmt
let provide_an_email_address =
"Provide an email address if you want us to be able to reply.";
v_stack() v_stack()
.elevation_3(cx) .elevation_3(cx)
.key_context("GiveFeedback") .key_context("GiveFeedback")
@ -434,11 +429,8 @@ impl Render for FeedbackModal {
.max_w(rems(96.)) .max_w(rems(96.))
.h(rems(32.)) .h(rems(32.))
.p_4() .p_4()
.gap_4() .gap_2()
.child(v_stack().child( .child(Headline::new("Share Feedback"))
// TODO: Add Headline component to `ui2`
div().text_xl().child("Share Feedback"),
))
.child( .child(
Label::new(if self.character_count < *FEEDBACK_CHAR_LIMIT.start() { Label::new(if self.character_count < *FEEDBACK_CHAR_LIMIT.start() {
format!( format!(
@ -468,17 +460,26 @@ impl Render for FeedbackModal {
.child(self.feedback_editor.clone()), .child(self.feedback_editor.clone()),
) )
.child( .child(
h_stack() v_stack()
.bg(cx.theme().colors().editor_background) .gap_1()
.p_2() .child(
.border() h_stack()
.rounded_md() .bg(cx.theme().colors().editor_background)
.border_color(if self.valid_email_address() { .p_2()
cx.theme().colors().border .border()
} else { .rounded_md()
red() .border_color(if self.valid_email_address() {
}) cx.theme().colors().border
.child(self.email_address_editor.clone()), } else {
cx.theme().status().error_border
})
.child(self.email_address_editor.clone()),
)
.child(
Label::new("Provide an email address if you want us to be able to reply.")
.size(LabelSize::Small)
.color(Color::Muted),
),
) )
.child( .child(
h_stack() h_stack()
@ -487,7 +488,7 @@ impl Render for FeedbackModal {
.child( .child(
Button::new("community_repository", "Community Repository") Button::new("community_repository", "Community Repository")
.style(ButtonStyle::Transparent) .style(ButtonStyle::Transparent)
.icon(Icon::ExternalLink) .icon(IconName::ExternalLink)
.icon_position(IconPosition::End) .icon_position(IconPosition::End)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.on_click(open_community_repo), .on_click(open_community_repo),
@ -515,12 +516,7 @@ impl Render for FeedbackModal {
this.submit(cx).detach(); this.submit(cx).detach();
})) }))
.tooltip(move |cx| { .tooltip(move |cx| {
Tooltip::with_meta( Tooltip::text("Submit feedback to the Zed team.", cx)
"Submit feedback to the Zed team.",
None,
provide_an_email_address,
cx,
)
}) })
.when(!self.can_submit(), |this| this.disabled(true)), .when(!self.can_submit(), |this| this.disabled(true)),
), ),

View file

@ -1297,7 +1297,7 @@ mod tests {
// so that one should be sorted earlier // so that one should be sorted earlier
let b_path = ProjectPath { let b_path = ProjectPath {
worktree_id, worktree_id,
path: Arc::from(Path::new("/root/dir2/b.txt")), path: Arc::from(Path::new("dir2/b.txt")),
}; };
workspace workspace
.update(cx, |workspace, cx| { .update(cx, |workspace, cx| {

View file

@ -50,7 +50,7 @@ impl Render for Menu {
.on_action(|this, move: &MoveDown, cx| { .on_action(|this, move: &MoveDown, cx| {
// ... // ...
}) })
.children(todo!()) .children(unimplemented!())
} }
} }
``` ```
@ -68,7 +68,7 @@ impl Render for Menu {
.on_action(|this, move: &MoveDown, cx| { .on_action(|this, move: &MoveDown, cx| {
// ... // ...
}) })
.children(todo!()) .children(unimplemented!())
} }
} }
``` ```

View file

@ -104,7 +104,7 @@ pub struct ActionData {
} }
/// This constant must be public to be accessible from other crates. /// This constant must be public to be accessible from other crates.
/// But it's existence is an implementation detail and should not be used directly. /// But its existence is an implementation detail and should not be used directly.
#[doc(hidden)] #[doc(hidden)]
#[linkme::distributed_slice] #[linkme::distributed_slice]
pub static __GPUI_ACTIONS: [MacroActionBuilder]; pub static __GPUI_ACTIONS: [MacroActionBuilder];
@ -114,14 +114,26 @@ impl ActionRegistry {
pub(crate) fn load_actions(&mut self) { pub(crate) fn load_actions(&mut self) {
for builder in __GPUI_ACTIONS { for builder in __GPUI_ACTIONS {
let action = builder(); let action = builder();
//todo(remove) self.insert_action(action);
let name: SharedString = action.name.into();
self.builders_by_name.insert(name.clone(), action.build);
self.names_by_type_id.insert(action.type_id, name.clone());
self.all_names.push(name);
} }
} }
#[cfg(test)]
pub(crate) fn load_action<A: Action>(&mut self) {
self.insert_action(ActionData {
name: A::debug_name(),
type_id: TypeId::of::<A>(),
build: A::build,
});
}
fn insert_action(&mut self, action: ActionData) {
let name: SharedString = action.name.into();
self.builders_by_name.insert(name.clone(), action.build);
self.names_by_type_id.insert(action.type_id, name.clone());
self.all_names.push(name);
}
/// Construct an action based on its name and optional JSON parameters sourced from the keymap. /// Construct an action based on its name and optional JSON parameters sourced from the keymap.
pub fn build_action_type(&self, type_id: &TypeId) -> Result<Box<dyn Action>> { pub fn build_action_type(&self, type_id: &TypeId) -> Result<Box<dyn Action>> {
let name = self let name = self
@ -203,7 +215,6 @@ macro_rules! __impl_action {
) )
} }
// todo!() why is this needed in addition to name?
fn debug_name() -> &'static str fn debug_name() -> &'static str
where where
Self: ::std::marker::Sized Self: ::std::marker::Sized

View file

@ -45,11 +45,13 @@ use util::{
/// Temporary(?) wrapper around [`RefCell<AppContext>`] to help us debug any double borrows. /// Temporary(?) wrapper around [`RefCell<AppContext>`] to help us debug any double borrows.
/// Strongly consider removing after stabilization. /// Strongly consider removing after stabilization.
#[doc(hidden)]
pub struct AppCell { pub struct AppCell {
app: RefCell<AppContext>, app: RefCell<AppContext>,
} }
impl AppCell { impl AppCell {
#[doc(hidden)]
#[track_caller] #[track_caller]
pub fn borrow(&self) -> AppRef { pub fn borrow(&self) -> AppRef {
if option_env!("TRACK_THREAD_BORROWS").is_some() { if option_env!("TRACK_THREAD_BORROWS").is_some() {
@ -59,6 +61,7 @@ impl AppCell {
AppRef(self.app.borrow()) AppRef(self.app.borrow())
} }
#[doc(hidden)]
#[track_caller] #[track_caller]
pub fn borrow_mut(&self) -> AppRefMut { pub fn borrow_mut(&self) -> AppRefMut {
if option_env!("TRACK_THREAD_BORROWS").is_some() { if option_env!("TRACK_THREAD_BORROWS").is_some() {
@ -69,6 +72,7 @@ impl AppCell {
} }
} }
#[doc(hidden)]
#[derive(Deref, DerefMut)] #[derive(Deref, DerefMut)]
pub struct AppRef<'a>(Ref<'a, AppContext>); pub struct AppRef<'a>(Ref<'a, AppContext>);
@ -81,6 +85,7 @@ impl<'a> Drop for AppRef<'a> {
} }
} }
#[doc(hidden)]
#[derive(Deref, DerefMut)] #[derive(Deref, DerefMut)]
pub struct AppRefMut<'a>(RefMut<'a, AppContext>); pub struct AppRefMut<'a>(RefMut<'a, AppContext>);
@ -93,6 +98,8 @@ impl<'a> Drop for AppRefMut<'a> {
} }
} }
/// A reference to a GPUI application, typically constructed in the `main` function of your app.
/// You won't interact with this type much outside of initial configuration and startup.
pub struct App(Rc<AppCell>); pub struct App(Rc<AppCell>);
/// Represents an application before it is fully launched. Once your app is /// Represents an application before it is fully launched. Once your app is
@ -136,6 +143,8 @@ impl App {
self self
} }
/// Invokes a handler when an already-running application is launched.
/// On macOS, this can occur when the application icon is double-clicked or the app is launched via the dock.
pub fn on_reopen<F>(&self, mut callback: F) -> &Self pub fn on_reopen<F>(&self, mut callback: F) -> &Self
where where
F: 'static + FnMut(&mut AppContext), F: 'static + FnMut(&mut AppContext),
@ -149,18 +158,22 @@ impl App {
self self
} }
/// Returns metadata associated with the application
pub fn metadata(&self) -> AppMetadata { pub fn metadata(&self) -> AppMetadata {
self.0.borrow().app_metadata.clone() self.0.borrow().app_metadata.clone()
} }
/// Returns a handle to the [`BackgroundExecutor`] associated with this app, which can be used to spawn futures in the background.
pub fn background_executor(&self) -> BackgroundExecutor { pub fn background_executor(&self) -> BackgroundExecutor {
self.0.borrow().background_executor.clone() self.0.borrow().background_executor.clone()
} }
/// Returns a handle to the [`ForegroundExecutor`] associated with this app, which can be used to spawn futures in the foreground.
pub fn foreground_executor(&self) -> ForegroundExecutor { pub fn foreground_executor(&self) -> ForegroundExecutor {
self.0.borrow().foreground_executor.clone() self.0.borrow().foreground_executor.clone()
} }
/// Returns a reference to the [`TextSystem`] associated with this app.
pub fn text_system(&self) -> Arc<TextSystem> { pub fn text_system(&self) -> Arc<TextSystem> {
self.0.borrow().text_system.clone() self.0.borrow().text_system.clone()
} }
@ -174,12 +187,6 @@ type QuitHandler = Box<dyn FnOnce(&mut AppContext) -> LocalBoxFuture<'static, ()
type ReleaseListener = Box<dyn FnOnce(&mut dyn Any, &mut AppContext) + 'static>; type ReleaseListener = Box<dyn FnOnce(&mut dyn Any, &mut AppContext) + 'static>;
type NewViewListener = Box<dyn FnMut(AnyView, &mut WindowContext) + 'static>; type NewViewListener = Box<dyn FnMut(AnyView, &mut WindowContext) + 'static>;
// struct FrameConsumer {
// next_frame_callbacks: Vec<FrameCallback>,
// task: Task<()>,
// display_linker
// }
pub struct AppContext { pub struct AppContext {
pub(crate) this: Weak<AppCell>, pub(crate) this: Weak<AppCell>,
pub(crate) platform: Rc<dyn Platform>, pub(crate) platform: Rc<dyn Platform>,
@ -292,7 +299,7 @@ impl AppContext {
app app
} }
/// Quit the application gracefully. Handlers registered with `ModelContext::on_app_quit` /// Quit the application gracefully. Handlers registered with [`ModelContext::on_app_quit`]
/// will be given 100ms to complete before exiting. /// will be given 100ms to complete before exiting.
pub fn shutdown(&mut self) { pub fn shutdown(&mut self) {
let mut futures = Vec::new(); let mut futures = Vec::new();
@ -314,10 +321,12 @@ impl AppContext {
} }
} }
/// Gracefully quit the application via the platform's standard routine.
pub fn quit(&mut self) { pub fn quit(&mut self) {
self.platform.quit(); self.platform.quit();
} }
/// Get metadata about the app and platform.
pub fn app_metadata(&self) -> AppMetadata { pub fn app_metadata(&self) -> AppMetadata {
self.app_metadata.clone() self.app_metadata.clone()
} }
@ -340,6 +349,7 @@ impl AppContext {
result result
} }
/// Arrange a callback to be invoked when the given model or view calls `notify` on its respective context.
pub fn observe<W, E>( pub fn observe<W, E>(
&mut self, &mut self,
entity: &E, entity: &E,
@ -355,7 +365,7 @@ impl AppContext {
}) })
} }
pub fn observe_internal<W, E>( pub(crate) fn observe_internal<W, E>(
&mut self, &mut self,
entity: &E, entity: &E,
mut on_notify: impl FnMut(E, &mut AppContext) -> bool + 'static, mut on_notify: impl FnMut(E, &mut AppContext) -> bool + 'static,
@ -380,15 +390,17 @@ impl AppContext {
subscription subscription
} }
pub fn subscribe<T, E, Evt>( /// Arrange for the given callback to be invoked whenever the given model or view emits an event of a given type.
/// The callback is provided a handle to the emitting entity and a reference to the emitted event.
pub fn subscribe<T, E, Event>(
&mut self, &mut self,
entity: &E, entity: &E,
mut on_event: impl FnMut(E, &Evt, &mut AppContext) + 'static, mut on_event: impl FnMut(E, &Event, &mut AppContext) + 'static,
) -> Subscription ) -> Subscription
where where
T: 'static + EventEmitter<Evt>, T: 'static + EventEmitter<Event>,
E: Entity<T>, E: Entity<T>,
Evt: 'static, Event: 'static,
{ {
self.subscribe_internal(entity, move |entity, event, cx| { self.subscribe_internal(entity, move |entity, event, cx| {
on_event(entity, event, cx); on_event(entity, event, cx);
@ -426,6 +438,9 @@ impl AppContext {
subscription subscription
} }
/// Returns handles to all open windows in the application.
/// Each handle could be downcast to a handle typed for the root view of that window.
/// To find all windows of a given type, you could filter on
pub fn windows(&self) -> Vec<AnyWindowHandle> { pub fn windows(&self) -> Vec<AnyWindowHandle> {
self.windows self.windows
.values() .values()
@ -565,7 +580,7 @@ impl AppContext {
self.pending_effects.push_back(effect); self.pending_effects.push_back(effect);
} }
/// Called at the end of AppContext::update to complete any side effects /// Called at the end of [`AppContext::update`] to complete any side effects
/// such as notifying observers, emitting events, etc. Effects can themselves /// such as notifying observers, emitting events, etc. Effects can themselves
/// cause effects, so we continue looping until all effects are processed. /// cause effects, so we continue looping until all effects are processed.
fn flush_effects(&mut self) { fn flush_effects(&mut self) {

View file

@ -82,6 +82,7 @@ impl Context for AsyncAppContext {
} }
impl AsyncAppContext { impl AsyncAppContext {
/// Schedules all windows in the application to be redrawn.
pub fn refresh(&mut self) -> Result<()> { pub fn refresh(&mut self) -> Result<()> {
let app = self let app = self
.app .app
@ -92,14 +93,17 @@ impl AsyncAppContext {
Ok(()) Ok(())
} }
/// Get an executor which can be used to spawn futures in the background.
pub fn background_executor(&self) -> &BackgroundExecutor { pub fn background_executor(&self) -> &BackgroundExecutor {
&self.background_executor &self.background_executor
} }
/// Get an executor which can be used to spawn futures in the foreground.
pub fn foreground_executor(&self) -> &ForegroundExecutor { pub fn foreground_executor(&self) -> &ForegroundExecutor {
&self.foreground_executor &self.foreground_executor
} }
/// Invoke the given function in the context of the app, then flush any effects produced during its invocation.
pub fn update<R>(&self, f: impl FnOnce(&mut AppContext) -> R) -> Result<R> { pub fn update<R>(&self, f: impl FnOnce(&mut AppContext) -> R) -> Result<R> {
let app = self let app = self
.app .app
@ -109,6 +113,7 @@ impl AsyncAppContext {
Ok(f(&mut lock)) Ok(f(&mut lock))
} }
/// Open a window with the given options based on the root view returned by the given function.
pub fn open_window<V>( pub fn open_window<V>(
&self, &self,
options: crate::WindowOptions, options: crate::WindowOptions,
@ -125,6 +130,7 @@ impl AsyncAppContext {
Ok(lock.open_window(options, build_root_view)) Ok(lock.open_window(options, build_root_view))
} }
/// Schedule a future to be polled in the background.
pub fn spawn<Fut, R>(&self, f: impl FnOnce(AsyncAppContext) -> Fut) -> Task<R> pub fn spawn<Fut, R>(&self, f: impl FnOnce(AsyncAppContext) -> Fut) -> Task<R>
where where
Fut: Future<Output = R> + 'static, Fut: Future<Output = R> + 'static,

View file

@ -19,7 +19,10 @@ use std::{
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
use collections::HashMap; use collections::HashMap;
slotmap::new_key_type! { pub struct EntityId; } slotmap::new_key_type! {
/// A unique identifier for a model or view across the application.
pub struct EntityId;
}
impl EntityId { impl EntityId {
pub fn as_u64(self) -> u64 { pub fn as_u64(self) -> u64 {

View file

@ -1,3 +1,5 @@
#![deny(missing_docs)]
use crate::{ use crate::{
div, Action, AnyView, AnyWindowHandle, AppCell, AppContext, AsyncAppContext, div, Action, AnyView, AnyWindowHandle, AppCell, AppContext, AsyncAppContext,
BackgroundExecutor, ClipboardItem, Context, Entity, EventEmitter, ForegroundExecutor, BackgroundExecutor, ClipboardItem, Context, Entity, EventEmitter, ForegroundExecutor,
@ -9,14 +11,21 @@ use anyhow::{anyhow, bail};
use futures::{Stream, StreamExt}; use futures::{Stream, StreamExt};
use std::{future::Future, ops::Deref, rc::Rc, sync::Arc, time::Duration}; use std::{future::Future, ops::Deref, rc::Rc, sync::Arc, time::Duration};
/// A TestAppContext is provided to tests created with `#[gpui::test]`, it provides
/// an implementation of `Context` with additional methods that are useful in tests.
#[derive(Clone)] #[derive(Clone)]
pub struct TestAppContext { pub struct TestAppContext {
#[doc(hidden)]
pub app: Rc<AppCell>, pub app: Rc<AppCell>,
#[doc(hidden)]
pub background_executor: BackgroundExecutor, pub background_executor: BackgroundExecutor,
#[doc(hidden)]
pub foreground_executor: ForegroundExecutor, pub foreground_executor: ForegroundExecutor,
#[doc(hidden)]
pub dispatcher: TestDispatcher, pub dispatcher: TestDispatcher,
pub test_platform: Rc<TestPlatform>, test_platform: Rc<TestPlatform>,
text_system: Arc<TextSystem>, text_system: Arc<TextSystem>,
fn_name: Option<&'static str>,
} }
impl Context for TestAppContext { impl Context for TestAppContext {
@ -76,7 +85,8 @@ impl Context for TestAppContext {
} }
impl TestAppContext { impl TestAppContext {
pub fn new(dispatcher: TestDispatcher) -> Self { /// Creates a new `TestAppContext`. Usually you can rely on `#[gpui::test]` to do this for you.
pub fn new(dispatcher: TestDispatcher, fn_name: Option<&'static str>) -> Self {
let arc_dispatcher = Arc::new(dispatcher.clone()); let arc_dispatcher = Arc::new(dispatcher.clone());
let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
let foreground_executor = ForegroundExecutor::new(arc_dispatcher); let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
@ -92,41 +102,61 @@ impl TestAppContext {
dispatcher: dispatcher.clone(), dispatcher: dispatcher.clone(),
test_platform: platform, test_platform: platform,
text_system, text_system,
fn_name,
} }
} }
pub fn new_app(&self) -> TestAppContext { /// The name of the test function that created this `TestAppContext`
Self::new(self.dispatcher.clone()) pub fn test_function_name(&self) -> Option<&'static str> {
self.fn_name
} }
/// Checks whether there have been any new path prompts received by the platform.
pub fn did_prompt_for_new_path(&self) -> bool {
self.test_platform.did_prompt_for_new_path()
}
/// returns a new `TestAppContext` re-using the same executors to interleave tasks.
pub fn new_app(&self) -> TestAppContext {
Self::new(self.dispatcher.clone(), self.fn_name)
}
/// Simulates quitting the app.
pub fn quit(&self) { pub fn quit(&self) {
self.app.borrow_mut().shutdown(); self.app.borrow_mut().shutdown();
} }
/// Schedules all windows to be redrawn on the next effect cycle.
pub fn refresh(&mut self) -> Result<()> { pub fn refresh(&mut self) -> Result<()> {
let mut app = self.app.borrow_mut(); let mut app = self.app.borrow_mut();
app.refresh(); app.refresh();
Ok(()) Ok(())
} }
/// Returns an executor (for running tasks in the background)
pub fn executor(&self) -> BackgroundExecutor { pub fn executor(&self) -> BackgroundExecutor {
self.background_executor.clone() self.background_executor.clone()
} }
/// Returns an executor (for running tasks on the main thread)
pub fn foreground_executor(&self) -> &ForegroundExecutor { pub fn foreground_executor(&self) -> &ForegroundExecutor {
&self.foreground_executor &self.foreground_executor
} }
/// Gives you an `&mut AppContext` for the duration of the closure
pub fn update<R>(&self, f: impl FnOnce(&mut AppContext) -> R) -> R { pub fn update<R>(&self, f: impl FnOnce(&mut AppContext) -> R) -> R {
let mut cx = self.app.borrow_mut(); let mut cx = self.app.borrow_mut();
cx.update(f) cx.update(f)
} }
/// Gives you an `&AppContext` for the duration of the closure
pub fn read<R>(&self, f: impl FnOnce(&AppContext) -> R) -> R { pub fn read<R>(&self, f: impl FnOnce(&AppContext) -> R) -> R {
let cx = self.app.borrow(); let cx = self.app.borrow();
f(&*cx) f(&*cx)
} }
/// Adds a new window. The Window will always be backed by a `TestWindow` which
/// can be retrieved with `self.test_window(handle)`
pub fn add_window<F, V>(&mut self, build_window: F) -> WindowHandle<V> pub fn add_window<F, V>(&mut self, build_window: F) -> WindowHandle<V>
where where
F: FnOnce(&mut ViewContext<V>) -> V, F: FnOnce(&mut ViewContext<V>) -> V,
@ -136,12 +166,16 @@ impl TestAppContext {
cx.open_window(WindowOptions::default(), |cx| cx.new_view(build_window)) cx.open_window(WindowOptions::default(), |cx| cx.new_view(build_window))
} }
/// Adds a new window with no content.
pub fn add_empty_window(&mut self) -> AnyWindowHandle { pub fn add_empty_window(&mut self) -> AnyWindowHandle {
let mut cx = self.app.borrow_mut(); let mut cx = self.app.borrow_mut();
cx.open_window(WindowOptions::default(), |cx| cx.new_view(|_| EmptyView {})) cx.open_window(WindowOptions::default(), |cx| cx.new_view(|_| EmptyView {}))
.any_handle .any_handle
} }
/// Adds a new window, and returns its root view and a `VisualTestContext` which can be used
/// as a `WindowContext` for the rest of the test. Typically you would shadow this context with
/// the returned one. `let (view, cx) = cx.add_window_view(...);`
pub fn add_window_view<F, V>(&mut self, build_window: F) -> (View<V>, &mut VisualTestContext) pub fn add_window_view<F, V>(&mut self, build_window: F) -> (View<V>, &mut VisualTestContext)
where where
F: FnOnce(&mut ViewContext<V>) -> V, F: FnOnce(&mut ViewContext<V>) -> V,
@ -152,22 +186,28 @@ impl TestAppContext {
drop(cx); drop(cx);
let view = window.root_view(self).unwrap(); let view = window.root_view(self).unwrap();
let cx = Box::new(VisualTestContext::from_window(*window.deref(), self)); let cx = Box::new(VisualTestContext::from_window(*window.deref(), self));
cx.run_until_parked();
// it might be nice to try and cleanup these at the end of each test. // it might be nice to try and cleanup these at the end of each test.
(view, Box::leak(cx)) (view, Box::leak(cx))
} }
/// returns the TextSystem
pub fn text_system(&self) -> &Arc<TextSystem> { pub fn text_system(&self) -> &Arc<TextSystem> {
&self.text_system &self.text_system
} }
/// Simulates writing to the platform clipboard
pub fn write_to_clipboard(&self, item: ClipboardItem) { pub fn write_to_clipboard(&self, item: ClipboardItem) {
self.test_platform.write_to_clipboard(item) self.test_platform.write_to_clipboard(item)
} }
/// Simulates reading from the platform clipboard.
/// This will return the most recent value from `write_to_clipboard`.
pub fn read_from_clipboard(&self) -> Option<ClipboardItem> { pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
self.test_platform.read_from_clipboard() self.test_platform.read_from_clipboard()
} }
/// Simulates choosing a File in the platform's "Open" dialog.
pub fn simulate_new_path_selection( pub fn simulate_new_path_selection(
&self, &self,
select_path: impl FnOnce(&std::path::Path) -> Option<std::path::PathBuf>, select_path: impl FnOnce(&std::path::Path) -> Option<std::path::PathBuf>,
@ -175,22 +215,27 @@ impl TestAppContext {
self.test_platform.simulate_new_path_selection(select_path); self.test_platform.simulate_new_path_selection(select_path);
} }
/// Simulates clicking a button in an platform-level alert dialog.
pub fn simulate_prompt_answer(&self, button_ix: usize) { pub fn simulate_prompt_answer(&self, button_ix: usize) {
self.test_platform.simulate_prompt_answer(button_ix); self.test_platform.simulate_prompt_answer(button_ix);
} }
/// Returns true if there's an alert dialog open.
pub fn has_pending_prompt(&self) -> bool { pub fn has_pending_prompt(&self) -> bool {
self.test_platform.has_pending_prompt() self.test_platform.has_pending_prompt()
} }
/// Simulates the user resizing the window to the new size.
pub fn simulate_window_resize(&self, window_handle: AnyWindowHandle, size: Size<Pixels>) { pub fn simulate_window_resize(&self, window_handle: AnyWindowHandle, size: Size<Pixels>) {
self.test_window(window_handle).simulate_resize(size); self.test_window(window_handle).simulate_resize(size);
} }
/// Returns all windows open in the test.
pub fn windows(&self) -> Vec<AnyWindowHandle> { pub fn windows(&self) -> Vec<AnyWindowHandle> {
self.app.borrow().windows().clone() self.app.borrow().windows().clone()
} }
/// Run the given task on the main thread.
pub fn spawn<Fut, R>(&self, f: impl FnOnce(AsyncAppContext) -> Fut) -> Task<R> pub fn spawn<Fut, R>(&self, f: impl FnOnce(AsyncAppContext) -> Fut) -> Task<R>
where where
Fut: Future<Output = R> + 'static, Fut: Future<Output = R> + 'static,
@ -199,16 +244,20 @@ impl TestAppContext {
self.foreground_executor.spawn(f(self.to_async())) self.foreground_executor.spawn(f(self.to_async()))
} }
/// true if the given global is defined
pub fn has_global<G: 'static>(&self) -> bool { pub fn has_global<G: 'static>(&self) -> bool {
let app = self.app.borrow(); let app = self.app.borrow();
app.has_global::<G>() app.has_global::<G>()
} }
/// runs the given closure with a reference to the global
/// panics if `has_global` would return false.
pub fn read_global<G: 'static, R>(&self, read: impl FnOnce(&G, &AppContext) -> R) -> R { pub fn read_global<G: 'static, R>(&self, read: impl FnOnce(&G, &AppContext) -> R) -> R {
let app = self.app.borrow(); let app = self.app.borrow();
read(app.global(), &app) read(app.global(), &app)
} }
/// runs the given closure with a reference to the global (if set)
pub fn try_read_global<G: 'static, R>( pub fn try_read_global<G: 'static, R>(
&self, &self,
read: impl FnOnce(&G, &AppContext) -> R, read: impl FnOnce(&G, &AppContext) -> R,
@ -217,11 +266,13 @@ impl TestAppContext {
Some(read(lock.try_global()?, &lock)) Some(read(lock.try_global()?, &lock))
} }
/// sets the global in this context.
pub fn set_global<G: 'static>(&mut self, global: G) { pub fn set_global<G: 'static>(&mut self, global: G) {
let mut lock = self.app.borrow_mut(); let mut lock = self.app.borrow_mut();
lock.set_global(global); lock.set_global(global);
} }
/// updates the global in this context. (panics if `has_global` would return false)
pub fn update_global<G: 'static, R>( pub fn update_global<G: 'static, R>(
&mut self, &mut self,
update: impl FnOnce(&mut G, &mut AppContext) -> R, update: impl FnOnce(&mut G, &mut AppContext) -> R,
@ -230,6 +281,8 @@ impl TestAppContext {
lock.update_global(update) lock.update_global(update)
} }
/// Returns an `AsyncAppContext` which can be used to run tasks that expect to be on a background
/// thread on the current thread in tests.
pub fn to_async(&self) -> AsyncAppContext { pub fn to_async(&self) -> AsyncAppContext {
AsyncAppContext { AsyncAppContext {
app: Rc::downgrade(&self.app), app: Rc::downgrade(&self.app),
@ -238,6 +291,12 @@ impl TestAppContext {
} }
} }
/// Wait until there are no more pending tasks.
pub fn run_until_parked(&mut self) {
self.background_executor.run_until_parked()
}
/// Simulate dispatching an action to the currently focused node in the window.
pub fn dispatch_action<A>(&mut self, window: AnyWindowHandle, action: A) pub fn dispatch_action<A>(&mut self, window: AnyWindowHandle, action: A)
where where
A: Action, A: Action,
@ -251,7 +310,8 @@ impl TestAppContext {
/// simulate_keystrokes takes a space-separated list of keys to type. /// simulate_keystrokes takes a space-separated list of keys to type.
/// cx.simulate_keystrokes("cmd-shift-p b k s p enter") /// cx.simulate_keystrokes("cmd-shift-p b k s p enter")
/// will run backspace on the current editor through the command palette. /// in Zed, this will run backspace on the current editor through the command palette.
/// This will also run the background executor until it's parked.
pub fn simulate_keystrokes(&mut self, window: AnyWindowHandle, keystrokes: &str) { pub fn simulate_keystrokes(&mut self, window: AnyWindowHandle, keystrokes: &str) {
for keystroke in keystrokes for keystroke in keystrokes
.split(" ") .split(" ")
@ -266,7 +326,8 @@ impl TestAppContext {
/// simulate_input takes a string of text to type. /// simulate_input takes a string of text to type.
/// cx.simulate_input("abc") /// cx.simulate_input("abc")
/// will type abc into your current editor. /// will type abc into your current editor
/// This will also run the background executor until it's parked.
pub fn simulate_input(&mut self, window: AnyWindowHandle, input: &str) { pub fn simulate_input(&mut self, window: AnyWindowHandle, input: &str) {
for keystroke in input.split("").map(Keystroke::parse).map(Result::unwrap) { for keystroke in input.split("").map(Keystroke::parse).map(Result::unwrap) {
self.dispatch_keystroke(window, keystroke.into(), false); self.dispatch_keystroke(window, keystroke.into(), false);
@ -275,6 +336,7 @@ impl TestAppContext {
self.background_executor.run_until_parked() self.background_executor.run_until_parked()
} }
/// dispatches a single Keystroke (see also `simulate_keystrokes` and `simulate_input`)
pub fn dispatch_keystroke( pub fn dispatch_keystroke(
&mut self, &mut self,
window: AnyWindowHandle, window: AnyWindowHandle,
@ -285,6 +347,7 @@ impl TestAppContext {
.simulate_keystroke(keystroke, is_held) .simulate_keystroke(keystroke, is_held)
} }
/// Returns the `TestWindow` backing the given handle.
pub fn test_window(&self, window: AnyWindowHandle) -> TestWindow { pub fn test_window(&self, window: AnyWindowHandle) -> TestWindow {
self.app self.app
.borrow_mut() .borrow_mut()
@ -299,6 +362,7 @@ impl TestAppContext {
.clone() .clone()
} }
/// Returns a stream of notifications whenever the View or Model is updated.
pub fn notifications<T: 'static>(&mut self, entity: &impl Entity<T>) -> impl Stream<Item = ()> { pub fn notifications<T: 'static>(&mut self, entity: &impl Entity<T>) -> impl Stream<Item = ()> {
let (tx, rx) = futures::channel::mpsc::unbounded(); let (tx, rx) = futures::channel::mpsc::unbounded();
self.update(|cx| { self.update(|cx| {
@ -315,6 +379,7 @@ impl TestAppContext {
rx rx
} }
/// Retuens a stream of events emitted by the given Model.
pub fn events<Evt, T: 'static + EventEmitter<Evt>>( pub fn events<Evt, T: 'static + EventEmitter<Evt>>(
&mut self, &mut self,
entity: &Model<T>, entity: &Model<T>,
@ -333,6 +398,8 @@ impl TestAppContext {
rx rx
} }
/// Runs until the given condition becomes true. (Prefer `run_until_parked` if you
/// don't need to jump in at a specific time).
pub async fn condition<T: 'static>( pub async fn condition<T: 'static>(
&mut self, &mut self,
model: &Model<T>, model: &Model<T>,
@ -362,6 +429,7 @@ impl TestAppContext {
} }
impl<T: Send> Model<T> { impl<T: Send> Model<T> {
/// Block until the next event is emitted by the model, then return it.
pub fn next_event<Evt>(&self, cx: &mut TestAppContext) -> Evt pub fn next_event<Evt>(&self, cx: &mut TestAppContext) -> Evt
where where
Evt: Send + Clone + 'static, Evt: Send + Clone + 'static,
@ -391,6 +459,7 @@ impl<T: Send> Model<T> {
} }
impl<V: 'static> View<V> { impl<V: 'static> View<V> {
/// Returns a future that resolves when the view is next updated.
pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> { pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
use postage::prelude::{Sink as _, Stream as _}; use postage::prelude::{Sink as _, Stream as _};
@ -417,6 +486,7 @@ impl<V: 'static> View<V> {
} }
impl<V> View<V> { impl<V> View<V> {
/// Returns a future that resolves when the condition becomes true.
pub fn condition<Evt>( pub fn condition<Evt>(
&self, &self,
cx: &TestAppContext, cx: &TestAppContext,
@ -429,7 +499,7 @@ impl<V> View<V> {
use postage::prelude::{Sink as _, Stream as _}; use postage::prelude::{Sink as _, Stream as _};
let (tx, mut rx) = postage::mpsc::channel(1024); let (tx, mut rx) = postage::mpsc::channel(1024);
let timeout_duration = Duration::from_millis(100); //todo!() cx.condition_duration(); let timeout_duration = Duration::from_millis(100);
let mut cx = cx.app.borrow_mut(); let mut cx = cx.app.borrow_mut();
let subscriptions = ( let subscriptions = (
@ -467,12 +537,11 @@ impl<V> View<V> {
} }
} }
// todo!(start_waiting) cx.borrow().background_executor().start_waiting();
// cx.borrow().foreground_executor().start_waiting();
rx.recv() rx.recv()
.await .await
.expect("view dropped with pending condition"); .expect("view dropped with pending condition");
// cx.borrow().foreground_executor().finish_waiting(); cx.borrow().background_executor().finish_waiting();
} }
}) })
.await .await
@ -484,18 +553,25 @@ impl<V> View<V> {
use derive_more::{Deref, DerefMut}; use derive_more::{Deref, DerefMut};
#[derive(Deref, DerefMut, Clone)] #[derive(Deref, DerefMut, Clone)]
/// A VisualTestContext is the test-equivalent of a `WindowContext`. It allows you to
/// run window-specific test code.
pub struct VisualTestContext { pub struct VisualTestContext {
#[deref] #[deref]
#[deref_mut] #[deref_mut]
cx: TestAppContext, /// cx is the original TestAppContext (you can more easily access this using Deref)
pub cx: TestAppContext,
window: AnyWindowHandle, window: AnyWindowHandle,
} }
impl<'a> VisualTestContext { impl<'a> VisualTestContext {
/// Provides the `WindowContext` for the duration of the closure.
pub fn update<R>(&mut self, f: impl FnOnce(&mut WindowContext) -> R) -> R { pub fn update<R>(&mut self, f: impl FnOnce(&mut WindowContext) -> R) -> R {
self.cx.update_window(self.window, |_, cx| f(cx)).unwrap() self.cx.update_window(self.window, |_, cx| f(cx)).unwrap()
} }
/// Create a new VisualTestContext. You would typically shadow the passed in
/// TestAppContext with this, as this is typically more useful.
/// `let cx = VisualTestContext::from_window(window, cx);`
pub fn from_window(window: AnyWindowHandle, cx: &TestAppContext) -> Self { pub fn from_window(window: AnyWindowHandle, cx: &TestAppContext) -> Self {
Self { Self {
cx: cx.clone(), cx: cx.clone(),
@ -503,10 +579,12 @@ impl<'a> VisualTestContext {
} }
} }
/// Wait until there are no more pending tasks.
pub fn run_until_parked(&self) { pub fn run_until_parked(&self) {
self.cx.background_executor.run_until_parked(); self.cx.background_executor.run_until_parked();
} }
/// Dispatch the action to the currently focused node.
pub fn dispatch_action<A>(&mut self, action: A) pub fn dispatch_action<A>(&mut self, action: A)
where where
A: Action, A: Action,
@ -514,24 +592,32 @@ impl<'a> VisualTestContext {
self.cx.dispatch_action(self.window, action) self.cx.dispatch_action(self.window, action)
} }
/// Read the title off the window (set by `WindowContext#set_window_title`)
pub fn window_title(&mut self) -> Option<String> { pub fn window_title(&mut self) -> Option<String> {
self.cx.test_window(self.window).0.lock().title.clone() self.cx.test_window(self.window).0.lock().title.clone()
} }
/// Simulate a sequence of keystrokes `cx.simulate_keystrokes("cmd-p escape")`
/// Automatically runs until parked.
pub fn simulate_keystrokes(&mut self, keystrokes: &str) { pub fn simulate_keystrokes(&mut self, keystrokes: &str) {
self.cx.simulate_keystrokes(self.window, keystrokes) self.cx.simulate_keystrokes(self.window, keystrokes)
} }
/// Simulate typing text `cx.simulate_input("hello")`
/// Automatically runs until parked.
pub fn simulate_input(&mut self, input: &str) { pub fn simulate_input(&mut self, input: &str) {
self.cx.simulate_input(self.window, input) self.cx.simulate_input(self.window, input)
} }
/// Simulates the user blurring the window.
pub fn deactivate_window(&mut self) { pub fn deactivate_window(&mut self) {
if Some(self.window) == self.test_platform.active_window() { if Some(self.window) == self.test_platform.active_window() {
self.test_platform.set_active_window(None) self.test_platform.set_active_window(None)
} }
self.background_executor.run_until_parked(); self.background_executor.run_until_parked();
} }
/// Simulates the user closing the window.
/// Returns true if the window was closed. /// Returns true if the window was closed.
pub fn simulate_close(&mut self) -> bool { pub fn simulate_close(&mut self) -> bool {
let handler = self let handler = self
@ -668,6 +754,7 @@ impl VisualContext for VisualTestContext {
} }
impl AnyWindowHandle { impl AnyWindowHandle {
/// Creates the given view in this window.
pub fn build_view<V: Render + 'static>( pub fn build_view<V: Render + 'static>(
&self, &self,
cx: &mut TestAppContext, cx: &mut TestAppContext,
@ -677,6 +764,7 @@ impl AnyWindowHandle {
} }
} }
/// An EmptyView for testing.
pub struct EmptyView {} pub struct EmptyView {}
impl Render for EmptyView { impl Render for EmptyView {

View file

@ -66,18 +66,19 @@ impl Arena {
} }
unsafe { unsafe {
let layout = alloc::Layout::new::<T>().pad_to_align(); let layout = alloc::Layout::new::<T>();
let next_offset = self.offset.add(layout.size()); let offset = self.offset.add(self.offset.align_offset(layout.align()));
assert!(next_offset <= self.end); let next_offset = offset.add(layout.size());
assert!(next_offset <= self.end, "not enough space in Arena");
let result = ArenaBox { let result = ArenaBox {
ptr: self.offset.cast(), ptr: offset.cast(),
valid: self.valid.clone(), valid: self.valid.clone(),
}; };
inner_writer(result.ptr, f); inner_writer(result.ptr, f);
self.elements.push(ArenaElement { self.elements.push(ArenaElement {
value: self.offset, value: offset,
drop: drop::<T>, drop: drop::<T>,
}); });
self.offset = next_offset; self.offset = next_offset;
@ -199,4 +200,43 @@ mod tests {
arena.clear(); arena.clear();
assert!(dropped.get()); assert!(dropped.get());
} }
#[test]
#[should_panic(expected = "not enough space in Arena")]
fn test_arena_overflow() {
let mut arena = Arena::new(16);
arena.alloc(|| 1u64);
arena.alloc(|| 2u64);
// This should panic.
arena.alloc(|| 3u64);
}
#[test]
fn test_arena_alignment() {
let mut arena = Arena::new(256);
let x1 = arena.alloc(|| 1u8);
let x2 = arena.alloc(|| 2u16);
let x3 = arena.alloc(|| 3u32);
let x4 = arena.alloc(|| 4u64);
let x5 = arena.alloc(|| 5u64);
assert_eq!(*x1, 1);
assert_eq!(*x2, 2);
assert_eq!(*x3, 3);
assert_eq!(*x4, 4);
assert_eq!(*x5, 5);
assert_eq!(x1.ptr.align_offset(std::mem::align_of_val(&*x1)), 0);
assert_eq!(x2.ptr.align_offset(std::mem::align_of_val(&*x2)), 0);
}
#[test]
#[should_panic(expected = "attempted to dereference an ArenaRef after its Arena was cleared")]
fn test_arena_use_after_clear() {
let mut arena = Arena::new(16);
let value = arena.alloc(|| 1u64);
arena.clear();
let _read_value = *value;
}
} }

View file

@ -321,7 +321,7 @@ impl Hsla {
/// ///
/// Assumptions: /// Assumptions:
/// - Alpha values are contained in the range [0, 1], with 1 as fully opaque and 0 as fully transparent. /// - Alpha values are contained in the range [0, 1], with 1 as fully opaque and 0 as fully transparent.
/// - The relative contributions of `self` and `other` is based on `self`'s alpha value (`self.a`) and `other`'s alpha value (`other.a`), `self` contributing `self.a * (1.0 - other.a)` and `other` contributing it's own alpha value. /// - The relative contributions of `self` and `other` is based on `self`'s alpha value (`self.a`) and `other`'s alpha value (`other.a`), `self` contributing `self.a * (1.0 - other.a)` and `other` contributing its own alpha value.
/// - RGB color components are contained in the range [0, 1]. /// - RGB color components are contained in the range [0, 1].
/// - If `self` and `other` colors are out of the valid range, the blend operation's output and behavior is undefined. /// - If `self` and `other` colors are out of the valid range, the blend operation's output and behavior is undefined.
pub fn blend(self, other: Hsla) -> Hsla { pub fn blend(self, other: Hsla) -> Hsla {

View file

@ -31,14 +31,14 @@ pub trait IntoElement: Sized {
/// The specific type of element into which the implementing type is converted. /// The specific type of element into which the implementing type is converted.
type Element: Element; type Element: Element;
/// The [ElementId] of self once converted into an [Element]. /// The [`ElementId`] of self once converted into an [`Element`].
/// If present, the resulting element's state will be carried across frames. /// If present, the resulting element's state will be carried across frames.
fn element_id(&self) -> Option<ElementId>; fn element_id(&self) -> Option<ElementId>;
/// Convert self into a type that implements [Element]. /// Convert self into a type that implements [`Element`].
fn into_element(self) -> Self::Element; fn into_element(self) -> Self::Element;
/// Convert self into a dynamically-typed [AnyElement]. /// Convert self into a dynamically-typed [`AnyElement`].
fn into_any_element(self) -> AnyElement { fn into_any_element(self) -> AnyElement {
self.into_element().into_any() self.into_element().into_any()
} }
@ -115,7 +115,7 @@ pub trait Render: 'static + Sized {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement; fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement;
} }
/// You can derive [IntoElement] on any type that implements this trait. /// You can derive [`IntoElement`] on any type that implements this trait.
/// It is used to allow views to be expressed in terms of abstract data. /// It is used to allow views to be expressed in terms of abstract data.
pub trait RenderOnce: 'static { pub trait RenderOnce: 'static {
fn render(self, cx: &mut WindowContext) -> impl IntoElement; fn render(self, cx: &mut WindowContext) -> impl IntoElement;
@ -224,7 +224,7 @@ enum ElementDrawPhase<S> {
}, },
} }
/// A wrapper around an implementer of [Element] that allows it to be drawn in a window. /// A wrapper around an implementer of [`Element`] that allows it to be drawn in a window.
impl<E: Element> DrawableElement<E> { impl<E: Element> DrawableElement<E> {
fn new(element: E) -> Self { fn new(element: E) -> Self {
DrawableElement { DrawableElement {

View file

@ -1003,7 +1003,7 @@ impl Interactivity {
if let Some(text) = cx if let Some(text) = cx
.text_system() .text_system()
.shape_text( .shape_text(
&element_id, element_id.into(),
FONT_SIZE, FONT_SIZE,
&[cx.text_style().to_run(str_len)], &[cx.text_style().to_run(str_len)],
None, None,
@ -1055,22 +1055,11 @@ impl Interactivity {
}; };
eprintln!( eprintln!(
"This element is created at:\n{}:{}:{}", "This element was created at:\n{}:{}:{}",
location.file(), dir.join(location.file()).to_string_lossy(),
location.line(), location.line(),
location.column() location.column()
); );
std::process::Command::new("zed")
.arg(format!(
"{}/{}:{}:{}",
dir.to_string_lossy(),
location.file(),
location.line(),
location.column()
))
.spawn()
.ok();
} }
} }
}); });

View file

@ -2,7 +2,7 @@ use std::sync::Arc;
use crate::{ use crate::{
point, size, BorrowWindow, Bounds, DevicePixels, Element, ImageData, InteractiveElement, point, size, BorrowWindow, Bounds, DevicePixels, Element, ImageData, InteractiveElement,
InteractiveElementState, Interactivity, IntoElement, LayoutId, Pixels, SharedString, Size, InteractiveElementState, Interactivity, IntoElement, LayoutId, Pixels, SharedUrl, Size,
StyleRefinement, Styled, WindowContext, StyleRefinement, Styled, WindowContext,
}; };
use futures::FutureExt; use futures::FutureExt;
@ -12,13 +12,13 @@ use util::ResultExt;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum ImageSource { pub enum ImageSource {
/// Image content will be loaded from provided URI at render time. /// Image content will be loaded from provided URI at render time.
Uri(SharedString), Uri(SharedUrl),
Data(Arc<ImageData>), Data(Arc<ImageData>),
Surface(CVImageBuffer), Surface(CVImageBuffer),
} }
impl From<SharedString> for ImageSource { impl From<SharedUrl> for ImageSource {
fn from(value: SharedString) -> Self { fn from(value: SharedUrl) -> Self {
Self::Uri(value) Self::Uri(value)
} }
} }

View file

@ -14,9 +14,8 @@ pub struct Overlay {
children: SmallVec<[AnyElement; 2]>, children: SmallVec<[AnyElement; 2]>,
anchor_corner: AnchorCorner, anchor_corner: AnchorCorner,
fit_mode: OverlayFitMode, fit_mode: OverlayFitMode,
// todo!();
anchor_position: Option<Point<Pixels>>, anchor_position: Option<Point<Pixels>>,
// position_mode: OverlayPositionMode, position_mode: OverlayPositionMode,
} }
/// overlay gives you a floating element that will avoid overflowing the window bounds. /// overlay gives you a floating element that will avoid overflowing the window bounds.
@ -27,6 +26,7 @@ pub fn overlay() -> Overlay {
anchor_corner: AnchorCorner::TopLeft, anchor_corner: AnchorCorner::TopLeft,
fit_mode: OverlayFitMode::SwitchAnchor, fit_mode: OverlayFitMode::SwitchAnchor,
anchor_position: None, anchor_position: None,
position_mode: OverlayPositionMode::Window,
} }
} }
@ -44,6 +44,14 @@ impl Overlay {
self self
} }
/// Sets the position mode for this overlay. Local will have this
/// interpret its [`Overlay::position`] as relative to the parent element.
/// While Window will have it interpret the position as relative to the window.
pub fn position_mode(mut self, mode: OverlayPositionMode) -> Self {
self.position_mode = mode;
self
}
/// Snap to window edge instead of switching anchor corner when an overflow would occur. /// Snap to window edge instead of switching anchor corner when an overflow would occur.
pub fn snap_to_window(mut self) -> Self { pub fn snap_to_window(mut self) -> Self {
self.fit_mode = OverlayFitMode::SnapToWindow; self.fit_mode = OverlayFitMode::SnapToWindow;
@ -100,9 +108,14 @@ impl Element for Overlay {
child_max = child_max.max(&child_bounds.lower_right()); child_max = child_max.max(&child_bounds.lower_right());
} }
let size: Size<Pixels> = (child_max - child_min).into(); let size: Size<Pixels> = (child_max - child_min).into();
let origin = self.anchor_position.unwrap_or(bounds.origin);
let mut desired = self.anchor_corner.get_bounds(origin, size); let (origin, mut desired) = self.position_mode.get_position_and_bounds(
self.anchor_position,
self.anchor_corner,
size,
bounds,
);
let limits = Bounds { let limits = Bounds {
origin: Point::default(), origin: Point::default(),
size: cx.viewport_size(), size: cx.viewport_size(),
@ -184,6 +197,35 @@ pub enum OverlayFitMode {
SwitchAnchor, SwitchAnchor,
} }
#[derive(Copy, Clone, PartialEq)]
pub enum OverlayPositionMode {
Window,
Local,
}
impl OverlayPositionMode {
fn get_position_and_bounds(
&self,
anchor_position: Option<Point<Pixels>>,
anchor_corner: AnchorCorner,
size: Size<Pixels>,
bounds: Bounds<Pixels>,
) -> (Point<Pixels>, Bounds<Pixels>) {
match self {
OverlayPositionMode::Window => {
let anchor_position = anchor_position.unwrap_or_else(|| bounds.origin);
let bounds = anchor_corner.get_bounds(anchor_position, size);
(anchor_position, bounds)
}
OverlayPositionMode::Local => {
let anchor_position = anchor_position.unwrap_or_default();
let bounds = anchor_corner.get_bounds(bounds.origin + anchor_position, size);
(anchor_position, bounds)
}
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum AnchorCorner { pub enum AnchorCorner {
TopLeft, TopLeft,

View file

@ -202,7 +202,10 @@ impl TextState {
let Some(lines) = cx let Some(lines) = cx
.text_system() .text_system()
.shape_text( .shape_text(
&text, font_size, &runs, wrap_width, // Wrap if we know the width. text.clone(),
font_size,
&runs,
wrap_width, // Wrap if we know the width.
) )
.log_err() .log_err()
else { else {

View file

@ -1,7 +1,7 @@
use crate::{ use crate::{
point, px, size, AnyElement, AvailableSpace, BorrowWindow, Bounds, ContentMask, Element, point, px, size, AnyElement, AvailableSpace, BorrowWindow, Bounds, ContentMask, Element,
ElementId, InteractiveElement, InteractiveElementState, Interactivity, IntoElement, LayoutId, ElementId, InteractiveElement, InteractiveElementState, Interactivity, IntoElement, LayoutId,
Pixels, Point, Render, Size, StyleRefinement, Styled, View, ViewContext, WindowContext, Pixels, Render, Size, StyleRefinement, Styled, View, ViewContext, WindowContext,
}; };
use smallvec::SmallVec; use smallvec::SmallVec;
use std::{cell::RefCell, cmp, ops::Range, rc::Rc}; use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
@ -64,40 +64,19 @@ pub struct UniformList {
} }
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct UniformListScrollHandle(Rc<RefCell<Option<ScrollHandleState>>>); pub struct UniformListScrollHandle {
deferred_scroll_to_item: Rc<RefCell<Option<usize>>>,
#[derive(Clone, Debug)]
struct ScrollHandleState {
item_height: Pixels,
list_height: Pixels,
scroll_offset: Rc<RefCell<Point<Pixels>>>,
} }
impl UniformListScrollHandle { impl UniformListScrollHandle {
pub fn new() -> Self { pub fn new() -> Self {
Self(Rc::new(RefCell::new(None))) Self {
} deferred_scroll_to_item: Rc::new(RefCell::new(None)),
pub fn scroll_to_item(&self, ix: usize) {
if let Some(state) = &*self.0.borrow() {
let mut scroll_offset = state.scroll_offset.borrow_mut();
let item_top = state.item_height * ix;
let item_bottom = item_top + state.item_height;
let scroll_top = -scroll_offset.y;
if item_top < scroll_top {
scroll_offset.y = -item_top;
} else if item_bottom > scroll_top + state.list_height {
scroll_offset.y = -(item_bottom - state.list_height);
}
} }
} }
pub fn scroll_top(&self) -> Pixels { pub fn scroll_to_item(&mut self, ix: usize) {
if let Some(state) = &*self.0.borrow() { self.deferred_scroll_to_item.replace(Some(ix));
-state.scroll_offset.borrow().y
} else {
Pixels::ZERO
}
} }
} }
@ -190,18 +169,14 @@ impl Element for UniformList {
let shared_scroll_offset = element_state let shared_scroll_offset = element_state
.interactive .interactive
.scroll_offset .scroll_offset
.get_or_insert_with(|| { .get_or_insert_with(|| Rc::default())
if let Some(scroll_handle) = self.scroll_handle.as_ref() {
if let Some(scroll_handle) = scroll_handle.0.borrow().as_ref() {
return scroll_handle.scroll_offset.clone();
}
}
Rc::default()
})
.clone(); .clone();
let item_height = self.measure_item(Some(padded_bounds.size.width), cx).height; let item_height = self.measure_item(Some(padded_bounds.size.width), cx).height;
let shared_scroll_to_item = self
.scroll_handle
.as_mut()
.and_then(|handle| handle.deferred_scroll_to_item.take());
self.interactivity.paint( self.interactivity.paint(
bounds, bounds,
@ -228,12 +203,18 @@ impl Element for UniformList {
scroll_offset.y = min_scroll_offset; scroll_offset.y = min_scroll_offset;
} }
if let Some(scroll_handle) = self.scroll_handle.clone() { if let Some(ix) = shared_scroll_to_item {
scroll_handle.0.borrow_mut().replace(ScrollHandleState { let list_height = padded_bounds.size.height;
item_height, let mut updated_scroll_offset = shared_scroll_offset.borrow_mut();
list_height: padded_bounds.size.height, let item_top = item_height * ix;
scroll_offset: shared_scroll_offset, let item_bottom = item_top + item_height;
}); let scroll_top = -updated_scroll_offset.y;
if item_top < scroll_top {
updated_scroll_offset.y = -item_top;
} else if item_bottom > scroll_top + list_height {
updated_scroll_offset.y = -(item_bottom - list_height);
}
scroll_offset = *updated_scroll_offset;
} }
let first_visible_element_ix = let first_visible_element_ix =

View file

@ -32,6 +32,12 @@ pub struct ForegroundExecutor {
not_send: PhantomData<Rc<()>>, not_send: PhantomData<Rc<()>>,
} }
/// Task is a primitive that allows work to happen in the background.
///
/// It implements [`Future`] so you can `.await` on it.
///
/// If you drop a task it will be cancelled immediately. Calling [`Task::detach`] allows
/// the task to continue running in the background, but with no way to return a value.
#[must_use] #[must_use]
#[derive(Debug)] #[derive(Debug)]
pub enum Task<T> { pub enum Task<T> {
@ -40,10 +46,12 @@ pub enum Task<T> {
} }
impl<T> Task<T> { impl<T> Task<T> {
/// Create a new task that will resolve with the value
pub fn ready(val: T) -> Self { pub fn ready(val: T) -> Self {
Task::Ready(Some(val)) Task::Ready(Some(val))
} }
/// Detaching a task runs it to completion in the background
pub fn detach(self) { pub fn detach(self) {
match self { match self {
Task::Ready(_) => {} Task::Ready(_) => {}
@ -57,6 +65,8 @@ where
T: 'static, T: 'static,
E: 'static + Debug, E: 'static + Debug,
{ {
/// Run the task to completion in the background and log any
/// errors that occur.
#[track_caller] #[track_caller]
pub fn detach_and_log_err(self, cx: &mut AppContext) { pub fn detach_and_log_err(self, cx: &mut AppContext) {
let location = core::panic::Location::caller(); let location = core::panic::Location::caller();
@ -97,6 +107,10 @@ type AnyLocalFuture<R> = Pin<Box<dyn 'static + Future<Output = R>>>;
type AnyFuture<R> = Pin<Box<dyn 'static + Send + Future<Output = R>>>; type AnyFuture<R> = Pin<Box<dyn 'static + Send + Future<Output = R>>>;
/// BackgroundExecutor lets you run things on background threads.
/// In production this is a thread pool with no ordering guarantees.
/// In tests this is simalated by running tasks one by one in a deterministic
/// (but arbitrary) order controlled by the `SEED` environment variable.
impl BackgroundExecutor { impl BackgroundExecutor {
pub fn new(dispatcher: Arc<dyn PlatformDispatcher>) -> Self { pub fn new(dispatcher: Arc<dyn PlatformDispatcher>) -> Self {
Self { dispatcher } Self { dispatcher }
@ -135,6 +149,7 @@ impl BackgroundExecutor {
Task::Spawned(task) Task::Spawned(task)
} }
/// Used by the test harness to run an async test in a syncronous fashion.
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
#[track_caller] #[track_caller]
pub fn block_test<R>(&self, future: impl Future<Output = R>) -> R { pub fn block_test<R>(&self, future: impl Future<Output = R>) -> R {
@ -145,6 +160,8 @@ impl BackgroundExecutor {
} }
} }
/// Block the current thread until the given future resolves.
/// Consider using `block_with_timeout` instead.
pub fn block<R>(&self, future: impl Future<Output = R>) -> R { pub fn block<R>(&self, future: impl Future<Output = R>) -> R {
if let Ok(value) = self.block_internal(true, future, usize::MAX) { if let Ok(value) = self.block_internal(true, future, usize::MAX) {
value value
@ -206,6 +223,8 @@ impl BackgroundExecutor {
} }
} }
/// Block the current thread until the given future resolves
/// or `duration` has elapsed.
pub fn block_with_timeout<R>( pub fn block_with_timeout<R>(
&self, &self,
duration: Duration, duration: Duration,
@ -238,6 +257,8 @@ impl BackgroundExecutor {
} }
} }
/// Scoped lets you start a number of tasks and waits
/// for all of them to complete before returning.
pub async fn scoped<'scope, F>(&self, scheduler: F) pub async fn scoped<'scope, F>(&self, scheduler: F)
where where
F: FnOnce(&mut Scope<'scope>), F: FnOnce(&mut Scope<'scope>),
@ -253,6 +274,9 @@ impl BackgroundExecutor {
} }
} }
/// Returns a task that will complete after the given duration.
/// Depending on other concurrent tasks the elapsed duration may be longer
/// than reqested.
pub fn timer(&self, duration: Duration) -> Task<()> { pub fn timer(&self, duration: Duration) -> Task<()> {
let (runnable, task) = async_task::spawn(async move {}, { let (runnable, task) = async_task::spawn(async move {}, {
let dispatcher = self.dispatcher.clone(); let dispatcher = self.dispatcher.clone();
@ -262,65 +286,81 @@ impl BackgroundExecutor {
Task::Spawned(task) Task::Spawned(task)
} }
/// in tests, start_waiting lets you indicate which task is waiting (for debugging only)
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub fn start_waiting(&self) { pub fn start_waiting(&self) {
self.dispatcher.as_test().unwrap().start_waiting(); self.dispatcher.as_test().unwrap().start_waiting();
} }
/// in tests, removes the debugging data added by start_waiting
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub fn finish_waiting(&self) { pub fn finish_waiting(&self) {
self.dispatcher.as_test().unwrap().finish_waiting(); self.dispatcher.as_test().unwrap().finish_waiting();
} }
/// in tests, run an arbitrary number of tasks (determined by the SEED environment variable)
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub fn simulate_random_delay(&self) -> impl Future<Output = ()> { pub fn simulate_random_delay(&self) -> impl Future<Output = ()> {
self.dispatcher.as_test().unwrap().simulate_random_delay() self.dispatcher.as_test().unwrap().simulate_random_delay()
} }
/// in tests, indicate that a given task from `spawn_labeled` should run after everything else
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub fn deprioritize(&self, task_label: TaskLabel) { pub fn deprioritize(&self, task_label: TaskLabel) {
self.dispatcher.as_test().unwrap().deprioritize(task_label) self.dispatcher.as_test().unwrap().deprioritize(task_label)
} }
/// in tests, move time forward. This does not run any tasks, but does make `timer`s ready.
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub fn advance_clock(&self, duration: Duration) { pub fn advance_clock(&self, duration: Duration) {
self.dispatcher.as_test().unwrap().advance_clock(duration) self.dispatcher.as_test().unwrap().advance_clock(duration)
} }
/// in tests, run one task.
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub fn tick(&self) -> bool { pub fn tick(&self) -> bool {
self.dispatcher.as_test().unwrap().tick(false) self.dispatcher.as_test().unwrap().tick(false)
} }
/// in tests, run all tasks that are ready to run. If after doing so
/// the test still has outstanding tasks, this will panic. (See also `allow_parking`)
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub fn run_until_parked(&self) { pub fn run_until_parked(&self) {
self.dispatcher.as_test().unwrap().run_until_parked() self.dispatcher.as_test().unwrap().run_until_parked()
} }
/// in tests, prevents `run_until_parked` from panicking if there are outstanding tasks.
/// This is useful when you are integrating other (non-GPUI) futures, like disk access, that
/// do take real async time to run.
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub fn allow_parking(&self) { pub fn allow_parking(&self) {
self.dispatcher.as_test().unwrap().allow_parking(); self.dispatcher.as_test().unwrap().allow_parking();
} }
/// in tests, returns the rng used by the dispatcher and seeded by the `SEED` environment variable
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub fn rng(&self) -> StdRng { pub fn rng(&self) -> StdRng {
self.dispatcher.as_test().unwrap().rng() self.dispatcher.as_test().unwrap().rng()
} }
/// How many CPUs are available to the dispatcher
pub fn num_cpus(&self) -> usize { pub fn num_cpus(&self) -> usize {
num_cpus::get() num_cpus::get()
} }
/// Whether we're on the main thread.
pub fn is_main_thread(&self) -> bool { pub fn is_main_thread(&self) -> bool {
self.dispatcher.is_main_thread() self.dispatcher.is_main_thread()
} }
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
/// in tests, control the number of ticks that `block_with_timeout` will run before timing out.
pub fn set_block_on_ticks(&self, range: std::ops::RangeInclusive<usize>) { pub fn set_block_on_ticks(&self, range: std::ops::RangeInclusive<usize>) {
self.dispatcher.as_test().unwrap().set_block_on_ticks(range); self.dispatcher.as_test().unwrap().set_block_on_ticks(range);
} }
} }
/// ForegroundExecutor runs things on the main thread.
impl ForegroundExecutor { impl ForegroundExecutor {
pub fn new(dispatcher: Arc<dyn PlatformDispatcher>) -> Self { pub fn new(dispatcher: Arc<dyn PlatformDispatcher>) -> Self {
Self { Self {
@ -329,8 +369,7 @@ impl ForegroundExecutor {
} }
} }
/// Enqueues the given closure to be run on any thread. The closure returns /// Enqueues the given Task to run on the main thread at some point in the future.
/// a future which will be run to completion on any available thread.
pub fn spawn<R>(&self, future: impl Future<Output = R> + 'static) -> Task<R> pub fn spawn<R>(&self, future: impl Future<Output = R> + 'static) -> Task<R>
where where
R: 'static, R: 'static,
@ -350,6 +389,7 @@ impl ForegroundExecutor {
} }
} }
/// Scope manages a set of tasks that are enqueued and waited on together. See [`BackgroundExecutor::scoped`].
pub struct Scope<'a> { pub struct Scope<'a> {
executor: BackgroundExecutor, executor: BackgroundExecutor,
futures: Vec<Pin<Box<dyn Future<Output = ()> + Send + 'static>>>, futures: Vec<Pin<Box<dyn Future<Output = ()> + Send + 'static>>>,

View file

@ -18,6 +18,7 @@ mod platform;
pub mod prelude; pub mod prelude;
mod scene; mod scene;
mod shared_string; mod shared_string;
mod shared_url;
mod style; mod style;
mod styled; mod styled;
mod subscription; mod subscription;
@ -67,6 +68,7 @@ pub use refineable::*;
pub use scene::*; pub use scene::*;
use seal::Sealed; use seal::Sealed;
pub use shared_string::*; pub use shared_string::*;
pub use shared_url::*;
pub use smol::Timer; pub use smol::Timer;
pub use style::*; pub use style::*;
pub use styled::*; pub use styled::*;

View file

@ -1,4 +1,4 @@
use crate::{ImageData, ImageId, SharedString}; use crate::{ImageData, ImageId, SharedUrl};
use collections::HashMap; use collections::HashMap;
use futures::{ use futures::{
future::{BoxFuture, Shared}, future::{BoxFuture, Shared},
@ -44,7 +44,7 @@ impl From<ImageError> for Error {
pub struct ImageCache { pub struct ImageCache {
client: Arc<dyn HttpClient>, client: Arc<dyn HttpClient>,
images: Arc<Mutex<HashMap<SharedString, FetchImageFuture>>>, images: Arc<Mutex<HashMap<SharedUrl, FetchImageFuture>>>,
} }
type FetchImageFuture = Shared<BoxFuture<'static, Result<Arc<ImageData>, Error>>>; type FetchImageFuture = Shared<BoxFuture<'static, Result<Arc<ImageData>, Error>>>;
@ -59,7 +59,7 @@ impl ImageCache {
pub fn get( pub fn get(
&self, &self,
uri: impl Into<SharedString>, uri: impl Into<SharedUrl>,
) -> Shared<BoxFuture<'static, Result<Arc<ImageData>, Error>>> { ) -> Shared<BoxFuture<'static, Result<Arc<ImageData>, Error>>> {
let uri = uri.into(); let uri = uri.into();
let mut images = self.images.lock(); let mut images = self.images.lock();

View file

@ -34,7 +34,7 @@ pub trait InputHandler: 'static + Sized {
) -> Option<Bounds<Pixels>>; ) -> Option<Bounds<Pixels>>;
} }
/// The canonical implementation of `PlatformInputHandler`. Call `WindowContext::handle_input` /// The canonical implementation of [`PlatformInputHandler`]. Call [`WindowContext::handle_input`]
/// with an instance during your element's paint. /// with an instance during your element's paint.
pub struct ElementInputHandler<V> { pub struct ElementInputHandler<V> {
view: View<V>, view: View<V>,

View file

@ -178,6 +178,20 @@ impl ScrollDelta {
ScrollDelta::Lines(delta) => point(line_height * delta.x, line_height * delta.y), ScrollDelta::Lines(delta) => point(line_height * delta.x, line_height * delta.y),
} }
} }
pub fn coalesce(self, other: ScrollDelta) -> ScrollDelta {
match (self, other) {
(ScrollDelta::Pixels(px_a), ScrollDelta::Pixels(px_b)) => {
ScrollDelta::Pixels(px_a + px_b)
}
(ScrollDelta::Lines(lines_a), ScrollDelta::Lines(lines_b)) => {
ScrollDelta::Lines(lines_a + lines_b)
}
_ => other,
}
}
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]

View file

@ -192,8 +192,8 @@ impl DispatchTree {
keymap keymap
.bindings_for_action(action) .bindings_for_action(action)
.filter(|binding| { .filter(|binding| {
for i in 1..context_stack.len() { for i in 0..context_stack.len() {
let context = &context_stack[0..i]; let context = &context_stack[0..=i];
if keymap.binding_enabled(binding, context) { if keymap.binding_enabled(binding, context) {
return true; return true;
} }
@ -283,3 +283,76 @@ impl DispatchTree {
*self.node_stack.last().unwrap() *self.node_stack.last().unwrap()
} }
} }
#[cfg(test)]
mod tests {
use std::{rc::Rc, sync::Arc};
use parking_lot::Mutex;
use crate::{Action, ActionRegistry, DispatchTree, KeyBinding, KeyContext, Keymap};
#[derive(PartialEq, Eq)]
struct TestAction;
impl Action for TestAction {
fn name(&self) -> &'static str {
"test::TestAction"
}
fn debug_name() -> &'static str
where
Self: ::std::marker::Sized,
{
"test::TestAction"
}
fn partial_eq(&self, action: &dyn Action) -> bool {
action
.as_any()
.downcast_ref::<Self>()
.map_or(false, |a| self == a)
}
fn boxed_clone(&self) -> std::boxed::Box<dyn Action> {
Box::new(TestAction)
}
fn as_any(&self) -> &dyn ::std::any::Any {
self
}
fn build(_value: serde_json::Value) -> anyhow::Result<Box<dyn Action>>
where
Self: Sized,
{
Ok(Box::new(TestAction))
}
}
#[test]
fn test_keybinding_for_action_bounds() {
let keymap = Keymap::new(vec![KeyBinding::new(
"cmd-n",
TestAction,
Some("ProjectPanel"),
)]);
let mut registry = ActionRegistry::default();
registry.load_action::<TestAction>();
let keymap = Arc::new(Mutex::new(keymap));
let tree = DispatchTree::new(keymap, Rc::new(registry));
let contexts = vec![
KeyContext::parse("Workspace").unwrap(),
KeyContext::parse("ProjectPanel").unwrap(),
];
let keybinding = tree.bindings_for_action(&TestAction, &contexts);
assert!(keybinding[0].action.partial_eq(&TestAction))
}
}

View file

@ -14,12 +14,12 @@ pub struct MacDisplay(pub(crate) CGDirectDisplayID);
unsafe impl Send for MacDisplay {} unsafe impl Send for MacDisplay {}
impl MacDisplay { impl MacDisplay {
/// Get the screen with the given [DisplayId]. /// Get the screen with the given [`DisplayId`].
pub fn find_by_id(id: DisplayId) -> Option<Self> { pub fn find_by_id(id: DisplayId) -> Option<Self> {
Self::all().find(|screen| screen.id() == id) Self::all().find(|screen| screen.id() == id)
} }
/// Get the screen with the given persistent [Uuid]. /// Get the screen with the given persistent [`Uuid`].
pub fn find_by_uuid(uuid: Uuid) -> Option<Self> { pub fn find_by_uuid(uuid: Uuid) -> Option<Self> {
Self::all().find(|screen| screen.uuid().ok() == Some(uuid)) Self::all().find(|screen| screen.uuid().ok() == Some(uuid))
} }

View file

@ -338,6 +338,7 @@ struct MacWindowState {
ime_state: ImeState, ime_state: ImeState,
// Retains the last IME Text // Retains the last IME Text
ime_text: Option<String>, ime_text: Option<String>,
external_files_dragged: bool,
} }
impl MacWindowState { impl MacWindowState {
@ -567,6 +568,7 @@ impl MacWindow {
previous_modifiers_changed_event: None, previous_modifiers_changed_event: None,
ime_state: ImeState::None, ime_state: ImeState::None,
ime_text: None, ime_text: None,
external_files_dragged: false,
}))); })));
(*native_window).set_ivar( (*native_window).set_ivar(
@ -1223,15 +1225,20 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) {
.. ..
}, },
) => { ) => {
lock.synthetic_drag_counter += 1; // Synthetic drag is used for selecting long buffer contents while buffer is being scrolled.
let executor = lock.executor.clone(); // External file drag and drop is able to emit its own synthetic mouse events which will conflict
executor // with these ones.
.spawn(synthetic_drag( if !lock.external_files_dragged {
weak_window_state, lock.synthetic_drag_counter += 1;
lock.synthetic_drag_counter, let executor = lock.executor.clone();
event.clone(), executor
)) .spawn(synthetic_drag(
.detach(); weak_window_state,
lock.synthetic_drag_counter,
event.clone(),
))
.detach();
}
} }
InputEvent::MouseMove(_) if !(is_active || lock.kind == WindowKind::PopUp) => return, InputEvent::MouseMove(_) if !(is_active || lock.kind == WindowKind::PopUp) => return,
@ -1675,6 +1682,7 @@ extern "C" fn dragging_entered(this: &Object, _: Sel, dragging_info: id) -> NSDr
let paths = external_paths_from_event(dragging_info); let paths = external_paths_from_event(dragging_info);
InputEvent::FileDrop(FileDropEvent::Entered { position, paths }) InputEvent::FileDrop(FileDropEvent::Entered { position, paths })
}) { }) {
window_state.lock().external_files_dragged = true;
NSDragOperationCopy NSDragOperationCopy
} else { } else {
NSDragOperationNone NSDragOperationNone
@ -1697,6 +1705,7 @@ extern "C" fn dragging_updated(this: &Object, _: Sel, dragging_info: id) -> NSDr
extern "C" fn dragging_exited(this: &Object, _: Sel, _: id) { extern "C" fn dragging_exited(this: &Object, _: Sel, _: id) {
let window_state = unsafe { get_window_state(this) }; let window_state = unsafe { get_window_state(this) };
send_new_event(&window_state, InputEvent::FileDrop(FileDropEvent::Exited)); send_new_event(&window_state, InputEvent::FileDrop(FileDropEvent::Exited));
window_state.lock().external_files_dragged = false;
} }
extern "C" fn perform_drag_operation(this: &Object, _: Sel, dragging_info: id) -> BOOL { extern "C" fn perform_drag_operation(this: &Object, _: Sel, dragging_info: id) -> BOOL {

View file

@ -32,7 +32,7 @@ impl PlatformDisplay for TestDisplay {
} }
fn as_any(&self) -> &dyn std::any::Any { fn as_any(&self) -> &dyn std::any::Any {
todo!() unimplemented!()
} }
fn bounds(&self) -> crate::Bounds<crate::GlobalPixels> { fn bounds(&self) -> crate::Bounds<crate::GlobalPixels> {

View file

@ -15,6 +15,7 @@ use std::{
time::Duration, time::Duration,
}; };
/// TestPlatform implements the Platform trait for use in tests.
pub struct TestPlatform { pub struct TestPlatform {
background_executor: BackgroundExecutor, background_executor: BackgroundExecutor,
foreground_executor: ForegroundExecutor, foreground_executor: ForegroundExecutor,
@ -101,9 +102,12 @@ impl TestPlatform {
}) })
.detach(); .detach();
} }
pub(crate) fn did_prompt_for_new_path(&self) -> bool {
self.prompts.borrow().new_path.len() > 0
}
} }
// todo!("implement out what our tests needed in GPUI 1")
impl Platform for TestPlatform { impl Platform for TestPlatform {
fn background_executor(&self) -> BackgroundExecutor { fn background_executor(&self) -> BackgroundExecutor {
self.background_executor.clone() self.background_executor.clone()
@ -278,8 +282,7 @@ impl Platform for TestPlatform {
} }
fn should_auto_hide_scrollbars(&self) -> bool { fn should_auto_hide_scrollbars(&self) -> bool {
// todo() false
true
} }
fn write_to_clipboard(&self, item: ClipboardItem) { fn write_to_clipboard(&self, item: ClipboardItem) {

View file

@ -0,0 +1,25 @@
use derive_more::{Deref, DerefMut};
use crate::SharedString;
/// A [`SharedString`] containing a URL.
#[derive(Deref, DerefMut, Default, PartialEq, Eq, Hash, Clone)]
pub struct SharedUrl(SharedString);
impl std::fmt::Debug for SharedUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::fmt::Display for SharedUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.as_ref())
}
}
impl<T: Into<SharedString>> From<T> for SharedUrl {
fn from(value: T) -> Self {
Self(value.into())
}
}

View file

@ -165,7 +165,8 @@ impl Default for TextStyle {
fn default() -> Self { fn default() -> Self {
TextStyle { TextStyle {
color: black(), color: black(),
font_family: "Helvetica".into(), // todo!("Get a font we know exists on the system") // Helvetica is a web safe font, so it should be available
font_family: "Helvetica".into(),
font_features: FontFeatures::default(), font_features: FontFeatures::default(),
font_size: rems(1.).into(), font_size: rems(1.).into(),
line_height: phi(), line_height: phi(),

View file

@ -37,10 +37,10 @@ where
}))) })))
} }
/// Inserts a new `[Subscription]` for the given `emitter_key`. By default, subscriptions /// Inserts a new [`Subscription`] for the given `emitter_key`. By default, subscriptions
/// are inert, meaning that they won't be listed when calling `[SubscriberSet::remove]` or `[SubscriberSet::retain]`. /// are inert, meaning that they won't be listed when calling `[SubscriberSet::remove]` or `[SubscriberSet::retain]`.
/// This method returns a tuple of a `[Subscription]` and an `impl FnOnce`, and you can use the latter /// This method returns a tuple of a [`Subscription`] and an `impl FnOnce`, and you can use the latter
/// to activate the `[Subscription]`. /// to activate the [`Subscription`].
#[must_use] #[must_use]
pub fn insert( pub fn insert(
&self, &self,

View file

@ -1,3 +1,30 @@
//! Test support for GPUI.
//!
//! GPUI provides first-class support for testing, which includes a macro to run test that rely on having a context,
//! and a test implementation of the `ForegroundExecutor` and `BackgroundExecutor` which ensure that your tests run
//! deterministically even in the face of arbitrary parallelism.
//!
//! The output of the `gpui::test` macro is understood by other rust test runners, so you can use it with `cargo test`
//! or `cargo-nextest`, or another runner of your choice.
//!
//! To make it possible to test collaborative user interfaces (like Zed) you can ask for as many different contexts
//! as you need.
//!
//! ## Example
//!
//! ```
//! use gpui;
//!
//! #[gpui::test]
//! async fn test_example(cx: &TestAppContext) {
//! assert!(true)
//! }
//!
//! #[gpui::test]
//! async fn test_collaboration_example(cx_a: &TestAppContext, cx_b: &TestAppContext) {
//! assert!(true)
//! }
//! ```
use crate::{Entity, Subscription, TestAppContext, TestDispatcher}; use crate::{Entity, Subscription, TestAppContext, TestDispatcher};
use futures::StreamExt as _; use futures::StreamExt as _;
use rand::prelude::*; use rand::prelude::*;
@ -12,7 +39,6 @@ pub fn run_test(
max_retries: usize, max_retries: usize,
test_fn: &mut (dyn RefUnwindSafe + Fn(TestDispatcher, u64)), test_fn: &mut (dyn RefUnwindSafe + Fn(TestDispatcher, u64)),
on_fail_fn: Option<fn()>, on_fail_fn: Option<fn()>,
_fn_name: String, // todo!("re-enable fn_name")
) { ) {
let starting_seed = env::var("SEED") let starting_seed = env::var("SEED")
.map(|seed| seed.parse().expect("invalid SEED variable")) .map(|seed| seed.parse().expect("invalid SEED variable"))
@ -68,6 +94,7 @@ impl<T: 'static> futures::Stream for Observation<T> {
} }
} }
/// observe returns a stream of the change events from the given `View` or `Model`
pub fn observe<T: 'static>(entity: &impl Entity<T>, cx: &mut TestAppContext) -> Observation<()> { pub fn observe<T: 'static>(entity: &impl Entity<T>, cx: &mut TestAppContext) -> Observation<()> {
let (tx, rx) = smol::channel::unbounded(); let (tx, rx) = smol::channel::unbounded();
let _subscription = cx.update(|cx| { let _subscription = cx.update(|cx| {

View file

@ -258,7 +258,7 @@ impl TextSystem {
pub fn shape_text( pub fn shape_text(
&self, &self,
text: &str, // todo!("pass a SharedString and preserve it when passed a single line?") text: SharedString,
font_size: Pixels, font_size: Pixels,
runs: &[TextRun], runs: &[TextRun],
wrap_width: Option<Pixels>, wrap_width: Option<Pixels>,
@ -268,8 +268,8 @@ impl TextSystem {
let mut lines = SmallVec::new(); let mut lines = SmallVec::new();
let mut line_start = 0; let mut line_start = 0;
for line_text in text.split('\n') {
let line_text = SharedString::from(line_text.to_string()); let mut process_line = |line_text: SharedString| {
let line_end = line_start + line_text.len(); let line_end = line_start + line_text.len();
let mut last_font: Option<Font> = None; let mut last_font: Option<Font> = None;
@ -335,6 +335,24 @@ impl TextSystem {
} }
font_runs.clear(); font_runs.clear();
};
let mut split_lines = text.split('\n');
let mut processed = false;
if let Some(first_line) = split_lines.next() {
if let Some(second_line) = split_lines.next() {
processed = true;
process_line(first_line.to_string().into());
process_line(second_line.to_string().into());
for line_text in split_lines {
process_line(line_text.to_string().into());
}
}
}
if !processed {
process_line(text);
} }
self.font_runs_pool.lock().push(font_runs); self.font_runs_pool.lock().push(font_runs);

View file

@ -143,7 +143,7 @@ mod tests {
#[test] #[test]
fn test_wrap_line() { fn test_wrap_line() {
let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0)); let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0));
let cx = TestAppContext::new(dispatcher); let cx = TestAppContext::new(dispatcher, None);
cx.update(|cx| { cx.update(|cx| {
let text_system = cx.text_system().clone(); let text_system = cx.text_system().clone();

View file

@ -1,3 +1,5 @@
#![deny(missing_docs)]
use crate::{ use crate::{
px, size, transparent_black, Action, AnyDrag, AnyView, AppContext, Arena, ArenaBox, ArenaRef, px, size, transparent_black, Action, AnyDrag, AnyView, AppContext, Arena, ArenaBox, ArenaRef,
AsyncWindowContext, AvailableSpace, Bounds, BoxShadow, Context, Corners, CursorStyle, AsyncWindowContext, AvailableSpace, Bounds, BoxShadow, Context, Corners, CursorStyle,
@ -85,10 +87,12 @@ pub enum DispatchPhase {
} }
impl DispatchPhase { impl DispatchPhase {
/// Returns true if this represents the "bubble" phase.
pub fn bubble(self) -> bool { pub fn bubble(self) -> bool {
self == DispatchPhase::Bubble self == DispatchPhase::Bubble
} }
/// Returns true if this represents the "capture" phase.
pub fn capture(self) -> bool { pub fn capture(self) -> bool {
self == DispatchPhase::Capture self == DispatchPhase::Capture
} }
@ -103,7 +107,10 @@ struct FocusEvent {
current_focus_path: SmallVec<[FocusId; 8]>, current_focus_path: SmallVec<[FocusId; 8]>,
} }
slotmap::new_key_type! { pub struct FocusId; } slotmap::new_key_type! {
/// A globally unique identifier for a focusable element.
pub struct FocusId;
}
thread_local! { thread_local! {
pub(crate) static ELEMENT_ARENA: RefCell<Arena> = RefCell::new(Arena::new(4 * 1024 * 1024)); pub(crate) static ELEMENT_ARENA: RefCell<Arena> = RefCell::new(Arena::new(4 * 1024 * 1024));
@ -231,6 +238,7 @@ impl Drop for FocusHandle {
/// FocusableView allows users of your view to easily /// FocusableView allows users of your view to easily
/// focus it (using cx.focus_view(view)) /// focus it (using cx.focus_view(view))
pub trait FocusableView: 'static + Render { pub trait FocusableView: 'static + Render {
/// Returns the focus handle associated with this view.
fn focus_handle(&self, cx: &AppContext) -> FocusHandle; fn focus_handle(&self, cx: &AppContext) -> FocusHandle;
} }
@ -240,9 +248,11 @@ pub trait ManagedView: FocusableView + EventEmitter<DismissEvent> {}
impl<M: FocusableView + EventEmitter<DismissEvent>> ManagedView for M {} impl<M: FocusableView + EventEmitter<DismissEvent>> ManagedView for M {}
/// Emitted by implementers of [`ManagedView`] to indicate the view should be dismissed, such as when a view is presented as a modal.
pub struct DismissEvent; pub struct DismissEvent;
// Holds the state for a specific window. // Holds the state for a specific window.
#[doc(hidden)]
pub struct Window { pub struct Window {
pub(crate) handle: AnyWindowHandle, pub(crate) handle: AnyWindowHandle,
pub(crate) removed: bool, pub(crate) removed: bool,
@ -259,7 +269,7 @@ pub struct Window {
frame_arena: Arena, frame_arena: Arena,
pub(crate) focus_handles: Arc<RwLock<SlotMap<FocusId, AtomicUsize>>>, pub(crate) focus_handles: Arc<RwLock<SlotMap<FocusId, AtomicUsize>>>,
focus_listeners: SubscriberSet<(), AnyWindowFocusListener>, focus_listeners: SubscriberSet<(), AnyWindowFocusListener>,
blur_listeners: SubscriberSet<(), AnyObserver>, focus_lost_listeners: SubscriberSet<(), AnyObserver>,
default_prevented: bool, default_prevented: bool,
mouse_position: Point<Pixels>, mouse_position: Point<Pixels>,
modifiers: Modifiers, modifiers: Modifiers,
@ -286,6 +296,7 @@ pub(crate) struct ElementStateBox {
pub(crate) struct Frame { pub(crate) struct Frame {
focus: Option<FocusId>, focus: Option<FocusId>,
window_active: bool,
pub(crate) element_states: FxHashMap<GlobalElementId, ElementStateBox>, pub(crate) element_states: FxHashMap<GlobalElementId, ElementStateBox>,
mouse_listeners: FxHashMap<TypeId, Vec<(StackingOrder, AnyMouseListener)>>, mouse_listeners: FxHashMap<TypeId, Vec<(StackingOrder, AnyMouseListener)>>,
pub(crate) dispatch_tree: DispatchTree, pub(crate) dispatch_tree: DispatchTree,
@ -301,6 +312,7 @@ impl Frame {
fn new(dispatch_tree: DispatchTree) -> Self { fn new(dispatch_tree: DispatchTree) -> Self {
Frame { Frame {
focus: None, focus: None,
window_active: false,
element_states: FxHashMap::default(), element_states: FxHashMap::default(),
mouse_listeners: FxHashMap::default(), mouse_listeners: FxHashMap::default(),
dispatch_tree, dispatch_tree,
@ -407,7 +419,7 @@ impl Window {
frame_arena: Arena::new(1024 * 1024), frame_arena: Arena::new(1024 * 1024),
focus_handles: Arc::new(RwLock::new(SlotMap::with_key())), focus_handles: Arc::new(RwLock::new(SlotMap::with_key())),
focus_listeners: SubscriberSet::new(), focus_listeners: SubscriberSet::new(),
blur_listeners: SubscriberSet::new(), focus_lost_listeners: SubscriberSet::new(),
default_prevented: true, default_prevented: true,
mouse_position, mouse_position,
modifiers, modifiers,
@ -434,6 +446,7 @@ impl Window {
#[derive(Clone, Debug, Default, PartialEq, Eq)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
#[repr(C)] #[repr(C)]
pub struct ContentMask<P: Clone + Default + Debug> { pub struct ContentMask<P: Clone + Default + Debug> {
/// The bounds
pub bounds: Bounds<P>, pub bounds: Bounds<P>,
} }
@ -453,8 +466,8 @@ impl ContentMask<Pixels> {
} }
/// Provides access to application state in the context of a single window. Derefs /// Provides access to application state in the context of a single window. Derefs
/// to an `AppContext`, so you can also pass a `WindowContext` to any method that takes /// to an [`AppContext`], so you can also pass a [`WindowContext`] to any method that takes
/// an `AppContext` and call any `AppContext` methods. /// an [`AppContext`] and call any [`AppContext`] methods.
pub struct WindowContext<'a> { pub struct WindowContext<'a> {
pub(crate) app: &'a mut AppContext, pub(crate) app: &'a mut AppContext,
pub(crate) window: &'a mut Window, pub(crate) window: &'a mut Window,
@ -482,20 +495,20 @@ impl<'a> WindowContext<'a> {
self.window.removed = true; self.window.removed = true;
} }
/// Obtain a new `FocusHandle`, which allows you to track and manipulate the keyboard focus /// Obtain a new [`FocusHandle`], which allows you to track and manipulate the keyboard focus
/// for elements rendered within this window. /// for elements rendered within this window.
pub fn focus_handle(&mut self) -> FocusHandle { pub fn focus_handle(&mut self) -> FocusHandle {
FocusHandle::new(&self.window.focus_handles) FocusHandle::new(&self.window.focus_handles)
} }
/// Obtain the currently focused `FocusHandle`. If no elements are focused, returns `None`. /// Obtain the currently focused [`FocusHandle`]. If no elements are focused, returns `None`.
pub fn focused(&self) -> Option<FocusHandle> { pub fn focused(&self) -> Option<FocusHandle> {
self.window self.window
.focus .focus
.and_then(|id| FocusHandle::for_id(id, &self.window.focus_handles)) .and_then(|id| FocusHandle::for_id(id, &self.window.focus_handles))
} }
/// Move focus to the element associated with the given `FocusHandle`. /// Move focus to the element associated with the given [`FocusHandle`].
pub fn focus(&mut self, handle: &FocusHandle) { pub fn focus(&mut self, handle: &FocusHandle) {
if !self.window.focus_enabled || self.window.focus == Some(handle.id) { if !self.window.focus_enabled || self.window.focus == Some(handle.id) {
return; return;
@ -525,11 +538,13 @@ impl<'a> WindowContext<'a> {
self.notify(); self.notify();
} }
/// Blur the window and don't allow anything in it to be focused again.
pub fn disable_focus(&mut self) { pub fn disable_focus(&mut self) {
self.blur(); self.blur();
self.window.focus_enabled = false; self.window.focus_enabled = false;
} }
/// Dispatch the given action on the currently focused element.
pub fn dispatch_action(&mut self, action: Box<dyn Action>) { pub fn dispatch_action(&mut self, action: Box<dyn Action>) {
let focus_handle = self.focused(); let focus_handle = self.focused();
@ -591,6 +606,9 @@ impl<'a> WindowContext<'a> {
}); });
} }
/// Subscribe to events emitted by a model or view.
/// The entity to which you're subscribing must implement the [`EventEmitter`] trait.
/// The callback will be invoked a handle to the emitting entity (either a [`View`] or [`Model`]), the event, and a window context for the current window.
pub fn subscribe<Emitter, E, Evt>( pub fn subscribe<Emitter, E, Evt>(
&mut self, &mut self,
entity: &E, entity: &E,
@ -754,6 +772,9 @@ impl<'a> WindowContext<'a> {
.request_measured_layout(style, rem_size, measure) .request_measured_layout(style, rem_size, measure)
} }
/// Compute the layout for the given id within the given available space.
/// This method is called for its side effect, typically by the framework prior to painting.
/// After calling it, you can request the bounds of the given layout node id or any descendant.
pub fn compute_layout(&mut self, layout_id: LayoutId, available_space: Size<AvailableSpace>) { pub fn compute_layout(&mut self, layout_id: LayoutId, available_space: Size<AvailableSpace>) {
let mut layout_engine = self.window.layout_engine.take().unwrap(); let mut layout_engine = self.window.layout_engine.take().unwrap();
layout_engine.compute_layout(layout_id, available_space, self); layout_engine.compute_layout(layout_id, available_space, self);
@ -788,30 +809,37 @@ impl<'a> WindowContext<'a> {
.retain(&(), |callback| callback(self)); .retain(&(), |callback| callback(self));
} }
/// Returns the bounds of the current window in the global coordinate space, which could span across multiple displays.
pub fn window_bounds(&self) -> WindowBounds { pub fn window_bounds(&self) -> WindowBounds {
self.window.bounds self.window.bounds
} }
/// Returns the size of the drawable area within the window.
pub fn viewport_size(&self) -> Size<Pixels> { pub fn viewport_size(&self) -> Size<Pixels> {
self.window.viewport_size self.window.viewport_size
} }
/// Returns whether this window is focused by the operating system (receiving key events).
pub fn is_window_active(&self) -> bool { pub fn is_window_active(&self) -> bool {
self.window.active self.window.active
} }
/// Toggle zoom on the window.
pub fn zoom_window(&self) { pub fn zoom_window(&self) {
self.window.platform_window.zoom(); self.window.platform_window.zoom();
} }
/// Update the window's title at the platform level.
pub fn set_window_title(&mut self, title: &str) { pub fn set_window_title(&mut self, title: &str) {
self.window.platform_window.set_title(title); self.window.platform_window.set_title(title);
} }
/// Mark the window as dirty at the platform level.
pub fn set_window_edited(&mut self, edited: bool) { pub fn set_window_edited(&mut self, edited: bool) {
self.window.platform_window.set_edited(edited); self.window.platform_window.set_edited(edited);
} }
/// Determine the display on which the window is visible.
pub fn display(&self) -> Option<Rc<dyn PlatformDisplay>> { pub fn display(&self) -> Option<Rc<dyn PlatformDisplay>> {
self.platform self.platform
.displays() .displays()
@ -819,6 +847,7 @@ impl<'a> WindowContext<'a> {
.find(|display| display.id() == self.window.display_id) .find(|display| display.id() == self.window.display_id)
} }
/// Show the platform character palette.
pub fn show_character_palette(&self) { pub fn show_character_palette(&self) {
self.window.platform_window.show_character_palette(); self.window.platform_window.show_character_palette();
} }
@ -936,6 +965,7 @@ impl<'a> WindowContext<'a> {
.on_action(action_type, ArenaRef::from(listener)); .on_action(action_type, ArenaRef::from(listener));
} }
/// Determine whether the given action is available along the dispatch path to the currently focused element.
pub fn is_action_available(&self, action: &dyn Action) -> bool { pub fn is_action_available(&self, action: &dyn Action) -> bool {
let target = self let target = self
.focused() .focused()
@ -962,6 +992,7 @@ impl<'a> WindowContext<'a> {
self.window.modifiers self.window.modifiers
} }
/// Update the cursor style at the platform level.
pub fn set_cursor_style(&mut self, style: CursorStyle) { pub fn set_cursor_style(&mut self, style: CursorStyle) {
self.window.requested_cursor_style = Some(style) self.window.requested_cursor_style = Some(style)
} }
@ -991,7 +1022,7 @@ impl<'a> WindowContext<'a> {
true true
} }
pub fn was_top_layer_under_active_drag( pub(crate) fn was_top_layer_under_active_drag(
&self, &self,
point: &Point<Pixels>, point: &Point<Pixels>,
level: &StackingOrder, level: &StackingOrder,
@ -1377,29 +1408,14 @@ impl<'a> WindowContext<'a> {
self.window.focus, self.window.focus,
); );
self.window.next_frame.focus = self.window.focus; self.window.next_frame.focus = self.window.focus;
self.window.next_frame.window_active = self.window.active;
self.window.root_view = Some(root_view); self.window.root_view = Some(root_view);
let previous_focus_path = self.window.rendered_frame.focus_path(); let previous_focus_path = self.window.rendered_frame.focus_path();
let previous_window_active = self.window.rendered_frame.window_active;
mem::swap(&mut self.window.rendered_frame, &mut self.window.next_frame); mem::swap(&mut self.window.rendered_frame, &mut self.window.next_frame);
let current_focus_path = self.window.rendered_frame.focus_path(); let current_focus_path = self.window.rendered_frame.focus_path();
let current_window_active = self.window.rendered_frame.window_active;
if previous_focus_path != current_focus_path {
if !previous_focus_path.is_empty() && current_focus_path.is_empty() {
self.window
.blur_listeners
.clone()
.retain(&(), |listener| listener(self));
}
let event = FocusEvent {
previous_focus_path,
current_focus_path,
};
self.window
.focus_listeners
.clone()
.retain(&(), |listener| listener(&event, self));
}
let scene = self.window.rendered_frame.scene_builder.build(); let scene = self.window.rendered_frame.scene_builder.build();
@ -1416,6 +1432,34 @@ impl<'a> WindowContext<'a> {
self.window.drawing = false; self.window.drawing = false;
ELEMENT_ARENA.with_borrow_mut(|element_arena| element_arena.clear()); ELEMENT_ARENA.with_borrow_mut(|element_arena| element_arena.clear());
if previous_focus_path != current_focus_path
|| previous_window_active != current_window_active
{
if !previous_focus_path.is_empty() && current_focus_path.is_empty() {
self.window
.focus_lost_listeners
.clone()
.retain(&(), |listener| listener(self));
}
let event = FocusEvent {
previous_focus_path: if previous_window_active {
previous_focus_path
} else {
Default::default()
},
current_focus_path: if current_window_active {
current_focus_path
} else {
Default::default()
},
};
self.window
.focus_listeners
.clone()
.retain(&(), |listener| listener(&event, self));
}
scene scene
} }
@ -1445,9 +1489,7 @@ impl<'a> WindowContext<'a> {
InputEvent::MouseUp(mouse_up) InputEvent::MouseUp(mouse_up)
} }
InputEvent::MouseExited(mouse_exited) => { InputEvent::MouseExited(mouse_exited) => {
// todo!("Should we record that the mouse is outside of the window somehow? Or are these global pixels?")
self.window.modifiers = mouse_exited.modifiers; self.window.modifiers = mouse_exited.modifiers;
InputEvent::MouseExited(mouse_exited) InputEvent::MouseExited(mouse_exited)
} }
InputEvent::ModifiersChanged(modifiers_changed) => { InputEvent::ModifiersChanged(modifiers_changed) => {
@ -1649,6 +1691,7 @@ impl<'a> WindowContext<'a> {
self.dispatch_keystroke_observers(event, None); self.dispatch_keystroke_observers(event, None);
} }
/// Determine whether a potential multi-stroke key binding is in progress on this window.
pub fn has_pending_keystrokes(&self) -> bool { pub fn has_pending_keystrokes(&self) -> bool {
self.window self.window
.rendered_frame .rendered_frame
@ -1715,27 +1758,34 @@ impl<'a> WindowContext<'a> {
subscription subscription
} }
/// Focus the current window and bring it to the foreground at the platform level.
pub fn activate_window(&self) { pub fn activate_window(&self) {
self.window.platform_window.activate(); self.window.platform_window.activate();
} }
/// Minimize the current window at the platform level.
pub fn minimize_window(&self) { pub fn minimize_window(&self) {
self.window.platform_window.minimize(); self.window.platform_window.minimize();
} }
/// Toggle full screen status on the current window at the platform level.
pub fn toggle_full_screen(&self) { pub fn toggle_full_screen(&self) {
self.window.platform_window.toggle_full_screen(); self.window.platform_window.toggle_full_screen();
} }
/// Present a platform dialog.
/// The provided message will be presented, along with buttons for each answer.
/// When a button is clicked, the returned Receiver will receive the index of the clicked button.
pub fn prompt( pub fn prompt(
&self, &self,
level: PromptLevel, level: PromptLevel,
msg: &str, message: &str,
answers: &[&str], answers: &[&str],
) -> oneshot::Receiver<usize> { ) -> oneshot::Receiver<usize> {
self.window.platform_window.prompt(level, msg, answers) self.window.platform_window.prompt(level, message, answers)
} }
/// Returns all available actions for the focused element.
pub fn available_actions(&self) -> Vec<Box<dyn Action>> { pub fn available_actions(&self) -> Vec<Box<dyn Action>> {
let node_id = self let node_id = self
.window .window
@ -1754,6 +1804,7 @@ impl<'a> WindowContext<'a> {
.available_actions(node_id) .available_actions(node_id)
} }
/// Returns key bindings that invoke the given action on the currently focused element.
pub fn bindings_for_action(&self, action: &dyn Action) -> Vec<KeyBinding> { pub fn bindings_for_action(&self, action: &dyn Action) -> Vec<KeyBinding> {
self.window self.window
.rendered_frame .rendered_frame
@ -1764,6 +1815,7 @@ impl<'a> WindowContext<'a> {
) )
} }
/// Returns any bindings that would invoke the given action on the given focus handle if it were focused.
pub fn bindings_for_action_in( pub fn bindings_for_action_in(
&self, &self,
action: &dyn Action, action: &dyn Action,
@ -1782,6 +1834,7 @@ impl<'a> WindowContext<'a> {
dispatch_tree.bindings_for_action(action, &context_stack) dispatch_tree.bindings_for_action(action, &context_stack)
} }
/// Returns a generic event listener that invokes the given listener with the view and context associated with the given view handle.
pub fn listener_for<V: Render, E>( pub fn listener_for<V: Render, E>(
&self, &self,
view: &View<V>, view: &View<V>,
@ -1793,6 +1846,7 @@ impl<'a> WindowContext<'a> {
} }
} }
/// Returns a generic handler that invokes the given handler with the view and context associated with the given view handle.
pub fn handler_for<V: Render>( pub fn handler_for<V: Render>(
&self, &self,
view: &View<V>, view: &View<V>,
@ -1804,7 +1858,8 @@ impl<'a> WindowContext<'a> {
} }
} }
//========== ELEMENT RELATED FUNCTIONS =========== /// Invoke the given function with the given focus handle present on the key dispatch stack.
/// If you want an element to participate in key dispatch, use this method to push its key context and focus handle into the stack during paint.
pub fn with_key_dispatch<R>( pub fn with_key_dispatch<R>(
&mut self, &mut self,
context: Option<KeyContext>, context: Option<KeyContext>,
@ -1843,6 +1898,8 @@ impl<'a> WindowContext<'a> {
} }
} }
/// Register a callback that can interrupt the closing of the current window based the returned boolean.
/// If the callback returns false, the window won't be closed.
pub fn on_window_should_close(&mut self, f: impl Fn(&mut WindowContext) -> bool + 'static) { pub fn on_window_should_close(&mut self, f: impl Fn(&mut WindowContext) -> bool + 'static) {
let mut this = self.to_async(); let mut this = self.to_async();
self.window self.window
@ -2017,19 +2074,24 @@ impl<'a> BorrowMut<AppContext> for WindowContext<'a> {
} }
} }
/// This trait contains functionality that is shared across [`ViewContext`] and [`WindowContext`]
pub trait BorrowWindow: BorrowMut<Window> + BorrowMut<AppContext> { pub trait BorrowWindow: BorrowMut<Window> + BorrowMut<AppContext> {
#[doc(hidden)]
fn app_mut(&mut self) -> &mut AppContext { fn app_mut(&mut self) -> &mut AppContext {
self.borrow_mut() self.borrow_mut()
} }
#[doc(hidden)]
fn app(&self) -> &AppContext { fn app(&self) -> &AppContext {
self.borrow() self.borrow()
} }
#[doc(hidden)]
fn window(&self) -> &Window { fn window(&self) -> &Window {
self.borrow() self.borrow()
} }
#[doc(hidden)]
fn window_mut(&mut self) -> &mut Window { fn window_mut(&mut self) -> &mut Window {
self.borrow_mut() self.borrow_mut()
} }
@ -2279,6 +2341,10 @@ impl BorrowMut<Window> for WindowContext<'_> {
impl<T> BorrowWindow for T where T: BorrowMut<AppContext> + BorrowMut<Window> {} impl<T> BorrowWindow for T where T: BorrowMut<AppContext> + BorrowMut<Window> {}
/// Provides access to application state that is specialized for a particular [`View`].
/// Allows you to interact with focus, emit events, etc.
/// ViewContext also derefs to [`WindowContext`], giving you access to all of its methods as well.
/// When you call [`View::update`], you're passed a `&mut V` and an `&mut ViewContext<V>`.
pub struct ViewContext<'a, V> { pub struct ViewContext<'a, V> {
window_cx: WindowContext<'a>, window_cx: WindowContext<'a>,
view: &'a View<V>, view: &'a View<V>,
@ -2316,14 +2382,17 @@ impl<'a, V: 'static> ViewContext<'a, V> {
} }
} }
/// Get the entity_id of this view.
pub fn entity_id(&self) -> EntityId { pub fn entity_id(&self) -> EntityId {
self.view.entity_id() self.view.entity_id()
} }
/// Get the view pointer underlying this context.
pub fn view(&self) -> &View<V> { pub fn view(&self) -> &View<V> {
self.view self.view
} }
/// Get the model underlying this view.
pub fn model(&self) -> &Model<V> { pub fn model(&self) -> &Model<V> {
&self.view.model &self.view.model
} }
@ -2333,6 +2402,7 @@ impl<'a, V: 'static> ViewContext<'a, V> {
&mut self.window_cx &mut self.window_cx
} }
/// Set a given callback to be run on the next frame.
pub fn on_next_frame(&mut self, f: impl FnOnce(&mut V, &mut ViewContext<V>) + 'static) pub fn on_next_frame(&mut self, f: impl FnOnce(&mut V, &mut ViewContext<V>) + 'static)
where where
V: 'static, V: 'static,
@ -2350,6 +2420,7 @@ impl<'a, V: 'static> ViewContext<'a, V> {
}); });
} }
/// Observe another model or view for changes to its state, as tracked by [`ModelContext::notify`].
pub fn observe<V2, E>( pub fn observe<V2, E>(
&mut self, &mut self,
entity: &E, entity: &E,
@ -2383,6 +2454,9 @@ impl<'a, V: 'static> ViewContext<'a, V> {
subscription subscription
} }
/// Subscribe to events emitted by another model or view.
/// The entity to which you're subscribing must implement the [`EventEmitter`] trait.
/// The callback will be invoked with a reference to the current view, a handle to the emitting entity (either a [`View`] or [`Model`]), the event, and a view context for the current view.
pub fn subscribe<V2, E, Evt>( pub fn subscribe<V2, E, Evt>(
&mut self, &mut self,
entity: &E, entity: &E,
@ -2440,6 +2514,7 @@ impl<'a, V: 'static> ViewContext<'a, V> {
subscription subscription
} }
/// Register a callback to be invoked when the given Model or View is released.
pub fn observe_release<V2, E>( pub fn observe_release<V2, E>(
&mut self, &mut self,
entity: &E, entity: &E,
@ -2466,6 +2541,8 @@ impl<'a, V: 'static> ViewContext<'a, V> {
subscription subscription
} }
/// Indicate that this view has changed, which will invoke any observers and also mark the window as dirty.
/// If this view or any of its ancestors are *cached*, notifying it will cause it or its ancestors to be redrawn.
pub fn notify(&mut self) { pub fn notify(&mut self) {
if !self.window.drawing { if !self.window.drawing {
self.window_cx.notify(); self.window_cx.notify();
@ -2475,6 +2552,7 @@ impl<'a, V: 'static> ViewContext<'a, V> {
} }
} }
/// Register a callback to be invoked when the window is resized.
pub fn observe_window_bounds( pub fn observe_window_bounds(
&mut self, &mut self,
mut callback: impl FnMut(&mut V, &mut ViewContext<V>) + 'static, mut callback: impl FnMut(&mut V, &mut ViewContext<V>) + 'static,
@ -2488,6 +2566,7 @@ impl<'a, V: 'static> ViewContext<'a, V> {
subscription subscription
} }
/// Register a callback to be invoked when the window is activated or deactivated.
pub fn observe_window_activation( pub fn observe_window_activation(
&mut self, &mut self,
mut callback: impl FnMut(&mut V, &mut ViewContext<V>) + 'static, mut callback: impl FnMut(&mut V, &mut ViewContext<V>) + 'static,
@ -2579,14 +2658,16 @@ impl<'a, V: 'static> ViewContext<'a, V> {
subscription subscription
} }
/// Register a listener to be called when the window loses focus. /// Register a listener to be called when nothing in the window has focus.
/// This typically happens when the node that was focused is removed from the tree,
/// and this callback lets you chose a default place to restore the users focus.
/// Returns a subscription and persists until the subscription is dropped. /// Returns a subscription and persists until the subscription is dropped.
pub fn on_blur_window( pub fn on_focus_lost(
&mut self, &mut self,
mut listener: impl FnMut(&mut V, &mut ViewContext<V>) + 'static, mut listener: impl FnMut(&mut V, &mut ViewContext<V>) + 'static,
) -> Subscription { ) -> Subscription {
let view = self.view.downgrade(); let view = self.view.downgrade();
let (subscription, activate) = self.window.blur_listeners.insert( let (subscription, activate) = self.window.focus_lost_listeners.insert(
(), (),
Box::new(move |cx| view.update(cx, |view, cx| listener(view, cx)).is_ok()), Box::new(move |cx| view.update(cx, |view, cx| listener(view, cx)).is_ok()),
); );
@ -2620,6 +2701,10 @@ impl<'a, V: 'static> ViewContext<'a, V> {
subscription subscription
} }
/// Schedule a future to be run asynchronously.
/// The given callback is invoked with a [`WeakView<V>`] to avoid leaking the view for a long-running process.
/// It's also given an [`AsyncWindowContext`], which can be used to access the state of the view across await points.
/// The returned future will be polled on the main thread.
pub fn spawn<Fut, R>( pub fn spawn<Fut, R>(
&mut self, &mut self,
f: impl FnOnce(WeakView<V>, AsyncWindowContext) -> Fut, f: impl FnOnce(WeakView<V>, AsyncWindowContext) -> Fut,
@ -2632,6 +2717,7 @@ impl<'a, V: 'static> ViewContext<'a, V> {
self.window_cx.spawn(|cx| f(view, cx)) self.window_cx.spawn(|cx| f(view, cx))
} }
/// Update the global state of the given type.
pub fn update_global<G, R>(&mut self, f: impl FnOnce(&mut G, &mut Self) -> R) -> R pub fn update_global<G, R>(&mut self, f: impl FnOnce(&mut G, &mut Self) -> R) -> R
where where
G: 'static, G: 'static,
@ -2642,6 +2728,7 @@ impl<'a, V: 'static> ViewContext<'a, V> {
result result
} }
/// Register a callback to be invoked when the given global state changes.
pub fn observe_global<G: 'static>( pub fn observe_global<G: 'static>(
&mut self, &mut self,
mut f: impl FnMut(&mut V, &mut ViewContext<'_, V>) + 'static, mut f: impl FnMut(&mut V, &mut ViewContext<'_, V>) + 'static,
@ -2660,6 +2747,9 @@ impl<'a, V: 'static> ViewContext<'a, V> {
subscription subscription
} }
/// Add a listener for any mouse event that occurs in the window.
/// This is a fairly low level method.
/// Typically, you'll want to use methods on UI elements, which perform bounds checking etc.
pub fn on_mouse_event<Event: 'static>( pub fn on_mouse_event<Event: 'static>(
&mut self, &mut self,
handler: impl Fn(&mut V, &Event, DispatchPhase, &mut ViewContext<V>) + 'static, handler: impl Fn(&mut V, &Event, DispatchPhase, &mut ViewContext<V>) + 'static,
@ -2672,6 +2762,7 @@ impl<'a, V: 'static> ViewContext<'a, V> {
}); });
} }
/// Register a callback to be invoked when the given Key Event is dispatched to the window.
pub fn on_key_event<Event: 'static>( pub fn on_key_event<Event: 'static>(
&mut self, &mut self,
handler: impl Fn(&mut V, &Event, DispatchPhase, &mut ViewContext<V>) + 'static, handler: impl Fn(&mut V, &Event, DispatchPhase, &mut ViewContext<V>) + 'static,
@ -2684,6 +2775,7 @@ impl<'a, V: 'static> ViewContext<'a, V> {
}); });
} }
/// Register a callback to be invoked when the given Action type is dispatched to the window.
pub fn on_action( pub fn on_action(
&mut self, &mut self,
action_type: TypeId, action_type: TypeId,
@ -2698,6 +2790,7 @@ impl<'a, V: 'static> ViewContext<'a, V> {
}); });
} }
/// Emit an event to be handled any other views that have subscribed via [ViewContext::subscribe].
pub fn emit<Evt>(&mut self, event: Evt) pub fn emit<Evt>(&mut self, event: Evt)
where where
Evt: 'static, Evt: 'static,
@ -2711,6 +2804,7 @@ impl<'a, V: 'static> ViewContext<'a, V> {
}); });
} }
/// Move focus to the current view, assuming it implements [`FocusableView`].
pub fn focus_self(&mut self) pub fn focus_self(&mut self)
where where
V: FocusableView, V: FocusableView,
@ -2718,6 +2812,11 @@ impl<'a, V: 'static> ViewContext<'a, V> {
self.defer(|view, cx| view.focus_handle(cx).focus(cx)) self.defer(|view, cx| view.focus_handle(cx).focus(cx))
} }
/// Convenience method for accessing view state in an event callback.
///
/// Many GPUI callbacks take the form of `Fn(&E, &mut WindowContext)`,
/// but it's often useful to be able to access view state in these
/// callbacks. This method provides a convenient way to do so.
pub fn listener<E>( pub fn listener<E>(
&self, &self,
f: impl Fn(&mut V, &E, &mut ViewContext<V>) + 'static, f: impl Fn(&mut V, &E, &mut ViewContext<V>) + 'static,
@ -2827,14 +2926,20 @@ impl<'a, V> std::ops::DerefMut for ViewContext<'a, V> {
} }
// #[derive(Clone, Copy, Eq, PartialEq, Hash)] // #[derive(Clone, Copy, Eq, PartialEq, Hash)]
slotmap::new_key_type! { pub struct WindowId; } slotmap::new_key_type! {
/// A unique identifier for a window.
pub struct WindowId;
}
impl WindowId { impl WindowId {
/// Converts this window ID to a `u64`.
pub fn as_u64(&self) -> u64 { pub fn as_u64(&self) -> u64 {
self.0.as_ffi() self.0.as_ffi()
} }
} }
/// A handle to a window with a specific root view type.
/// Note that this does not keep the window alive on its own.
#[derive(Deref, DerefMut)] #[derive(Deref, DerefMut)]
pub struct WindowHandle<V> { pub struct WindowHandle<V> {
#[deref] #[deref]
@ -2844,6 +2949,8 @@ pub struct WindowHandle<V> {
} }
impl<V: 'static + Render> WindowHandle<V> { impl<V: 'static + Render> WindowHandle<V> {
/// Create a new handle from a window ID.
/// This does not check if the root type of the window is `V`.
pub fn new(id: WindowId) -> Self { pub fn new(id: WindowId) -> Self {
WindowHandle { WindowHandle {
any_handle: AnyWindowHandle { any_handle: AnyWindowHandle {
@ -2854,6 +2961,9 @@ impl<V: 'static + Render> WindowHandle<V> {
} }
} }
/// Get the root view out of this window.
///
/// This will fail if the window is closed or if the root view's type does not match `V`.
pub fn root<C>(&self, cx: &mut C) -> Result<View<V>> pub fn root<C>(&self, cx: &mut C) -> Result<View<V>>
where where
C: Context, C: Context,
@ -2865,6 +2975,9 @@ impl<V: 'static + Render> WindowHandle<V> {
})) }))
} }
/// Update the root view of this window.
///
/// This will fail if the window has been closed or if the root view's type does not match
pub fn update<C, R>( pub fn update<C, R>(
&self, &self,
cx: &mut C, cx: &mut C,
@ -2881,6 +2994,9 @@ impl<V: 'static + Render> WindowHandle<V> {
})? })?
} }
/// Read the root view out of this window.
///
/// This will fail if the window is closed or if the root view's type does not match `V`.
pub fn read<'a>(&self, cx: &'a AppContext) -> Result<&'a V> { pub fn read<'a>(&self, cx: &'a AppContext) -> Result<&'a V> {
let x = cx let x = cx
.windows .windows
@ -2897,6 +3013,9 @@ impl<V: 'static + Render> WindowHandle<V> {
Ok(x.read(cx)) Ok(x.read(cx))
} }
/// Read the root view out of this window, with a callback
///
/// This will fail if the window is closed or if the root view's type does not match `V`.
pub fn read_with<C, R>(&self, cx: &C, read_with: impl FnOnce(&V, &AppContext) -> R) -> Result<R> pub fn read_with<C, R>(&self, cx: &C, read_with: impl FnOnce(&V, &AppContext) -> R) -> Result<R>
where where
C: Context, C: Context,
@ -2904,6 +3023,9 @@ impl<V: 'static + Render> WindowHandle<V> {
cx.read_window(self, |root_view, cx| read_with(root_view.read(cx), cx)) cx.read_window(self, |root_view, cx| read_with(root_view.read(cx), cx))
} }
/// Read the root view pointer off of this window.
///
/// This will fail if the window is closed or if the root view's type does not match `V`.
pub fn root_view<C>(&self, cx: &C) -> Result<View<V>> pub fn root_view<C>(&self, cx: &C) -> Result<View<V>>
where where
C: Context, C: Context,
@ -2911,6 +3033,9 @@ impl<V: 'static + Render> WindowHandle<V> {
cx.read_window(self, |root_view, _cx| root_view.clone()) cx.read_window(self, |root_view, _cx| root_view.clone())
} }
/// Check if this window is 'active'.
///
/// Will return `None` if the window is closed.
pub fn is_active(&self, cx: &AppContext) -> Option<bool> { pub fn is_active(&self, cx: &AppContext) -> Option<bool> {
cx.windows cx.windows
.get(self.id) .get(self.id)
@ -2946,6 +3071,7 @@ impl<V: 'static> From<WindowHandle<V>> for AnyWindowHandle {
} }
} }
/// A handle to a window with any root view type, which can be downcast to a window with a specific root view type.
#[derive(Copy, Clone, PartialEq, Eq, Hash)] #[derive(Copy, Clone, PartialEq, Eq, Hash)]
pub struct AnyWindowHandle { pub struct AnyWindowHandle {
pub(crate) id: WindowId, pub(crate) id: WindowId,
@ -2953,10 +3079,13 @@ pub struct AnyWindowHandle {
} }
impl AnyWindowHandle { impl AnyWindowHandle {
/// Get the ID of this window.
pub fn window_id(&self) -> WindowId { pub fn window_id(&self) -> WindowId {
self.id self.id
} }
/// Attempt to convert this handle to a window handle with a specific root view type.
/// If the types do not match, this will return `None`.
pub fn downcast<T: 'static>(&self) -> Option<WindowHandle<T>> { pub fn downcast<T: 'static>(&self) -> Option<WindowHandle<T>> {
if TypeId::of::<T>() == self.state_type { if TypeId::of::<T>() == self.state_type {
Some(WindowHandle { Some(WindowHandle {
@ -2968,6 +3097,9 @@ impl AnyWindowHandle {
} }
} }
/// Update the state of the root view of this window.
///
/// This will fail if the window has been closed.
pub fn update<C, R>( pub fn update<C, R>(
self, self,
cx: &mut C, cx: &mut C,
@ -2979,6 +3111,9 @@ impl AnyWindowHandle {
cx.update_window(self, update) cx.update_window(self, update)
} }
/// Read the state of the root view of this window.
///
/// This will fail if the window has been closed.
pub fn read<T, C, R>(self, cx: &C, read: impl FnOnce(View<T>, &AppContext) -> R) -> Result<R> pub fn read<T, C, R>(self, cx: &C, read: impl FnOnce(View<T>, &AppContext) -> R) -> Result<R>
where where
C: Context, C: Context,
@ -2999,12 +3134,21 @@ impl AnyWindowHandle {
// } // }
// } // }
/// An identifier for an [`Element`](crate::Element).
///
/// Can be constructed with a string, a number, or both, as well
/// as other internal representations.
#[derive(Clone, Debug, Eq, PartialEq, Hash)] #[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub enum ElementId { pub enum ElementId {
/// The ID of a View element
View(EntityId), View(EntityId),
/// An integer ID.
Integer(usize), Integer(usize),
/// A string based ID.
Name(SharedString), Name(SharedString),
/// An ID that's equated with a focus handle.
FocusHandle(FocusId), FocusHandle(FocusId),
/// A combination of a name and an integer.
NamedInteger(SharedString, usize), NamedInteger(SharedString, usize),
} }
@ -3074,7 +3218,8 @@ impl From<(&'static str, u64)> for ElementId {
} }
} }
/// A rectangle, to be rendered on the screen by GPUI at the given position and size. /// A rectangle to be rendered in the window at the given position and size.
/// Passed as an argument [`WindowContext::paint_quad`].
#[derive(Clone)] #[derive(Clone)]
pub struct PaintQuad { pub struct PaintQuad {
bounds: Bounds<Pixels>, bounds: Bounds<Pixels>,

View file

@ -18,33 +18,33 @@ fn test_action_macros() {
impl gpui::Action for RegisterableAction { impl gpui::Action for RegisterableAction {
fn boxed_clone(&self) -> Box<dyn gpui::Action> { fn boxed_clone(&self) -> Box<dyn gpui::Action> {
todo!() unimplemented!()
} }
fn as_any(&self) -> &dyn std::any::Any { fn as_any(&self) -> &dyn std::any::Any {
todo!() unimplemented!()
} }
fn partial_eq(&self, _action: &dyn gpui::Action) -> bool { fn partial_eq(&self, _action: &dyn gpui::Action) -> bool {
todo!() unimplemented!()
} }
fn name(&self) -> &str { fn name(&self) -> &str {
todo!() unimplemented!()
} }
fn debug_name() -> &'static str fn debug_name() -> &'static str
where where
Self: Sized, Self: Sized,
{ {
todo!() unimplemented!()
} }
fn build(_value: serde_json::Value) -> anyhow::Result<Box<dyn gpui::Action>> fn build(_value: serde_json::Value) -> anyhow::Result<Box<dyn gpui::Action>>
where where
Self: Sized, Self: Sized,
{ {
todo!() unimplemented!()
} }
} }
} }

View file

@ -7,26 +7,56 @@ mod test;
use proc_macro::TokenStream; use proc_macro::TokenStream;
#[proc_macro] #[proc_macro]
/// register_action! can be used to register an action with the GPUI runtime.
/// You should typically use `gpui::actions!` or `gpui::impl_actions!` instead,
/// but this can be used for fine grained customization.
pub fn register_action(ident: TokenStream) -> TokenStream { pub fn register_action(ident: TokenStream) -> TokenStream {
register_action::register_action_macro(ident) register_action::register_action_macro(ident)
} }
#[proc_macro_derive(IntoElement)] #[proc_macro_derive(IntoElement)]
// #[derive(IntoElement)] is used to create a Component out of anything that implements
// the `RenderOnce` trait.
pub fn derive_into_element(input: TokenStream) -> TokenStream { pub fn derive_into_element(input: TokenStream) -> TokenStream {
derive_into_element::derive_into_element(input) derive_into_element::derive_into_element(input)
} }
#[proc_macro_derive(Render)] #[proc_macro_derive(Render)]
#[doc(hidden)]
pub fn derive_render(input: TokenStream) -> TokenStream { pub fn derive_render(input: TokenStream) -> TokenStream {
derive_render::derive_render(input) derive_render::derive_render(input)
} }
// Used by gpui to generate the style helpers.
#[proc_macro] #[proc_macro]
#[doc(hidden)]
pub fn style_helpers(input: TokenStream) -> TokenStream { pub fn style_helpers(input: TokenStream) -> TokenStream {
style_helpers::style_helpers(input) style_helpers::style_helpers(input)
} }
#[proc_macro_attribute] #[proc_macro_attribute]
/// #[gpui::test] can be used to annotate test functions that run with GPUI support.
/// it supports both synchronous and asynchronous tests, and can provide you with
/// as many `TestAppContext` instances as you need.
/// The output contains a `#[test]` annotation so this can be used with any existing
/// test harness (`cargo test` or `cargo-nextest`).
///
/// ```
/// #[gpui::test]
/// async fn test_foo(mut cx: &TestAppContext) { }
/// ```
///
/// In addition to passing a TestAppContext, you can also ask for a `StdRnd` instance.
/// this will be seeded with the `SEED` environment variable and is used internally by
/// the ForegroundExecutor and BackgroundExecutor to run tasks deterministically in tests.
/// Using the same `StdRng` for behaviour in your test will allow you to exercise a wide
/// variety of scenarios and interleavings just by changing the seed.
///
/// #[gpui::test] also takes three different arguments:
/// - `#[gpui::test(interations=10)]` will run the test ten times with a different initial SEED.
/// - `#[gpui::test(retries=3)]` will run the test up to four times if it fails to try and make it pass.
/// - `#[gpui::test(on_failure="crate::test::report_failure")]` will call the specified function after the
/// tests fail so that you can write out more detail about the failure.
pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
test::test(args, function) test::test(args, function)
} }

View file

@ -106,7 +106,8 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
let cx_varname = format_ident!("cx_{}", ix); let cx_varname = format_ident!("cx_{}", ix);
cx_vars.extend(quote!( cx_vars.extend(quote!(
let mut #cx_varname = gpui::TestAppContext::new( let mut #cx_varname = gpui::TestAppContext::new(
dispatcher.clone() dispatcher.clone(),
Some(stringify!(#outer_fn_name)),
); );
)); ));
cx_teardowns.extend(quote!( cx_teardowns.extend(quote!(
@ -140,8 +141,7 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
executor.block_test(#inner_fn_name(#inner_fn_args)); executor.block_test(#inner_fn_name(#inner_fn_args));
#cx_teardowns #cx_teardowns
}, },
#on_failure_fn_name, #on_failure_fn_name
stringify!(#outer_fn_name).to_string(),
); );
} }
} }
@ -169,7 +169,8 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
let cx_varname_lock = format_ident!("cx_{}_lock", ix); let cx_varname_lock = format_ident!("cx_{}_lock", ix);
cx_vars.extend(quote!( cx_vars.extend(quote!(
let mut #cx_varname = gpui::TestAppContext::new( let mut #cx_varname = gpui::TestAppContext::new(
dispatcher.clone() dispatcher.clone(),
Some(stringify!(#outer_fn_name))
); );
let mut #cx_varname_lock = #cx_varname.app.borrow_mut(); let mut #cx_varname_lock = #cx_varname.app.borrow_mut();
)); ));
@ -186,7 +187,8 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
let cx_varname = format_ident!("cx_{}", ix); let cx_varname = format_ident!("cx_{}", ix);
cx_vars.extend(quote!( cx_vars.extend(quote!(
let mut #cx_varname = gpui::TestAppContext::new( let mut #cx_varname = gpui::TestAppContext::new(
dispatcher.clone() dispatcher.clone(),
Some(stringify!(#outer_fn_name))
); );
)); ));
cx_teardowns.extend(quote!( cx_teardowns.extend(quote!(
@ -222,7 +224,6 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
#cx_teardowns #cx_teardowns
}, },
#on_failure_fn_name, #on_failure_fn_name,
stringify!(#outer_fn_name).to_string(),
); );
} }
} }

View file

@ -254,6 +254,7 @@ pub enum Event {
LanguageChanged, LanguageChanged,
Reparsed, Reparsed,
DiagnosticsUpdated, DiagnosticsUpdated,
CapabilityChanged,
Closed, Closed,
} }
@ -631,6 +632,11 @@ impl Buffer {
.set_language_registry(language_registry); .set_language_registry(language_registry);
} }
pub fn set_capability(&mut self, capability: Capability, cx: &mut ModelContext<Self>) {
self.capability = capability;
cx.emit(Event::CapabilityChanged)
}
pub fn did_save( pub fn did_save(
&mut self, &mut self,
version: clock::Global, version: clock::Global,

View file

@ -258,19 +258,19 @@ fn test_typing_multiple_new_injections() {
let (buffer, syntax_map) = test_edit_sequence( let (buffer, syntax_map) = test_edit_sequence(
"Rust", "Rust",
&[ &[
"fn a() { dbg }", "fn a() { test_macro }",
"fn a() { dbg«!» }", "fn a() { test_macro«!» }",
"fn a() { dbg!«()» }", "fn a() { test_macro!«()» }",
"fn a() { dbg!(«b») }", "fn a() { test_macro!(«b») }",
"fn a() { dbg!(b«.») }", "fn a() { test_macro!(b«.») }",
"fn a() { dbg!(b.«c») }", "fn a() { test_macro!(b.«c») }",
"fn a() { dbg!(b.c«()») }", "fn a() { test_macro!(b.c«()») }",
"fn a() { dbg!(b.c(«vec»)) }", "fn a() { test_macro!(b.c(«vec»)) }",
"fn a() { dbg!(b.c(vec«!»)) }", "fn a() { test_macro!(b.c(vec«!»)) }",
"fn a() { dbg!(b.c(vec!«[]»)) }", "fn a() { test_macro!(b.c(vec!«[]»)) }",
"fn a() { dbg!(b.c(vec![«d»])) }", "fn a() { test_macro!(b.c(vec![«d»])) }",
"fn a() { dbg!(b.c(vec![d«.»])) }", "fn a() { test_macro!(b.c(vec![d«.»])) }",
"fn a() { dbg!(b.c(vec![d.«e»])) }", "fn a() { test_macro!(b.c(vec![d.«e»])) }",
], ],
); );
@ -278,7 +278,7 @@ fn test_typing_multiple_new_injections() {
&syntax_map, &syntax_map,
&buffer, &buffer,
&["field"], &["field"],
"fn a() { dbg!(b.«c»(vec![d.«e»])) }", "fn a() { test_macro!(b.«c»(vec![d.«e»])) }",
); );
} }

View file

@ -2,13 +2,12 @@ use editor::{scroll::autoscroll::Autoscroll, Anchor, Editor, ExcerptId};
use gpui::{ use gpui::{
actions, canvas, div, rems, uniform_list, AnyElement, AppContext, AvailableSpace, Div, actions, canvas, div, rems, uniform_list, AnyElement, AppContext, AvailableSpace, Div,
EventEmitter, FocusHandle, FocusableView, Hsla, InteractiveElement, IntoElement, Model, EventEmitter, FocusHandle, FocusableView, Hsla, InteractiveElement, IntoElement, Model,
MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Render, Styled, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Render, Styled,
UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WindowContext, UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WindowContext,
}; };
use language::{Buffer, OwnedSyntaxLayerInfo}; use language::{Buffer, OwnedSyntaxLayerInfo};
use settings::Settings;
use std::{mem, ops::Range}; use std::{mem, ops::Range};
use theme::{ActiveTheme, ThemeSettings}; use theme::ActiveTheme;
use tree_sitter::{Node, TreeCursor}; use tree_sitter::{Node, TreeCursor};
use ui::{h_stack, popover_menu, ButtonLike, Color, ContextMenu, Label, LabelCommon, PopoverMenu}; use ui::{h_stack, popover_menu, ButtonLike, Color, ContextMenu, Label, LabelCommon, PopoverMenu};
use workspace::{ use workspace::{
@ -34,8 +33,6 @@ pub fn init(cx: &mut AppContext) {
pub struct SyntaxTreeView { pub struct SyntaxTreeView {
workspace_handle: WeakView<Workspace>, workspace_handle: WeakView<Workspace>,
editor: Option<EditorState>, editor: Option<EditorState>,
mouse_y: Option<Pixels>,
line_height: Option<Pixels>,
list_scroll_handle: UniformListScrollHandle, list_scroll_handle: UniformListScrollHandle,
selected_descendant_ix: Option<usize>, selected_descendant_ix: Option<usize>,
hovered_descendant_ix: Option<usize>, hovered_descendant_ix: Option<usize>,
@ -70,8 +67,6 @@ impl SyntaxTreeView {
workspace_handle: workspace_handle.clone(), workspace_handle: workspace_handle.clone(),
list_scroll_handle: UniformListScrollHandle::new(), list_scroll_handle: UniformListScrollHandle::new(),
editor: None, editor: None,
mouse_y: None,
line_height: None,
hovered_descendant_ix: None, hovered_descendant_ix: None,
selected_descendant_ix: None, selected_descendant_ix: None,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
@ -208,39 +203,6 @@ impl SyntaxTreeView {
Some(()) Some(())
} }
fn handle_click(&mut self, y: Pixels, cx: &mut ViewContext<SyntaxTreeView>) -> Option<()> {
let line_height = self.line_height?;
let ix = ((self.list_scroll_handle.scroll_top() + y) / line_height) as usize;
self.update_editor_with_range_for_descendant_ix(ix, cx, |editor, mut range, cx| {
// Put the cursor at the beginning of the node.
mem::swap(&mut range.start, &mut range.end);
editor.change_selections(Some(Autoscroll::newest()), cx, |selections| {
selections.select_ranges(vec![range]);
});
});
Some(())
}
fn hover_state_changed(&mut self, cx: &mut ViewContext<SyntaxTreeView>) {
if let Some((y, line_height)) = self.mouse_y.zip(self.line_height) {
let ix = ((self.list_scroll_handle.scroll_top() + y) / line_height) as usize;
if self.hovered_descendant_ix != Some(ix) {
self.hovered_descendant_ix = Some(ix);
self.update_editor_with_range_for_descendant_ix(ix, cx, |editor, range, cx| {
editor.clear_background_highlights::<Self>(cx);
editor.highlight_background::<Self>(
vec![range],
|theme| theme.editor_document_highlight_write_background,
cx,
);
});
cx.notify();
}
}
}
fn update_editor_with_range_for_descendant_ix( fn update_editor_with_range_for_descendant_ix(
&self, &self,
descendant_ix: usize, descendant_ix: usize,
@ -306,15 +268,6 @@ impl SyntaxTreeView {
impl Render for SyntaxTreeView { impl Render for SyntaxTreeView {
fn render(&mut self, cx: &mut gpui::ViewContext<'_, Self>) -> impl IntoElement { fn render(&mut self, cx: &mut gpui::ViewContext<'_, Self>) -> impl IntoElement {
let settings = ThemeSettings::get_global(cx);
let line_height = cx
.text_style()
.line_height_in_pixels(settings.buffer_font_size(cx));
if Some(line_height) != self.line_height {
self.line_height = Some(line_height);
self.hover_state_changed(cx);
}
let mut rendered = div().flex_1(); let mut rendered = div().flex_1();
if let Some(layer) = self if let Some(layer) = self
@ -345,12 +298,51 @@ impl Render for SyntaxTreeView {
break; break;
} }
} else { } else {
items.push(Self::render_node( items.push(
&cursor, Self::render_node(
depth, &cursor,
Some(descendant_ix) == this.selected_descendant_ix, depth,
cx, Some(descendant_ix) == this.selected_descendant_ix,
)); cx,
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |tree_view, _: &MouseDownEvent, cx| {
tree_view.update_editor_with_range_for_descendant_ix(
descendant_ix,
cx,
|editor, mut range, cx| {
// Put the cursor at the beginning of the node.
mem::swap(&mut range.start, &mut range.end);
editor.change_selections(
Some(Autoscroll::newest()),
cx,
|selections| {
selections.select_ranges(vec![range]);
},
);
},
);
}),
)
.on_mouse_move(cx.listener(
move |tree_view, _: &MouseMoveEvent, cx| {
if tree_view.hovered_descendant_ix != Some(descendant_ix) {
tree_view.hovered_descendant_ix = Some(descendant_ix);
tree_view.update_editor_with_range_for_descendant_ix(descendant_ix, cx, |editor, range, cx| {
editor.clear_background_highlights::<Self>(cx);
editor.highlight_background::<Self>(
vec![range],
|theme| theme.editor_document_highlight_write_background,
cx,
);
});
cx.notify();
}
},
)),
);
descendant_ix += 1; descendant_ix += 1;
if cursor.goto_first_child() { if cursor.goto_first_child() {
depth += 1; depth += 1;
@ -364,16 +356,6 @@ impl Render for SyntaxTreeView {
) )
.size_full() .size_full()
.track_scroll(self.list_scroll_handle.clone()) .track_scroll(self.list_scroll_handle.clone())
.on_mouse_move(cx.listener(move |tree_view, event: &MouseMoveEvent, cx| {
tree_view.mouse_y = Some(event.position.y);
tree_view.hover_state_changed(cx);
}))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |tree_view, event: &MouseDownEvent, cx| {
tree_view.handle_click(event.position.y, cx);
}),
)
.text_bg(cx.theme().colors().background); .text_bg(cx.theme().colors().background);
rendered = rendered.child( rendered = rendered.child(

View file

@ -1,7 +1,7 @@
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use futures::StreamExt; use futures::StreamExt;
use gpui::{actions, KeyBinding}; use gpui::{actions, KeyBinding, Menu, MenuItem};
use live_kit_client::{ use live_kit_client::{
LocalAudioTrack, LocalVideoTrack, RemoteAudioTrackUpdate, RemoteVideoTrackUpdate, Room, LocalAudioTrack, LocalVideoTrack, RemoteAudioTrackUpdate, RemoteVideoTrackUpdate, Room,
}; };
@ -26,15 +26,14 @@ fn main() {
cx.on_action(quit); cx.on_action(quit);
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
// todo!() cx.set_menus(vec![Menu {
// cx.set_menus(vec![Menu { name: "Zed",
// name: "Zed", items: vec![MenuItem::Action {
// items: vec![MenuItem::Action { name: "Quit",
// name: "Quit", action: Box::new(Quit),
// action: Box::new(Quit), os_action: None,
// os_action: None, }],
// }], }]);
// }]);
let live_kit_url = std::env::var("LIVE_KIT_URL").unwrap_or("http://localhost:7880".into()); let live_kit_url = std::env::var("LIVE_KIT_URL").unwrap_or("http://localhost:7880".into());
let live_kit_key = std::env::var("LIVE_KIT_KEY").unwrap_or("devkey".into()); let live_kit_key = std::env::var("LIVE_KIT_KEY").unwrap_or("devkey".into());

View file

@ -164,29 +164,26 @@ pub enum ConnectionState {
} }
pub struct Room { pub struct Room {
native_room: Mutex<swift::Room>, native_room: swift::Room,
connection: Mutex<( connection: Mutex<(
watch::Sender<ConnectionState>, watch::Sender<ConnectionState>,
watch::Receiver<ConnectionState>, watch::Receiver<ConnectionState>,
)>, )>,
remote_audio_track_subscribers: Mutex<Vec<mpsc::UnboundedSender<RemoteAudioTrackUpdate>>>, remote_audio_track_subscribers: Mutex<Vec<mpsc::UnboundedSender<RemoteAudioTrackUpdate>>>,
remote_video_track_subscribers: Mutex<Vec<mpsc::UnboundedSender<RemoteVideoTrackUpdate>>>, remote_video_track_subscribers: Mutex<Vec<mpsc::UnboundedSender<RemoteVideoTrackUpdate>>>,
_delegate: Mutex<RoomDelegate>, _delegate: RoomDelegate,
} }
trait AssertSendSync: Send {}
impl AssertSendSync for Room {}
impl Room { impl Room {
pub fn new() -> Arc<Self> { pub fn new() -> Arc<Self> {
Arc::new_cyclic(|weak_room| { Arc::new_cyclic(|weak_room| {
let delegate = RoomDelegate::new(weak_room.clone()); let delegate = RoomDelegate::new(weak_room.clone());
Self { Self {
native_room: Mutex::new(unsafe { LKRoomCreate(delegate.native_delegate) }), native_room: unsafe { LKRoomCreate(delegate.native_delegate) },
connection: Mutex::new(watch::channel_with(ConnectionState::Disconnected)), connection: Mutex::new(watch::channel_with(ConnectionState::Disconnected)),
remote_audio_track_subscribers: Default::default(), remote_audio_track_subscribers: Default::default(),
remote_video_track_subscribers: Default::default(), remote_video_track_subscribers: Default::default(),
_delegate: Mutex::new(delegate), _delegate: delegate,
} }
}) })
} }
@ -201,7 +198,7 @@ impl Room {
let (did_connect, tx, rx) = Self::build_done_callback(); let (did_connect, tx, rx) = Self::build_done_callback();
unsafe { unsafe {
LKRoomConnect( LKRoomConnect(
*self.native_room.lock(), self.native_room,
url.as_concrete_TypeRef(), url.as_concrete_TypeRef(),
token.as_concrete_TypeRef(), token.as_concrete_TypeRef(),
did_connect, did_connect,
@ -271,7 +268,7 @@ impl Room {
} }
unsafe { unsafe {
LKRoomPublishVideoTrack( LKRoomPublishVideoTrack(
*self.native_room.lock(), self.native_room,
track.0, track.0,
callback, callback,
Box::into_raw(Box::new(tx)) as *mut c_void, Box::into_raw(Box::new(tx)) as *mut c_void,
@ -301,7 +298,7 @@ impl Room {
} }
unsafe { unsafe {
LKRoomPublishAudioTrack( LKRoomPublishAudioTrack(
*self.native_room.lock(), self.native_room,
track.0, track.0,
callback, callback,
Box::into_raw(Box::new(tx)) as *mut c_void, Box::into_raw(Box::new(tx)) as *mut c_void,
@ -312,14 +309,14 @@ impl Room {
pub fn unpublish_track(&self, publication: LocalTrackPublication) { pub fn unpublish_track(&self, publication: LocalTrackPublication) {
unsafe { unsafe {
LKRoomUnpublishTrack(*self.native_room.lock(), publication.0); LKRoomUnpublishTrack(self.native_room, publication.0);
} }
} }
pub fn remote_video_tracks(&self, participant_id: &str) -> Vec<Arc<RemoteVideoTrack>> { pub fn remote_video_tracks(&self, participant_id: &str) -> Vec<Arc<RemoteVideoTrack>> {
unsafe { unsafe {
let tracks = LKRoomVideoTracksForRemoteParticipant( let tracks = LKRoomVideoTracksForRemoteParticipant(
*self.native_room.lock(), self.native_room,
CFString::new(participant_id).as_concrete_TypeRef(), CFString::new(participant_id).as_concrete_TypeRef(),
); );
@ -348,7 +345,7 @@ impl Room {
pub fn remote_audio_tracks(&self, participant_id: &str) -> Vec<Arc<RemoteAudioTrack>> { pub fn remote_audio_tracks(&self, participant_id: &str) -> Vec<Arc<RemoteAudioTrack>> {
unsafe { unsafe {
let tracks = LKRoomAudioTracksForRemoteParticipant( let tracks = LKRoomAudioTracksForRemoteParticipant(
*self.native_room.lock(), self.native_room,
CFString::new(participant_id).as_concrete_TypeRef(), CFString::new(participant_id).as_concrete_TypeRef(),
); );
@ -380,7 +377,7 @@ impl Room {
) -> Vec<Arc<RemoteTrackPublication>> { ) -> Vec<Arc<RemoteTrackPublication>> {
unsafe { unsafe {
let tracks = LKRoomAudioTrackPublicationsForRemoteParticipant( let tracks = LKRoomAudioTrackPublicationsForRemoteParticipant(
*self.native_room.lock(), self.native_room,
CFString::new(participant_id).as_concrete_TypeRef(), CFString::new(participant_id).as_concrete_TypeRef(),
); );
@ -508,23 +505,23 @@ impl Room {
impl Drop for Room { impl Drop for Room {
fn drop(&mut self) { fn drop(&mut self) {
unsafe { unsafe {
let native_room = &*self.native_room.lock(); LKRoomDisconnect(self.native_room);
LKRoomDisconnect(*native_room); CFRelease(self.native_room.0);
CFRelease(native_room.0);
} }
} }
} }
struct RoomDelegate { struct RoomDelegate {
native_delegate: swift::RoomDelegate, native_delegate: swift::RoomDelegate,
_weak_room: Weak<Room>, weak_room: *mut c_void,
} }
impl RoomDelegate { impl RoomDelegate {
fn new(weak_room: Weak<Room>) -> Self { fn new(weak_room: Weak<Room>) -> Self {
let weak_room = weak_room.into_raw() as *mut c_void;
let native_delegate = unsafe { let native_delegate = unsafe {
LKRoomDelegateCreate( LKRoomDelegateCreate(
weak_room.as_ptr() as *mut c_void, weak_room,
Self::on_did_disconnect, Self::on_did_disconnect,
Self::on_did_subscribe_to_remote_audio_track, Self::on_did_subscribe_to_remote_audio_track,
Self::on_did_unsubscribe_from_remote_audio_track, Self::on_did_unsubscribe_from_remote_audio_track,
@ -536,7 +533,7 @@ impl RoomDelegate {
}; };
Self { Self {
native_delegate, native_delegate,
_weak_room: weak_room, weak_room,
} }
} }
@ -651,6 +648,7 @@ impl Drop for RoomDelegate {
fn drop(&mut self) { fn drop(&mut self) {
unsafe { unsafe {
CFRelease(self.native_delegate.0); CFRelease(self.native_delegate.0);
let _ = Weak::from_raw(self.weak_room);
} }
} }
} }
@ -725,31 +723,22 @@ impl Drop for LocalTrackPublication {
} }
} }
pub struct RemoteTrackPublication { pub struct RemoteTrackPublication(swift::RemoteTrackPublication);
native_publication: Mutex<swift::RemoteTrackPublication>,
}
impl RemoteTrackPublication { impl RemoteTrackPublication {
pub fn new(native_track_publication: swift::RemoteTrackPublication) -> Self { pub fn new(native_track_publication: swift::RemoteTrackPublication) -> Self {
unsafe { unsafe {
CFRetain(native_track_publication.0); CFRetain(native_track_publication.0);
} }
Self { Self(native_track_publication)
native_publication: Mutex::new(native_track_publication),
}
} }
pub fn sid(&self) -> String { pub fn sid(&self) -> String {
unsafe { unsafe { CFString::wrap_under_get_rule(LKRemoteTrackPublicationGetSid(self.0)).to_string() }
CFString::wrap_under_get_rule(LKRemoteTrackPublicationGetSid(
*self.native_publication.lock(),
))
.to_string()
}
} }
pub fn is_muted(&self) -> bool { pub fn is_muted(&self) -> bool {
unsafe { LKRemoteTrackPublicationIsMuted(*self.native_publication.lock()) } unsafe { LKRemoteTrackPublicationIsMuted(self.0) }
} }
pub fn set_enabled(&self, enabled: bool) -> impl Future<Output = Result<()>> { pub fn set_enabled(&self, enabled: bool) -> impl Future<Output = Result<()>> {
@ -767,7 +756,7 @@ impl RemoteTrackPublication {
unsafe { unsafe {
LKRemoteTrackPublicationSetEnabled( LKRemoteTrackPublicationSetEnabled(
*self.native_publication.lock(), self.0,
enabled, enabled,
complete_callback, complete_callback,
Box::into_raw(Box::new(tx)) as *mut c_void, Box::into_raw(Box::new(tx)) as *mut c_void,
@ -780,13 +769,13 @@ impl RemoteTrackPublication {
impl Drop for RemoteTrackPublication { impl Drop for RemoteTrackPublication {
fn drop(&mut self) { fn drop(&mut self) {
unsafe { CFRelease((*self.native_publication.lock()).0) } unsafe { CFRelease(self.0 .0) }
} }
} }
#[derive(Debug)] #[derive(Debug)]
pub struct RemoteAudioTrack { pub struct RemoteAudioTrack {
native_track: Mutex<swift::RemoteAudioTrack>, native_track: swift::RemoteAudioTrack,
sid: Sid, sid: Sid,
publisher_id: String, publisher_id: String,
} }
@ -797,7 +786,7 @@ impl RemoteAudioTrack {
CFRetain(native_track.0); CFRetain(native_track.0);
} }
Self { Self {
native_track: Mutex::new(native_track), native_track,
sid, sid,
publisher_id, publisher_id,
} }
@ -822,13 +811,13 @@ impl RemoteAudioTrack {
impl Drop for RemoteAudioTrack { impl Drop for RemoteAudioTrack {
fn drop(&mut self) { fn drop(&mut self) {
unsafe { CFRelease(self.native_track.lock().0) } unsafe { CFRelease(self.native_track.0) }
} }
} }
#[derive(Debug)] #[derive(Debug)]
pub struct RemoteVideoTrack { pub struct RemoteVideoTrack {
native_track: Mutex<swift::RemoteVideoTrack>, native_track: swift::RemoteVideoTrack,
sid: Sid, sid: Sid,
publisher_id: String, publisher_id: String,
} }
@ -839,7 +828,7 @@ impl RemoteVideoTrack {
CFRetain(native_track.0); CFRetain(native_track.0);
} }
Self { Self {
native_track: Mutex::new(native_track), native_track,
sid, sid,
publisher_id, publisher_id,
} }
@ -888,7 +877,7 @@ impl RemoteVideoTrack {
on_frame, on_frame,
on_drop, on_drop,
); );
LKVideoTrackAddRenderer(*self.native_track.lock(), renderer); LKVideoTrackAddRenderer(self.native_track, renderer);
rx rx
} }
} }
@ -896,7 +885,7 @@ impl RemoteVideoTrack {
impl Drop for RemoteVideoTrack { impl Drop for RemoteVideoTrack {
fn drop(&mut self) { fn drop(&mut self) {
unsafe { CFRelease(self.native_track.lock().0) } unsafe { CFRelease(self.native_track.0) }
} }
} }

View file

@ -3,7 +3,7 @@ use async_trait::async_trait;
use collections::{BTreeMap, HashMap}; use collections::{BTreeMap, HashMap};
use futures::Stream; use futures::Stream;
use gpui::BackgroundExecutor; use gpui::BackgroundExecutor;
use live_kit_server::token; use live_kit_server::{proto, token};
use media::core_video::CVImageBuffer; use media::core_video::CVImageBuffer;
use parking_lot::Mutex; use parking_lot::Mutex;
use postage::watch; use postage::watch;
@ -151,6 +151,21 @@ impl TestServer {
Ok(()) Ok(())
} }
async fn update_participant(
&self,
room_name: String,
identity: String,
permission: proto::ParticipantPermission,
) -> Result<()> {
self.executor.simulate_random_delay().await;
let mut server_rooms = self.rooms.lock();
let room = server_rooms
.get_mut(&room_name)
.ok_or_else(|| anyhow!("room {} does not exist", room_name))?;
room.participant_permissions.insert(identity, permission);
Ok(())
}
pub async fn disconnect_client(&self, client_identity: String) { pub async fn disconnect_client(&self, client_identity: String) {
self.executor.simulate_random_delay().await; self.executor.simulate_random_delay().await;
let mut server_rooms = self.rooms.lock(); let mut server_rooms = self.rooms.lock();
@ -172,6 +187,17 @@ impl TestServer {
.get_mut(&*room_name) .get_mut(&*room_name)
.ok_or_else(|| anyhow!("room {} does not exist", room_name))?; .ok_or_else(|| anyhow!("room {} does not exist", room_name))?;
let can_publish = room
.participant_permissions
.get(&identity)
.map(|permission| permission.can_publish)
.or(claims.video.can_publish)
.unwrap_or(true);
if !can_publish {
return Err(anyhow!("user is not allowed to publish"));
}
let track = Arc::new(RemoteVideoTrack { let track = Arc::new(RemoteVideoTrack {
sid: nanoid::nanoid!(17), sid: nanoid::nanoid!(17),
publisher_id: identity.clone(), publisher_id: identity.clone(),
@ -210,6 +236,17 @@ impl TestServer {
.get_mut(&*room_name) .get_mut(&*room_name)
.ok_or_else(|| anyhow!("room {} does not exist", room_name))?; .ok_or_else(|| anyhow!("room {} does not exist", room_name))?;
let can_publish = room
.participant_permissions
.get(&identity)
.map(|permission| permission.can_publish)
.or(claims.video.can_publish)
.unwrap_or(true);
if !can_publish {
return Err(anyhow!("user is not allowed to publish"));
}
let track = Arc::new(RemoteAudioTrack { let track = Arc::new(RemoteAudioTrack {
sid: nanoid::nanoid!(17), sid: nanoid::nanoid!(17),
publisher_id: identity.clone(), publisher_id: identity.clone(),
@ -265,6 +302,7 @@ struct TestServerRoom {
client_rooms: HashMap<Sid, Arc<Room>>, client_rooms: HashMap<Sid, Arc<Room>>,
video_tracks: Vec<Arc<RemoteVideoTrack>>, video_tracks: Vec<Arc<RemoteVideoTrack>>,
audio_tracks: Vec<Arc<RemoteAudioTrack>>, audio_tracks: Vec<Arc<RemoteAudioTrack>>,
participant_permissions: HashMap<Sid, proto::ParticipantPermission>,
} }
impl TestServerRoom {} impl TestServerRoom {}
@ -297,6 +335,19 @@ impl live_kit_server::api::Client for TestApiClient {
Ok(()) Ok(())
} }
async fn update_participant(
&self,
room: String,
identity: String,
permission: live_kit_server::proto::ParticipantPermission,
) -> Result<()> {
let server = TestServer::get(&self.url)?;
server
.update_participant(room, identity, permission)
.await?;
Ok(())
}
fn room_token(&self, room: &str, identity: &str) -> Result<String> { fn room_token(&self, room: &str, identity: &str) -> Result<String> {
let server = TestServer::get(&self.url)?; let server = TestServer::get(&self.url)?;
token::create( token::create(

View file

@ -11,10 +11,18 @@ pub trait Client: Send + Sync {
async fn create_room(&self, name: String) -> Result<()>; async fn create_room(&self, name: String) -> Result<()>;
async fn delete_room(&self, name: String) -> Result<()>; async fn delete_room(&self, name: String) -> Result<()>;
async fn remove_participant(&self, room: String, identity: String) -> Result<()>; async fn remove_participant(&self, room: String, identity: String) -> Result<()>;
async fn update_participant(
&self,
room: String,
identity: String,
permission: proto::ParticipantPermission,
) -> Result<()>;
fn room_token(&self, room: &str, identity: &str) -> Result<String>; fn room_token(&self, room: &str, identity: &str) -> Result<String>;
fn guest_token(&self, room: &str, identity: &str) -> Result<String>; fn guest_token(&self, room: &str, identity: &str) -> Result<String>;
} }
pub struct LiveKitParticipantUpdate {}
#[derive(Clone)] #[derive(Clone)]
pub struct LiveKitClient { pub struct LiveKitClient {
http: reqwest::Client, http: reqwest::Client,
@ -131,6 +139,27 @@ impl Client for LiveKitClient {
Ok(()) Ok(())
} }
async fn update_participant(
&self,
room: String,
identity: String,
permission: proto::ParticipantPermission,
) -> Result<()> {
let _: proto::ParticipantInfo = self
.request(
"twirp/livekit.RoomService/UpdateParticipant",
token::VideoGrant::to_admin(&room),
proto::UpdateParticipantRequest {
room: room.clone(),
identity,
metadata: "".to_string(),
permission: Some(permission),
},
)
.await?;
Ok(())
}
fn room_token(&self, room: &str, identity: &str) -> Result<String> { fn room_token(&self, room: &str, identity: &str) -> Result<String> {
token::create( token::create(
&self.key, &self.key,

View file

@ -1,3 +1,3 @@
pub mod api; pub mod api;
mod proto; pub mod proto;
pub mod token; pub mod token;

View file

@ -80,6 +80,7 @@ pub enum Event {
Reloaded, Reloaded,
DiffBaseChanged, DiffBaseChanged,
LanguageChanged, LanguageChanged,
CapabilityChanged,
Reparsed, Reparsed,
Saved, Saved,
FileHandleChanged, FileHandleChanged,
@ -1404,7 +1405,7 @@ impl MultiBuffer {
fn on_buffer_event( fn on_buffer_event(
&mut self, &mut self,
_: Model<Buffer>, buffer: Model<Buffer>,
event: &language::Event, event: &language::Event,
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
) { ) {
@ -1421,6 +1422,10 @@ impl MultiBuffer {
language::Event::Reparsed => Event::Reparsed, language::Event::Reparsed => Event::Reparsed,
language::Event::DiagnosticsUpdated => Event::DiagnosticsUpdated, language::Event::DiagnosticsUpdated => Event::DiagnosticsUpdated,
language::Event::Closed => Event::Closed, language::Event::Closed => Event::Closed,
language::Event::CapabilityChanged => {
self.capability = buffer.read(cx).capability();
Event::CapabilityChanged
}
// //
language::Event::Operation(_) => return, language::Event::Operation(_) => return,

View file

@ -34,7 +34,7 @@ Rhai actually exposes a pretty nice interface for working with native Rust types
> **Note**: Rhai uses strings, but I wonder if you could get away with something more compact using `TypeIds`. Maybe not, given that `TypeId`s are not deterministic across builds, and we'd need matching IDs both host-side and guest side. > **Note**: Rhai uses strings, but I wonder if you could get away with something more compact using `TypeIds`. Maybe not, given that `TypeId`s are not deterministic across builds, and we'd need matching IDs both host-side and guest side.
In Rhai, we can alternatively use the method `Engine::register_type_with_name::<T: Variant + Clone>(name: &str)` if we have a different type name host-side (in Rust) and guest-side (in Rhai). In Rhai, we can alternatively use the method `Engine::register_type_with_name::<T: Variant + Clone>(name: &str)` if we have a different type name host-side (in Rust) and guest-side (in Rhai).
With respect to Wasm plugins, I think an interface like this is fairly important, because we don't know whether the original plugin was written in Rust. (This may not be true now, because we write all the plugins Zed uses, but once we allow packaging and shipping plugins, it's important to maintain a consistent interface, because even Rust changes over time.) With respect to Wasm plugins, I think an interface like this is fairly important, because we don't know whether the original plugin was written in Rust. (This may not be true now, because we write all the plugins Zed uses, but once we allow packaging and shipping plugins, it's important to maintain a consistent interface, because even Rust changes over time.)
@ -72,15 +72,15 @@ Union::Variant(v, ..) => (*v).as_boxed_any().downcast().ok().map(|x| *x),
Now Rhai can do this because it's implemented in Rust. In other words, unlike Wasm, Rhai scripts can, indirectly, hold references to places in host memory. For us to implement something like this for Wasm plugins, we'd have to keep track of a "`ResourcePool`"—alive for the duration of each function call—that we can check rust types into and out of. Now Rhai can do this because it's implemented in Rust. In other words, unlike Wasm, Rhai scripts can, indirectly, hold references to places in host memory. For us to implement something like this for Wasm plugins, we'd have to keep track of a "`ResourcePool`"—alive for the duration of each function call—that we can check rust types into and out of.
I think I've got a handle on how Rhai works now, so let's stop talking about Rhai and discuss what this opaque object system would look like if we implemented it in Rust. I think I've got a handle on how Rhai works now, so let's stop talking about Rhai and discuss what this opaque object system would look like if we implemented it in Rust.
# Design Sketch # Design Sketch
First things first, we'd have to generalize the arguments we can pass to and return from functions host-side. Currently, we support anything that's `serde`able. We'd have to create a new trait, say `Value`, that has blanket implementations for both `serde` and `Clone` (or something like this; if a type is both `serde` and `clone`, we'd have to figure out a way to disambiguate). First things first, we'd have to generalize the arguments we can pass to and return from functions host-side. Currently, we support anything that's `serde`able. We'd have to create a new trait, say `Value`, that has blanket implementations for both `serde` and `Clone` (or something like this; if a type is both `serde` and `clone`, we'd have to figure out a way to disambiguate).
We'd also create a `ResourcePool` struct that essentially is a `Vec` of `Box<dyn Any>`. When calling a function, all `Value` arguments that are resources (e.g. `Clone` instead of `serde`) would be typecasted to `dyn Any` and stored in the `ResourcePool`. We'd also create a `ResourcePool` struct that essentially is a `Vec` of `Box<dyn Any>`. When calling a function, all `Value` arguments that are resources (e.g. `Clone` instead of `serde`) would be typecasted to `dyn Any` and stored in the `ResourcePool`.
We'd probably also need a `Resource` trait that defines an associated handle for a resource. Something like this: We'd probably also need a `Resource` trait that defines an associated handle for a resource. Something like this:
```rust ```rust
pub trait Resource { pub trait Resource {
type Handle: Serialize + DeserializeOwned; type Handle: Serialize + DeserializeOwned;
@ -88,24 +88,24 @@ First things first, we'd have to generalize the arguments we can pass to and ret
fn index(handle: Self) -> u32; fn index(handle: Self) -> u32;
} }
``` ```
Where a handle is just a dead-simple wrapper around a `u32`: Where a handle is just a dead-simple wrapper around a `u32`:
```rust ```rust
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct CoolHandle(u32); pub struct CoolHandle(u32);
``` ```
It's important that this handle be accessible *both* host-side and plugin side. I don't know if this means that we have another crate, like `plugin_handles`, that contains a bunch of u32 wrappers, or something else. Because a `Resource::Handle` is just a u32, it's trivially `serde`, and can cross the ABI boundary. It's important that this handle be accessible *both* host-side and plugin side. I don't know if this means that we have another crate, like `plugin_handles`, that contains a bunch of u32 wrappers, or something else. Because a `Resource::Handle` is just a u32, it's trivially `serde`, and can cross the ABI boundary.
So when we add each `T: Resource` to the `ResourcePool`, the resource pool typecasts it to `Any`, appends it to the `Vec`, and returns the associated `Resource::Handle`. This handle is what we pass through to Wasm. So when we add each `T: Resource` to the `ResourcePool`, the resource pool typecasts it to `Any`, appends it to the `Vec`, and returns the associated `Resource::Handle`. This handle is what we pass through to Wasm.
```rust ```rust
// Implementations and attributes omitted // Implementations and attributes omitted
pub struct Rope { ... }; pub struct Rope { ... };
pub struct RopeHandle(u32); pub struct RopeHandle(u32);
impl Resource for Arc<RwLock<Rope>> { ... } impl Resource for Arc<RwLock<Rope>> { ... }
let builder: PluginBuilder = ...; let builder: PluginBuilder = ...;
let builder = builder let builder = builder
.host_fn_async( .host_fn_async(
@ -127,7 +127,7 @@ use plugin_handles::RopeHandle;
pub fn append(rope: RopeHandle, string: &str); pub fn append(rope: RopeHandle, string: &str);
``` ```
This allows us to perform an operation on a `Rope`, but how do we get a `RopeHandle` into a plugin? Well, as plugins, we can only acquire resources to handles we're given, so we'd need to expose a function that takes a handle. This allows us to perform an operation on a `Rope`, but how do we get a `RopeHandle` into a plugin? Well, as plugins, we can only acquire resources to handles we're given, so we'd need to expose a function that takes a handle.
To illustrate that point, here's an example. First, we'd define a plugin-side function as follows: To illustrate that point, here's an example. First, we'd define a plugin-side function as follows:
@ -185,4 +185,4 @@ Using this approach, it should be possible to add fairly good support for resour
This next week, I'll try to get a production-ready version of this working, using the `Language` resource required by some Language Server Adapters. This next week, I'll try to get a production-ready version of this working, using the `Language` resource required by some Language Server Adapters.
Hope this guide made sense! Hope this guide made sense!

View file

@ -164,7 +164,7 @@ To call the functions that a plugin exports host-side, you need to have 'handles
For example, let's suppose we're creating a plugin that: For example, let's suppose we're creating a plugin that:
1. formats a message 1. formats a message
2. processes a list of numbers somehow 2. processes a list of numbers somehow
We could create a struct for this plugin as follows: We could create a struct for this plugin as follows:
@ -179,7 +179,7 @@ pub struct CoolPlugin {
} }
``` ```
Note that this plugin also holds an owned reference to the runtime, which is stored in the `Plugin` type. In asynchronous or multithreaded contexts, it may be required to put `Plugin` behind an `Arc<Mutex<Plugin>>`. Although plugins expose an asynchronous interface, the underlying Wasm engine can only execute a single function at a time. Note that this plugin also holds an owned reference to the runtime, which is stored in the `Plugin` type. In asynchronous or multithreaded contexts, it may be required to put `Plugin` behind an `Arc<Mutex<Plugin>>`. Although plugins expose an asynchronous interface, the underlying Wasm engine can only execute a single function at a time.
> **Note**: This is a limitation of the WebAssembly standard itself. In the future, to work around this, we've been considering starting a pool of plugins, or instantiating a new plugin per call (this isn't as bad as it sounds, as instantiating a new plugin only takes about 30µs). > **Note**: This is a limitation of the WebAssembly standard itself. In the future, to work around this, we've been considering starting a pool of plugins, or instantiating a new plugin per call (this isn't as bad as it sounds, as instantiating a new plugin only takes about 30µs).
@ -203,7 +203,7 @@ To add a sync native function to a plugin, use the `.host_function` method:
```rust ```rust
let builder = builder.host_function( let builder = builder.host_function(
"add_f64", "add_f64",
|(a, b): (f64, f64)| a + b, |(a, b): (f64, f64)| a + b,
).unwrap(); ).unwrap();
``` ```
@ -224,7 +224,7 @@ To add an async native function to a plugin, use the `.host_function_async` meth
```rust ```rust
let builder = builder.host_function_async( let builder = builder.host_function_async(
"half", "half",
|n: f64| async move { n / 2.0 }, |n: f64| async move { n / 2.0 },
).unwrap(); ).unwrap();
``` ```
@ -252,9 +252,9 @@ let plugin = builder
.unwrap(); .unwrap();
``` ```
The `.init` method takes a single argument containing the plugin binary. The `.init` method takes a single argument containing the plugin binary.
1. If not precompiled, use `PluginBinary::Wasm(bytes)`. This supports both the WebAssembly Textual format (`.wat`) and the WebAssembly Binary format (`.wasm`). 1. If not precompiled, use `PluginBinary::Wasm(bytes)`. This supports both the WebAssembly Textual format (`.wat`) and the WebAssembly Binary format (`.wasm`).
2. If precompiled, use `PluginBinary::Precompiled(bytes)`. This supports precompiled plugins ending in `.wasm.pre`. You need to be extra-careful when using precompiled plugins to ensure that the plugin target matches the target of the binary you are compiling. 2. If precompiled, use `PluginBinary::Precompiled(bytes)`. This supports precompiled plugins ending in `.wasm.pre`. You need to be extra-careful when using precompiled plugins to ensure that the plugin target matches the target of the binary you are compiling.
@ -317,4 +317,4 @@ The `.call` method takes two arguments:
This method is async, and must be `.await`ed. If something goes wrong (e.g. the plugin panics, or there is a type mismatch between the plugin and `WasiFn`), then this method will return an error. This method is async, and must be `.await`ed. If something goes wrong (e.g. the plugin panics, or there is a type mismatch between the plugin and `WasiFn`), then this method will return an error.
## Last Notes ## Last Notes
This has been a brief overview of how the plugin system currently works in Zed. We hope to implement higher-level affordances as time goes on, to make writing plugins easier, and providing tooling so that users of Zed may also write plugins to extend their own editors. This has been a brief overview of how the plugin system currently works in Zed. We hope to implement higher-level affordances as time goes on, to make writing plugins easier, and providing tooling so that users of Zed may also write plugins to extend their own editors.

Some files were not shown because too many files have changed in this diff Show more