diff --git a/Cargo.lock b/Cargo.lock index 035a00728d0a091c907321fd356c37840f871549..6cd30a6dbc253f737bdbd0a8785b12d6f0464ebe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1580,6 +1580,28 @@ dependencies = [ "rustc-hash", ] +[[package]] +name = "color" +version = "0.1.0" +dependencies = [ + "anyhow", + "fs", + "indexmap 1.9.3", + "itertools 0.11.0", + "palette", + "parking_lot 0.11.2", + "refineable", + "schemars", + "serde", + "serde_derive", + "serde_json", + "settings", + "story", + "toml 0.5.11", + "util", + "uuid 1.4.1", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -4977,6 +4999,7 @@ dependencies = [ "approx", "fast-srgb8", "palette_derive", + "phf", ] [[package]] @@ -5165,6 +5188,48 @@ dependencies = [ "indexmap 2.0.0", ] +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.37", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher 0.3.11", +] + [[package]] name = "picker" version = "0.1.0" @@ -7074,6 +7139,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b8de496cf83d4ed58b6be86c3a275b8602f6ffe98d3024a869e124147a9a3ac" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slab" version = "0.4.9" @@ -7644,7 +7715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c536faaff1a10837cfe373142583f6e27d81e96beba339147e77b67c9f260ff" dependencies = [ "float-cmp", - "siphasher", + "siphasher 0.2.3", ] [[package]] @@ -7775,6 +7846,7 @@ dependencies = [ "schemars", "serde", "serde_derive", + "serde_json", "settings", "shellexpand", "smallvec", @@ -7854,6 +7926,7 @@ name = "theme" version = "0.1.0" dependencies = [ "anyhow", + "color", "fs", "gpui", "indexmap 1.9.3", @@ -8857,7 +8930,7 @@ dependencies = [ "roxmltree", "rustybuzz", "simplecss", - "siphasher", + "siphasher 0.2.3", "svgtypes", "ttf-parser 0.12.3", "unicode-bidi", @@ -9649,6 +9722,7 @@ dependencies = [ "client", "collab_ui", "collections", + "color", "command_palette", "copilot", "copilot_ui", diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 1372f7a4157da475aa8ccbff8aeb940c85d2ea79..f18cc2a111ec432a283f9f08f3fd1acecdddda52 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -402,7 +402,7 @@ "cmd-r": "workspace::ToggleRightDock", "cmd-j": "workspace::ToggleBottomDock", "alt-cmd-y": "workspace::CloseAllDocks", - "cmd-shift-f": "workspace::DeploySearch", + "cmd-shift-f": "pane::DeploySearch", "cmd-k cmd-t": "theme_selector::Toggle", "cmd-k cmd-s": "zed::OpenKeymap", "cmd-t": "project_symbols::Toggle", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 81235bb72ad7075be27605c462fb03b822b69140..1da6f0ef8c5da25a60742fda933125c23eac81bf 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -99,7 +99,7 @@ "ctrl-i": "pane::GoForward", "ctrl-]": "editor::GoToDefinition", "escape": ["vim::SwitchMode", "Normal"], - "ctrl+[": ["vim::SwitchMode", "Normal"], + "ctrl-[": ["vim::SwitchMode", "Normal"], "v": "vim::ToggleVisual", "shift-v": "vim::ToggleVisualLine", "ctrl-v": "vim::ToggleVisualBlock", @@ -288,7 +288,7 @@ "context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting", "bindings": { "escape": "editor::Cancel", - "ctrl+[": "editor::Cancel" + "ctrl-[": "editor::Cancel" } }, { @@ -441,7 +441,7 @@ "r": ["vim::PushOperator", "Replace"], "ctrl-c": ["vim::SwitchMode", "Normal"], "escape": ["vim::SwitchMode", "Normal"], - "ctrl+[": ["vim::SwitchMode", "Normal"], + "ctrl-[": ["vim::SwitchMode", "Normal"], ">": "editor::Indent", "<": "editor::Outdent", "i": [ @@ -481,7 +481,7 @@ "tab": "vim::Tab", "enter": "vim::Enter", "escape": ["vim::SwitchMode", "Normal"], - "ctrl+[": ["vim::SwitchMode", "Normal"] + "ctrl-[": ["vim::SwitchMode", "Normal"] } }, { diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index 32cf9efba230da83f68d568818d20a52bfc862dd..5ee039a8cb23c939351c4ff85283569fe4d0dd95 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -143,10 +143,10 @@ const MAX_QUEUE_LEN: usize = 5; const MAX_QUEUE_LEN: usize = 50; #[cfg(debug_assertions)] -const FLUSH_DEBOUNCE_INTERVAL: Duration = Duration::from_secs(1); +const FLUSH_INTERVAL: Duration = Duration::from_secs(1); #[cfg(not(debug_assertions))] -const FLUSH_DEBOUNCE_INTERVAL: Duration = Duration::from_secs(60 * 5); +const FLUSH_INTERVAL: Duration = Duration::from_secs(60 * 5); impl Telemetry { pub fn new(client: Arc, cx: &mut AppContext) -> Arc { @@ -457,6 +457,15 @@ impl Telemetry { return; } + if state.flush_events_task.is_none() { + let this = self.clone(); + let executor = self.executor.clone(); + state.flush_events_task = Some(self.executor.spawn(async move { + executor.timer(FLUSH_INTERVAL).await; + this.flush_events(); + })); + } + let signed_in = state.metrics_id.is_some(); state.events_queue.push(EventWrapper { signed_in, event }); @@ -464,13 +473,6 @@ impl Telemetry { if state.events_queue.len() >= MAX_QUEUE_LEN { drop(state); self.flush_events(); - } else { - let this = self.clone(); - let executor = self.executor.clone(); - state.flush_events_task = Some(self.executor.spawn(async move { - executor.timer(FLUSH_DEBOUNCE_INTERVAL).await; - this.flush_events(); - })); } } } diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 921e3388a65637fad51f9a093f8538500965923a..140d5be4c6548664d1518ff3b6eff5583d3e22ab 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -1,4 +1,4 @@ -use crate::{collab_panel, is_channels_feature_enabled, ChatPanelSettings}; +use crate::{collab_panel, ChatPanelSettings}; use anyhow::Result; use call::{room, ActiveCall}; use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore}; @@ -630,9 +630,6 @@ impl Panel for ChatPanel { self.active = active; if active { self.acknowledge_last_message(cx); - if !is_channels_feature_enabled(cx) { - cx.emit(PanelEvent::Close); - } } } @@ -641,10 +638,6 @@ impl Panel for ChatPanel { } fn icon(&self, cx: &WindowContext) -> Option { - if !is_channels_feature_enabled(cx) { - return None; - } - Some(ui::IconName::MessageBubbles).filter(|_| ChatPanelSettings::get_global(cx).button) } diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 9cb447153c1bc39b9d291a2e17020cae788b43ca..d6de5135711b7e56854eaa541afc6b23a3020544 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -12,7 +12,6 @@ use client::{Client, Contact, User, UserStore}; use contact_finder::ContactFinder; use db::kvp::KEY_VALUE_STORE; use editor::{Editor, EditorElement, EditorStyle}; -use feature_flags::{ChannelsAlpha, FeatureFlagAppExt, FeatureFlagViewExt}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ actions, canvas, div, fill, list, overlay, point, prelude::*, px, AnyElement, AppContext, @@ -31,8 +30,8 @@ use smallvec::SmallVec; use std::{mem, sync::Arc}; use theme::{ActiveTheme, ThemeSettings}; use ui::{ - prelude::*, Avatar, Button, Color, ContextMenu, Icon, IconButton, IconName, IconSize, Label, - ListHeader, ListItem, Tooltip, + prelude::*, Avatar, AvatarAvailabilityIndicator, Button, Color, ContextMenu, Icon, IconButton, + IconName, IconSize, Label, ListHeader, ListItem, Tooltip, }; use util::{maybe, ResultExt, TryFutureExt}; use workspace::{ @@ -265,10 +264,6 @@ impl CollabPanel { })); this.subscriptions .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx))); - this.subscriptions - .push(cx.observe_flag::(move |_, this, cx| { - this.update_entries(true, cx) - })); this.subscriptions.push(cx.subscribe( &this.channel_store, |this, _channel_store, e, cx| match e { @@ -504,115 +499,118 @@ impl CollabPanel { let mut request_entries = Vec::new(); - if cx.has_flag::() { - self.entries.push(ListEntry::Header(Section::Channels)); + self.entries.push(ListEntry::Header(Section::Channels)); - if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() { - self.match_candidates.clear(); - self.match_candidates - .extend(channel_store.ordered_channels().enumerate().map( - |(ix, (_, channel))| StringMatchCandidate { + if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() { + self.match_candidates.clear(); + self.match_candidates + .extend( + channel_store + .ordered_channels() + .enumerate() + .map(|(ix, (_, channel))| StringMatchCandidate { id: ix, string: channel.name.clone().into(), char_bag: channel.name.chars().collect(), - }, - )); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - if let Some(state) = &self.channel_editing_state { - if matches!(state, ChannelEditingState::Create { location: None, .. }) { - self.entries.push(ListEntry::ChannelEditor { depth: 0 }); - } + }), + ); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + if let Some(state) = &self.channel_editing_state { + if matches!(state, ChannelEditingState::Create { location: None, .. }) { + self.entries.push(ListEntry::ChannelEditor { depth: 0 }); } - let mut collapse_depth = None; - for mat in matches { - let channel = channel_store.channel_at_index(mat.candidate_id).unwrap(); - let depth = channel.parent_path.len(); - - if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) { + } + let mut collapse_depth = None; + for mat in matches { + let channel = channel_store.channel_at_index(mat.candidate_id).unwrap(); + let depth = channel.parent_path.len(); + + if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) { + collapse_depth = Some(depth); + } else if let Some(collapsed_depth) = collapse_depth { + if depth > collapsed_depth { + continue; + } + if self.is_channel_collapsed(channel.id) { collapse_depth = Some(depth); - } else if let Some(collapsed_depth) = collapse_depth { - if depth > collapsed_depth { - continue; - } - if self.is_channel_collapsed(channel.id) { - collapse_depth = Some(depth); - } else { - collapse_depth = None; - } + } else { + collapse_depth = None; } + } - let has_children = channel_store - .channel_at_index(mat.candidate_id + 1) - .map_or(false, |next_channel| { - next_channel.parent_path.ends_with(&[channel.id]) - }); + let has_children = channel_store + .channel_at_index(mat.candidate_id + 1) + .map_or(false, |next_channel| { + next_channel.parent_path.ends_with(&[channel.id]) + }); - match &self.channel_editing_state { - Some(ChannelEditingState::Create { - location: parent_id, - .. - }) if *parent_id == Some(channel.id) => { - self.entries.push(ListEntry::Channel { - channel: channel.clone(), - depth, - has_children: false, - }); - self.entries - .push(ListEntry::ChannelEditor { depth: depth + 1 }); - } - Some(ChannelEditingState::Rename { - location: parent_id, - .. - }) if parent_id == &channel.id => { - self.entries.push(ListEntry::ChannelEditor { depth }); - } - _ => { - self.entries.push(ListEntry::Channel { - channel: channel.clone(), - depth, - has_children, - }); - } + match &self.channel_editing_state { + Some(ChannelEditingState::Create { + location: parent_id, + .. + }) if *parent_id == Some(channel.id) => { + self.entries.push(ListEntry::Channel { + channel: channel.clone(), + depth, + has_children: false, + }); + self.entries + .push(ListEntry::ChannelEditor { depth: depth + 1 }); + } + Some(ChannelEditingState::Rename { + location: parent_id, + .. + }) if parent_id == &channel.id => { + self.entries.push(ListEntry::ChannelEditor { depth }); + } + _ => { + self.entries.push(ListEntry::Channel { + channel: channel.clone(), + depth, + has_children, + }); } } } + } - let channel_invites = channel_store.channel_invitations(); - if !channel_invites.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend(channel_invites.iter().enumerate().map(|(ix, channel)| { - StringMatchCandidate { - id: ix, - string: channel.name.clone().into(), - char_bag: channel.name.chars().collect(), - } - })); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - request_entries.extend(matches.iter().map(|mat| { - ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone()) + let channel_invites = channel_store.channel_invitations(); + if !channel_invites.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend(channel_invites.iter().enumerate().map(|(ix, channel)| { + StringMatchCandidate { + id: ix, + string: channel.name.clone().into(), + char_bag: channel.name.chars().collect(), + } })); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + request_entries.extend( + matches + .iter() + .map(|mat| ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())), + ); - if !request_entries.is_empty() { - self.entries - .push(ListEntry::Header(Section::ChannelInvites)); - if !self.collapsed_sections.contains(&Section::ChannelInvites) { - self.entries.append(&mut request_entries); - } + if !request_entries.is_empty() { + self.entries + .push(ListEntry::Header(Section::ChannelInvites)); + if !self.collapsed_sections.contains(&Section::ChannelInvites) { + self.entries.append(&mut request_entries); } } } @@ -2000,43 +1998,49 @@ impl CollabPanel { let busy = contact.busy || calling; let user_id = contact.user.id; let github_login = SharedString::from(contact.user.github_login.clone()); - let item = - ListItem::new(github_login.clone()) - .indent_level(1) - .indent_step_size(px(20.)) - .selected(is_selected) - .on_click(cx.listener(move |this, _, cx| this.call(user_id, cx))) - .child( - h_flex() - .w_full() - .justify_between() - .child(Label::new(github_login.clone())) - .when(calling, |el| { - el.child(Label::new("Calling").color(Color::Muted)) - }) - .when(!calling, |el| { - el.child( - IconButton::new("remove_contact", IconName::Close) - .icon_color(Color::Muted) - .visible_on_hover("") - .tooltip(|cx| Tooltip::text("Remove Contact", cx)) - .on_click(cx.listener({ - let github_login = github_login.clone(); - move |this, _, cx| { - this.remove_contact(user_id, &github_login, cx); - } - })), - ) - }), - ) - .start_slot( - // todo handle contacts with no avatar - Avatar::new(contact.user.avatar_uri.clone()) - .availability_indicator(if online { Some(!busy) } else { None }), - ) - .when(online && !busy, |el| { - el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx))) - }); + let item = ListItem::new(github_login.clone()) + .indent_level(1) + .indent_step_size(px(20.)) + .selected(is_selected) + .on_click(cx.listener(move |this, _, cx| this.call(user_id, cx))) + .child( + h_flex() + .w_full() + .justify_between() + .child(Label::new(github_login.clone())) + .when(calling, |el| { + el.child(Label::new("Calling").color(Color::Muted)) + }) + .when(!calling, |el| { + el.child( + IconButton::new("remove_contact", IconName::Close) + .icon_color(Color::Muted) + .visible_on_hover("") + .tooltip(|cx| Tooltip::text("Remove Contact", cx)) + .on_click(cx.listener({ + let github_login = github_login.clone(); + move |this, _, cx| { + this.remove_contact(user_id, &github_login, cx); + } + })), + ) + }), + ) + .start_slot( + // todo handle contacts with no avatar + Avatar::new(contact.user.avatar_uri.clone()) + .indicator::(if online { + Some(AvatarAvailabilityIndicator::new(match busy { + true => ui::Availability::Busy, + false => ui::Availability::Free, + })) + } else { + None + }), + ) + .when(online && !busy, |el| { + el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx))) + }); div() .id(github_login.clone()) @@ -2310,7 +2314,7 @@ impl CollabPanel { .child( IconButton::new("channel_chat", IconName::MessageBubbles) .style(ButtonStyle::Filled) - .size(ButtonSize::Compact) + .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) .icon_color(if has_messages_notification { Color::Default @@ -2328,7 +2332,7 @@ impl CollabPanel { .child( IconButton::new("channel_notes", IconName::File) .style(ButtonStyle::Filled) - .size(ButtonSize::Compact) + .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) .icon_color(if has_notes_notification { Color::Default diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index c207e31bbeaa2b087578860c09ae176827074388..11890bcbe6d3dace458827583c13d331c6670e7b 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -348,6 +348,10 @@ impl PickerDelegate for ChannelModalDelegate { fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { if let Some((selected_user, role)) = self.user_at_index(self.selected_index) { + if Some(selected_user.id) == self.user_store.read(cx).current_user().map(|user| user.id) + { + return; + } match self.mode { Mode::ManageMembers => { self.show_context_menu(selected_user, role.unwrap_or(ChannelRole::Member), cx) @@ -383,6 +387,7 @@ impl PickerDelegate for ChannelModalDelegate { ) -> Option { let (user, role) = self.user_at_index(ix)?; let request_status = self.member_status(user.id, cx); + let is_me = self.user_store.read(cx).current_user().map(|user| user.id) == Some(user.id); Some( ListItem::new(ix) @@ -406,7 +411,10 @@ impl PickerDelegate for ChannelModalDelegate { Some(ChannelRole::Guest) => Some(Label::new("Guest")), _ => None, }) - .child(IconButton::new("ellipsis", IconName::Ellipsis)) + .when(!is_me, |el| { + el.child(IconButton::new("ellipsis", IconName::Ellipsis)) + }) + .when(is_me, |el| el.child(Label::new("You").color(Color::Muted))) .children( if let (Some((menu, _)), true) = (&self.context_menu, selected) { Some( diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 432f8f6cd242b3b7bbeb53464de98980b4079e47..01f0ccb179fd9f870bcad138aee2fd7a9d925987 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -1,9 +1,9 @@ use crate::face_pile::FacePile; use auto_update::AutoUpdateStatus; use call::{ActiveCall, ParticipantLocation, Room}; -use client::{proto::PeerId, Client, ParticipantIndex, User, UserStore}; +use client::{proto::PeerId, Client, User, UserStore}; use gpui::{ - actions, canvas, div, point, px, rems, Action, AnyElement, AppContext, Element, Hsla, + actions, canvas, div, point, px, Action, AnyElement, AppContext, Element, Hsla, InteractiveElement, IntoElement, Model, ParentElement, Path, Render, StatefulInteractiveElement, Styled, Subscription, View, ViewContext, VisualContext, WeakView, WindowBounds, @@ -12,14 +12,14 @@ use project::{Project, RepositoryEntry}; use recent_projects::RecentProjects; use rpc::proto; use std::sync::Arc; -use theme::{ActiveTheme, PlayerColors}; +use theme::ActiveTheme; use ui::{ - h_flex, popover_menu, prelude::*, Avatar, Button, ButtonLike, ButtonStyle, ContextMenu, Icon, - IconButton, IconName, TintColor, Tooltip, + h_flex, popover_menu, prelude::*, Avatar, AvatarAudioStatusIndicator, Button, ButtonLike, + ButtonStyle, ContextMenu, Icon, IconButton, IconName, TintColor, Tooltip, }; use util::ResultExt; use vcs_menu::{build_branch_list, BranchList, OpenRecent as ToggleVcsMenu}; -use workspace::{notifications::NotifyResultExt, Workspace}; +use workspace::{notifications::NotifyResultExt, titlebar_height, Workspace}; const MAX_PROJECT_NAME_LENGTH: usize = 40; const MAX_BRANCH_NAME_LENGTH: usize = 40; @@ -62,10 +62,7 @@ impl Render for CollabTitlebarItem { .id("titlebar") .justify_between() .w_full() - .h(rems(1.75)) - // Set a non-scaling min-height here to ensure the titlebar is - // always at least the height of the traffic lights. - .min_h(px(32.)) + .h(titlebar_height(cx)) .map(|this| { if matches!(cx.window_bounds(), WindowBounds::Fullscreen) { this.pl_2() @@ -97,7 +94,7 @@ impl Render for CollabTitlebarItem { room.remote_participants().values().collect::>(); remote_participants.sort_by_key(|p| p.participant_index.0); - this.children(self.render_collaborator( + let current_user_face_pile = self.render_collaborator( ¤t_user, peer_id, true, @@ -107,7 +104,13 @@ impl Render for CollabTitlebarItem { project_id, ¤t_user, cx, - )) + ); + + this.children(current_user_face_pile.map(|face_pile| { + v_flex() + .child(face_pile) + .child(render_color_ribbon(player_colors.local().cursor)) + })) .children( remote_participants.iter().filter_map(|collaborator| { let is_present = project_id.map_or(false, |project_id| { @@ -132,8 +135,11 @@ impl Render for CollabTitlebarItem { .id(("collaborator", collaborator.user.id)) .child(face_pile) .child(render_color_ribbon( - collaborator.participant_index, - player_colors, + player_colors + .color_for_participant( + collaborator.participant_index.0, + ) + .cursor, )) .cursor_pointer() .on_click({ @@ -312,8 +318,7 @@ impl Render for CollabTitlebarItem { } } -fn render_color_ribbon(participant_index: ParticipantIndex, colors: &PlayerColors) -> gpui::Canvas { - let color = colors.color_for_participant(participant_index.0).cursor; +fn render_color_ribbon(color: Hsla) -> gpui::Canvas { canvas(move |bounds, cx| { let height = bounds.size.height; let horizontal_offset = height; @@ -472,7 +477,9 @@ impl CollabTitlebarItem { return None; } + const FACEPILE_LIMIT: usize = 3; let followers = project_id.map_or(&[] as &[_], |id| room.followers_for(peer_id, id)); + let extra_count = followers.len().saturating_sub(FACEPILE_LIMIT); let pile = FacePile::default() .child( @@ -480,24 +487,48 @@ impl CollabTitlebarItem { .grayscale(!is_present) .border_color(if is_speaking { cx.theme().status().info_border - } else if is_muted { - cx.theme().status().error_border } else { - Hsla::default() + // We draw the border in a transparent color rather to avoid + // the layout shift that would come with adding/removing the border. + gpui::transparent_black() + }) + .when(is_muted, |avatar| { + avatar.indicator( + AvatarAudioStatusIndicator::new(ui::AudioStatus::Muted).tooltip({ + let github_login = user.github_login.clone(); + move |cx| Tooltip::text(format!("{} is muted", github_login), cx) + }), + ) + }), + ) + .children( + followers + .iter() + .take(FACEPILE_LIMIT) + .filter_map(|follower_peer_id| { + let follower = room + .remote_participants() + .values() + .find_map(|p| (p.peer_id == *follower_peer_id).then_some(&p.user)) + .or_else(|| { + (self.client.peer_id() == Some(*follower_peer_id)) + .then_some(current_user) + })? + .clone(); + + Some(Avatar::new(follower.avatar_uri.clone())) }), ) - .children(followers.iter().filter_map(|follower_peer_id| { - let follower = room - .remote_participants() - .values() - .find_map(|p| (p.peer_id == *follower_peer_id).then_some(&p.user)) - .or_else(|| { - (self.client.peer_id() == Some(*follower_peer_id)).then_some(current_user) - })? - .clone(); - - Some(Avatar::new(follower.avatar_uri.clone())) - })); + .children(if extra_count > 0 { + Some( + div() + .ml_1() + .child(Label::new(format!("+{extra_count}"))) + .into_any_element(), + ) + } else { + None + }); Some(pile) } diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index f4248506781a4dca1dbe23c59fd67182a59f392f..befdf5db7419da23130ccd72bfb52c1c7cb5dbe6 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -12,7 +12,6 @@ use std::{rc::Rc, sync::Arc}; use call::{report_call_event_for_room, ActiveCall}; pub use collab_panel::CollabPanel; pub use collab_titlebar_item::CollabTitlebarItem; -use feature_flags::{ChannelsAlpha, FeatureFlagAppExt}; use gpui::{ actions, point, AppContext, GlobalPixels, Pixels, PlatformDisplay, Size, Task, WindowBounds, WindowKind, WindowOptions, @@ -121,7 +120,3 @@ fn notification_window_options( display_id: Some(screen.id()), } } - -fn is_channels_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool { - cx.is_staff() || cx.has_flag::() -} diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..c6416f9691b3ca417c1f7426ace5359c199be88b --- /dev/null +++ b/crates/color/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "color" +version = "0.1.0" +edition = "2021" +publish = false + +[features] +default = [] +stories = ["dep:itertools", "dep:story"] + +[lib] +path = "src/color.rs" +doctest = true + +[dependencies] +# TODO: Clean up dependencies +anyhow.workspace = true +fs = { path = "../fs" } +indexmap = "1.6.2" +parking_lot.workspace = true +refineable.workspace = true +schemars.workspace = true +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true +settings = { path = "../settings" } +story = { path = "../story", optional = true } +toml.workspace = true +uuid.workspace = true +util = { path = "../util" } +itertools = { version = "0.11.0", optional = true } +palette = "0.7.3" diff --git a/crates/color/src/color.rs b/crates/color/src/color.rs new file mode 100644 index 0000000000000000000000000000000000000000..8529f3bc5feea6b3248875e412914dc24b39a4b5 --- /dev/null +++ b/crates/color/src/color.rs @@ -0,0 +1,234 @@ +//! # Color +//! +//! The `color` crate provides a set utilities for working with colors. It is a wrapper around the [`palette`](https://docs.rs/palette) crate with some additional functionality. +//! +//! It is used to create a manipulate colors when building themes. +//! +//! === In development note === +//! +//! This crate is meant to sit between gpui and the theme/ui for all the color related stuff. +//! +//! It could be folded into gpui, ui or theme potentially but for now we'll continue +//! to develop it in isolation. +//! +//! Once we have a good idea of the needs of the theme system and color in gpui in general I see 3 paths: +//! 1. Use `palette` (or another color library) directly in gpui and everywhere else, rather than rolling our own color system. +//! 2. Keep this crate as a thin wrapper around `palette` and use it everywhere except gpui, and convert to gpui's color system when needed. +//! 3. Build the needed functionality into gpui and keep using it's color system everywhere. +//! +//! I'm leaning towards 2 in the short term and 1 in the long term, but we'll need to discuss it more. +//! +//! === End development note === +use palette::{ + blend::Blend, convert::FromColorUnclamped, encoding, rgb::Rgb, Clamp, Mix, Srgb, WithAlpha, +}; + +/// The types of blend modes supported +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum BlendMode { + /// Multiplies the colors, resulting in a darker color. This mode is useful for creating shadows. + Multiply, + /// Lightens the color by adding the source and destination colors. It results in a lighter color. + Screen, + /// Combines Multiply and Screen blend modes. Parts of the image that are lighter than 50% gray are lightened, and parts that are darker are darkened. + Overlay, + /// Selects the darker of the base or blend color as the resulting color. Useful for darkening images without affecting the overall contrast. + Darken, + /// Selects the lighter of the base or blend color as the resulting color. Useful for lightening images without affecting the overall contrast. + Lighten, + /// Brightens the base color to reflect the blend color. The result is a lightened image. + Dodge, + /// Darkens the base color to reflect the blend color. The result is a darkened image. + Burn, + /// Similar to Overlay, but with a stronger effect. Hard Light can either multiply or screen colors, depending on the blend color. + HardLight, + /// A softer version of Hard Light. Soft Light either darkens or lightens colors, depending on the blend color. + SoftLight, + /// Subtracts the darker of the two constituent colors from the lighter color. Difference mode is useful for creating more vivid colors. + Difference, + /// Similar to Difference, but with a lower contrast. Exclusion mode produces an effect similar to Difference but with less intensity. + Exclusion, +} + +/// Converts a hexadecimal color string to a `palette::Hsla` color. +/// +/// This function supports the following hex formats: +/// `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`. +pub fn hex_to_hsla(s: &str) -> Result { + let hex = s.trim_start_matches('#'); + + // Expand shorthand formats #RGB and #RGBA to #RRGGBB and #RRGGBBAA + let hex = match hex.len() { + 3 => hex + .chars() + .map(|c| c.to_string().repeat(2)) + .collect::(), + 4 => { + let (rgb, alpha) = hex.split_at(3); + let rgb = rgb + .chars() + .map(|c| c.to_string().repeat(2)) + .collect::(); + let alpha = alpha.chars().next().unwrap().to_string().repeat(2); + format!("{}{}", rgb, alpha) + } + 6 => format!("{}ff", hex), // Add alpha if missing + 8 => hex.to_string(), // Already in full format + _ => return Err("Invalid hexadecimal string length".to_string()), + }; + + let hex_val = + u32::from_str_radix(&hex, 16).map_err(|_| format!("Invalid hexadecimal string: {}", s))?; + + let r = ((hex_val >> 24) & 0xFF) as f32 / 255.0; + let g = ((hex_val >> 16) & 0xFF) as f32 / 255.0; + let b = ((hex_val >> 8) & 0xFF) as f32 / 255.0; + let a = (hex_val & 0xFF) as f32 / 255.0; + + let color = RGBAColor { r, g, b, a }; + + Ok(color) +} + +// These derives implement to and from palette's color types. +#[derive(FromColorUnclamped, WithAlpha, Debug, Clone)] +#[palette(skip_derives(Rgb), rgb_standard = "encoding::Srgb")] +pub struct RGBAColor { + r: f32, + g: f32, + b: f32, + // Let Palette know this is our alpha channel. + #[palette(alpha)] + a: f32, +} + +impl FromColorUnclamped for RGBAColor { + fn from_color_unclamped(color: RGBAColor) -> RGBAColor { + color + } +} + +impl FromColorUnclamped> for RGBAColor +where + Srgb: FromColorUnclamped>, +{ + fn from_color_unclamped(color: Rgb) -> RGBAColor { + let srgb = Srgb::from_color_unclamped(color); + RGBAColor { + r: srgb.red, + g: srgb.green, + b: srgb.blue, + a: 1.0, + } + } +} + +impl FromColorUnclamped for Rgb +where + Rgb: FromColorUnclamped, +{ + fn from_color_unclamped(color: RGBAColor) -> Self { + let srgb = Srgb::new(color.r, color.g, color.b); + Self::from_color_unclamped(srgb) + } +} + +impl Clamp for RGBAColor { + fn clamp(self) -> Self { + RGBAColor { + r: self.r.min(1.0).max(0.0), + g: self.g.min(1.0).max(0.0), + b: self.b.min(1.0).max(0.0), + a: self.a.min(1.0).max(0.0), + } + } +} + +impl RGBAColor { + /// Creates a new color from the given RGBA values. + /// + /// This color can be used to convert to any [`palette::Color`] type. + pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self { + RGBAColor { r, g, b, a } + } + + /// Returns a set of states for this color. + pub fn states(self, is_light: bool) -> ColorStates { + states_for_color(self, is_light) + } + + /// Mixes this color with another [`palette::Hsl`] color at the given `mix_ratio`. + pub fn mixed(&self, other: RGBAColor, mix_ratio: f32) -> Self { + let srgb_self = Srgb::new(self.r, self.g, self.b); + let srgb_other = Srgb::new(other.r, other.g, other.b); + + // Directly mix the colors as sRGB values + let mixed = srgb_self.mix(srgb_other, mix_ratio); + RGBAColor::from_color_unclamped(mixed) + } + + pub fn blend(&self, other: RGBAColor, blend_mode: BlendMode) -> Self { + let srgb_self = Srgb::new(self.r, self.g, self.b); + let srgb_other = Srgb::new(other.r, other.g, other.b); + + let blended = match blend_mode { + // replace hsl methods with the respective sRGB methods + BlendMode::Multiply => srgb_self.multiply(srgb_other), + _ => unimplemented!(), + }; + + Self { + r: blended.red, + g: blended.green, + b: blended.blue, + a: self.a, + } + } +} + +/// A set of colors for different states of an element. +#[derive(Debug, Clone)] +pub struct ColorStates { + /// The default color. + pub default: RGBAColor, + /// The color when the mouse is hovering over the element. + pub hover: RGBAColor, + /// The color when the mouse button is held down on the element. + pub active: RGBAColor, + /// The color when the element is focused with the keyboard. + pub focused: RGBAColor, + /// The color when the element is disabled. + pub disabled: RGBAColor, +} + +/// Returns a set of colors for different states of an element. +/// +/// todo!("This should take a theme and use appropriate colors from it") +pub fn states_for_color(color: RGBAColor, is_light: bool) -> ColorStates { + let adjustment_factor = if is_light { 0.1 } else { -0.1 }; + let hover_adjustment = 1.0 - adjustment_factor; + let active_adjustment = 1.0 - 2.0 * adjustment_factor; + let focused_adjustment = 1.0 - 3.0 * adjustment_factor; + let disabled_adjustment = 1.0 - 4.0 * adjustment_factor; + + let make_adjustment = |color: RGBAColor, adjustment: f32| -> RGBAColor { + // Adjust lightness for each state + // Note: Adjustment logic may differ; simplify as needed for sRGB + RGBAColor::new( + color.r * adjustment, + color.g * adjustment, + color.b * adjustment, + color.a, + ) + }; + + let color = color.clamp(); + + ColorStates { + default: color.clone(), + hover: make_adjustment(color.clone(), hover_adjustment), + active: make_adjustment(color.clone(), active_adjustment), + focused: make_adjustment(color.clone(), focused_adjustment), + disabled: make_adjustment(color.clone(), disabled_adjustment), + } +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 895df153406934f9d38dca0d25994baf6f69adb6..b82bd55bcf5e898299ca8a3a0e1d88cf37c72367 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -2288,17 +2288,18 @@ impl EditorElement { .map(|p| SharedString::from(p.to_string_lossy().to_string() + "/")); } - div() + v_flex() .id(("path header container", block_id)) .size_full() - .p_1p5() + .justify_center() + .p(gpui::px(6.)) .child( h_flex() .id("path header block") - .py_1p5() - .pl_3() - .pr_2() - .rounded_lg() + .size_full() + .pl(gpui::px(12.)) + .pr(gpui::px(8.)) + .rounded_md() .shadow_md() .border() .border_color(cx.theme().colors().border) @@ -2861,6 +2862,7 @@ impl Element for EditorElement { cx.with_text_style( Some(gpui::TextStyleRefinement { font_size: Some(self.style.text.font_size), + line_height: Some(self.style.text.line_height), ..Default::default() }), |cx| { diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 8da2f50c198a01136d1318922a5159e3814a894f..609c20ac680819ff738f4a4f3ab08ffa58762146 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -339,6 +339,7 @@ fn show_hover( this.hover_state.info_popover = hover_popover; cx.notify(); + cx.refresh(); })?; Ok::<_, anyhow::Error>(()) diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 065d06f96d1b1765828329464ddb149da371d63b..ea16ff3f7291fd838be45fd826e7c7504ba914fd 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -16,12 +16,6 @@ pub trait FeatureFlag { const NAME: &'static str; } -pub enum ChannelsAlpha {} - -impl FeatureFlag for ChannelsAlpha { - const NAME: &'static str = "channels_alpha"; -} - pub trait FeatureFlagViewExt { fn observe_flag(&mut self, callback: F) -> Subscription where diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 5a2335919ebe6ff9b2277e02d4f6f55b4dc9a80c..f165cd9c2b5aa71a3a982a5e23faafaca2b8bf3a 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -192,7 +192,7 @@ pub trait PlatformDispatcher: Send + Sync { pub trait PlatformTextSystem: Send + Sync { fn add_fonts(&self, fonts: &[Arc>]) -> Result<()>; - fn all_font_families(&self) -> Vec; + fn all_font_names(&self) -> Vec; fn font_id(&self, descriptor: &Font) -> Result; fn font_metrics(&self, font_id: FontId) -> FontMetrics; fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result>; diff --git a/crates/gpui/src/platform/mac/metal_renderer.rs b/crates/gpui/src/platform/mac/metal_renderer.rs index 20e749a2f607fa96ec6fbdfc76574307648a621d..1589757d935894ff676de7371fca97204d1ee63a 100644 --- a/crates/gpui/src/platform/mac/metal_renderer.rs +++ b/crates/gpui/src/platform/mac/metal_renderer.rs @@ -82,7 +82,7 @@ impl MetalRenderer { ]; let unit_vertices = device.new_buffer_with_data( unit_vertices.as_ptr() as *const c_void, - (unit_vertices.len() * mem::size_of::()) as u64, + mem::size_of_val(&unit_vertices) as u64, MTLResourceOptions::StorageModeManaged, ); let instances = device.new_buffer( @@ -340,7 +340,8 @@ impl MetalRenderer { for (texture_id, vertices) in vertices_by_texture_id { align_offset(offset); - let next_offset = *offset + vertices.len() * mem::size_of::>(); + let vertices_bytes_len = mem::size_of_val(vertices.as_slice()); + let next_offset = *offset + vertices_bytes_len; if next_offset > INSTANCE_BUFFER_SIZE { return None; } @@ -373,7 +374,6 @@ impl MetalRenderer { &texture_size as *const Size as *const _, ); - let vertices_bytes_len = mem::size_of::>() * vertices.len(); let buffer_contents = unsafe { (self.instances.contents() as *mut u8).add(*offset) }; unsafe { ptr::copy_nonoverlapping( @@ -430,7 +430,7 @@ impl MetalRenderer { &viewport_size as *const Size as *const _, ); - let shadow_bytes_len = std::mem::size_of_val(shadows); + let shadow_bytes_len = mem::size_of_val(shadows); let buffer_contents = unsafe { (self.instances.contents() as *mut u8).add(*offset) }; let next_offset = *offset + shadow_bytes_len; @@ -491,7 +491,7 @@ impl MetalRenderer { &viewport_size as *const Size as *const _, ); - let quad_bytes_len = std::mem::size_of_val(quads); + let quad_bytes_len = mem::size_of_val(quads); let buffer_contents = unsafe { (self.instances.contents() as *mut u8).add(*offset) }; let next_offset = *offset + quad_bytes_len; @@ -591,7 +591,7 @@ impl MetalRenderer { command_encoder .set_fragment_texture(SpriteInputIndex::AtlasTexture as u64, Some(&texture)); - let sprite_bytes_len = mem::size_of::() * sprites.len(); + let sprite_bytes_len = mem::size_of_val(sprites.as_slice()); let next_offset = *offset + sprite_bytes_len; if next_offset > INSTANCE_BUFFER_SIZE { return false; @@ -656,21 +656,22 @@ impl MetalRenderer { &viewport_size as *const Size as *const _, ); - let quad_bytes_len = std::mem::size_of_val(underlines); + let underline_bytes_len = mem::size_of_val(underlines); let buffer_contents = unsafe { (self.instances.contents() as *mut u8).add(*offset) }; + + let next_offset = *offset + underline_bytes_len; + if next_offset > INSTANCE_BUFFER_SIZE { + return false; + } + unsafe { ptr::copy_nonoverlapping( underlines.as_ptr() as *const u8, buffer_contents, - quad_bytes_len, + underline_bytes_len, ); } - let next_offset = *offset + quad_bytes_len; - if next_offset > INSTANCE_BUFFER_SIZE { - return false; - } - command_encoder.draw_primitives_instanced( metal::MTLPrimitiveType::Triangle, 0, @@ -727,7 +728,7 @@ impl MetalRenderer { ); command_encoder.set_fragment_texture(SpriteInputIndex::AtlasTexture as u64, Some(&texture)); - let sprite_bytes_len = std::mem::size_of_val(sprites); + let sprite_bytes_len = mem::size_of_val(sprites); let buffer_contents = unsafe { (self.instances.contents() as *mut u8).add(*offset) }; let next_offset = *offset + sprite_bytes_len; @@ -799,7 +800,7 @@ impl MetalRenderer { ); command_encoder.set_fragment_texture(SpriteInputIndex::AtlasTexture as u64, Some(&texture)); - let sprite_bytes_len = std::mem::size_of_val(sprites); + let sprite_bytes_len = mem::size_of_val(sprites); let buffer_contents = unsafe { (self.instances.contents() as *mut u8).add(*offset) }; let next_offset = *offset + sprite_bytes_len; diff --git a/crates/gpui/src/platform/mac/text_system.rs b/crates/gpui/src/platform/mac/text_system.rs index 68f4a63326757f8a87217a133aef005d0c8e5c86..06179e126b6b779a56d25b1bf154eef37162a646 100644 --- a/crates/gpui/src/platform/mac/text_system.rs +++ b/crates/gpui/src/platform/mac/text_system.rs @@ -5,7 +5,7 @@ use crate::{ }; use anyhow::anyhow; use cocoa::appkit::{CGFloat, CGPoint}; -use collections::HashMap; +use collections::{BTreeSet, HashMap}; use core_foundation::{ array::CFIndex, attributed_string::{CFAttributedStringRef, CFMutableAttributedString}, @@ -78,12 +78,16 @@ impl PlatformTextSystem for MacTextSystem { self.0.write().add_fonts(fonts) } - fn all_font_families(&self) -> Vec { - self.0 - .read() - .system_source - .all_families() - .expect("core text should never return an error") + fn all_font_names(&self) -> Vec { + let collection = core_text::font_collection::create_for_all_families(); + let Some(descriptors) = collection.get_descriptors() else { + return vec![]; + }; + let mut names = BTreeSet::new(); + for descriptor in descriptors.into_iter() { + names.insert(descriptor.display_name()); + } + names.into_iter().collect() } fn font_id(&self, font: &Font) -> Result { diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 11341a2cbc524899a3455f490b20652e7f17fc4b..de031704cd2888917534083bffd90e00c7e8b49b 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -299,8 +299,8 @@ impl<'a> Iterator for BatchIterator<'a> { let first = orders_and_kinds[0]; let second = orders_and_kinds[1]; - let (batch_kind, max_order) = if first.0.is_some() { - (first.1, second.0.unwrap_or(u32::MAX)) + let (batch_kind, max_order_and_kind) = if first.0.is_some() { + (first.1, (second.0.unwrap_or(u32::MAX), second.1)) } else { return None; }; @@ -312,7 +312,7 @@ impl<'a> Iterator for BatchIterator<'a> { self.shadows_iter.next(); while self .shadows_iter - .next_if(|shadow| shadow.order < max_order) + .next_if(|shadow| (shadow.order, batch_kind) < max_order_and_kind) .is_some() { shadows_end += 1; @@ -328,7 +328,7 @@ impl<'a> Iterator for BatchIterator<'a> { self.quads_iter.next(); while self .quads_iter - .next_if(|quad| quad.order < max_order) + .next_if(|quad| (quad.order, batch_kind) < max_order_and_kind) .is_some() { quads_end += 1; @@ -342,7 +342,7 @@ impl<'a> Iterator for BatchIterator<'a> { self.paths_iter.next(); while self .paths_iter - .next_if(|path| path.order < max_order) + .next_if(|path| (path.order, batch_kind) < max_order_and_kind) .is_some() { paths_end += 1; @@ -356,7 +356,7 @@ impl<'a> Iterator for BatchIterator<'a> { self.underlines_iter.next(); while self .underlines_iter - .next_if(|underline| underline.order < max_order) + .next_if(|underline| (underline.order, batch_kind) < max_order_and_kind) .is_some() { underlines_end += 1; @@ -374,7 +374,8 @@ impl<'a> Iterator for BatchIterator<'a> { while self .monochrome_sprites_iter .next_if(|sprite| { - sprite.order < max_order && sprite.tile.texture_id == texture_id + (sprite.order, batch_kind) < max_order_and_kind + && sprite.tile.texture_id == texture_id }) .is_some() { @@ -394,7 +395,8 @@ impl<'a> Iterator for BatchIterator<'a> { while self .polychrome_sprites_iter .next_if(|sprite| { - sprite.order < max_order && sprite.tile.texture_id == texture_id + (sprite.order, batch_kind) < max_order_and_kind + && sprite.tile.texture_id == texture_id }) .is_some() { @@ -412,7 +414,7 @@ impl<'a> Iterator for BatchIterator<'a> { self.surfaces_iter.next(); while self .surfaces_iter - .next_if(|surface| surface.order < max_order) + .next_if(|surface| (surface.order, batch_kind) < max_order_and_kind) .is_some() { surfaces_end += 1; diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 9f46d8dab6fd6214b0f22cf0b4e616300d595e8a..8fdb926b27ebc8e1ab6b4531a5902e0395f53f8b 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -386,7 +386,7 @@ impl Style { let background_color = self.background.as_ref().and_then(Fill::color); if background_color.map_or(false, |color| !color.is_transparent()) { - cx.with_z_index(1, |cx| { + cx.with_z_index(0, |cx| { let mut border_color = background_color.unwrap_or_default(); border_color.a = 0.; cx.paint_quad(quad( @@ -399,12 +399,12 @@ impl Style { }); } - cx.with_z_index(2, |cx| { + cx.with_z_index(0, |cx| { continuation(cx); }); if self.is_border_visible() { - cx.with_z_index(3, |cx| { + cx.with_z_index(0, |cx| { let corner_radii = self.corner_radii.to_pixels(bounds.size, rem_size); let border_widths = self.border_widths.to_pixels(rem_size); let max_border_width = border_widths.max(); diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index 24438d8c819527a3e8dd869f9e1dac179d9dd618..34470aff021297d9b8a12025e5348550cd6111c5 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -65,8 +65,8 @@ impl TextSystem { } } - pub fn all_font_families(&self) -> Vec { - let mut families = self.platform_text_system.all_font_families(); + pub fn all_font_names(&self) -> Vec { + let mut families = self.platform_text_system.all_font_names(); families.append( &mut self .fallback_font_stack @@ -101,7 +101,6 @@ impl TextSystem { if let Ok(font_id) = self.font_id(font) { return font_id; } - for fallback in &self.fallback_font_stack { if let Ok(font_id) = self.font_id(fallback) { return font_id; diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 869d6b18268cc64bc18f9187dd34e8032237d28b..0269ccfb6c8ef55df1696157302f6156e041f744 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -315,6 +315,7 @@ pub(crate) struct Frame { pub(crate) depth_map: Vec<(StackingOrder, EntityId, Bounds)>, pub(crate) z_index_stack: StackingOrder, pub(crate) next_stacking_order_id: u32, + next_root_z_index: u8, content_mask_stack: Vec>, element_offset_stack: Vec>, requested_input_handler: Option, @@ -337,6 +338,7 @@ impl Frame { depth_map: Vec::new(), z_index_stack: StackingOrder::default(), next_stacking_order_id: 0, + next_root_z_index: 0, content_mask_stack: Vec::new(), element_offset_stack: Vec::new(), requested_input_handler: None, @@ -354,6 +356,7 @@ impl Frame { self.dispatch_tree.clear(); self.depth_map.clear(); self.next_stacking_order_id = 0; + self.next_root_z_index = 0; self.reused_views.clear(); self.scene.clear(); self.requested_input_handler.take(); @@ -2450,8 +2453,13 @@ pub trait BorrowWindow: BorrowMut + BorrowMut { }; let new_stacking_order_id = post_inc(&mut self.window_mut().next_frame.next_stacking_order_id); + let new_root_z_index = post_inc(&mut self.window_mut().next_frame.next_root_z_index); let old_stacking_order = mem::take(&mut self.window_mut().next_frame.z_index_stack); self.window_mut().next_frame.z_index_stack.id = new_stacking_order_id; + self.window_mut() + .next_frame + .z_index_stack + .push(new_root_z_index); self.window_mut().next_frame.content_mask_stack.push(mask); let result = f(self); self.window_mut().next_frame.content_mask_stack.pop(); diff --git a/crates/gpui_macros/src/style_helpers.rs b/crates/gpui_macros/src/style_helpers.rs index b86bb2dfa60b7a97394cafe3fc4e1aaf22b87302..00d3672033fd9042d72ccff5c93b9e8b1f41055e 100644 --- a/crates/gpui_macros/src/style_helpers.rs +++ b/crates/gpui_macros/src/style_helpers.rs @@ -85,6 +85,18 @@ fn generate_methods() -> Vec { } for (prefix, fields, prefix_doc_string) in border_prefixes() { + methods.push(generate_custom_value_setter( + // The plain method names (e.g., `border`, `border_t`, `border_r`, etc.) are special-cased + // versions of the 1px variants. This better matches Tailwind, but breaks our existing + // convention of the suffix-less variant of the method being the one that accepts a custom value + // + // To work around this, we're assigning a `_width` suffix here. + &format!("{prefix}_width"), + quote! { AbsoluteLength }, + &fields, + prefix_doc_string, + )); + for (suffix, width_tokens, suffix_doc_string) in border_suffixes() { methods.push(generate_predefined_setter( prefix, @@ -141,7 +153,7 @@ fn generate_predefined_setter( } fn generate_custom_value_setter( - prefix: &'static str, + prefix: &str, length_type: TokenStream2, fields: &[TokenStream2], doc_string: &str, diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 3827b46c67fc29c96c5b361440fb66143c96db1a..49eb24ce9ee9e267dc921b6ddb8eb10e92c83c97 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -54,14 +54,10 @@ actions!( [SearchInNew, ToggleFocus, NextField, ToggleFilters] ); -#[derive(Default)] -struct ActiveSearches(HashMap, WeakView>); - #[derive(Default)] struct ActiveSettings(HashMap, ProjectSearchSettings>); pub fn init(cx: &mut AppContext) { - cx.set_global(ActiveSearches::default()); cx.set_global(ActiveSettings::default()); cx.observe_new_views(|workspace: &mut Workspace, _cx| { workspace @@ -282,6 +278,8 @@ impl EventEmitter for ProjectSearchView {} impl Render for ProjectSearchView { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + const PLEASE_AUTHENTICATE: &str = "API Key Missing: Please set 'OPENAI_API_KEY' in Environment Variables. If you authenticated using the Assistant Panel, please restart Zed to Authenticate."; + if self.has_matches() { div() .flex_1() @@ -303,40 +301,39 @@ impl Render for ProjectSearchView { let mut show_minor_text = true; let semantic_status = self.semantic_state.as_ref().and_then(|semantic| { let status = semantic.index_status; - match status { - SemanticIndexStatus::NotAuthenticated => { - major_text = Label::new("Not Authenticated"); - show_minor_text = false; - Some( - "API Key Missing: Please set 'OPENAI_API_KEY' in Environment Variables. If you authenticated using the Assistant Panel, please restart Zed to Authenticate.".to_string()) - } - SemanticIndexStatus::Indexed => Some("Indexing complete".to_string()), - SemanticIndexStatus::Indexing { - remaining_files, - rate_limit_expiry, - } => { - if remaining_files == 0 { - Some("Indexing...".to_string()) - } else { - if let Some(rate_limit_expiry) = rate_limit_expiry { - let remaining_seconds = - rate_limit_expiry.duration_since(Instant::now()); - if remaining_seconds > Duration::from_secs(0) { - Some(format!( - "Remaining files to index (rate limit resets in {}s): {}", - remaining_seconds.as_secs(), - remaining_files - )) - } else { - Some(format!("Remaining files to index: {}", remaining_files)) - } - } else { - Some(format!("Remaining files to index: {}", remaining_files)) - } - } - } - SemanticIndexStatus::NotIndexed => None, + match status { + SemanticIndexStatus::NotAuthenticated => { + major_text = Label::new("Not Authenticated"); + show_minor_text = false; + Some(PLEASE_AUTHENTICATE.to_string()) + } + SemanticIndexStatus::Indexed => Some("Indexing complete".to_string()), + SemanticIndexStatus::Indexing { + remaining_files, + rate_limit_expiry, + } => { + if remaining_files == 0 { + Some("Indexing...".to_string()) + } else { + if let Some(rate_limit_expiry) = rate_limit_expiry { + let remaining_seconds = + rate_limit_expiry.duration_since(Instant::now()); + if remaining_seconds > Duration::from_secs(0) { + Some(format!( + "Remaining files to index (rate limit resets in {}s): {}", + remaining_seconds.as_secs(), + remaining_files + )) + } else { + Some(format!("Remaining files to index: {}", remaining_files)) } + } else { + Some(format!("Remaining files to index: {}", remaining_files)) + } + } + } + SemanticIndexStatus::NotIndexed => None, + } }); let major_text = div().justify_center().max_w_96().child(major_text); @@ -947,25 +944,19 @@ impl ProjectSearchView { }); } - // Re-activate the most recently activated search or the most recent if it has been closed. + // Re-activate the most recently activated search in this pane or the most recent if it has been closed. // If no search exists in the workspace, create a new one. fn deploy_search( workspace: &mut Workspace, _: &workspace::DeploySearch, cx: &mut ViewContext, ) { - let active_search = cx - .global::() - .0 - .get(&workspace.project().downgrade()); - let existing = active_search - .and_then(|active_search| { - workspace - .items_of_type::(cx) - .filter(|search| &search.downgrade() == active_search) - .last() - }) - .or_else(|| workspace.item_of_type::(cx)); + let existing = workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()); + Self::existing_or_new_search(workspace, existing, cx) } @@ -983,11 +974,6 @@ impl ProjectSearchView { existing: Option>, cx: &mut ViewContext, ) { - // Clean up entries for dropped projects - cx.update_global(|state: &mut ActiveSearches, _cx| { - state.0.retain(|project, _| project.is_upgradable()) - }); - let query = workspace.active_item(cx).and_then(|item| { let editor = item.act_as::(cx)?; let query = editor.query_suggestion(cx); @@ -1019,6 +1005,7 @@ impl ProjectSearchView { workspace.add_item(Box::new(view.clone()), cx); view }; + search.update(cx, |search, cx| { if let Some(query) = query { search.set_query(&query, cx); @@ -3117,6 +3104,7 @@ pub mod tests { async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) { init_test(cx); + // Setup 2 panes, both with a file open and one with a project search. let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( "/dir", @@ -3175,6 +3163,8 @@ pub mod tests { } }) .unwrap(); + + // Add a project search item to the second pane window .update(cx, { let search_bar = search_bar.clone(); @@ -3194,6 +3184,8 @@ pub mod tests { cx.run_until_parked(); assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2); assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1); + + // Focus the first pane window .update(cx, |workspace, cx| { assert_eq!(workspace.active_pane(), &second_pane); @@ -3212,20 +3204,47 @@ pub mod tests { assert_eq!(second_pane.read(cx).items_len(), 2); }) .unwrap(); + + // Deploy a new search cx.dispatch_action(window.into(), DeploySearch); - // We should have same # of items in workspace, the only difference being that - // the search we've deployed previously should now be focused. + // Both panes should now have a project search in them window .update(cx, |workspace, cx| { - assert_eq!(workspace.active_pane(), &second_pane); - second_pane.update(cx, |this, _| { + assert_eq!(workspace.active_pane(), &first_pane); + first_pane.update(cx, |this, _| { assert_eq!(this.active_item_index(), 1); assert_eq!(this.items_len(), 2); }); - first_pane.update(cx, |this, cx| { + second_pane.update(cx, |this, cx| { assert!(!cx.focus_handle().contains_focused(cx)); - assert_eq!(this.items_len(), 1); + assert_eq!(this.items_len(), 2); + }); + }) + .unwrap(); + + // Focus the second pane's non-search item + window + .update(cx, |_workspace, cx| { + second_pane.update(cx, |pane, cx| pane.activate_next_item(true, cx)); + }) + .unwrap(); + + // Deploy a new search + cx.dispatch_action(window.into(), DeploySearch); + + // The project search view should now be focused in the second pane + // And the number of items should be unchanged. + window + .update(cx, |_workspace, cx| { + second_pane.update(cx, |pane, _cx| { + assert!(pane + .active_item() + .unwrap() + .downcast::() + .is_some()); + + assert_eq!(pane.items_len(), 2); }); }) .unwrap(); @@ -3235,7 +3254,7 @@ pub mod tests { cx.update(|cx| { let settings = SettingsStore::test(cx); cx.set_global(settings); - cx.set_global(ActiveSearches::default()); + SemanticIndexSettings::register(cx); theme::init(theme::LoadThemes::JustBase, cx); diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index ce2a158e2076a852addcef71afc2644cdd903531..4dc253fd5343b8530fed48b6e6f17400dcb82bca 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -33,6 +33,7 @@ thiserror.workspace = true lazy_static.workspace = true serde.workspace = true serde_derive.workspace = true +serde_json.workspace = true [dev-dependencies] rand.workspace = true diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index 14cff3b5a690a6bc370579604305ff5a1217a351..b9b79c9c6b6996e674f1af25485343a093b3fe85 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -1,6 +1,12 @@ use gpui::{px, AbsoluteLength, AppContext, FontFeatures, Pixels}; -use schemars::JsonSchema; +use schemars::{ + gen::SchemaGenerator, + schema::{InstanceType, RootSchema, Schema, SchemaObject}, + JsonSchema, +}; use serde_derive::{Deserialize, Serialize}; +use serde_json::Value; +use settings::SettingsJsonSchemaParams; use std::{collections::HashMap, path::PathBuf}; #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -153,6 +159,39 @@ impl settings::Settings for TerminalSettings { ) -> anyhow::Result { Self::load_via_json_merge(default_value, user_values) } + fn json_schema( + generator: &mut SchemaGenerator, + _: &SettingsJsonSchemaParams, + cx: &AppContext, + ) -> RootSchema { + let mut root_schema = generator.root_schema_for::(); + let available_fonts = cx + .text_system() + .all_font_names() + .into_iter() + .map(Value::String) + .collect(); + let fonts_schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + enum_values: Some(available_fonts), + ..Default::default() + }; + root_schema + .definitions + .extend([("FontFamilies".into(), fonts_schema.into())]); + root_schema + .schema + .object + .as_mut() + .unwrap() + .properties + .extend([( + "font_family".to_owned(), + Schema::new_ref("#/definitions/FontFamilies".into()), + )]); + + root_schema + } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, Default)] diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 1fec041de9c633493993696cdc6fdda142355c77..6298b4c16a07b47054430a6e2dcacd9e4f6e033e 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -250,8 +250,8 @@ impl TerminalElement { //Layout current cell text { - let cell_text = cell.c.to_string(); if !is_blank(&cell) { + let cell_text = cell.c.to_string(); let cell_style = TerminalElement::cell_style(&cell, fg, theme, text_style, hyperlink); @@ -586,24 +586,6 @@ impl TerminalElement { } } - fn register_key_listeners(&self, cx: &mut WindowContext) { - cx.on_key_event({ - let this = self.terminal.clone(); - move |event: &ModifiersChangedEvent, phase, cx| { - if phase != DispatchPhase::Bubble { - return; - } - - let handled = - this.update(cx, |term, _| term.try_modifiers_change(&event.modifiers)); - - if handled { - cx.refresh(); - } - } - }); - } - fn register_mouse_listeners( &mut self, origin: Point, @@ -771,53 +753,68 @@ impl Element for TerminalElement { self.register_mouse_listeners(origin, layout.mode, bounds, cx); - let mut interactivity = mem::take(&mut self.interactivity); - interactivity.paint(bounds, bounds.size, state, cx, |_, _, cx| { - cx.handle_input(&self.focus, terminal_input_handler); + self.interactivity + .paint(bounds, bounds.size, state, cx, |_, _, cx| { + cx.handle_input(&self.focus, terminal_input_handler); - self.register_key_listeners(cx); + cx.on_key_event({ + let this = self.terminal.clone(); + move |event: &ModifiersChangedEvent, phase, cx| { + if phase != DispatchPhase::Bubble { + return; + } - for rect in &layout.rects { - rect.paint(origin, &layout, cx); - } + let handled = + this.update(cx, |term, _| term.try_modifiers_change(&event.modifiers)); - cx.with_z_index(1, |cx| { - for (relative_highlighted_range, color) in layout.relative_highlighted_ranges.iter() - { - if let Some((start_y, highlighted_range_lines)) = - to_highlighted_range_lines(relative_highlighted_range, &layout, origin) - { - let hr = HighlightedRange { - start_y, //Need to change this - line_height: layout.dimensions.line_height, - lines: highlighted_range_lines, - color: color.clone(), - //Copied from editor. TODO: move to theme or something - corner_radius: 0.15 * layout.dimensions.line_height, - }; - hr.paint(bounds, cx); + if handled { + cx.refresh(); + } } - } - }); + }); - cx.with_z_index(2, |cx| { - for cell in &layout.cells { - cell.paint(origin, &layout, bounds, cx); + for rect in &layout.rects { + rect.paint(origin, &layout, cx); } - }); - if self.cursor_visible { - cx.with_z_index(3, |cx| { - if let Some(cursor) = &layout.cursor { - cursor.paint(origin, cx); + cx.with_z_index(1, |cx| { + for (relative_highlighted_range, color) in + layout.relative_highlighted_ranges.iter() + { + if let Some((start_y, highlighted_range_lines)) = + to_highlighted_range_lines(relative_highlighted_range, &layout, origin) + { + let hr = HighlightedRange { + start_y, //Need to change this + line_height: layout.dimensions.line_height, + lines: highlighted_range_lines, + color: color.clone(), + //Copied from editor. TODO: move to theme or something + corner_radius: 0.15 * layout.dimensions.line_height, + }; + hr.paint(bounds, cx); + } } }); - } - if let Some(mut element) = layout.hyperlink_tooltip.take() { - element.draw(origin, bounds.size.map(AvailableSpace::Definite), cx) - } - }); + cx.with_z_index(2, |cx| { + for cell in &layout.cells { + cell.paint(origin, &layout, bounds, cx); + } + }); + + if self.cursor_visible { + cx.with_z_index(3, |cx| { + if let Some(cursor) = &layout.cursor { + cursor.paint(origin, cx); + } + }); + } + + if let Some(mut element) = layout.hyperlink_tooltip.take() { + element.draw(origin, bounds.size.map(AvailableSpace::Definite), cx) + } + }); } } diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index 1c30176b25b41130b79ccbc8879167fe34275895..428bcaac10b76501c78a772652f44370d4a74156 100644 --- a/crates/theme/Cargo.toml +++ b/crates/theme/Cargo.toml @@ -34,6 +34,7 @@ story = { path = "../story", optional = true } toml.workspace = true uuid.workspace = true util = { path = "../util" } +color = {path = "../color"} itertools = { version = "0.11.0", optional = true } [dev-dependencies] diff --git a/crates/theme/src/settings.rs b/crates/theme/src/settings.rs index efc62ed59c429b97420fc276d1f2e2eca6c841cc..bced187411594b5979245103c6f99c1aedcd7fa5 100644 --- a/crates/theme/src/settings.rs +++ b/crates/theme/src/settings.rs @@ -205,7 +205,7 @@ impl settings::Settings for ThemeSettings { let available_fonts = cx .text_system() - .all_font_families() + .all_font_names() .into_iter() .map(Value::String) .collect(); @@ -234,6 +234,10 @@ impl settings::Settings for ThemeSettings { "buffer_font_family".to_owned(), Schema::new_ref("#/definitions/FontFamilies".into()), ), + ( + "ui_font_family".to_owned(), + Schema::new_ref("#/definitions/FontFamilies".into()), + ), ]); root_schema diff --git a/crates/ui/src/components/avatar.rs b/crates/ui/src/components/avatar.rs index a97adb73b7d88ae0dfb1c60c25eff3942c5ad52d..6c2d88916e7fe47f9fbcfe86b7d88ebd43e4b62f 100644 --- a/crates/ui/src/components/avatar.rs +++ b/crates/ui/src/components/avatar.rs @@ -1,135 +1,7 @@ -use crate::prelude::*; -use gpui::{img, Hsla, ImageSource, Img, IntoElement, Styled}; +mod avatar; +mod avatar_audio_status_indicator; +mod avatar_availability_indicator; -/// The shape of an [`Avatar`]. -#[derive(Debug, Default, PartialEq, Clone)] -pub enum AvatarShape { - /// The avatar is shown in a circle. - #[default] - Circle, - /// The avatar is shown in a rectangle with rounded corners. - RoundedRectangle, -} - -/// An element that renders a user avatar with customizable appearance options. -/// -/// # Examples -/// -/// ``` -/// use ui::{Avatar, AvatarShape}; -/// -/// Avatar::new("path/to/image.png") -/// .shape(AvatarShape::Circle) -/// .grayscale(true) -/// .border_color(gpui::red()); -/// ``` -#[derive(IntoElement)] -pub struct Avatar { - image: Img, - size: Option, - border_color: Option, - is_available: Option, -} - -impl RenderOnce for Avatar { - fn render(mut self, cx: &mut WindowContext) -> impl IntoElement { - if self.image.style().corner_radii.top_left.is_none() { - self = self.shape(AvatarShape::Circle); - } - - let size = self.size.unwrap_or_else(|| cx.rem_size()); - - div() - .size(size + px(2.)) - .map(|mut div| { - div.style().corner_radii = self.image.style().corner_radii.clone(); - div - }) - .when_some(self.border_color, |this, color| { - this.border().border_color(color) - }) - .child( - self.image - .size(size) - .bg(cx.theme().colors().ghost_element_background), - ) - .children(self.is_available.map(|is_free| { - // HACK: non-integer sizes result in oval indicators. - let indicator_size = (size * 0.4).round(); - - div() - .absolute() - .z_index(1) - .bg(if is_free { - cx.theme().status().created - } else { - cx.theme().status().deleted - }) - .size(indicator_size) - .rounded(indicator_size) - .bottom_0() - .right_0() - })) - } -} - -impl Avatar { - pub fn new(src: impl Into) -> Self { - Avatar { - image: img(src), - is_available: None, - border_color: None, - size: None, - } - } - - /// Sets the shape of the avatar image. - /// - /// This method allows the shape of the avatar to be specified using a [`Shape`]. - /// It modifies the corner radius of the image to match the specified shape. - /// - /// # Examples - /// - /// ``` - /// use ui::{Avatar, AvatarShape}; - /// - /// Avatar::new("path/to/image.png").shape(AvatarShape::Circle); - /// ``` - pub fn shape(mut self, shape: AvatarShape) -> Self { - self.image = match shape { - AvatarShape::Circle => self.image.rounded_full(), - AvatarShape::RoundedRectangle => self.image.rounded_md(), - }; - self - } - - /// Applies a grayscale filter to the avatar image. - /// - /// # Examples - /// - /// ``` - /// use ui::{Avatar, AvatarShape}; - /// - /// let avatar = Avatar::new("path/to/image.png").grayscale(true); - /// ``` - pub fn grayscale(mut self, grayscale: bool) -> Self { - self.image = self.image.grayscale(grayscale); - self - } - - pub fn border_color(mut self, color: impl Into) -> Self { - self.border_color = Some(color.into()); - self - } - - pub fn availability_indicator(mut self, is_available: impl Into>) -> Self { - self.is_available = is_available.into(); - self - } - - /// Size overrides the avatar size. By default they are 1rem. - pub fn size(mut self, size: impl Into>) -> Self { - self.size = size.into(); - self - } -} +pub use avatar::*; +pub use avatar_audio_status_indicator::*; +pub use avatar_availability_indicator::*; diff --git a/crates/ui/src/components/avatar/avatar.rs b/crates/ui/src/components/avatar/avatar.rs new file mode 100644 index 0000000000000000000000000000000000000000..932cc9e243558fb78cbf6e15340974a0551699c9 --- /dev/null +++ b/crates/ui/src/components/avatar/avatar.rs @@ -0,0 +1,130 @@ +use crate::prelude::*; + +use gpui::{img, AnyElement, Hsla, ImageSource, Img, IntoElement, Styled}; + +/// The shape of an [`Avatar`]. +#[derive(Debug, Default, PartialEq, Clone)] +pub enum AvatarShape { + /// The avatar is shown in a circle. + #[default] + Circle, + /// The avatar is shown in a rectangle with rounded corners. + RoundedRectangle, +} + +/// An element that renders a user avatar with customizable appearance options. +/// +/// # Examples +/// +/// ``` +/// use ui::{Avatar, AvatarShape}; +/// +/// Avatar::new("path/to/image.png") +/// .shape(AvatarShape::Circle) +/// .grayscale(true) +/// .border_color(gpui::red()); +/// ``` +#[derive(IntoElement)] +pub struct Avatar { + image: Img, + size: Option, + border_color: Option, + indicator: Option, +} + +impl Avatar { + pub fn new(src: impl Into) -> Self { + Avatar { + image: img(src), + size: None, + border_color: None, + indicator: None, + } + } + + /// Sets the shape of the avatar image. + /// + /// This method allows the shape of the avatar to be specified using a [`Shape`]. + /// It modifies the corner radius of the image to match the specified shape. + /// + /// # Examples + /// + /// ``` + /// use ui::{Avatar, AvatarShape}; + /// + /// Avatar::new("path/to/image.png").shape(AvatarShape::Circle); + /// ``` + pub fn shape(mut self, shape: AvatarShape) -> Self { + self.image = match shape { + AvatarShape::Circle => self.image.rounded_full(), + AvatarShape::RoundedRectangle => self.image.rounded_md(), + }; + self + } + + /// Applies a grayscale filter to the avatar image. + /// + /// # Examples + /// + /// ``` + /// use ui::{Avatar, AvatarShape}; + /// + /// let avatar = Avatar::new("path/to/image.png").grayscale(true); + /// ``` + pub fn grayscale(mut self, grayscale: bool) -> Self { + self.image = self.image.grayscale(grayscale); + self + } + + pub fn border_color(mut self, color: impl Into) -> Self { + self.border_color = Some(color.into()); + self + } + + /// Size overrides the avatar size. By default they are 1rem. + pub fn size(mut self, size: impl Into>) -> Self { + self.size = size.into(); + self + } + + pub fn indicator(mut self, indicator: impl Into>) -> Self { + self.indicator = indicator.into().map(IntoElement::into_any_element); + self + } +} + +impl RenderOnce for Avatar { + fn render(mut self, cx: &mut WindowContext) -> impl IntoElement { + if self.image.style().corner_radii.top_left.is_none() { + self = self.shape(AvatarShape::Circle); + } + + let border_width = if self.border_color.is_some() { + px(2.) + } else { + px(0.) + }; + + let image_size = self.size.unwrap_or_else(|| cx.rem_size()); + let container_size = image_size + border_width * 2.; + + div() + .size(container_size) + .map(|mut div| { + div.style().corner_radii = self.image.style().corner_radii.clone(); + div + }) + .when_some(self.border_color, |this, color| { + this.border_width(border_width).border_color(color) + }) + .child( + self.image + .size(image_size) + .bg(cx.theme().colors().ghost_element_background), + ) + .children( + self.indicator + .map(|indicator| div().z_index(1).child(indicator)), + ) + } +} diff --git a/crates/ui/src/components/avatar/avatar_audio_status_indicator.rs b/crates/ui/src/components/avatar/avatar_audio_status_indicator.rs new file mode 100644 index 0000000000000000000000000000000000000000..943a8d4826110a020d3c35ba562651315ee53c3a --- /dev/null +++ b/crates/ui/src/components/avatar/avatar_audio_status_indicator.rs @@ -0,0 +1,65 @@ +use gpui::AnyView; + +use crate::prelude::*; + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +pub enum AudioStatus { + Muted, + Deafened, +} + +#[derive(IntoElement)] +pub struct AvatarAudioStatusIndicator { + audio_status: AudioStatus, + tooltip: Option AnyView>>, +} + +impl AvatarAudioStatusIndicator { + pub fn new(audio_status: AudioStatus) -> Self { + Self { + audio_status, + tooltip: None, + } + } + + pub fn tooltip(mut self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self { + self.tooltip = Some(Box::new(tooltip)); + self + } +} + +impl RenderOnce for AvatarAudioStatusIndicator { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let icon_size = IconSize::Indicator; + + let width_in_px = icon_size.rems() * cx.rem_size(); + let padding_x = px(4.); + + div() + .absolute() + .bottom(rems(-3. / 16.)) + .right(rems(-6. / 16.)) + .w(width_in_px + padding_x) + .h(icon_size.rems()) + .child( + h_flex() + .id("muted-indicator") + .justify_center() + .px(padding_x) + .py(px(2.)) + .bg(cx.theme().status().error_background) + .rounded_md() + .child( + Icon::new(match self.audio_status { + AudioStatus::Muted => IconName::MicMute, + AudioStatus::Deafened => IconName::AudioOff, + }) + .size(icon_size) + .color(Color::Error), + ) + .when_some(self.tooltip, |this, tooltip| { + this.tooltip(move |cx| tooltip(cx)) + }), + ) + } +} diff --git a/crates/ui/src/components/avatar/avatar_availability_indicator.rs b/crates/ui/src/components/avatar/avatar_availability_indicator.rs new file mode 100644 index 0000000000000000000000000000000000000000..3a033cd3959c5d63af5960ba839fb37950fbec57 --- /dev/null +++ b/crates/ui/src/components/avatar/avatar_availability_indicator.rs @@ -0,0 +1,48 @@ +use crate::prelude::*; + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +pub enum Availability { + Free, + Busy, +} + +#[derive(IntoElement)] +pub struct AvatarAvailabilityIndicator { + availability: Availability, + avatar_size: Option, +} + +impl AvatarAvailabilityIndicator { + pub fn new(availability: Availability) -> Self { + Self { + availability, + avatar_size: None, + } + } + + /// Sets the size of the [`Avatar`] this indicator appears on. + pub fn avatar_size(mut self, size: impl Into>) -> Self { + self.avatar_size = size.into(); + self + } +} + +impl RenderOnce for AvatarAvailabilityIndicator { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let avatar_size = self.avatar_size.unwrap_or_else(|| cx.rem_size()); + + // HACK: non-integer sizes result in oval indicators. + let indicator_size = (avatar_size * 0.4).round(); + + div() + .absolute() + .bottom_0() + .right_0() + .size(indicator_size) + .rounded(indicator_size) + .bg(match self.availability { + Availability::Free => cx.theme().status().created, + Availability::Busy => cx.theme().status().deleted, + }) + } +} diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index 1e37a872922b4473616b551352f8100bbd9b1327..cc1e31b65cb0836be9a307107302ba8705597d6c 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -127,16 +127,25 @@ impl VisibleOnHover for IconButton { } impl RenderOnce for IconButton { - fn render(self, _cx: &mut WindowContext) -> impl IntoElement { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { let is_disabled = self.base.disabled; let is_selected = self.base.selected; let selected_style = self.base.selected_style; self.base .map(|this| match self.shape { - IconButtonShape::Square => this - .width(self.icon_size.rems().into()) - .height(self.icon_size.rems().into()), + IconButtonShape::Square => { + let icon_size = self.icon_size.rems() * cx.rem_size(); + let padding = match self.icon_size { + IconSize::Indicator => px(0.), + IconSize::XSmall => px(0.), + IconSize::Small => px(2.), + IconSize::Medium => px(2.), + }; + + this.width((icon_size + padding * 2.).into()) + .height((icon_size + padding * 2.).into()) + } IconButtonShape::Wide => this, }) .child( diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 5c4f110a415b2e1e8ef0ac2d36b79d7d8bc2b4be..4b6837799976579b6bc469753c51cb964e49e7b0 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -51,6 +51,7 @@ impl ContextMenu { let _on_blur_subscription = cx.on_blur(&focus_handle, |this: &mut ContextMenu, cx| { this.cancel(&menu::Cancel, cx) }); + cx.refresh(); f( Self { items: Default::default(), diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 908e76ef918b56aefff6949e86ea6473a272253d..bdc691dc9a26e178ad65eaaeda85caa7973dd526 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -5,6 +5,7 @@ use crate::prelude::*; #[derive(Default, PartialEq, Copy, Clone)] pub enum IconSize { + Indicator, XSmall, Small, #[default] @@ -14,6 +15,7 @@ pub enum IconSize { impl IconSize { pub fn rems(self) -> Rems { match self { + IconSize::Indicator => rems(10. / 16.), IconSize::XSmall => rems(12. / 16.), IconSize::Small => rems(14. / 16.), IconSize::Medium => rems(16. / 16.), diff --git a/crates/ui/src/components/stories/avatar.rs b/crates/ui/src/components/stories/avatar.rs index e447486d69ca4a22a10e3d8975546b4ef661cf0d..9da475b0d9be59d64580b4db416ed99afbb1e402 100644 --- a/crates/ui/src/components/stories/avatar.rs +++ b/crates/ui/src/components/stories/avatar.rs @@ -1,29 +1,63 @@ use gpui::Render; -use story::Story; +use story::{StoryContainer, StoryItem, StorySection}; -use crate::prelude::*; -use crate::Avatar; +use crate::{prelude::*, AudioStatus, Availability, AvatarAvailabilityIndicator}; +use crate::{Avatar, AvatarAudioStatusIndicator}; pub struct AvatarStory; impl Render for AvatarStory { - fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - Story::container() - .child(Story::title_for::()) - .child(Story::label("Default")) - .child(Avatar::new( - "https://avatars.githubusercontent.com/u/1714999?v=4", - )) - .child(Avatar::new( - "https://avatars.githubusercontent.com/u/326587?v=4", - )) + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + StoryContainer::new("Avatar", "crates/ui/src/components/stories/avatar.rs") .child( - Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") - .availability_indicator(true), + StorySection::new() + .child(StoryItem::new( + "Default", + Avatar::new("https://avatars.githubusercontent.com/u/1714999?v=4"), + )) + .child(StoryItem::new( + "Default", + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4"), + )), ) .child( - Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") - .availability_indicator(false), + StorySection::new() + .child(StoryItem::new( + "With free availability indicator", + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") + .indicator(AvatarAvailabilityIndicator::new(Availability::Free)), + )) + .child(StoryItem::new( + "With busy availability indicator", + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") + .indicator(AvatarAvailabilityIndicator::new(Availability::Busy)), + )), + ) + .child( + StorySection::new() + .child(StoryItem::new( + "With info border", + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") + .border_color(cx.theme().status().info_border), + )) + .child(StoryItem::new( + "With error border", + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") + .border_color(cx.theme().status().error_border), + )), + ) + .child( + StorySection::new() + .child(StoryItem::new( + "With muted audio indicator", + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") + .indicator(AvatarAudioStatusIndicator::new(AudioStatus::Muted)), + )) + .child(StoryItem::new( + "With deafened audio indicator", + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") + .indicator(AvatarAudioStatusIndicator::new(AudioStatus::Deafened)), + )), ) } } diff --git a/crates/ui/src/components/stories/icon_button.rs b/crates/ui/src/components/stories/icon_button.rs index df9f37b164782f35c9a2ca1cfba6aa96d8783d60..ba3d5fd8660988298a38307cb8a690d1a365c7f4 100644 --- a/crates/ui/src/components/stories/icon_button.rs +++ b/crates/ui/src/components/stories/icon_button.rs @@ -1,7 +1,7 @@ use gpui::Render; use story::{StoryContainer, StoryItem, StorySection}; -use crate::{prelude::*, Tooltip}; +use crate::{prelude::*, IconButtonShape, Tooltip}; use crate::{IconButton, IconName}; pub struct IconButtonStory; @@ -115,7 +115,34 @@ impl Render for IconButtonStory { "Icon Button", "crates/ui2/src/components/stories/icon_button.rs", ) - .children(vec![StorySection::new().children(buttons)]) + .child(StorySection::new().children(buttons)) + .child( + StorySection::new().child(StoryItem::new( + "Square", + h_flex() + .gap_2() + .child( + IconButton::new("square-medium", IconName::Close) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Medium), + ) + .child( + IconButton::new("square-small", IconName::Close) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small), + ) + .child( + IconButton::new("square-xsmall", IconName::Close) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall), + ) + .child( + IconButton::new("square-indicator", IconName::Close) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Indicator), + ), + )), + ) .into_element() } } diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index b23c49c9a3fd4f23d1d547f7165df64cb33c1f75..fa2dcb45cda61f4637b5bcb6ac0cd1d43236def3 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -71,6 +71,30 @@ async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) { assert_eq!(cx.mode(), Mode::Normal); } +#[gpui::test] +async fn test_cancel_selection(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {"The quick brown fox juˇmps over the lazy dog"}, + Mode::Normal, + ); + // jumps + cx.simulate_keystrokes(["v", "l", "l"]); + cx.assert_editor_state("The quick brown fox ju«mpsˇ» over the lazy dog"); + + cx.simulate_keystrokes(["escape"]); + cx.assert_editor_state("The quick brown fox jumpˇs over the lazy dog"); + + // go back to the same selection state + cx.simulate_keystrokes(["v", "h", "h"]); + cx.assert_editor_state("The quick brown fox ju«ˇmps» over the lazy dog"); + + // Ctrl-[ should behave like Esc + cx.simulate_keystrokes(["ctrl-["]); + cx.assert_editor_state("The quick brown fox juˇmps over the lazy dog"); +} + #[gpui::test] async fn test_buffer_search(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/welcome/src/welcome.rs b/crates/welcome/src/welcome.rs index 677b57a0225059430b141ca23573d42433f52b77..53b78b917fec1d43c8c194a1b1bf0ee8198fe2a5 100644 --- a/crates/welcome/src/welcome.rs +++ b/crates/welcome/src/welcome.rs @@ -60,190 +60,197 @@ pub struct WelcomePage { impl Render for WelcomePage { fn render(&mut self, cx: &mut gpui::ViewContext) -> impl IntoElement { - h_flex().full().track_focus(&self.focus_handle).child( - v_flex() - .w_96() - .gap_4() - .mx_auto() - .child( - svg() - .path("icons/logo_96.svg") - .text_color(gpui::white()) - .w(px(96.)) - .h(px(96.)) - .mx_auto(), - ) - .child( - h_flex() - .justify_center() - .child(Label::new("Code at the speed of thought")), - ) - .child( - v_flex() - .gap_2() - .child( - Button::new("choose-theme", "Choose a theme") - .full_width() - .on_click(cx.listener(|this, _, cx| { - this.telemetry - .report_app_event("welcome page: change theme".to_string()); - this.workspace - .update(cx, |workspace, cx| { - theme_selector::toggle( - workspace, - &Default::default(), - cx, - ) - }) - .ok(); - })), - ) - .child( - Button::new("choose-keymap", "Choose a keymap") - .full_width() - .on_click(cx.listener(|this, _, cx| { - this.telemetry.report_app_event( - "welcome page: change keymap".to_string(), - ); - this.workspace - .update(cx, |workspace, cx| { - base_keymap_picker::toggle( - workspace, - &Default::default(), - cx, - ) - }) - .ok(); - })), - ) - .child( - Button::new("install-cli", "Install the CLI") - .full_width() - .on_click(cx.listener(|this, _, cx| { - this.telemetry - .report_app_event("welcome page: install cli".to_string()); - cx.app_mut() - .spawn( - |cx| async move { install_cli::install_cli(&cx).await }, + h_flex() + .full() + .bg(cx.theme().colors().editor_background) + .track_focus(&self.focus_handle) + .child( + v_flex() + .w_96() + .gap_4() + .mx_auto() + .child( + svg() + .path("icons/logo_96.svg") + .text_color(gpui::white()) + .w(px(96.)) + .h(px(96.)) + .mx_auto(), + ) + .child( + h_flex() + .justify_center() + .child(Label::new("Code at the speed of thought")), + ) + .child( + v_flex() + .gap_2() + .child( + Button::new("choose-theme", "Choose a theme") + .full_width() + .on_click(cx.listener(|this, _, cx| { + this.telemetry.report_app_event( + "welcome page: change theme".to_string(), + ); + this.workspace + .update(cx, |workspace, cx| { + theme_selector::toggle( + workspace, + &Default::default(), + cx, + ) + }) + .ok(); + })), + ) + .child( + Button::new("choose-keymap", "Choose a keymap") + .full_width() + .on_click(cx.listener(|this, _, cx| { + this.telemetry.report_app_event( + "welcome page: change keymap".to_string(), + ); + this.workspace + .update(cx, |workspace, cx| { + base_keymap_picker::toggle( + workspace, + &Default::default(), + cx, + ) + }) + .ok(); + })), + ) + .child( + Button::new("install-cli", "Install the CLI") + .full_width() + .on_click(cx.listener(|this, _, cx| { + this.telemetry.report_app_event( + "welcome page: install cli".to_string(), + ); + cx.app_mut() + .spawn(|cx| async move { + install_cli::install_cli(&cx).await + }) + .detach_and_log_err(cx); + })), + ), + ) + .child( + v_flex() + .p_3() + .gap_2() + .bg(cx.theme().colors().elevated_surface_background) + .border_1() + .border_color(cx.theme().colors().border) + .rounded_md() + .child( + h_flex() + .gap_2() + .child( + Checkbox::new( + "enable-vim", + if VimModeSetting::get_global(cx).0 { + ui::Selection::Selected + } else { + ui::Selection::Unselected + }, ) - .detach_and_log_err(cx); - })), - ), - ) - .child( - v_flex() - .p_3() - .gap_2() - .bg(cx.theme().colors().elevated_surface_background) - .border_1() - .border_color(cx.theme().colors().border) - .rounded_md() - .child( - h_flex() - .gap_2() - .child( - Checkbox::new( - "enable-vim", - if VimModeSetting::get_global(cx).0 { - ui::Selection::Selected - } else { - ui::Selection::Unselected - }, + .on_click( + cx.listener(move |this, selection, cx| { + this.telemetry.report_app_event( + "welcome page: toggle vim".to_string(), + ); + this.update_settings::( + selection, + cx, + |setting, value| *setting = Some(value), + ); + }), + ), ) - .on_click(cx.listener( - move |this, selection, cx| { - this.telemetry.report_app_event( - "welcome page: toggle vim".to_string(), - ); - this.update_settings::( - selection, - cx, - |setting, value| *setting = Some(value), - ); - }, - )), - ) - .child(Label::new("Enable vim mode")), - ) - .child( - h_flex() - .gap_2() - .child( - Checkbox::new( - "enable-telemetry", - if TelemetrySettings::get_global(cx).metrics { - ui::Selection::Selected - } else { - ui::Selection::Unselected - }, - ) - .on_click(cx.listener( - move |this, selection, cx| { - this.telemetry.report_app_event( - "welcome page: toggle metric telemetry".to_string(), - ); - this.update_settings::( - selection, - cx, - { - let telemetry = this.telemetry.clone(); + .child(Label::new("Enable vim mode")), + ) + .child( + h_flex() + .gap_2() + .child( + Checkbox::new( + "enable-telemetry", + if TelemetrySettings::get_global(cx).metrics { + ui::Selection::Selected + } else { + ui::Selection::Unselected + }, + ) + .on_click( + cx.listener(move |this, selection, cx| { + this.telemetry.report_app_event( + "welcome page: toggle metric telemetry" + .to_string(), + ); + this.update_settings::( + selection, + cx, + { + let telemetry = this.telemetry.clone(); - move |settings, value| { - settings.metrics = Some(value); + move |settings, value| { + settings.metrics = Some(value); - telemetry.report_setting_event( - "metric telemetry", - value.to_string(), - ); - } - }, - ); - }, - )), - ) - .child(Label::new("Send anonymous usage data")), - ) - .child( - h_flex() - .gap_2() - .child( - Checkbox::new( - "enable-crash", - if TelemetrySettings::get_global(cx).diagnostics { - ui::Selection::Selected - } else { - ui::Selection::Unselected - }, + telemetry.report_setting_event( + "metric telemetry", + value.to_string(), + ); + } + }, + ); + }), + ), ) - .on_click(cx.listener( - move |this, selection, cx| { - this.telemetry.report_app_event( - "welcome page: toggle diagnostic telemetry" - .to_string(), - ); - this.update_settings::( - selection, - cx, - { - let telemetry = this.telemetry.clone(); + .child(Label::new("Send anonymous usage data")), + ) + .child( + h_flex() + .gap_2() + .child( + Checkbox::new( + "enable-crash", + if TelemetrySettings::get_global(cx).diagnostics { + ui::Selection::Selected + } else { + ui::Selection::Unselected + }, + ) + .on_click( + cx.listener(move |this, selection, cx| { + this.telemetry.report_app_event( + "welcome page: toggle diagnostic telemetry" + .to_string(), + ); + this.update_settings::( + selection, + cx, + { + let telemetry = this.telemetry.clone(); - move |settings, value| { - settings.diagnostics = Some(value); + move |settings, value| { + settings.diagnostics = Some(value); - telemetry.report_setting_event( - "diagnostic telemetry", - value.to_string(), - ); - } - }, - ); - }, - )), - ) - .child(Label::new("Send crash reports")), - ), - ), - ) + telemetry.report_setting_event( + "diagnostic telemetry", + value.to_string(), + ); + } + }, + ); + }), + ), + ) + .child(Label::new("Send crash reports")), + ), + ), + ) } } diff --git a/crates/workspace/src/modal_layer.rs b/crates/workspace/src/modal_layer.rs index d940f1d16842a712bb8aaef23281c5e8ab06217f..c30ca35a68578cc3c8a77f4bbf3fa702291181fa 100644 --- a/crates/workspace/src/modal_layer.rs +++ b/crates/workspace/src/modal_layer.rs @@ -27,7 +27,7 @@ impl ModalViewHandle for View { pub struct ActiveModal { modal: Box, - _subscription: Subscription, + _subscriptions: [Subscription; 2], previous_focus_handle: Option, focus_handle: FocusHandle, } @@ -61,13 +61,19 @@ impl ModalLayer { where V: ModalView, { + let focus_handle = cx.focus_handle(); self.active_modal = Some(ActiveModal { modal: Box::new(new_modal.clone()), - _subscription: cx.subscribe(&new_modal, |this, _, _: &DismissEvent, cx| { - this.hide_modal(cx); - }), + _subscriptions: [ + cx.subscribe(&new_modal, |this, _, _: &DismissEvent, cx| { + this.hide_modal(cx); + }), + cx.on_focus_out(&focus_handle, |this, cx| { + this.hide_modal(cx); + }), + ], previous_focus_handle: cx.focused(), - focus_handle: cx.focus_handle(), + focus_handle, }); cx.focus_view(&new_modal); cx.notify(); @@ -108,7 +114,7 @@ impl ModalLayer { } impl Render for ModalLayer { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + fn render(&mut self, _: &mut ViewContext) -> impl IntoElement { let Some(active_modal) = &self.active_modal else { return div(); }; @@ -127,13 +133,7 @@ impl Render for ModalLayer { .flex_col() .items_center() .track_focus(&active_modal.focus_handle) - .child( - h_flex() - .on_mouse_down_out(cx.listener(|this, _, cx| { - this.hide_modal(cx); - })) - .child(active_modal.modal.view()), - ), + .child(h_flex().child(active_modal.modal.view())), ) } } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 655acc29c004e5750a7d949ab1f01088ee04ad58..a1f3e6992aef51291fc66b9f4092e3dbd24c7be6 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -99,6 +99,7 @@ actions!( CloseItemsToTheLeft, CloseItemsToTheRight, GoBack, + DeploySearch, GoForward, ReopenClosedItem, SplitLeft, diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index ce58e51678c589c39e97666d71ccc097bc0a5324..e631cd9c436b6918a974d2afdcc7ac9d4aa37520 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -3,14 +3,14 @@ use anyhow::{anyhow, Result}; use call::{ActiveCall, ParticipantLocation}; use collections::HashMap; use gpui::{ - point, size, AnyView, AnyWeakView, Axis, Bounds, Entity as _, IntoElement, Model, Pixels, + point, size, AnyView, AnyWeakView, Axis, Bounds, IntoElement, Model, MouseButton, Pixels, Point, View, ViewContext, }; use parking_lot::Mutex; use project::Project; use serde::Deserialize; use std::sync::Arc; -use ui::{prelude::*, Button}; +use ui::prelude::*; pub const HANDLE_HITBOX_SIZE: f32 = 4.0; const HORIZONTAL_MIN_SIZE: f32 = 80.; @@ -183,6 +183,7 @@ impl Member { let mut leader_border = None; let mut leader_status_box = None; + let mut leader_join_data = None; if let Some(leader) = &leader { let mut leader_color = cx .theme() @@ -199,44 +200,21 @@ impl Member { if Some(leader_project_id) == project.read(cx).remote_id() { None } else { - let leader_user = leader.user.clone(); - let leader_user_id = leader.user.id; - Some( - Button::new( - ("leader-status", pane.entity_id()), - format!( - "Follow {} to their active project", - leader_user.github_login, - ), - ) - .on_click(cx.listener( - move |this, _, cx| { - crate::join_remote_project( - leader_project_id, - leader_user_id, - this.app_state().clone(), - cx, - ) - .detach_and_log_err(cx); - }, - )), - ) + leader_join_data = Some((leader_project_id, leader.user.id)); + Some(Label::new(format!( + "Follow {} to their active project", + leader.user.github_login, + ))) } } - ParticipantLocation::UnsharedProject => Some(Button::new( - ("leader-status", pane.entity_id()), - format!( - "{} is viewing an unshared Zed project", - leader.user.github_login - ), - )), - ParticipantLocation::External => Some(Button::new( - ("leader-status", pane.entity_id()), - format!( - "{} is viewing a window outside of Zed", - leader.user.github_login - ), - )), + ParticipantLocation::UnsharedProject => Some(Label::new(format!( + "{} is viewing an unshared Zed project", + leader.user.github_login + ))), + ParticipantLocation::External => Some(Label::new(format!( + "{} is viewing a window outside of Zed", + leader.user.github_login + ))), }; } @@ -263,8 +241,27 @@ impl Member { .w_96() .bottom_3() .right_3() + .elevation_2(cx) + .p_1() .z_index(1) - .child(status_box), + .child(status_box) + .when_some( + leader_join_data, + |this, (leader_project_id, leader_user_id)| { + this.cursor_pointer().on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, cx| { + crate::join_remote_project( + leader_project_id, + leader_user_id, + this.app_state().clone(), + cx, + ) + .detach_and_log_err(cx); + }), + ) + }, + ), ) }) .into_any() diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index a1fb954d470ca9fd5a788f2c272274844dfd7ed4..1d06db5de3b459b289d3b0392c3fb91ac594a760 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -25,7 +25,7 @@ use futures::{ Future, FutureExt, StreamExt, }; use gpui::{ - actions, canvas, div, impl_actions, point, size, Action, AnyElement, AnyModel, AnyView, + actions, canvas, div, impl_actions, point, px, size, Action, AnyElement, AnyModel, AnyView, AnyWeakView, AppContext, AsyncAppContext, AsyncWindowContext, BorrowWindow, Bounds, Context, Div, DragMoveEvent, Element, Entity, EntityId, EventEmitter, FocusHandle, FocusableView, GlobalPixels, InteractiveElement, IntoElement, KeyContext, LayoutId, ManagedView, Model, @@ -108,7 +108,6 @@ actions!( NewCenterTerminal, ToggleTerminalFocus, NewSearch, - DeploySearch, Feedback, Restart, Welcome, @@ -4303,6 +4302,10 @@ fn parse_pixel_size_env_var(value: &str) -> Option> { Some(size((width as f64).into(), (height as f64).into())) } +pub fn titlebar_height(cx: &mut WindowContext) -> Pixels { + (1.75 * cx.rem_size()).max(px(32.)) +} + struct DisconnectedOverlay; impl Element for DisconnectedOverlay { @@ -4319,7 +4322,7 @@ impl Element for DisconnectedOverlay { .bg(background) .absolute() .left_0() - .top_0() + .top(titlebar_height(cx)) .size_full() .flex() .items_center() diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index e8daa342be7333d03068fb4159b9a46c4354bd60..42b789d6de4ae6964bbd124c077fd238a45e28db 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -29,6 +29,7 @@ command_palette = { path = "../command_palette" } # component_test = { path = "../component_test" } client = { path = "../client" } # clock = { path = "../clock" } +color = { path = "../color" } copilot = { path = "../copilot" } copilot_ui = { path = "../copilot_ui" } diagnostics = { path = "../diagnostics" }