feat: Basic Application Settings (#58)

* .

* .

* .

* update modal
This commit is contained in:
reya
2025-06-13 07:56:59 +07:00
committed by GitHub
parent e687204361
commit cc36adeafe
24 changed files with 1066 additions and 303 deletions

52
Cargo.lock generated
View File

@@ -745,9 +745,9 @@ checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee"
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.23.0" version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c" checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422"
dependencies = [ dependencies = [
"bytemuck_derive", "bytemuck_derive",
] ]
@@ -930,6 +930,7 @@ dependencies = [
"nostr", "nostr",
"nostr-sdk", "nostr-sdk",
"oneshot", "oneshot",
"settings",
"smallvec", "smallvec",
"smol", "smol",
] ]
@@ -1062,7 +1063,7 @@ dependencies = [
[[package]] [[package]]
name = "collections" name = "collections"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"rustc-hash 2.1.1", "rustc-hash 2.1.1",
@@ -1157,6 +1158,7 @@ dependencies = [
"rust-embed", "rust-embed",
"serde", "serde",
"serde_json", "serde_json",
"settings",
"smallvec", "smallvec",
"smol", "smol",
"theme", "theme",
@@ -1442,7 +1444,7 @@ dependencies = [
[[package]] [[package]]
name = "derive_refineable" name = "derive_refineable"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2264,7 +2266,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui" name = "gpui"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"as-raw-xcb-connection", "as-raw-xcb-connection",
@@ -2357,7 +2359,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_macros" name = "gpui_macros"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@@ -2410,9 +2412,9 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.3" version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
dependencies = [ dependencies = [
"foldhash", "foldhash",
] ]
@@ -2580,7 +2582,7 @@ dependencies = [
[[package]] [[package]]
name = "http_client" name = "http_client"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2597,7 +2599,7 @@ dependencies = [
[[package]] [[package]]
name = "http_client_tls" name = "http_client_tls"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5"
dependencies = [ dependencies = [
"rustls", "rustls",
"rustls-platform-verifier", "rustls-platform-verifier",
@@ -2873,7 +2875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.15.3", "hashbrown 0.15.4",
"serde", "serde",
] ]
@@ -3338,7 +3340,7 @@ dependencies = [
[[package]] [[package]]
name = "media" name = "media"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bindgen 0.71.1", "bindgen 0.71.1",
@@ -3451,7 +3453,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"codespan-reporting 0.12.0", "codespan-reporting 0.12.0",
"half", "half",
"hashbrown 0.15.3", "hashbrown 0.15.4",
"hexf-parse", "hexf-parse",
"indexmap", "indexmap",
"log", "log",
@@ -4661,7 +4663,7 @@ dependencies = [
[[package]] [[package]]
name = "refineable" name = "refineable"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5"
dependencies = [ dependencies = [
"derive_refineable", "derive_refineable",
"workspace-hack", "workspace-hack",
@@ -4798,7 +4800,7 @@ dependencies = [
[[package]] [[package]]
name = "reqwest_client" name = "reqwest_client"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -5269,7 +5271,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749"
[[package]] [[package]]
name = "semantic_version" name = "semantic_version"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",
@@ -5380,6 +5382,20 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "settings"
version = "0.1.5"
dependencies = [
"anyhow",
"global",
"gpui",
"log",
"nostr-sdk",
"serde",
"serde_json",
"smallvec",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"
@@ -5621,7 +5637,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "sum_tree" name = "sum_tree"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"log", "log",
@@ -6536,7 +6552,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "util" name = "util"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-fs", "async-fs",

View File

@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
global = { path = "../global" } global = { path = "../global" }
settings = { path = "../settings" }
gpui.workspace = true gpui.workspace = true
nostr.workspace = true nostr.workspace = true

View File

@@ -9,6 +9,7 @@ use global::shared_state;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window}; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::AppSettings;
use crate::constants::{DAYS_IN_MONTH, HOURS_IN_DAY, MINUTES_IN_HOUR, NOW, SECONDS_IN_MINUTE}; use crate::constants::{DAYS_IN_MONTH, HOURS_IN_DAY, MINUTES_IN_HOUR, NOW, SECONDS_IN_MINUTE};
use crate::message::Message; use crate::message::Message;
@@ -249,10 +250,12 @@ impl Room {
/// - For a direct message: the other person's avatar /// - For a direct message: the other person's avatar
/// - For a group chat: None /// - For a group chat: None
pub fn display_image(&self, cx: &App) -> SharedString { pub fn display_image(&self, cx: &App) -> SharedString {
let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars;
if let Some(picture) = self.picture.as_ref() { if let Some(picture) = self.picture.as_ref() {
picture.clone() picture.clone()
} else if !self.is_group() { } else if !self.is_group() {
self.first_member(cx).render_avatar() self.first_member(cx).render_avatar(proxy)
} else { } else {
"brand/group.png".into() "brand/group.png".into()
} }
@@ -630,6 +633,7 @@ impl Room {
let subject = self.subject.clone(); let subject = self.subject.clone();
let picture = self.picture.clone(); let picture = self.picture.clone();
let public_keys = Arc::clone(&self.members); let public_keys = Arc::clone(&self.members);
let backup = AppSettings::get_global(cx).settings().backup_messages;
cx.background_spawn(async move { cx.background_spawn(async move {
let signer = shared_state().client.signer().await?; let signer = shared_state().client.signer().await?;
@@ -697,7 +701,7 @@ impl Room {
} }
// Only send a backup message to current user if there are no issues when sending to others // Only send a backup message to current user if there are no issues when sending to others
if reports.is_empty() { if backup && reports.is_empty() {
if let Err(e) = shared_state() if let Err(e) = shared_state()
.client .client
.send_private_msg(*current_user, &content, tags.clone()) .send_private_msg(*current_user, &content, tags.clone())

View File

@@ -2,7 +2,6 @@ use std::collections::HashSet;
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::Arc; use std::sync::Arc;
use global::constants::NIP96_SERVER;
use gpui::{Image, ImageFormat}; use gpui::{Image, ImageFormat};
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@@ -11,11 +10,14 @@ use qrcode_generator::QrCodeEcc;
pub mod debounced_delay; pub mod debounced_delay;
pub mod profile; pub mod profile;
pub async fn nip96_upload(client: &Client, file: Vec<u8>) -> anyhow::Result<Url, anyhow::Error> { pub async fn nip96_upload(
client: &Client,
upload_to: Url,
file: Vec<u8>,
) -> anyhow::Result<Url, anyhow::Error> {
let signer = client.signer().await?; let signer = client.signer().await?;
let server_url = Url::parse(NIP96_SERVER)?;
let config: ServerConfig = nip96::get_server_config(server_url, None).await?; let config: ServerConfig = nip96::get_server_config(upload_to.to_owned(), None).await?;
let url = nip96::upload_data(&signer, &config, file, None, None).await?; let url = nip96::upload_data(&signer, &config, file, None, None).await?;
Ok(url) Ok(url)

View File

@@ -2,23 +2,29 @@ use global::constants::IMAGE_RESIZE_SERVICE;
use gpui::SharedString; use gpui::SharedString;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png";
pub trait RenderProfile { pub trait RenderProfile {
fn render_avatar(&self) -> SharedString; fn render_avatar(&self, proxy: bool) -> SharedString;
fn render_name(&self) -> SharedString; fn render_name(&self) -> SharedString;
} }
impl RenderProfile for Profile { impl RenderProfile for Profile {
fn render_avatar(&self) -> SharedString { fn render_avatar(&self, proxy: bool) -> SharedString {
self.metadata() self.metadata()
.picture .picture
.as_ref() .as_ref()
.filter(|picture| !picture.is_empty()) .filter(|picture| !picture.is_empty())
.map(|picture| { .map(|picture| {
format!( if proxy {
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&default=https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png&n=-1", format!(
IMAGE_RESIZE_SERVICE, picture "{}/?url={}&w=100&h=100&fit=cover&mask=circle&default={}&n=-1",
) IMAGE_RESIZE_SERVICE, picture, FALLBACK_IMG
.into() )
.into()
} else {
picture.into()
}
}) })
.unwrap_or_else(|| "brand/avatar.png".into()) .unwrap_or_else(|| "brand/avatar.png".into())
} }

View File

@@ -14,6 +14,7 @@ theme = { path = "../theme" }
common = { path = "../common" } common = { path = "../common" }
global = { path = "../global" } global = { path = "../global" }
chats = { path = "../chats" } chats = { path = "../chats" }
settings = { path = "../settings" }
auto_update = { path = "../auto_update" } auto_update = { path = "../auto_update" }
gpui.workspace = true gpui.workspace = true

View File

@@ -6,8 +6,8 @@ use global::constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH};
use global::shared_state; use global::shared_state;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, impl_internal_actions, px, App, AppContext, Axis, Context, Entity, InteractiveElement, div, impl_internal_actions, px, App, AppContext, Axis, Context, Entity, IntoElement,
IntoElement, ParentElement, Render, Styled, Subscription, Task, Window, ParentElement, Render, Styled, Subscription, Task, Window,
}; };
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use serde::Deserialize; use serde::Deserialize;
@@ -20,9 +20,7 @@ use ui::dock_area::{DockArea, DockItem};
use ui::{ContextModal, IconName, Root, Sizable, TitleBar}; use ui::{ContextModal, IconName, Root, Sizable, TitleBar};
use crate::views::chat::{self, Chat}; use crate::views::chat::{self, Chat};
use crate::views::{ use crate::views::{login, new_account, onboarding, preferences, sidebar, startup, welcome};
compose, login, new_account, onboarding, profile, relays, sidebar, startup, welcome,
};
impl_internal_actions!(dock, [ToggleModal]); impl_internal_actions!(dock, [ToggleModal]);
@@ -181,6 +179,17 @@ impl ChatSpace {
}); });
} }
pub fn open_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let settings = preferences::init(window, cx);
window.open_modal(cx, move |modal, _, _| {
modal
.title("Preferences")
.width(px(DEFAULT_MODAL_WIDTH))
.child(settings.clone())
});
}
fn titlebar(&mut self, status: bool, cx: &mut Context<Self>) { fn titlebar(&mut self, status: bool, cx: &mut Context<Self>) {
self.titlebar = status; self.titlebar = status;
cx.notify(); cx.notify();
@@ -206,53 +215,19 @@ impl ChatSpace {
}) })
} }
fn on_modal_action( fn toggle_appearance(&self, window: &mut Window, cx: &mut App) {
&mut self, if cx.theme().mode.is_dark() {
action: &ToggleModal, Theme::change(ThemeMode::Light, Some(window), cx);
window: &mut Window, } else {
cx: &mut Context<Self>, Theme::change(ThemeMode::Dark, Some(window), cx);
) { }
match action.modal { }
ModalKind::Profile => {
let profile = profile::init(window, cx);
window.open_modal(cx, move |modal, _, _| { fn logout(&self, _window: &mut Window, cx: &mut App) {
modal cx.background_spawn(async move {
.title("Profile") shared_state().unset_signer().await;
.width(px(DEFAULT_MODAL_WIDTH)) })
.child(profile.clone()) .detach();
})
}
ModalKind::Compose => {
let compose = compose::init(window, cx);
window.open_modal(cx, move |modal, _, _| {
modal
.title("Direct Messages")
.width(px(DEFAULT_MODAL_WIDTH))
.child(compose.clone())
})
}
ModalKind::Relay => {
let relays = relays::init(window, cx);
window.open_modal(cx, move |this, _, _| {
this.width(px(DEFAULT_MODAL_WIDTH))
.title("Edit your Messaging Relays")
.child(relays.clone())
});
}
ModalKind::SetupRelay => {
let relays = relays::init(window, cx);
window.open_modal(cx, move |this, _, _| {
this.width(px(DEFAULT_MODAL_WIDTH))
.title("Your Messaging Relays are not configured")
.child(relays.clone())
});
}
_ => {}
};
} }
pub(crate) fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) { pub(crate) fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) {
@@ -310,40 +285,28 @@ impl Render for ChatSpace {
this.icon(IconName::Moon) this.icon(IconName::Moon)
} }
}) })
.on_click(cx.listener(|_, _, window, cx| { .on_click(cx.listener(|this, _, window, cx| {
if cx.theme().mode.is_dark() { this.toggle_appearance(window, cx);
Theme::change(
ThemeMode::Light,
Some(window),
cx,
);
} else {
Theme::change(
ThemeMode::Dark,
Some(window),
cx,
);
}
})), })),
) )
.child( .child(
Button::new("settings") Button::new("preferences")
.tooltip("Open settings") .tooltip("Open Preferences")
.small() .small()
.ghost() .ghost()
.icon(IconName::Settings), .icon(IconName::Settings)
.on_click(cx.listener(|this, _, window, cx| {
this.open_settings(window, cx);
})),
) )
.child( .child(
Button::new("logout") Button::new("logout")
.tooltip("Log out") .tooltip("Log Out")
.small() .small()
.ghost() .ghost()
.icon(IconName::Logout) .icon(IconName::Logout)
.on_click(cx.listener(move |_, _, _window, cx| { .on_click(cx.listener(|this, _, window, cx| {
cx.background_spawn(async move { this.logout(window, cx);
shared_state().unset_signer().await;
})
.detach();
})), })),
), ),
), ),
@@ -356,7 +319,5 @@ impl Render for ChatSpace {
.child(div().absolute().top_8().children(notification_layer)) .child(div().absolute().top_8().children(notification_layer))
// Modals // Modals
.children(modal_layer) .children(modal_layer)
// Actions
.on_action(cx.listener(Self::on_modal_action))
} }
} }

View File

@@ -92,6 +92,8 @@ fn main() {
cx.activate(true); cx.activate(true);
// Initialize components // Initialize components
ui::init(cx); ui::init(cx);
// Initialize settings
settings::init(cx);
// Initialize auto update // Initialize auto update
auto_update::init(cx); auto_update::init(cx);
// Initialize chat state // Initialize chat state

View File

@@ -20,6 +20,7 @@ use gpui::{
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use smol::fs; use smol::fs;
use theme::ActiveTheme; use theme::ActiveTheme;
@@ -371,6 +372,7 @@ impl Chat {
self.uploading(true, cx); self.uploading(true, cx);
let nip96 = AppSettings::get_global(cx).settings().media_server.clone();
let paths = cx.prompt_for_paths(PathPromptOptions { let paths = cx.prompt_for_paths(PathPromptOptions {
files: true, files: true,
directories: false, directories: false,
@@ -389,7 +391,9 @@ impl Chat {
// Spawn task via async utility instead of GPUI context // Spawn task via async utility instead of GPUI context
spawn(async move { spawn(async move {
let url = match nip96_upload(&shared_state().client, file_data).await { let url = match nip96_upload(&shared_state().client, nip96, file_data)
.await
{
Ok(url) => Some(url), Ok(url) => Some(url),
Err(e) => { Err(e) => {
log::error!("Upload error: {e}"); log::error!("Upload error: {e}");
@@ -542,6 +546,9 @@ impl Chat {
return div().id(ix); return div().id(ix);
}; };
let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars;
let hide_avatar = AppSettings::get_global(cx).settings().hide_user_avatars;
let message = message.borrow(); let message = message.borrow();
// Message without ID, Author probably the placeholder // Message without ID, Author probably the placeholder
@@ -590,7 +597,9 @@ impl Chat {
div() div()
.flex() .flex()
.gap_3() .gap_3()
.child(Avatar::new(author.render_avatar()).size(rems(2.))) .when(!hide_avatar, |this| {
this.child(Avatar::new(author.render_avatar(proxy)).size(rems(2.)))
})
.child( .child(
div() div()
.flex_1() .flex_1()

View File

@@ -14,6 +14,7 @@ use gpui::{
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use smol::Timer; use smol::Timer;
use theme::ActiveTheme; use theme::ActiveTheme;
@@ -305,6 +306,8 @@ impl Render for Compose {
const DESCRIPTION: &str = const DESCRIPTION: &str =
"Start a conversation with someone using their npub or NIP-05 (like foo@bar.com)."; "Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).";
let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars;
let label: SharedString = if self.selected.read(cx).len() > 1 { let label: SharedString = if self.selected.read(cx).len() > 1 {
"Create Group DM".into() "Create Group DM".into()
} else { } else {
@@ -413,7 +416,7 @@ impl Render for Compose {
.gap_3() .gap_3()
.text_sm() .text_sm()
.child( .child(
img(item.render_avatar()) img(item.render_avatar(proxy))
.size_7() .size_7()
.flex_shrink_0(), .flex_shrink_0(),
) )

View File

@@ -3,6 +3,7 @@ pub mod compose;
pub mod login; pub mod login;
pub mod new_account; pub mod new_account;
pub mod onboarding; pub mod onboarding;
pub mod preferences;
pub mod profile; pub mod profile;
pub mod relays; pub mod relays;
pub mod sidebar; pub mod sidebar;

View File

@@ -9,6 +9,7 @@ use gpui::{
Styled, Window, Styled, Window,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::AppSettings;
use smol::fs; use smol::fs;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
@@ -94,6 +95,7 @@ impl NewAccount {
} }
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nip96 = AppSettings::get_global(cx).settings().media_server.clone();
let avatar_input = self.avatar_input.downgrade(); let avatar_input = self.avatar_input.downgrade();
let paths = cx.prompt_for_paths(PathPromptOptions { let paths = cx.prompt_for_paths(PathPromptOptions {
files: true, files: true,
@@ -122,7 +124,9 @@ impl NewAccount {
let (tx, rx) = oneshot::channel::<Url>(); let (tx, rx) = oneshot::channel::<Url>();
spawn(async move { spawn(async move {
if let Ok(url) = nip96_upload(&shared_state().client, file_data).await { if let Ok(url) =
nip96_upload(&shared_state().client, nip96, file_data).await
{
_ = tx.send(url); _ = tx.send(url);
} }
}); });

View File

@@ -0,0 +1,308 @@
use common::profile::RenderProfile;
use global::{
constants::{DEFAULT_MODAL_WIDTH, NIP96_SERVER},
shared_state,
};
use gpui::{
div, http_client::Url, prelude::FluentBuilder, px, relative, rems, App, AppContext, Context,
Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement, Render,
StatefulInteractiveElement, Styled, Window,
};
use settings::AppSettings;
use theme::ActiveTheme;
use ui::{
avatar::Avatar,
button::{Button, ButtonVariants},
input::{InputState, TextInput},
switch::Switch,
ContextModal, IconName, Sizable, Size, StyledExt,
};
use crate::views::{profile, relays};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
Preferences::new(window, cx)
}
pub struct Preferences {
media_input: Entity<InputState>,
focus_handle: FocusHandle,
}
impl Preferences {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| {
let media_server = AppSettings::get_global(cx)
.settings()
.media_server
.to_string();
let media_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(media_server)
.placeholder(NIP96_SERVER)
});
Self {
media_input,
focus_handle: cx.focus_handle(),
}
})
}
fn open_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
let profile = profile::init(window, cx);
window.open_modal(cx, move |modal, _, _| {
modal
.title("Profile")
.width(px(DEFAULT_MODAL_WIDTH))
.child(profile.clone())
});
}
fn open_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let relays = relays::init(window, cx);
window.open_modal(cx, move |this, _, _| {
this.width(px(DEFAULT_MODAL_WIDTH))
.title("Edit your Messaging Relays")
.child(relays.clone())
});
}
}
impl Render for Preferences {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const MEDIA_DESCRIPTION: &str = "Coop only supports NIP-96 media servers for now. If you're not sure about it, please keep the default value.";
const BACKUP_DESCRIPTION: &str =
"When a user sends a message, Coop won't back it up to the user's messaging relays";
const TRUSTED_DESCRIPTION: &str = "Show trusted requests by default";
const HIDE_AVATAR_DESCRIPTION: &str =
"Unload all avatar pictures to improve performance and reduce memory usage";
const PROXY_DESCRIPTION: &str =
"Use wsrv.nl to resize and downscale avatar pictures (saves ~50MB of data)";
let input_state = self.media_input.downgrade();
let settings = AppSettings::get_global(cx).settings();
div()
.track_focus(&self.focus_handle)
.size_full()
.px_3()
.pb_3()
.flex()
.flex_col()
.child(
div()
.py_2()
.flex()
.flex_col()
.gap_2()
.child(
div()
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child("Account"),
)
.when_some(shared_state().identity(), |this, profile| {
this.child(
div()
.w_full()
.flex()
.justify_between()
.items_center()
.child(
div()
.id("current-user")
.flex()
.items_center()
.gap_2()
.child(
Avatar::new(
profile.render_avatar(settings.proxy_user_avatars),
)
.size(rems(2.4)),
)
.child(
div()
.flex_1()
.text_sm()
.child(
div()
.line_height(relative(1.3))
.font_semibold()
.child(profile.render_name()),
)
.child(
div()
.line_height(relative(1.3))
.text_xs()
.text_color(cx.theme().text_muted)
.child("See your profile"),
),
)
.on_click(cx.listener(|this, _, window, cx| {
this.open_profile(window, cx);
})),
)
.child(
Button::new("relays")
.label("DM Relays")
.ghost()
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.open_relays(window, cx);
})),
),
)
}),
)
.child(
div()
.py_2()
.flex()
.flex_col()
.gap_2()
.border_t_1()
.border_color(cx.theme().border)
.child(
div()
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child("Media Server"),
)
.child(
div()
.flex()
.items_start()
.gap_1()
.child(TextInput::new(&self.media_input).xsmall())
.child(
Button::new("update")
.icon(IconName::CheckCircleFill)
.ghost()
.with_size(Size::Size(px(26.)))
.on_click(move |_, window, cx| {
if let Some(input) = input_state.upgrade() {
let value = input.read(cx).value();
let Ok(url) = Url::parse(value) else {
window.push_notification("URL is not valid", cx);
return;
};
AppSettings::global(cx).update(cx, |this, cx| {
this.settings.media_server = url;
cx.notify();
});
}
}),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(MEDIA_DESCRIPTION),
),
)
.child(
div()
.py_2()
.flex()
.flex_col()
.gap_2()
.border_t_1()
.border_color(cx.theme().border)
.child(
div()
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child("Messages"),
)
.child(
div()
.flex()
.flex_col()
.gap_2()
.child(
Switch::new("backup_messages")
.label("Backup messages")
.description(BACKUP_DESCRIPTION)
.checked(settings.backup_messages)
.on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
this.settings.backup_messages =
!this.settings.backup_messages;
cx.notify();
})
}),
)
.child(
Switch::new("only_show_trusted")
.label("Only trusted")
.description(TRUSTED_DESCRIPTION)
.checked(settings.only_show_trusted)
.on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
this.settings.only_show_trusted =
!this.settings.only_show_trusted;
cx.notify();
})
}),
),
),
)
.child(
div()
.py_2()
.flex()
.flex_col()
.gap_2()
.border_t_1()
.border_color(cx.theme().border)
.child(
div()
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child("Display"),
)
.child(
div()
.flex()
.flex_col()
.gap_2()
.child(
Switch::new("hide_user_avatars")
.label("Hide user avatars")
.description(HIDE_AVATAR_DESCRIPTION)
.checked(settings.hide_user_avatars)
.on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
this.settings.hide_user_avatars =
!this.settings.hide_user_avatars;
cx.notify();
})
}),
)
.child(
Switch::new("proxy_user_avatars")
.label("Proxy user avatars")
.description(PROXY_DESCRIPTION)
.checked(settings.proxy_user_avatars)
.on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
this.settings.proxy_user_avatars =
!this.settings.proxy_user_avatars;
cx.notify();
})
}),
),
),
)
}
}

View File

@@ -10,6 +10,7 @@ use gpui::{
PathPromptOptions, Render, Styled, Task, Window, PathPromptOptions, Render, Styled, Task, Window,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::AppSettings;
use smol::fs; use smol::fs;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
@@ -104,6 +105,7 @@ impl Profile {
} }
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nip96 = AppSettings::get_global(cx).settings().media_server.clone();
let avatar_input = self.avatar_input.downgrade(); let avatar_input = self.avatar_input.downgrade();
let paths = cx.prompt_for_paths(PathPromptOptions { let paths = cx.prompt_for_paths(PathPromptOptions {
files: true, files: true,
@@ -123,7 +125,9 @@ impl Profile {
let (tx, rx) = oneshot::channel::<Url>(); let (tx, rx) = oneshot::channel::<Url>();
spawn(async move { spawn(async move {
if let Ok(url) = nip96_upload(&shared_state().client, file_data).await { if let Ok(url) =
nip96_upload(&shared_state().client, nip96, file_data).await
{
_ = tx.send(url); _ = tx.send(url);
} }
}); });

View File

@@ -5,6 +5,7 @@ use gpui::{
div, img, rems, App, ClickEvent, Div, InteractiveElement, IntoElement, ParentElement as _, div, img, rems, App, ClickEvent, Div, InteractiveElement, IntoElement, ParentElement as _,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
}; };
use settings::AppSettings;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::StyledExt; use ui::StyledExt;
@@ -59,6 +60,7 @@ impl DisplayRoom {
impl RenderOnce for DisplayRoom { impl RenderOnce for DisplayRoom {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let handler = self.handler.clone(); let handler = self.handler.clone();
let hide_avatar = AppSettings::get_global(cx).settings().hide_user_avatars;
self.base self.base
.id(self.ix) .id(self.ix)
@@ -67,25 +69,27 @@ impl RenderOnce for DisplayRoom {
.gap_2() .gap_2()
.text_sm() .text_sm()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.child( .when(!hide_avatar, |this| {
div() this.child(
.flex_shrink_0() div()
.size_6() .flex_shrink_0()
.rounded_full() .size_6()
.overflow_hidden() .rounded_full()
.map(|this| { .overflow_hidden()
if let Some(path) = self.img { .map(|this| {
this.child(Avatar::new(path).size(rems(1.5))) if let Some(path) = self.img {
} else { this.child(Avatar::new(path).size(rems(1.5)))
this.child( } else {
img("brand/avatar.png") this.child(
.rounded_full() img("brand/avatar.png")
.size_6() .rounded_full()
.into_any_element(), .size_6()
) .into_any_element(),
} )
}), }
) }),
)
})
.child( .child(
div() div()
.flex_1() .flex_1()

View File

@@ -8,27 +8,29 @@ use chats::{ChatRegistry, RoomEmitter};
use common::debounced_delay::DebouncedDelay; use common::debounced_delay::DebouncedDelay;
use common::profile::RenderProfile; use common::profile::RenderProfile;
use element::DisplayRoom; use element::DisplayRoom;
use global::constants::SEARCH_RELAYS; use global::constants::{DEFAULT_MODAL_WIDTH, SEARCH_RELAYS};
use global::shared_state; use global::shared_state;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, rems, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter, div, px, rems, uniform_list, AnyElement, App, AppContext, ClipboardItem, Context, Entity,
FocusHandle, Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
Styled, Subscription, Task, Window, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
Window,
}; };
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonRounded, ButtonVariants}; use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent}; use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput}; use ui::input::{InputEvent, InputState, TextInput};
use ui::popup_menu::{PopupMenu, PopupMenuExt}; use ui::popup_menu::PopupMenu;
use ui::skeleton::Skeleton; use ui::skeleton::Skeleton;
use ui::{ContextModal, IconName, Selectable, Sizable, StyledExt}; use ui::{ContextModal, IconName, Selectable, Sizable, StyledExt};
use crate::chatspace::{ModalKind, ToggleModal}; use crate::views::compose;
mod element; mod element;
@@ -68,6 +70,7 @@ impl Sidebar {
let indicator = cx.new(|_| None); let indicator = cx.new(|_| None);
let local_result = cx.new(|_| None); let local_result = cx.new(|_| None);
let global_result = cx.new(|_| None); let global_result = cx.new(|_| None);
let trusted_only = AppSettings::get_global(cx).settings().only_show_trusted;
let find_input = let find_input =
cx.new(|cx| InputState::new(window, cx).placeholder("Find or start a conversation")); cx.new(|cx| InputState::new(window, cx).placeholder("Find or start a conversation"));
@@ -118,7 +121,7 @@ impl Sidebar {
image_cache: RetainAllImageCache::new(cx), image_cache: RetainAllImageCache::new(cx),
find_debouncer: DebouncedDelay::new(), find_debouncer: DebouncedDelay::new(),
finding: false, finding: false,
trusted_only: false, trusted_only,
indicator, indicator,
active_filter, active_filter,
find_input, find_input,
@@ -334,7 +337,20 @@ impl Sidebar {
}); });
} }
fn open_compose(&self, window: &mut Window, cx: &mut Context<Self>) {
let compose = compose::init(window, cx);
window.open_modal(cx, move |modal, _window, _cx| {
modal
.title("Direct Messages")
.width(px(DEFAULT_MODAL_WIDTH))
.child(compose.clone())
});
}
fn render_account(&self, profile: &Profile, cx: &Context<Self>) -> impl IntoElement { fn render_account(&self, profile: &Profile, cx: &Context<Self>) -> impl IntoElement {
let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars;
div() div()
.px_3() .px_3()
.h_8() .h_8()
@@ -344,56 +360,38 @@ impl Sidebar {
.items_center() .items_center()
.child( .child(
div() div()
.id("current-user")
.flex() .flex()
.items_center() .items_center()
.gap_2() .gap_2()
.text_sm() .text_sm()
.font_semibold() .font_semibold()
.child(Avatar::new(profile.render_avatar()).size(rems(1.75))) .child(Avatar::new(profile.render_avatar(proxy)).size(rems(1.75)))
.child(profile.render_name()), .child(profile.render_name())
.on_click(cx.listener({
let Ok(public_key) = profile.public_key().to_bech32();
let item = ClipboardItem::new_string(public_key);
move |_, _, window, cx| {
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
cx.write_to_primary(item.clone());
#[cfg(any(target_os = "windows", target_os = "macos"))]
cx.write_to_clipboard(item.clone());
window.push_notification("User's NPUB is copied", cx);
}
})),
) )
.child( .child(
div() Button::new("compose")
.flex() .icon(IconName::PlusFill)
.items_center() .tooltip("Create DM or Group DM")
.gap_2() .small()
.child( .primary()
Button::new("user") .rounded(ButtonRounded::Full)
.icon(IconName::Ellipsis) .on_click(cx.listener(|this, _, window, cx| {
.small() this.open_compose(window, cx);
.ghost() })),
.rounded(ButtonRounded::Full)
.popup_menu(|this, _window, _cx| {
this.menu(
"Profile",
Box::new(ToggleModal {
modal: ModalKind::Profile,
}),
)
.menu(
"Relays",
Box::new(ToggleModal {
modal: ModalKind::Relay,
}),
)
}),
)
.child(
Button::new("compose")
.icon(IconName::PlusFill)
.tooltip("Create DM or Group DM")
.small()
.primary()
.rounded(ButtonRounded::Full)
.on_click(cx.listener(|_, _, window, cx| {
window.dispatch_action(
Box::new(ToggleModal {
modal: ModalKind::Compose,
}),
cx,
);
})),
),
) )
} }

View File

@@ -33,7 +33,7 @@ pub const DEFAULT_SIDEBAR_WIDTH: f32 = 280.;
/// Image Resize Service /// Image Resize Service
pub const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl"; pub const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl";
/// NIP96 Media Server. /// Default NIP96 Media Server.
pub const NIP96_SERVER: &str = "https://nostrmedia.com"; pub const NIP96_SERVER: &str = "https://nostrmedia.com";
pub(crate) const GLOBAL_CHANNEL_LIMIT: usize = 2048; pub(crate) const GLOBAL_CHANNEL_LIMIT: usize = 2048;

View File

@@ -461,7 +461,7 @@ impl Globals {
/// Stores an unwrapped event in local database with reference to original /// Stores an unwrapped event in local database with reference to original
async fn set_unwrapped(&self, root: EventId, event: &Event, keys: &Keys) -> Result<(), Error> { async fn set_unwrapped(&self, root: EventId, event: &Event, keys: &Keys) -> Result<(), Error> {
// Must be use the random generated keys to sign this event // Must be use the random generated keys to sign this event
let event = EventBuilder::new(Kind::Custom(30078), event.as_json()) let event = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json())
.tags(vec![Tag::identifier(root), Tag::event(root)]) .tags(vec![Tag::identifier(root), Tag::event(root)])
.sign(keys) .sign(keys)
.await?; .await?;

View File

@@ -0,0 +1,16 @@
[package]
name = "settings"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
global = { path = "../global" }
nostr-sdk.workspace = true
gpui.workspace = true
anyhow.workspace = true
log.workspace = true
smallvec.workspace = true
serde.workspace = true
serde_json.workspace = true

146
crates/settings/src/lib.rs Normal file
View File

@@ -0,0 +1,146 @@
use anyhow::anyhow;
use global::shared_state;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use smallvec::{smallvec, SmallVec};
pub fn init(cx: &mut App) {
let state = cx.new(AppSettings::new);
// Observe for state changes and save settings to database
state.update(cx, |this, cx| {
this.subscriptions
.push(cx.observe(&state, |this, _state, cx| {
this.set_settings(cx);
}));
});
AppSettings::set_global(state, cx);
}
#[derive(Serialize, Deserialize)]
pub struct Settings {
pub media_server: Url,
pub proxy_user_avatars: bool,
pub hide_user_avatars: bool,
pub only_show_trusted: bool,
pub backup_messages: bool,
}
impl AsRef<Settings> for Settings {
fn as_ref(&self) -> &Settings {
self
}
}
struct GlobalAppSettings(Entity<AppSettings>);
impl Global for GlobalAppSettings {}
pub struct AppSettings {
pub settings: Settings,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
}
impl AppSettings {
/// Retrieve the Global Settings instance
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAppSettings>().0.clone()
}
/// Retrieve the Settings instance
pub fn get_global(cx: &App) -> &Self {
cx.global::<GlobalAppSettings>().0.read(cx)
}
/// Set the global Settings instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAppSettings(state));
}
fn new(cx: &mut Context<Self>) -> Self {
let settings = Settings {
media_server: Url::parse("https://nostrmedia.com").expect("it's fine"),
proxy_user_avatars: true,
hide_user_avatars: false,
only_show_trusted: false,
backup_messages: true,
};
let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_new::<Self>(|this, _window, cx| {
this.get_settings_from_db(cx);
}));
Self {
settings,
subscriptions,
}
}
pub fn settings(&self) -> &Settings {
self.settings.as_ref()
}
fn get_settings_from_db(&self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, anyhow::Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier("coop-settings")
.limit(1);
if let Some(event) = shared_state()
.client
.database()
.query(filter)
.await?
.first_owned()
{
log::info!("Successfully loaded settings from database");
Ok(serde_json::from_str(&event.content)?)
} else {
Err(anyhow!("Not found"))
}
});
cx.spawn(async move |this, cx| {
if let Ok(settings) = task.await {
this.update(cx, |this, cx| {
this.settings = settings;
cx.notify();
})
.ok();
}
})
.detach();
}
fn set_settings(&self, cx: &mut Context<Self>) {
if let Ok(content) = serde_json::to_string(&self.settings) {
cx.background_spawn(async move {
let Some(identity) = shared_state().identity() else {
return;
};
let keys = Keys::generate();
let ident = Tag::identifier("coop-settings");
if let Ok(event) = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tags(vec![ident])
.build(identity.public_key())
.sign(&keys)
.await
{
if let Err(e) = shared_state().client.database().save_event(&event).await {
log::error!("Failed to save user settings: {e}");
} else {
log::info!("New settings have been saved successfully");
}
}
})
.detach();
}
}
}

View File

@@ -373,6 +373,7 @@ impl Theme {
Self::change(appearance, window, cx); Self::change(appearance, window, cx);
} }
/// Change the app's appearance
pub fn change(mode: impl Into<ThemeMode>, window: Option<&mut Window>, cx: &mut App) { pub fn change(mode: impl Into<ThemeMode>, window: Option<&mut Window>, cx: &mut App) {
let mode = mode.into(); let mode = mode.into();
let colors = match mode { let colors = match mode {

View File

@@ -1,54 +1,117 @@
use std::rc::Rc; use std::{rc::Rc, time::Duration};
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
actions, anchored, div, point, px, relative, Animation, AnimationExt as _, AnyElement, App, anchored, div, point, prelude::FluentBuilder, px, relative, Animation, AnimationExt as _,
Bounds, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement, KeyBinding, MouseButton, AnyElement, App, Bounds, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement,
ParentElement, Pixels, Point, RenderOnce, SharedString, Styled, Window, KeyBinding, MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, Styled,
Window,
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::animation::cubic_bezier; use crate::{
use crate::button::{Button, ButtonCustomVariant, ButtonVariants as _}; actions::{Cancel, Confirm},
use crate::{v_flex, ContextModal, IconName, StyledExt}; animation::cubic_bezier,
button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _},
actions!(modal, [Escape]); h_flex, v_flex, ContextModal, IconName, Root, StyledExt,
};
const CONTEXT: &str = "Modal"; const CONTEXT: &str = "Modal";
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))]) cx.bind_keys([
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
]);
} }
type OnClose = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>; type OnClose = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
type OnOk = Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>>;
type OnCancel = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>;
type RenderButtonFn = Box<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
type FooterFn =
Box<dyn Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<AnyElement>>;
/// Modal button props.
pub struct ModalButtonProps {
ok_text: Option<SharedString>,
ok_variant: ButtonVariant,
cancel_text: Option<SharedString>,
cancel_variant: ButtonVariant,
}
impl Default for ModalButtonProps {
fn default() -> Self {
Self {
ok_text: None,
ok_variant: ButtonVariant::Primary,
cancel_text: None,
cancel_variant: ButtonVariant::default(),
}
}
}
impl ModalButtonProps {
/// Sets the text of the OK button. Default is `OK`.
pub fn ok_text(mut self, ok_text: impl Into<SharedString>) -> Self {
self.ok_text = Some(ok_text.into());
self
}
/// Sets the variant of the OK button. Default is `ButtonVariant::Primary`.
pub fn ok_variant(mut self, ok_variant: ButtonVariant) -> Self {
self.ok_variant = ok_variant;
self
}
/// Sets the text of the Cancel button. Default is `Cancel`.
pub fn cancel_text(mut self, cancel_text: impl Into<SharedString>) -> Self {
self.cancel_text = Some(cancel_text.into());
self
}
/// Sets the variant of the Cancel button. Default is `ButtonVariant::default()`.
pub fn cancel_variant(mut self, cancel_variant: ButtonVariant) -> Self {
self.cancel_variant = cancel_variant;
self
}
}
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct Modal { pub struct Modal {
base: Div, base: Div,
title: Option<AnyElement>, title: Option<AnyElement>,
footer: Option<AnyElement>, footer: Option<FooterFn>,
content: Div, content: Div,
width: Pixels, width: Pixels,
max_width: Option<Pixels>, max_width: Option<Pixels>,
margin_top: Option<Pixels>, margin_top: Option<Pixels>,
on_close: OnClose, on_close: OnClose,
closable: bool, on_ok: OnOk,
on_cancel: OnCancel,
button_props: ModalButtonProps,
show_close: bool,
overlay: bool,
overlay_closable: bool,
keyboard: bool, keyboard: bool,
/// This will be change when open the modal, the focus handle is create when open the modal. /// This will be change when open the modal, the focus handle is create when open the modal.
pub(crate) focus_handle: FocusHandle, pub(crate) focus_handle: FocusHandle,
pub(crate) layer_ix: usize, pub(crate) layer_ix: usize,
pub(crate) overlay: bool, pub(crate) overlay_visible: bool,
} }
impl Modal { impl Modal {
pub fn new(_window: &mut Window, cx: &mut App) -> Self { pub fn new(_window: &mut Window, cx: &mut App) -> Self {
let radius = (cx.theme().radius * 2.).min(px(20.));
let base = v_flex() let base = v_flex()
.bg(cx.theme().background) .bg(cx.theme().background)
.border_1() .border_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.rounded_xl() .rounded(radius)
.shadow_md(); .shadow_xl()
.min_h_24();
Self { Self {
base, base,
@@ -61,9 +124,14 @@ impl Modal {
max_width: None, max_width: None,
overlay: true, overlay: true,
keyboard: true, keyboard: true,
closable: true,
layer_ix: 0, layer_ix: 0,
overlay_visible: false,
on_close: Rc::new(|_, _, _| {}), on_close: Rc::new(|_, _, _| {}),
on_ok: None,
on_cancel: Rc::new(|_, _, _| true),
button_props: ModalButtonProps::default(),
show_close: true,
overlay_closable: true,
} }
} }
@@ -74,12 +142,54 @@ impl Modal {
} }
/// Set the footer of the modal. /// Set the footer of the modal.
pub fn footer(mut self, footer: impl IntoElement) -> Self { ///
self.footer = Some(footer.into_any_element()); /// The `footer` is a function that takes two `RenderButtonFn` and a `WindowContext` and returns a list of `AnyElement`.
///
/// - First `RenderButtonFn` is the render function for the OK button.
/// - Second `RenderButtonFn` is the render function for the CANCEL button.
///
/// When you set the footer, the footer will be placed default footer buttons.
pub fn footer<E, F>(mut self, footer: F) -> Self
where
E: IntoElement,
F: Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<E> + 'static,
{
self.footer = Some(Box::new(move |ok, cancel, window, cx| {
footer(ok, cancel, window, cx)
.into_iter()
.map(|e| e.into_any_element())
.collect()
}));
self
}
/// Set to use confirm modal, with OK and Cancel buttons.
///
/// See also [`Self::alert`]
pub fn confirm(self) -> Self {
self.footer(|ok, cancel, window, cx| vec![cancel(window, cx), ok(window, cx)])
.overlay_closable(false)
.show_close(false)
}
/// Set to as a alter modal, with OK button.
///
/// See also [`Self::confirm`]
pub fn alert(self) -> Self {
self.footer(|ok, _, window, cx| vec![ok(window, cx)])
.overlay_closable(false)
.show_close(false)
}
/// Set the button props of the modal.
pub fn button_props(mut self, button_props: ModalButtonProps) -> Self {
self.button_props = button_props;
self self
} }
/// Sets the callback for when the modal is closed. /// Sets the callback for when the modal is closed.
///
/// Called after [`Self::on_ok`] or [`Self::on_cancel`] callback.
pub fn on_close( pub fn on_close(
mut self, mut self,
on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -88,9 +198,31 @@ impl Modal {
self self
} }
/// Sets the false to make modal unclosable, default: true /// Sets the callback for when the modal is has been confirmed.
pub fn closable(mut self, closable: bool) -> Self { ///
self.closable = closable; /// The callback should return `true` to close the modal, if return `false` the modal will not be closed.
pub fn on_ok(
mut self,
on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
) -> Self {
self.on_ok = Some(Rc::new(on_ok));
self
}
/// Sets the callback for when the modal is has been canceled.
///
/// The callback should return `true` to close the modal, if return `false` the modal will not be closed.
pub fn on_cancel(
mut self,
on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
) -> Self {
self.on_cancel = Rc::new(on_cancel);
self
}
/// Sets the false to hide close icon, default: true
pub fn show_close(mut self, show_close: bool) -> Self {
self.show_close = show_close;
self self
} }
@@ -118,6 +250,14 @@ impl Modal {
self self
} }
/// Set the overlay closable of the modal, defaults to `true`.
///
/// When the overlay is clicked, the modal will be closed.
pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
self.overlay_closable = overlay_closable;
self
}
/// Set whether to support keyboard esc to close the modal, defaults to `true`. /// Set whether to support keyboard esc to close the modal, defaults to `true`.
pub fn keyboard(mut self, keyboard: bool) -> Self { pub fn keyboard(mut self, keyboard: bool) -> Self {
self.keyboard = keyboard; self.keyboard = keyboard;
@@ -145,6 +285,64 @@ impl RenderOnce for Modal {
fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement { fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement {
let layer_ix = self.layer_ix; let layer_ix = self.layer_ix;
let on_close = self.on_close.clone(); let on_close = self.on_close.clone();
let on_ok = self.on_ok.clone();
let on_cancel = self.on_cancel.clone();
let render_ok: RenderButtonFn = Box::new({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
let ok_text = self.button_props.ok_text.unwrap_or_else(|| "Ok".into());
let ok_variant = self.button_props.ok_variant;
move |_, _| {
Button::new("ok")
.label(ok_text)
.with_variant(ok_variant)
.on_click({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
move |_, window, cx| {
if let Some(on_ok) = &on_ok {
if !on_ok(&ClickEvent::default(), window, cx) {
return;
}
}
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
})
.into_any_element()
}
});
let render_cancel: RenderButtonFn = Box::new({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
let cancel_text = self
.button_props
.cancel_text
.unwrap_or_else(|| "Cancel".into());
let cancel_variant = self.button_props.cancel_variant;
move |_, _| {
Button::new("cancel")
.label(cancel_text)
.with_variant(cancel_variant)
.on_click({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
move |_, window, cx| {
if !on_cancel(&ClickEvent::default(), window, cx) {
return;
}
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
})
.into_any_element()
}
});
let window_paddings = crate::window_border::window_paddings(window, cx); let window_paddings = crate::window_border::window_paddings(window, cx);
let view_size = window.viewport_size() let view_size = window.viewport_size()
- gpui::size( - gpui::size(
@@ -155,8 +353,8 @@ impl RenderOnce for Modal {
origin: Point::default(), origin: Point::default(),
size: view_size, size: view_size,
}; };
let offset_top = px(layer_ix as f32 * 2.); let offset_top = px(layer_ix as f32 * 16.);
let y = self.margin_top.unwrap_or(view_size.height / 16.) + offset_top; let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
let x = bounds.center().x - self.width / 2.; let x = bounds.center().x - self.width / 2.;
anchored() anchored()
@@ -164,14 +362,22 @@ impl RenderOnce for Modal {
.snap_to_window() .snap_to_window()
.child( .child(
div() div()
.occlude()
.w(view_size.width) .w(view_size.width)
.h(view_size.height) .h(view_size.height)
.when(self.overlay, |this| this.bg(cx.theme().overlay)) .when(self.overlay_visible, |this| {
.when(self.keyboard, |this| { this.occlude().bg(cx.theme().overlay)
})
.when(self.overlay_closable, |this| {
// Only the last modal owns the `mouse down - close modal` event.
if (self.layer_ix + 1) != Root::read(window, cx).active_modals.len() {
return this;
}
this.on_mouse_down(MouseButton::Left, { this.on_mouse_down(MouseButton::Left, {
let on_close = self.on_close.clone(); let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
move |_, window, cx| { move |_, window, cx| {
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx); on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx); window.close_modal(cx);
} }
@@ -182,8 +388,39 @@ impl RenderOnce for Modal {
.id(SharedString::from(format!("modal-{layer_ix}"))) .id(SharedString::from(format!("modal-{layer_ix}")))
.key_context(CONTEXT) .key_context(CONTEXT)
.track_focus(&self.focus_handle) .track_focus(&self.focus_handle)
.when(self.keyboard, |this| {
this.on_action({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
move |_: &Cancel, window, cx| {
// FIXME:
//
// Here some Modal have no focus_handle, so it will not work will Escape key.
// But by now, we `cx.close_modal()` going to close the last active model, so the Escape is unexpected to work.
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
})
.on_action({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
let has_footer = self.footer.is_some();
move |_: &Confirm, window, cx| {
if let Some(on_ok) = &on_ok {
if on_ok(&ClickEvent::default(), window, cx) {
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
} else if has_footer {
window.close_modal(cx);
}
}
})
})
.absolute() .absolute()
.occlude() .occlude()
.relative()
.left(x) .left(x)
.top(y) .top(y)
.w(self.width) .w(self.width)
@@ -203,7 +440,7 @@ impl RenderOnce for Modal {
.child(title), .child(title),
) )
}) })
.when(self.closable, |this| { .when(self.show_close, |this| {
this.child( this.child(
Button::new(SharedString::from(format!( Button::new(SharedString::from(format!(
"modal-close-{layer_ix}" "modal-close-{layer_ix}"
@@ -221,26 +458,23 @@ impl RenderOnce for Modal {
) )
.on_click( .on_click(
move |_, window, cx| { move |_, window, cx| {
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx); on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx); window.close_modal(cx);
}, },
), ),
) )
}) })
.child(self.content) .child(div().w_full().flex_1().child(self.content))
.children(self.footer) .when(self.footer.is_some(), |this| {
.when(self.keyboard, |this| { let footer = self.footer.unwrap();
this.on_action({
let on_close = self.on_close.clone(); this.child(h_flex().gap_2().justify_end().children(footer(
move |_: &Escape, window, cx| { render_ok,
// FIXME: render_cancel,
// window,
// Here some Modal have no focus_handle, so it will not work will Escape key. cx,
// But by now, we `cx.close_modal()` going to close the last active model, so the Escape is unexpected to work. )))
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
})
}) })
.with_animation( .with_animation(
"slide-down", "slide-down",

View File

@@ -128,7 +128,7 @@ impl ContextModal for Window {
type Builder = Rc<dyn Fn(Modal, &mut Window, &mut App) -> Modal + 'static>; type Builder = Rc<dyn Fn(Modal, &mut Window, &mut App) -> Modal + 'static>;
#[derive(Clone)] #[derive(Clone)]
pub struct ActiveModal { pub(crate) struct ActiveModal {
focus_handle: FocusHandle, focus_handle: FocusHandle,
builder: Builder, builder: Builder,
} }
@@ -137,7 +137,7 @@ pub struct ActiveModal {
/// ///
/// It is used to manage the Modal, and Notification. /// It is used to manage the Modal, and Notification.
pub struct Root { pub struct Root {
pub active_modals: Vec<ActiveModal>, pub(crate) active_modals: Vec<ActiveModal>,
pub notification: Entity<NotificationList>, pub notification: Entity<NotificationList>,
pub focused_input: Option<Entity<InputState>>, pub focused_input: Option<Entity<InputState>>,
/// Used to store the focus handle of the previous view. /// Used to store the focus handle of the previous view.
@@ -194,36 +194,46 @@ impl Root {
/// Render the Modal layer. /// Render the Modal layer.
pub fn render_modal_layer(window: &mut Window, cx: &mut App) -> Option<impl IntoElement> { pub fn render_modal_layer(window: &mut Window, cx: &mut App) -> Option<impl IntoElement> {
let root = window.root::<Root>()??; let root = window.root::<Root>()??;
let active_modals = root.read(cx).active_modals.clone(); let active_modals = root.read(cx).active_modals.clone();
let mut has_overlay = false;
if active_modals.is_empty() { if active_modals.is_empty() {
return None; return None;
} }
Some( let mut show_overlay_ix = None;
div().children(active_modals.iter().enumerate().map(|(i, active_modal)| {
let mut modals = active_modals
.iter()
.enumerate()
.map(|(i, active_modal)| {
let mut modal = Modal::new(window, cx); let mut modal = Modal::new(window, cx);
modal = (active_modal.builder)(modal, window, cx); modal = (active_modal.builder)(modal, window, cx);
modal.layer_ix = i;
// Give the modal the focus handle, because `modal` is a temporary value, is not possible to // Give the modal the focus handle, because `modal` is a temporary value, is not possible to
// keep the focus handle in the modal. // keep the focus handle in the modal.
// //
// So we keep the focus handle in the `active_modal`, this is owned by the `Root`. // So we keep the focus handle in the `active_modal`, this is owned by the `Root`.
modal.focus_handle = active_modal.focus_handle.clone(); modal.focus_handle = active_modal.focus_handle.clone();
// Keep only have one overlay, we only render the first modal with overlay. modal.layer_ix = i;
if has_overlay { // Find the modal which one needs to show overlay.
modal.overlay = false;
}
if modal.has_overlay() { if modal.has_overlay() {
has_overlay = true; show_overlay_ix = Some(i);
} }
modal modal
})), })
) .collect::<Vec<_>>();
if let Some(ix) = show_overlay_ix {
if let Some(modal) = modals.get_mut(ix) {
modal.overlay_visible = true;
}
}
Some(div().children(modals))
} }
/// Return the root view of the Root. /// Return the root view of the Root.

View File

@@ -4,13 +4,13 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
div, px, Animation, AnimationExt as _, AnyElement, App, Element, ElementId, GlobalElementId, div, px, white, Animation, AnimationExt as _, AnyElement, App, Element, ElementId,
InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString, Styled as _, GlobalElementId, InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString,
Window, Styled as _, Window,
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::{h_flex, Disableable, Side, Sizable, Size}; use crate::{Disableable, Side, Sizable, Size};
type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>; type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>;
@@ -19,6 +19,7 @@ pub struct Switch {
checked: bool, checked: bool,
disabled: bool, disabled: bool,
label: Option<SharedString>, label: Option<SharedString>,
description: Option<SharedString>,
label_side: Side, label_side: Side,
on_click: OnClick, on_click: OnClick,
size: Size, size: Size,
@@ -27,13 +28,15 @@ pub struct Switch {
impl Switch { impl Switch {
pub fn new(id: impl Into<ElementId>) -> Self { pub fn new(id: impl Into<ElementId>) -> Self {
let id: ElementId = id.into(); let id: ElementId = id.into();
Self { Self {
id: id.clone(), id: id.clone(),
checked: false, checked: false,
disabled: false, disabled: false,
label: None, label: None,
description: None,
on_click: None, on_click: None,
label_side: Side::Right, label_side: Side::Left,
size: Size::Medium, size: Size::Medium,
} }
} }
@@ -48,6 +51,11 @@ impl Switch {
self self
} }
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn on_click<F>(mut self, handler: F) -> Self pub fn on_click<F>(mut self, handler: F) -> Self
where where
F: Fn(&bool, &mut Window, &mut App) + 'static, F: Fn(&bool, &mut Window, &mut App) + 'static,
@@ -116,8 +124,8 @@ impl Element for Switch {
let on_click = self.on_click.clone(); let on_click = self.on_click.clone();
let (bg, toggle_bg) = match self.checked { let (bg, toggle_bg) = match self.checked {
true => (theme.icon_accent, theme.background), true => (theme.element_background, white()),
false => (theme.element_background, theme.background), false => (theme.elevated_surface_background, white()),
}; };
let (bg, toggle_bg) = match self.disabled { let (bg, toggle_bg) = match self.disabled {
@@ -138,74 +146,98 @@ impl Element for Switch {
let inset = px(2.); let inset = px(2.);
let mut element = div() let mut element = div()
.flex()
.child( .child(
h_flex() div()
.id(self.id.clone()) .id(self.id.clone())
.items_center()
.gap_2()
.when(self.label_side.is_left(), |this| this.flex_row_reverse()) .when(self.label_side.is_left(), |this| this.flex_row_reverse())
.child( .child(
// Switch Bar
div() div()
.id(self.id.clone()) .w_full()
.w(bg_width)
.h(bg_height)
.rounded(bg_height / 2.)
.flex() .flex()
.justify_between()
.items_center() .items_center()
.border(inset) .gap_4()
.border_color(theme.border_transparent) .when_some(self.label.clone(), |this, label| {
.bg(bg) // Label
.when(!self.disabled, |this| this.cursor_pointer()) this.child(
div().text_sm().text_color(cx.theme().text).child(label),
)
})
.child( .child(
// Switch Toggle // Switch Bar
div().rounded_full().bg(toggle_bg).size(bar_width).map( div()
|this| { .id(self.id.clone())
let prev_checked = state.prev_checked.clone(); .flex_shrink_0()
if !self.disabled .w(bg_width)
&& prev_checked .h(bg_height)
.borrow() .rounded(bg_height / 2.)
.is_some_and(|prev| prev != checked) .flex()
{ .items_center()
let dur = Duration::from_secs_f64(0.15); .border(inset)
cx.spawn(async move |cx| { .border_color(theme.border_transparent)
cx.background_executor().timer(dur).await; .bg(bg)
*prev_checked.borrow_mut() = Some(checked); .when(!self.disabled, |this| this.cursor_pointer())
}) .child(
.detach(); // Switch Toggle
this.with_animation( div()
ElementId::NamedInteger( .rounded_full()
"move".into(), .shadow_sm()
checked as u64, .bg(toggle_bg)
), .size(bar_width)
Animation::new(dur), .map(|this| {
move |this, delta| { let prev_checked = state.prev_checked.clone();
if !self.disabled
&& prev_checked
.borrow()
.is_some_and(|prev| prev != checked)
{
let dur = Duration::from_secs_f64(0.15);
cx.spawn(async move |cx| {
cx.background_executor()
.timer(dur)
.await;
*prev_checked.borrow_mut() =
Some(checked);
})
.detach();
this.with_animation(
ElementId::NamedInteger(
"move".into(),
checked as u64,
),
Animation::new(dur),
move |this, delta| {
let max_x = bg_width
- bar_width
- inset * 2;
let x = if checked {
max_x * delta
} else {
max_x - max_x * delta
};
this.left(x)
},
)
.into_any_element()
} else {
let max_x = let max_x =
bg_width - bar_width - inset * 2; bg_width - bar_width - inset * 2;
let x = if checked { let x =
max_x * delta if checked { max_x } else { px(0.) };
} else { this.left(x).into_any_element()
max_x - max_x * delta }
}; }),
this.left(x) ),
},
)
.into_any_element()
} else {
let max_x = bg_width - bar_width - inset * 2;
let x = if checked { max_x } else { px(0.) };
this.left(x).into_any_element()
}
},
),
), ),
) )
.when_some(self.label.clone(), |this, label| { .when_some(self.description.clone(), |this, description| {
this.child(div().child(label).map(|this| match self.size { this.child(
Size::XSmall | Size::Small => this.text_sm(), div()
_ => this.text_base(), .w_3_4()
})) .text_xs()
.text_color(cx.theme().text_muted)
.child(description),
)
}) })
.when_some( .when_some(
on_click on_click