wip
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m25s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m13s

This commit is contained in:
2026-01-31 09:59:35 +07:00
parent 4336c67d8c
commit 9b03f873c2
28 changed files with 437 additions and 797 deletions

17
Cargo.lock generated
View File

@@ -1302,7 +1302,6 @@ dependencies = [
"gpui_tokio",
"indexset",
"itertools 0.13.0",
"key_store",
"log",
"nostr-connect",
"nostr-sdk",
@@ -3341,22 +3340,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "key_store"
version = "0.3.0"
dependencies = [
"anyhow",
"common",
"futures",
"gpui",
"log",
"nostr-sdk",
"serde",
"serde_json",
"smallvec",
"smol",
]
[[package]]
name = "khronos-egl"
version = "6.0.0"

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M9.8007 10.25C8.74816 10.25 8.16683 11.4713 8.83056 12.2882L11.0301 14.9953C11.5303 15.611 12.4701 15.611 12.9704 14.9953L15.1699 12.2882C15.8336 11.4713 15.2523 10.25 14.1997 10.25H9.8007Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 302 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12.7507 3.75C12.7507 3.33579 12.4149 3 12.0007 3C11.5865 3 11.2507 3.33579 11.2507 3.75V6.25C11.2507 6.66421 11.5865 7 12.0007 7C12.4149 7 12.7507 6.66421 12.7507 6.25V3.75Z" fill="currentColor"/><path d="M7.26594 5.19799C6.99969 4.88068 6.52662 4.8393 6.20932 5.10555C5.89201 5.3718 5.85062 5.84487 6.11687 6.16217L7.72384 8.07728C7.99009 8.39459 8.46316 8.43598 8.78047 8.16973C9.09777 7.90347 9.13916 7.43041 8.87291 7.1131L7.26594 5.19799Z" fill="currentColor"/><path d="M17.8726 6.16227C18.1389 5.84496 18.0975 5.37189 17.7802 5.10564C17.4629 4.83939 16.9898 4.88078 16.7235 5.19809L15.1166 7.1132C14.8503 7.4305 14.8917 7.90357 15.209 8.16982C15.5263 8.43607 15.9994 8.39468 16.2656 8.07738L17.8726 6.16227Z" fill="currentColor"/><path d="M5.22073 9C4.33013 9 3.52687 9.5355 3.18434 10.3576L2.78846 11.3077C2.61378 11.7269 2.20416 12 1.75 12C1.33579 12 1 12.3358 1 12.75V19.25C1 19.6642 1.33579 20 1.75 20H7.38937C9.39779 20 11.0763 18.4715 11.2637 16.4719L11.4255 14.746C11.6391 12.468 9.84697 10.5 7.559 10.5C7.46053 10.5 7.36858 10.4508 7.31396 10.3689L7.0563 9.98237C6.64715 9.36864 5.95834 9 5.22073 9Z" fill="currentColor"/><path d="M18.722 9C17.9844 9 17.2956 9.36864 16.8865 9.98237L16.6288 10.3689C16.5742 10.4508 16.4822 10.5 16.3838 10.5C14.0958 10.5 12.3037 12.468 12.5172 14.746L12.679 16.4719C12.8665 18.4715 14.545 20 16.5534 20H22.1928C22.607 20 22.9428 19.6642 22.9428 19.25V12.75C22.9428 12.3358 22.607 12 22.1928 12C21.7386 12 21.329 11.7269 21.1543 11.3077L20.7584 10.3576C20.4159 9.5355 19.6126 9 18.722 9Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M1.75 19.25H7.38937C9.0107 19.25 10.3657 18.0161 10.517 16.4019L10.6788 14.676C10.8511 12.8379 9.40511 11.25 7.559 11.25C7.20977 11.25 6.88364 11.0755 6.68992 10.7849L6.43226 10.3984C6.16221 9.99331 5.70757 9.75 5.22073 9.75C4.6329 9.75 4.10273 10.1034 3.87664 10.6461L3.48077 11.5962C3.18964 12.2949 2.50694 12.75 1.75 12.75M22.1928 19.25H16.5534C14.9321 19.25 13.5771 18.0161 13.4258 16.4019L13.264 14.676C13.0916 12.8379 14.5377 11.25 16.3838 11.25C16.733 11.25 17.0591 11.0755 17.2528 10.7849L17.5105 10.3984C17.7806 9.99331 18.2352 9.75 18.722 9.75C19.3099 9.75 19.84 10.1034 20.0661 10.6461L20.462 11.5962C20.7531 12.2949 21.4358 12.75 22.1928 12.75M12.0007 3.75V6.25M6.69141 5.68008L8.29838 7.59519M17.2981 5.68018L15.6911 7.59529" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 918 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.00001 13.7453L3.00004 5.75C3.00004 4.23122 4.23126 3.00001 5.75004 3L18.25 3C19.7688 3 21 4.23122 21 5.75V13.7466C21 13.7477 21 13.7489 21 13.75L21 18.25C21 19.7688 19.7688 21 18.25 21H5.75C4.32614 21 3.15502 19.9179 3.0142 18.5312C3.00481 18.4387 3 18.3449 3 18.25M5.75004 4.5L18.25 4.5C18.9403 4.5 19.5 5.05964 19.5 5.75V13L15.9298 13C15.5695 13 15.2601 13.2562 15.1929 13.6102C14.9078 15.1135 13.5858 16.25 12 16.25C10.4142 16.25 9.09221 15.1135 8.80706 13.6102C8.73991 13.2562 8.43051 13 8.0702 13H4.50002L4.50004 5.75C4.50004 5.05965 5.05968 4.50001 5.75004 4.5Z" fill="currentColor"/><path d="M3 18.25V13.75C3 13.7484 3 13.7469 3.00001 13.7453" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 805 B

3
assets/icons/inbox.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M3.75 12.75H8.0702C8.42126 14.6006 10.0472 16 12 16C13.9528 16 15.5787 14.6006 15.9298 12.75L20.25 12.75M18.25 20.25H5.75001C4.64543 20.25 3.75 19.3546 3.75001 18.25L3.75005 5.75C3.75005 4.64543 4.64548 3.75001 5.75005 3.75L18.25 3.75C19.3546 3.75 20.25 4.64543 20.25 5.75V18.25C20.25 19.3546 19.3546 20.25 18.25 20.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@@ -309,27 +309,21 @@ impl ChatRegistry {
.map(|this| this.downgrade())
}
/// Get all rooms.
pub fn rooms(&self, _cx: &App) -> &Vec<Entity<Room>> {
&self.rooms
}
/// Get all ongoing rooms.
pub fn ongoing_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
/// Get all rooms based on the filter.
pub fn rooms(&self, filter: &RoomKind, cx: &App) -> Vec<Entity<Room>> {
self.rooms
.iter()
.filter(|room| room.read(cx).kind == RoomKind::Ongoing)
.filter(|room| &room.read(cx).kind == filter)
.cloned()
.collect()
}
/// Get all request rooms.
pub fn request_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
/// Count the number of rooms based on the filter.
pub fn count(&self, filter: &RoomKind, cx: &App) -> usize {
self.rooms
.iter()
.filter(|room| room.read(cx).kind != RoomKind::Ongoing)
.cloned()
.collect()
.filter(|room| &room.read(cx).kind == filter)
.count()
}
/// Add a new room to the start of list.

View File

@@ -26,6 +26,3 @@ pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
/// Default timeout (in seconds) for Nostr Connect (Bunker)
pub const BUNKER_TIMEOUT: u64 = 30;
/// Default width of the sidebar.
pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.;

View File

@@ -35,7 +35,6 @@ theme = { path = "../theme" }
common = { path = "../common" }
state = { path = "../state" }
device = { path = "../device" }
key_store = { path = "../key_store" }
chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
settings = { path = "../settings" }

View File

@@ -1,6 +1,4 @@
use gpui::{actions, App};
use key_store::{KeyItem, KeyStore};
use state::NostrRegistry;
use gpui::actions;
// Sidebar actions
actions!(sidebar, [Reload, RelayStatus]);
@@ -19,30 +17,3 @@ actions!(
Quit
]
);
pub fn reset(cx: &mut App) {
let backend = KeyStore::global(cx).read(cx).backend();
let client = NostrRegistry::global(cx).read(cx).client();
cx.spawn(async move |cx| {
// Remove the signer
client.unset_signer().await;
// Delete user's credentials
backend
.delete_credentials(&KeyItem::User.to_string(), cx)
.await
.ok();
// Remove bunker's credentials if available
backend
.delete_credentials(&KeyItem::Bunker.to_string(), cx)
.await
.ok();
cx.update(|cx| {
cx.restart();
});
})
.detach();
}

View File

@@ -82,9 +82,6 @@ fn main() {
// Initialize theme registry
theme::init(cx);
// Initialize backend for keys storage
key_store::init(cx);
// Initialize the nostr client
state::init(cx);

View File

@@ -37,20 +37,13 @@ impl Panel for GreeterPanel {
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1p5()
div()
.child(
svg()
.path("brand/coop.svg")
.size_4()
.text_color(cx.theme().text_muted),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(self.name.clone()),
)
.into_any_element()
}
}

View File

@@ -125,14 +125,7 @@ impl RenderOnce for RoomListItem {
.flex_shrink_0()
.text_xs()
.text_color(cx.theme().text_placeholder)
.when_some(self.created_at, |this, created_at| this.child(created_at))
.when_some(self.kind, |this, kind| {
this.when(kind == RoomKind::Request, |this| {
this.child(
div().size_1().rounded_full().bg(cx.theme().icon_accent),
)
})
}),
.when_some(self.created_at, |this, created_at| this.child(created_at)),
),
)
.hover(|this| this.bg(cx.theme().elevated_surface_background))

View File

@@ -1,36 +1,23 @@
use std::ops::Range;
use std::time::Duration;
use anyhow::{anyhow, Error};
use chat::{ChatEvent, ChatRegistry, Room};
use common::{DebouncedDelay, RenderedTimestamp, TextUtils, BOOTSTRAP_RELAYS, SEARCH_RELAYS};
use chat::{ChatEvent, ChatRegistry, RoomKind};
use common::RenderedTimestamp;
use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder;
use gpui::{
deferred, div, rems, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
deferred, div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled,
Subscription, Task, Window,
Subscription, Window,
};
use gpui_tokio::Tokio;
use list_item::RoomListItem;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::{ActiveTheme, TITLEBAR_HEIGHT};
use ui::avatar::Avatar;
use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::input::{InputEvent, InputState, TextInput};
use ui::{h_flex, v_flex, IconName, Sizable, StyledExt, WindowExtension};
use crate::dialogs::compose::compose_button;
use ui::{h_flex, v_flex, IconName, Selectable, Sizable, StyledExt};
mod list_item;
const FIND_DELAY: u64 = 600;
const FIND_LIMIT: usize = 20;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
cx.new(|cx| Sidebar::new(window, cx))
}
@@ -38,48 +25,25 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
/// Sidebar.
pub struct Sidebar {
name: SharedString,
/// Focus handle for the sidebar
focus_handle: FocusHandle,
/// Image cache
image_cache: Entity<RetainAllImageCache>,
/// Search results
search_results: Entity<Option<Vec<Entity<Room>>>>,
/// Whether there are new chat requests
new_requests: bool,
/// Async search operation
search_task: Option<Task<()>>,
/// Search input state
find_input: Entity<InputState>,
/// Debounced delay for search input
find_debouncer: DebouncedDelay<Self>,
/// Whether searching is in progress
finding: bool,
/// New request flag
new_request: bool,
/// Chatroom filter
filter: Entity<RoomKind>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl Sidebar {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let search_results = cx.new(|_| None);
// Define the find input state
let find_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Find or start a conversation")
.clean_on_escape()
});
// Get the chat registry
let chat = ChatRegistry::global(cx);
let filter = cx.new(|_| RoomKind::Ongoing);
let mut subscriptions = smallvec![];
@@ -87,52 +51,23 @@ impl Sidebar {
// Subscribe for registry new events
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
if event == &ChatEvent::Ping {
this.new_request = true;
this.new_requests = true;
cx.notify();
};
}),
);
subscriptions.push(
// Subscribe for find input events
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
let delay = Duration::from_millis(FIND_DELAY);
match event {
InputEvent::PressEnter { .. } => {
this.search(window, cx);
}
InputEvent::Change => {
if state.read(cx).value().is_empty() {
// Clear the result when input is empty
this.clear(window, cx);
} else {
// Run debounced search
this.find_debouncer
.fire_new(delay, window, cx, |this, window, cx| {
this.debounced_search(window, cx)
});
}
}
_ => {}
};
}),
);
Self {
name: "Sidebar".into(),
focus_handle: cx.focus_handle(),
image_cache: RetainAllImageCache::new(cx),
find_debouncer: DebouncedDelay::new(),
finding: false,
new_request: false,
find_input,
search_results,
search_task: None,
new_requests: false,
filter,
_subscriptions: subscriptions,
}
}
/*
async fn nip50(client: &Client, query: &str) -> Result<Vec<Event>, Error> {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
@@ -405,31 +340,30 @@ impl Sidebar {
cx.notify();
});
}
*/
fn open(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
/// Get the active filter.
fn current_filter(&self, kind: &RoomKind, cx: &Context<Self>) -> bool {
self.filter.read(cx) == kind
}
/// Set the active filter for the sidebar.
fn set_filter(&mut self, kind: RoomKind, cx: &mut Context<Self>) {
self.filter.update(cx, |this, cx| {
*this = kind;
cx.notify();
});
self.new_requests = false;
}
/// Open a room.
fn open(&mut self, id: u64, _window: &mut Window, cx: &mut Context<Self>) {
let chat = ChatRegistry::global(cx);
match chat.read(cx).room(&id, cx) {
Some(room) => {
chat.update(cx, |this, cx| {
this.emit_room(room, cx);
});
}
None => {
if let Some(room) = self
.search_results
.read(cx)
.as_ref()
.and_then(|results| results.iter().find(|this| this.read(cx).id == id))
.map(|this| this.downgrade())
{
chat.update(cx, |this, cx| {
this.emit_room(room, cx);
});
// Clear all search results
self.clear(window, cx);
}
}
if let Some(room) = chat.read(cx).room(&id, cx) {
chat.update(cx, |this, cx| {
this.emit_room(room, cx);
});
}
}
@@ -439,15 +373,8 @@ impl Sidebar {
_window: &Window,
cx: &Context<Self>,
) -> Vec<impl IntoElement> {
// Get rooms from search results first
let rooms = match self.search_results.read(cx).as_ref() {
Some(results) => results,
None => {
// Fallback to chat registry if there are no search results
let chat = ChatRegistry::global(cx);
chat.read(cx).rooms(cx)
}
};
let chat = ChatRegistry::global(cx);
let rooms = chat.read(cx).rooms(self.filter.read(cx), cx);
rooms
.get(range.clone())
@@ -456,8 +383,8 @@ impl Sidebar {
.enumerate()
.map(|(ix, item)| {
let room = item.read(cx);
let id = room.id;
let public_key = room.display_member(cx).public_key();
let id = room.id;
let handler = cx.listener({
move |this, _ev, window, cx| {
@@ -494,73 +421,89 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let identity = nostr.read(cx).identity();
let chat = ChatRegistry::global(cx);
let loading = chat.read(cx).loading();
// Get total rooms
let total_rooms = match self.search_results.read(cx).as_ref() {
Some(results) => results.len(),
None => chat.read(cx).rooms(cx).len(),
};
let total_rooms = chat.read(cx).count(self.filter.read(cx), cx);
v_flex()
.image_cache(self.image_cache.clone())
.size_full()
.relative()
.gap_2()
.bg(cx.theme().surface_background)
// Titlebar
.child(
h_flex()
.px_2p5()
.h(TABBAR_HEIGHT)
.w_full()
.border_b_1()
.border_color(cx.theme().border)
.child(
h_flex()
.w_full()
.flex_1()
.h_full()
.gap_2()
.justify_between()
.when_some(identity.read(cx).public_key, |this, public_key| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
this.child(
h_flex()
.gap_1p5()
.text_xs()
.text_color(cx.theme().text_muted)
.child(Avatar::new(profile.avatar()).size(rems(1.25)))
.child(profile.name()),
)
})
.child(compose_button()),
.p_2()
.justify_center()
.child(
Button::new("all")
.map(|this| {
if self.current_filter(&RoomKind::Ongoing, cx) {
this.icon(IconName::InboxFill)
} else {
this.icon(IconName::Inbox)
}
})
.label("Inbox")
.tooltip("All ongoing conversations")
.xsmall()
.bold()
.ghost()
.flex_1()
.rounded_none()
.selected(self.current_filter(&RoomKind::Ongoing, cx))
.on_click(cx.listener(|this, _, _, cx| {
this.set_filter(RoomKind::Ongoing, cx);
})),
)
.child(
Button::new("requests")
.map(|this| {
if self.current_filter(&RoomKind::Request, cx) {
this.icon(IconName::FistbumpFill)
} else {
this.icon(IconName::Fistbump)
}
})
.label("Requests")
.tooltip("Incoming new conversations")
.xsmall()
.bold()
.ghost()
.flex_1()
.rounded_none()
.selected(!self.current_filter(&RoomKind::Ongoing, cx))
.when(self.new_requests, |this| {
this.child(
div().size_1().rounded_full().bg(cx.theme().cursor),
)
})
.on_click(cx.listener(|this, _, _, cx| {
this.set_filter(RoomKind::default(), cx);
})),
),
)
.h(TITLEBAR_HEIGHT),
)
// Search Input
.child(
div().px_2p5().child(
TextInput::new(&self.find_input)
.small()
.cleanable()
.appearance(true)
.text_xs()
.flex_none()
.map(|this| {
if !self.find_input.read(cx).loading {
this.suffix(
Button::new("find")
.icon(IconName::Search)
.tooltip("Press Enter to search")
.transparent()
.small(),
)
} else {
this
}
}),
),
.child(
h_flex()
.h_full()
.px_2()
.border_l_1()
.border_color(cx.theme().border)
.child(
Button::new("option")
.icon(IconName::Ellipsis)
.small()
.ghost(),
),
),
)
.when(!loading && total_rooms == 0, |this| {
this.child(
@@ -590,7 +533,6 @@ impl Render for Sidebar {
)),
)
})
// Chat Rooms
.child(
v_flex()
.px_1p5()

View File

@@ -1,33 +1,36 @@
use std::sync::Arc;
use std::time::Duration;
use chat::{ChatEvent, ChatRegistry};
use chat_ui::{CopyPublicKey, OpenPublicKey};
use common::DEFAULT_SIDEBAR_WIDTH;
use chat::{ChatEvent, ChatRegistry, Room};
use common::DebouncedDelay;
use dock::dock::DockPlacement;
use dock::panel::PanelView;
use dock::{ClosePanel, DockArea, DockItem};
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, relative, App, AppContext, Axis, ClipboardItem, Context, Entity, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
div, rems, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, Styled, Subscription, Task, Window,
};
use nostr_connect::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use theme::{ActiveTheme, Theme, ThemeRegistry};
use state::NostrRegistry;
use theme::{ActiveTheme, Theme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT};
use titlebar::TitleBar;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::modal::ModalButtonProps;
use ui::{h_flex, v_flex, Root, Sizable, WindowExtension};
use ui::input::{InputEvent, InputState, TextInput};
use ui::{h_flex, v_flex, Icon, IconName, Root, Sizable, WindowExtension};
use crate::actions::{reset, KeyringPopup, Logout, Themes};
use crate::dialogs::profile;
use crate::panels::greeter;
use crate::sidebar;
const FIND_DELAY: u64 = 600;
const FIND_LIMIT: usize = 20;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
cx.new(|cx| Workspace::new(window, cx))
}
#[derive(Debug)]
pub struct Workspace {
/// App's Title Bar
titlebar: Entity<TitleBar>,
@@ -35,17 +38,46 @@ pub struct Workspace {
/// App's Dock Area
dock: Entity<DockArea>,
/// Search results
search_results: Entity<Option<Vec<Entity<Room>>>>,
/// Async search operation
search_task: Option<Task<()>>,
/// Search input state
find_input: Entity<InputState>,
/// Debounced delay for search input
find_debouncer: DebouncedDelay<Self>,
/// Whether searching is in progress
finding: bool,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 3]>,
_subscriptions: SmallVec<[Subscription; 4]>,
}
impl Workspace {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx);
// App's titlebar
let titlebar = cx.new(|_| TitleBar::new());
// App's dock area
let dock =
cx.new(|cx| DockArea::new(window, cx).panel_style(dock::panel::PanelStyle::TabBar));
// Define the find input state
let find_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Find or start a conversation")
.clean_on_escape()
});
// Define the search results states
let search_results = cx.new(|_| None);
let mut subscriptions = smallvec![];
subscriptions.push(
@@ -91,7 +123,7 @@ impl Workspace {
subscriptions.push(
// Observe the chat registry
cx.observe(&chat, move |this, chat, cx| {
let ids = this.get_all_panels(cx);
let ids = this.panel_ids(cx);
chat.update(cx, |this, cx| {
this.refresh_rooms(ids, cx);
@@ -99,6 +131,32 @@ impl Workspace {
}),
);
subscriptions.push(
// Subscribe for find input events
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
let delay = Duration::from_millis(FIND_DELAY);
match event {
InputEvent::PressEnter { .. } => {
// this.search(window, cx);
}
InputEvent::Change => {
if state.read(cx).value().is_empty() {
// Clear the result when input is empty
// this.clear(window, cx);
} else {
// Run debounced search
//this.find_debouncer
// .fire_new(delay, window, cx, |this, window, cx| {
// this.debounced_search(window, cx)
// });
}
}
_ => {}
};
}),
);
// Set the default layout for app's dock
cx.defer_in(window, |this, window, cx| {
this.set_layout(window, cx);
@@ -107,6 +165,11 @@ impl Workspace {
Self {
titlebar,
dock,
find_debouncer: DebouncedDelay::new(),
finding: false,
find_input,
search_results,
search_task: None,
_subscriptions: subscriptions,
}
}
@@ -126,6 +189,19 @@ impl Workspace {
}
}
fn panel_ids(&self, cx: &App) -> Option<Vec<u64>> {
let ids: Vec<u64> = self
.dock
.read(cx)
.items
.panel_ids(cx)
.into_iter()
.filter_map(|panel| panel.parse::<u64>().ok())
.collect();
Some(ids)
}
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let weak_dock = self.dock.downgrade();
@@ -150,123 +226,59 @@ impl Workspace {
// Update the dock layout
self.dock.update(cx, |this, cx| {
this.set_left_dock(left, Some(px(DEFAULT_SIDEBAR_WIDTH)), true, window, cx);
this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx);
this.set_center(center, window, cx);
});
}
fn on_themes(&mut self, _ev: &Themes, window: &mut Window, cx: &mut Context<Self>) {
window.open_modal(cx, move |this, _window, cx| {
let registry = ThemeRegistry::global(cx);
let themes = registry.read(cx).themes();
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let identity = nostr.read(cx).identity();
this.title("Select theme")
.show_close(true)
.overlay_closable(true)
.child(v_flex().gap_2().pb_4().children({
let mut items = Vec::with_capacity(themes.len());
h_flex()
.h(TITLEBAR_HEIGHT)
.flex_1()
.justify_between()
.gap_2()
.when_some(identity.read(cx).public_key, |this, public_key| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
for (name, theme) in themes.iter() {
items.push(
h_flex()
.h_10()
.justify_between()
.child(
v_flex()
.child(
div()
.text_sm()
.text_color(cx.theme().text)
.line_height(relative(1.3))
.child(theme.name.clone()),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(theme.author.clone()),
),
)
.child(
Button::new(format!("change-{name}"))
.label("Set")
.small()
.ghost()
.on_click({
let theme = theme.clone();
move |_ev, window, cx| {
Theme::apply_theme(theme.clone(), Some(window), cx);
}
}),
),
);
}
items
}))
})
}
fn on_sign_out(&mut self, _e: &Logout, _window: &mut Window, cx: &mut Context<Self>) {
reset(cx);
}
fn on_open_pubkey(&mut self, ev: &OpenPublicKey, window: &mut Window, cx: &mut Context<Self>) {
let public_key = ev.0;
let view = profile::init(public_key, window, cx);
window.open_modal(cx, move |this, _window, _cx| {
this.alert()
.show_close(true)
.overlay_closable(true)
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text("View on njump.me"))
.on_ok(move |_, _window, cx| {
let bech32 = public_key.to_bech32().unwrap();
let url = format!("https://njump.me/{bech32}");
// Open the URL in the default browser
cx.open_url(&url);
// false to keep the modal open
false
})
});
}
fn on_copy_pubkey(&mut self, ev: &CopyPublicKey, window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = ev.0.to_bech32();
cx.write_to_clipboard(ClipboardItem::new_string(bech32));
window.push_notification("Copied", cx);
}
fn on_keyring(&mut self, _ev: &KeyringPopup, window: &mut Window, cx: &mut Context<Self>) {
window.open_modal(cx, move |this, _window, _cx| {
this.show_close(true)
.title(SharedString::from("Keyring is disabled"))
.child(
v_flex()
.gap_2()
.pb_4()
.text_sm()
.child(SharedString::from("Coop cannot access the Keyring Service on your system. By design, Coop uses Keyring to store your credentials."))
.child(SharedString::from("Without access to Keyring, Coop will store your credentials as plain text."))
.child(SharedString::from("If you want to store your credentials in the Keyring, please enable Keyring and allow Coop to access it.")),
this.child(
h_flex()
.gap_0p5()
.child(Avatar::new(profile.avatar()).size(rems(1.25)))
.child(
Icon::new(IconName::ChevronDown)
.small()
.text_color(cx.theme().text_muted),
),
)
});
})
}
fn get_all_panels(&self, cx: &App) -> Option<Vec<u64>> {
let ids: Vec<u64> = self
.dock
.read(cx)
.items
.panel_ids(cx)
.into_iter()
.filter_map(|panel| panel.parse::<u64>().ok())
.collect();
fn titlebar_center(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
h_flex().flex_1().child(
TextInput::new(&self.find_input)
.xsmall()
.cleanable()
.appearance(true)
.bordered(false)
.text_xs()
.when(!self.find_input.read(cx).loading, |this| {
this.prefix(
Button::new("find")
.icon(IconName::Search)
.tooltip("Press Enter to search")
.transparent()
.small(),
)
}),
)
}
Some(ids)
fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context<Self>) -> impl IntoElement {
h_flex().flex_1()
}
}
@@ -275,13 +287,18 @@ impl Render for Workspace {
let modal_layer = Root::render_modal_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
// Titlebar elements
let left = self.titlebar_left(window, cx).into_any_element();
let center = self.titlebar_center(window, cx).into_any_element();
let right = self.titlebar_right(window, cx).into_any_element();
// Update title bar children
self.titlebar.update(cx, |this, _cx| {
this.set_children(vec![left, center, right]);
});
div()
.id(SharedString::from("workspace"))
.on_action(cx.listener(Self::on_themes))
.on_action(cx.listener(Self::on_sign_out))
.on_action(cx.listener(Self::on_open_pubkey))
.on_action(cx.listener(Self::on_copy_pubkey))
.on_action(cx.listener(Self::on_keyring))
.relative()
.size_full()
.child(

View File

@@ -3,7 +3,7 @@ use gpui::{
div, px, AnyElement, App, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, StatefulInteractiveElement, Styled, Window,
};
use theme::{ActiveTheme, TITLEBAR_HEIGHT};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::{Selectable, Sizable, Size};
pub mod tab_bar;
@@ -105,37 +105,37 @@ impl Sizable for Tab {
impl RenderOnce for Tab {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let (text_color, bg_color, hover_bg_color, border_color) =
let (text_color, hover_text_color, bg_color, border_color) =
match (self.selected, self.disabled) {
(true, false) => (
cx.theme().text,
cx.theme().tab_active_foreground,
cx.theme().tab_hover_foreground,
cx.theme().tab_active_background,
cx.theme().tab_hover_background,
cx.theme().border,
),
(false, false) => (
cx.theme().text_muted,
cx.theme().tab_inactive_foreground,
cx.theme().tab_hover_foreground,
cx.theme().ghost_element_background,
cx.theme().tab_hover_background,
cx.theme().border_transparent,
),
(true, true) => (
cx.theme().text_muted,
cx.theme().tab_inactive_foreground,
cx.theme().tab_hover_foreground,
cx.theme().ghost_element_background,
cx.theme().tab_hover_background,
cx.theme().border_disabled,
),
(false, true) => (
cx.theme().text_muted,
cx.theme().tab_inactive_foreground,
cx.theme().tab_hover_foreground,
cx.theme().ghost_element_background,
cx.theme().tab_hover_background,
cx.theme().border_disabled,
),
};
self.base
.id(self.ix)
.h(TITLEBAR_HEIGHT)
.h(TABBAR_HEIGHT)
.px_4()
.relative()
.flex()
@@ -151,7 +151,7 @@ impl RenderOnce for Tab {
.border_r(px(1.))
.border_color(border_color)
.when(!self.selected && !self.disabled, |this| {
this.hover(|this| this.text_color(text_color).bg(hover_bg_color))
this.hover(|this| this.text_color(hover_text_color))
})
.when_some(self.prefix, |this, prefix| {
this.child(prefix).text_color(text_color)

View File

@@ -91,18 +91,12 @@ impl Sizable for TabBar {
}
impl RenderOnce for TabBar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let focused = window.is_window_active();
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
self.base
.group("tab-bar")
.relative()
.refine_style(&self.style)
.bg(cx.theme().elevated_surface_background)
.when(!focused, |this| {
// TODO: add specific styles for unfocused tab bar
this.bg(cx.theme().elevated_surface_background)
})
.bg(cx.theme().surface_background)
.child(
div()
.id("border-bottom")

View File

@@ -7,7 +7,7 @@ use gpui::{
MouseButton, ParentElement, Pixels, Render, ScrollHandle, SharedString,
StatefulInteractiveElement, Styled, WeakEntity, Window,
};
use theme::{ActiveTheme, TITLEBAR_HEIGHT};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::button::{Button, ButtonVariants as _};
use ui::popup_menu::{PopupMenu, PopupMenuExt};
use ui::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt};
@@ -645,7 +645,7 @@ impl TabPanel {
TabBar::new()
.track_scroll(&self.tab_bar_scroll_handle)
.h(TITLEBAR_HEIGHT)
.h(TABBAR_HEIGHT)
.when(has_extend_dock_button, |this| {
this.prefix(
h_flex()
@@ -656,7 +656,7 @@ impl TabPanel {
.border_b_1()
.h_full()
.border_color(cx.theme().border)
.bg(cx.theme().elevated_surface_background)
.bg(cx.theme().surface_background)
.px_2()
.children(left_dock_button)
.children(bottom_dock_button),

View File

@@ -1,19 +0,0 @@
[package]
name = "key_store"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
futures.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -1,211 +0,0 @@
use std::any::Any;
use std::collections::HashMap;
use std::fmt::Display;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use anyhow::Result;
use common::config_dir;
use futures::FutureExt as _;
use gpui::AsyncApp;
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Credential {
public_key: PublicKey,
secret: String,
}
impl Credential {
pub fn new(user: String, secret: Vec<u8>) -> Self {
Self {
public_key: PublicKey::parse(&user).unwrap(),
secret: String::from_utf8(secret).unwrap(),
}
}
pub fn public_key(&self) -> PublicKey {
self.public_key
}
pub fn secret(&self) -> &str {
&self.secret
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum KeyItem {
User,
Bunker,
}
impl Display for KeyItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::User => write!(f, "coop-user"),
Self::Bunker => write!(f, "coop-bunker"),
}
}
}
impl From<KeyItem> for String {
fn from(item: KeyItem) -> Self {
item.to_string()
}
}
pub trait KeyBackend: Any + Send + Sync {
fn name(&self) -> &str;
/// Reads the credentials from the provider.
#[allow(clippy::type_complexity)]
fn read_credentials<'a>(
&'a self,
url: &'a str,
cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>>;
/// Writes the credentials to the provider.
fn write_credentials<'a>(
&'a self,
url: &'a str,
username: &'a str,
password: &'a [u8],
cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>>;
/// Deletes the credentials from the provider.
fn delete_credentials<'a>(
&'a self,
url: &'a str,
cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>>;
}
/// A credentials provider that stores credentials in the system keychain.
pub struct KeyringProvider;
impl KeyBackend for KeyringProvider {
fn name(&self) -> &str {
"keyring"
}
fn read_credentials<'a>(
&'a self,
url: &'a str,
cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>> {
async move { cx.update(|cx| cx.read_credentials(url)).await }.boxed_local()
}
fn write_credentials<'a>(
&'a self,
url: &'a str,
username: &'a str,
password: &'a [u8],
cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
async move {
cx.update(move |cx| cx.write_credentials(url, username, password))
.await
}
.boxed_local()
}
fn delete_credentials<'a>(
&'a self,
url: &'a str,
cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
async move { cx.update(move |cx| cx.delete_credentials(url)).await }.boxed_local()
}
}
/// A credentials provider that stores credentials in a local file.
pub struct FileProvider {
path: PathBuf,
}
impl FileProvider {
pub fn new() -> Self {
let path = config_dir().join(".keys");
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
Self { path }
}
pub fn load_credentials(&self) -> Result<HashMap<String, (String, Vec<u8>)>> {
let json = std::fs::read(&self.path)?;
let credentials: HashMap<String, (String, Vec<u8>)> = serde_json::from_slice(&json)?;
Ok(credentials)
}
pub fn save_credentials(&self, credentials: &HashMap<String, (String, Vec<u8>)>) -> Result<()> {
let json = serde_json::to_string(credentials)?;
std::fs::write(&self.path, json)?;
Ok(())
}
}
impl Default for FileProvider {
fn default() -> Self {
Self::new()
}
}
impl KeyBackend for FileProvider {
fn name(&self) -> &str {
"file"
}
fn read_credentials<'a>(
&'a self,
url: &'a str,
_cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>> {
async move {
Ok(self
.load_credentials()
.unwrap_or_default()
.get(url)
.cloned())
}
.boxed_local()
}
fn write_credentials<'a>(
&'a self,
url: &'a str,
username: &'a str,
password: &'a [u8],
_cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
async move {
let mut credentials = self.load_credentials().unwrap_or_default();
credentials.insert(url.to_string(), (username.to_string(), password.to_vec()));
self.save_credentials(&credentials)
}
.boxed_local()
}
fn delete_credentials<'a>(
&'a self,
url: &'a str,
_cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
async move {
let mut credentials = self.load_credentials()?;
credentials.remove(url);
self.save_credentials(&credentials)
}
.boxed_local()
}
}

View File

@@ -1,94 +0,0 @@
use std::sync::{Arc, LazyLock};
pub use backend::*;
use gpui::{App, AppContext, Context, Entity, Global, Task};
use smallvec::{smallvec, SmallVec};
mod backend;
static DISABLE_KEYRING: LazyLock<bool> =
LazyLock::new(|| std::env::var("DISABLE_KEYRING").is_ok_and(|value| !value.is_empty()));
pub fn init(cx: &mut App) {
KeyStore::set_global(cx.new(KeyStore::new), cx);
}
struct GlobalKeyStore(Entity<KeyStore>);
impl Global for GlobalKeyStore {}
pub struct KeyStore {
/// Key Store for storing credentials
pub backend: Arc<dyn KeyBackend>,
/// Whether the keystore has been initialized
pub initialized: bool,
/// Tasks for asynchronous operations
_tasks: SmallVec<[Task<()>; 1]>,
}
impl KeyStore {
/// Retrieve the global keys state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalKeyStore>().0.clone()
}
/// Set the global keys instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalKeyStore(state));
}
/// Create a new keys instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
// Use the file system for keystore in development or when the user specifies it
let use_file_keystore = cfg!(debug_assertions) || *DISABLE_KEYRING;
// Construct the key backend
let backend: Arc<dyn KeyBackend> = if use_file_keystore {
Arc::new(FileProvider::default())
} else {
Arc::new(KeyringProvider)
};
// Only used for testing keyring availability on the user's system
let read_credential = cx.read_credentials("Coop");
let mut tasks = smallvec![];
tasks.push(
// Verify the keyring availability
cx.spawn(async move |this, cx| {
let result = read_credential.await;
this.update(cx, |this, cx| {
if let Err(e) = result {
log::error!("Keyring error: {e}");
// For Linux:
// The user has not installed secret service on their system
// Fall back to the file provider
this.backend = Arc::new(FileProvider::default());
}
this.initialized = true;
cx.notify();
})
.ok();
}),
);
Self {
backend,
initialized: false,
_tasks: tasks,
}
}
/// Returns the key backend.
pub fn backend(&self) -> Arc<dyn KeyBackend> {
Arc::clone(&self.backend)
}
/// Returns true if the keystore is a file key backend.
pub fn is_using_file_keystore(&self) -> bool {
self.backend.name() == "file"
}
}

View File

@@ -75,8 +75,10 @@ pub struct ThemeColors {
// Tab colors
pub tab_inactive_background: Hsla,
pub tab_hover_background: Hsla,
pub tab_inactive_foreground: Hsla,
pub tab_active_background: Hsla,
pub tab_active_foreground: Hsla,
pub tab_hover_foreground: Hsla,
// Scrollbar colors
pub scrollbar_thumb_background: Hsla,
@@ -107,7 +109,7 @@ impl ThemeColors {
background: neutral().dark().step_1(),
surface_background: neutral().dark().step_2(),
elevated_surface_background: neutral().dark().step_3(),
panel_background: neutral().dark().step_1(),
panel_background: neutral().dark().step_3(),
overlay: neutral().dark_alpha().step_3(),
border: neutral().dark().step_6(),
@@ -163,8 +165,10 @@ impl ThemeColors {
ghost_element_disabled: neutral().dark_alpha().step_2(),
tab_inactive_background: neutral().dark().step_2(),
tab_hover_background: neutral().dark().step_3(),
tab_active_background: neutral().dark().step_1(),
tab_inactive_foreground: neutral().dark().step_11(),
tab_active_background: neutral().dark().step_3(),
tab_active_foreground: neutral().dark().step_12(),
tab_hover_foreground: brand().dark().step_9(),
scrollbar_thumb_background: neutral().dark_alpha().step_3(),
scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(),
@@ -176,8 +180,8 @@ impl ThemeColors {
cursor: hsl(200., 100., 50.),
selection: hsl(200., 100., 50.).alpha(0.25),
titlebar: neutral().dark().step_2(),
titlebar_inactive: neutral().dark().step_3(),
titlebar: neutral().dark_alpha().step_1(),
titlebar_inactive: neutral().dark_alpha().step_2(),
}
}
}

View File

@@ -29,6 +29,15 @@ pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
/// Defines window titlebar height
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
/// Defines tabbar height
pub const TABBAR_HEIGHT: Pixels = px(30.);
/// Defines default sidebar width
pub const SIDEBAR_WIDTH: Pixels = px(240.);
/// Defines search input width
pub const SEARCH_INPUT_WIDTH: Pixels = px(420.);
pub fn init(cx: &mut App) {
registry::init(cx);

View File

@@ -1,12 +1,11 @@
use std::mem;
use gpui::prelude::FluentBuilder;
#[cfg(target_os = "linux")]
use gpui::MouseButton;
#[cfg(not(target_os = "windows"))]
use gpui::Pixels;
use gpui::{
div, px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement,
ParentElement, Pixels, Render, StatefulInteractiveElement as _, Styled, Window,
WindowControlArea,
px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement,
ParentElement, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea,
};
use smallvec::{smallvec, SmallVec};
use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING};
@@ -14,19 +13,18 @@ use ui::h_flex;
#[cfg(target_os = "linux")]
use crate::platforms::linux::LinuxWindowControls;
use crate::platforms::mac::TRAFFIC_LIGHT_PADDING;
use crate::platforms::windows::WindowsWindowControls;
mod platforms;
/// Titlebar
pub struct TitleBar {
/// Children elements of the title bar.
children: SmallVec<[AnyElement; 2]>,
should_move: bool,
}
impl Default for TitleBar {
fn default() -> Self {
Self::new()
}
/// Whether the title bar is currently being moved.
should_move: bool,
}
impl TitleBar {
@@ -38,16 +36,16 @@ impl TitleBar {
}
#[cfg(not(target_os = "windows"))]
pub fn height(window: &mut Window) -> Pixels {
pub fn height(&self, window: &mut Window) -> Pixels {
(1.75 * window.rem_size()).max(px(34.))
}
#[cfg(target_os = "windows")]
pub fn height(_window: &mut Window) -> Pixels {
pub fn height(&self, _window: &mut Window) -> Pixels {
px(32.)
}
pub fn title_bar_color(&self, window: &mut Window, cx: &mut Context<Self>) -> Hsla {
pub fn titlebar_color(&self, window: &mut Window, cx: &mut Context<Self>) -> Hsla {
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if window.is_window_active() && !self.should_move {
cx.theme().titlebar
@@ -67,20 +65,21 @@ impl TitleBar {
}
}
impl ParentElement for TitleBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
impl Default for TitleBar {
fn default() -> Self {
Self::new()
}
}
impl Render for TitleBar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let height = self.height(window);
let color = self.titlebar_color(window, cx);
let children = std::mem::take(&mut self.children);
#[cfg(target_os = "linux")]
let supported_controls = window.window_controls();
let decorations = window.window_decorations();
let height = Self::height(window);
let color = self.title_bar_color(window, cx);
let children = mem::take(&mut self.children);
h_flex()
.window_control_area(WindowControlArea::Drag)
@@ -90,11 +89,7 @@ impl Render for TitleBar {
if window.is_fullscreen() {
this.px_2()
} else if cx.theme().platform.is_mac() {
this.pl(px(platforms::mac::TRAFFIC_LIGHT_PADDING))
.pr_2()
.when(children.len() <= 1, |this| {
this.pr(px(platforms::mac::TRAFFIC_LIGHT_PADDING))
})
this.pr_2().pl(px(TRAFFIC_LIGHT_PADDING))
} else {
this.px_2()
}
@@ -114,11 +109,8 @@ impl Render for TitleBar {
.border_color(cx.theme().border)
.content_stretch()
.child(
div()
h_flex()
.id("title-bar")
.flex()
.flex_row()
.items_center()
.justify_between()
.w_full()
.when(cx.theme().platform.is_mac(), |this| {

View File

@@ -25,20 +25,26 @@ impl LinuxWindowControls {
impl RenderOnce for LinuxWindowControls {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let supported_controls = window.window_controls();
h_flex()
.id("linux-window-controls")
.gap_2()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(WindowControl::new(
LinuxControl::Minimize,
IconName::WindowMinimize,
))
.child({
if window.is_maximized() {
WindowControl::new(LinuxControl::Restore, IconName::WindowRestore)
} else {
WindowControl::new(LinuxControl::Maximize, IconName::WindowMaximize)
}
.when(supported_controls.minimize, |this| {
this.child(WindowControl::new(
LinuxControl::Minimize,
IconName::WindowMinimize,
))
})
.when(supported_controls.maximize, |this| {
this.child({
if window.is_maximized() {
WindowControl::new(LinuxControl::Restore, IconName::WindowRestore)
} else {
WindowControl::new(LinuxControl::Maximize, IconName::WindowMaximize)
}
})
})
.child(
WindowControl::new(LinuxControl::Close, IconName::WindowClose)

View File

@@ -12,6 +12,7 @@ pub enum IconName {
ArrowLeft,
ArrowRight,
Boom,
ChevronDown,
CaretDown,
CaretRight,
CaretUp,
@@ -27,6 +28,8 @@ pub enum IconName {
Eye,
Info,
Invite,
Inbox,
InboxFill,
Link,
Loader,
Moon,
@@ -52,6 +55,8 @@ pub enum IconName {
WindowMaximize,
WindowMinimize,
WindowRestore,
Fistbump,
FistbumpFill,
Zoom,
}
@@ -61,6 +66,7 @@ impl IconName {
Self::ArrowLeft => "icons/arrow-left.svg",
Self::ArrowRight => "icons/arrow-right.svg",
Self::Boom => "icons/boom.svg",
Self::ChevronDown => "icons/chevron-down.svg",
Self::CaretDown => "icons/caret-down.svg",
Self::CaretRight => "icons/caret-right.svg",
Self::CaretUp => "icons/caret-up.svg",
@@ -76,6 +82,8 @@ impl IconName {
Self::Eye => "icons/eye.svg",
Self::Info => "icons/info.svg",
Self::Invite => "icons/invite.svg",
Self::Inbox => "icons/inbox.svg",
Self::InboxFill => "icons/inbox-fill.svg",
Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg",
Self::Moon => "icons/moon.svg",
@@ -101,6 +109,8 @@ impl IconName {
Self::WindowMaximize => "icons/window-maximize.svg",
Self::WindowMinimize => "icons/window-minimize.svg",
Self::WindowRestore => "icons/window-restore.svg",
Self::Fistbump => "icons/fistbump.svg",
Self::FistbumpFill => "icons/fistbump-fill.svg",
Self::Zoom => "icons/zoom.svg",
}
.into()

View File

@@ -145,6 +145,7 @@ impl Styled for TextInput {
impl RenderOnce for TextInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
const LINE_HEIGHT: Rems = Rems(1.25);
let font = window.text_style().font();
let font_size = window.text_style().font_size.to_pixels(window.rem_size());
@@ -155,6 +156,7 @@ impl RenderOnce for TextInput {
});
let state = self.state.read(cx);
let focused = state.focus_handle.is_focused(window) && !state.disabled;
let gap_x = match self.size {
Size::Small => px(4.),
@@ -266,7 +268,16 @@ impl RenderOnce for TextInput {
.when_some(self.height, |this, height| this.h(height))
})
.when(self.appearance, |this| {
this.bg(bg).rounded(cx.theme().radius)
this.bg(bg)
.rounded(cx.theme().radius)
.when(self.bordered, |this| {
this.border_color(cx.theme().border)
.border_1()
.when(cx.theme().shadow, |this| this.shadow_xs())
.when(focused && self.focus_bordered, |this| {
this.border_color(cx.theme().border_focused)
})
})
})
.items_center()
.gap(gap_x)

View File

@@ -2,10 +2,10 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
canvas, div, point, px, rgba, AnyView, App, AppContext, Bounds, Context, CursorStyle,
canvas, div, point, px, size, AnyView, App, AppContext, Bounds, Context, CursorStyle,
Decorations, Edges, Entity, FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement,
MouseButton, ParentElement as _, Pixels, Point, Render, ResizeEdge, SharedString, Size, Styled,
WeakFocusHandle, Window,
Tiling, WeakFocusHandle, Window,
};
use theme::{
ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING,
@@ -241,7 +241,10 @@ impl Render for Root {
window.set_rem_size(rem_size);
// Set the client inset (linux only)
window.set_client_inset(CLIENT_SIDE_DECORATION_SHADOW);
match decorations {
Decorations::Client { .. } => window.set_client_inset(CLIENT_SIDE_DECORATION_SHADOW),
Decorations::Server => window.set_client_inset(px(0.0)),
}
div()
.id("window")
@@ -267,7 +270,7 @@ impl Render for Root {
let size = window.window_bounds().get_bounds().size;
let Some(edge) =
resize_edge(mouse, CLIENT_SIDE_DECORATION_SHADOW, size)
resize_edge(mouse, CLIENT_SIDE_DECORATION_SHADOW, size, tiling)
else {
return;
};
@@ -314,7 +317,9 @@ impl Render for Root {
let size = window.window_bounds().get_bounds().size;
let pos = e.position;
if let Some(edge) = resize_edge(pos, CLIENT_SIDE_DECORATION_SHADOW, size) {
if let Some(edge) =
resize_edge(pos, CLIENT_SIDE_DECORATION_SHADOW, size, tiling)
{
window.start_window_resize(edge)
};
}),
@@ -324,9 +329,7 @@ impl Render for Root {
.map(|div| match decorations {
Decorations::Server => div,
Decorations::Client { tiling } => div
.when(!cx.theme().platform.is_mac(), |div| {
div.border_color(rgba(0x64748b33))
})
.border_color(cx.theme().border)
.when(!(tiling.top || tiling.right), |div| {
div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
})
@@ -401,25 +404,59 @@ pub(crate) fn window_paddings(window: &Window, _cx: &App) -> Edges<Pixels> {
}
/// Get the window resize edge.
fn resize_edge(pos: Point<Pixels>, shadow_size: Pixels, size: Size<Pixels>) -> Option<ResizeEdge> {
let edge = if pos.y < shadow_size && pos.x < shadow_size {
ResizeEdge::TopLeft
} else if pos.y < shadow_size && pos.x > size.width - shadow_size {
ResizeEdge::TopRight
} else if pos.y < shadow_size {
ResizeEdge::Top
} else if pos.y > size.height - shadow_size && pos.x < shadow_size {
ResizeEdge::BottomLeft
} else if pos.y > size.height - shadow_size && pos.x > size.width - shadow_size {
ResizeEdge::BottomRight
} else if pos.y > size.height - shadow_size {
ResizeEdge::Bottom
} else if pos.x < shadow_size {
ResizeEdge::Left
} else if pos.x > size.width - shadow_size {
ResizeEdge::Right
} else {
fn resize_edge(
pos: Point<Pixels>,
shadow_size: Pixels,
window_size: Size<Pixels>,
tiling: Tiling,
) -> Option<ResizeEdge> {
let bounds = Bounds::new(Point::default(), window_size).inset(shadow_size * 1.5);
if bounds.contains(&pos) {
return None;
};
Some(edge)
}
let corner_size = size(shadow_size * 1.5, shadow_size * 1.5);
let top_left_bounds = Bounds::new(Point::new(px(0.), px(0.)), corner_size);
if !tiling.top && top_left_bounds.contains(&pos) {
return Some(ResizeEdge::TopLeft);
}
let top_right_bounds = Bounds::new(
Point::new(window_size.width - corner_size.width, px(0.)),
corner_size,
);
if !tiling.top && top_right_bounds.contains(&pos) {
return Some(ResizeEdge::TopRight);
}
let bottom_left_bounds = Bounds::new(
Point::new(px(0.), window_size.height - corner_size.height),
corner_size,
);
if !tiling.bottom && bottom_left_bounds.contains(&pos) {
return Some(ResizeEdge::BottomLeft);
}
let bottom_right_bounds = Bounds::new(
Point::new(
window_size.width - corner_size.width,
window_size.height - corner_size.height,
),
corner_size,
);
if !tiling.bottom && bottom_right_bounds.contains(&pos) {
return Some(ResizeEdge::BottomRight);
}
if !tiling.top && pos.y < shadow_size {
Some(ResizeEdge::Top)
} else if !tiling.bottom && pos.y > window_size.height - shadow_size {
Some(ResizeEdge::Bottom)
} else if !tiling.left && pos.x < shadow_size {
Some(ResizeEdge::Left)
} else if !tiling.right && pos.x > window_size.width - shadow_size {
Some(ResizeEdge::Right)
} else {
None
}
}