notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 9c9b4199f561daa315644cc5f3b8104a24d224e1
parent 415a0526020c2317fc4f1be7cb42d5d59ed0592a
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 26 Mar 2025 12:18:44 -0700

ui crate and chrome sidebar

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
MCargo.lock | 19+++++++++++++++++++
MCargo.toml | 4+++-
Mcrates/notedeck_chrome/Cargo.toml | 2++
Mcrates/notedeck_chrome/src/chrome.rs | 281++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/notedeck_chrome/src/preview.rs | 3+--
Mcrates/notedeck_columns/Cargo.toml | 1+
Mcrates/notedeck_columns/src/app.rs | 41++++++++++++++++++++++++-----------------
Dcrates/notedeck_columns/src/images.rs | 419-------------------------------------------------------------------------------
Mcrates/notedeck_columns/src/lib.rs | 3---
Mcrates/notedeck_columns/src/media_upload.rs | 3++-
Mcrates/notedeck_columns/src/post.rs | 2+-
Mcrates/notedeck_columns/src/ui/accounts.rs | 2+-
Mcrates/notedeck_columns/src/ui/add_column.rs | 25+++++++++++++++++++++++--
Mcrates/notedeck_columns/src/ui/column/header.rs | 3+--
Mcrates/notedeck_columns/src/ui/configure_deck.rs | 3++-
Mcrates/notedeck_columns/src/ui/images.rs | 75---------------------------------------------------------------------------
Mcrates/notedeck_columns/src/ui/mod.rs | 3++-
Mcrates/notedeck_columns/src/ui/note/contents.rs | 9++++++---
Mcrates/notedeck_columns/src/ui/note/post.rs | 8+++++---
Mcrates/notedeck_columns/src/ui/profile/edit.rs | 8+++++---
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 26+++++++-------------------
Dcrates/notedeck_columns/src/ui/profile/picture.rs | 265-------------------------------------------------------------------------------
Mcrates/notedeck_columns/src/ui/profile/preview.rs | 29+++--------------------------
Mcrates/notedeck_columns/src/ui/relay.rs | 4++--
Mcrates/notedeck_columns/src/ui/search_results.rs | 4++--
Mcrates/notedeck_columns/src/ui/side_panel.rs | 392+++++++++++++++++++++----------------------------------------------------------
Mcrates/notedeck_columns/src/ui/support.rs | 3++-
Mcrates/notedeck_columns/src/ui/timeline.rs | 2+-
Mcrates/notedeck_columns/src/ui/wallet.rs | 2+-
Mcrates/notedeck_dave/src/avatar.rs | 2+-
Mcrates/notedeck_dave/src/lib.rs | 8++++----
Acrates/notedeck_ui/Cargo.toml | 16++++++++++++++++
Acrates/notedeck_ui/src/anim.rs | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rcrates/notedeck_columns/src/colors.rs -> crates/notedeck_ui/src/colors.rs | 0
Rcrates/notedeck_columns/src/gif.rs -> crates/notedeck_ui/src/gif.rs | 0
Acrates/notedeck_ui/src/images.rs | 488+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_ui/src/lib.rs | 8++++++++
Acrates/notedeck_ui/src/profile/mod.rs | 17+++++++++++++++++
Acrates/notedeck_ui/src/profile/picture.rs | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
39 files changed, 1309 insertions(+), 1157 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3148,9 +3148,11 @@ dependencies = [ "egui", "egui-winit", "egui_extras", + "nostrdb", "notedeck", "notedeck_columns", "notedeck_dave", + "notedeck_ui", "profiling", "puffin", "puffin_egui", @@ -3188,6 +3190,7 @@ dependencies = [ "indexmap", "nostrdb", "notedeck", + "notedeck_ui", "open", "poll-promise", "pretty_assertions", @@ -3237,6 +3240,22 @@ dependencies = [ ] [[package]] +name = "notedeck_ui" +version = "0.3.1" +dependencies = [ + "egui", + "egui_extras", + "ehttp", + "image", + "nostrdb", + "notedeck", + "poll-promise", + "profiling", + "tokio", + "tracing", +] + +[[package]] name = "nu-ansi-term" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -6,8 +6,9 @@ members = [ "crates/notedeck_chrome", "crates/notedeck_columns", "crates/notedeck_dave", + "crates/notedeck_ui", - "crates/enostr", "crates/tokenator", "crates/notedeck_dave", + "crates/enostr", "crates/tokenator", "crates/notedeck_dave", "crates/notedeck_ui", ] [workspace.dependencies] @@ -42,6 +43,7 @@ notedeck = { path = "crates/notedeck" } notedeck_chrome = { path = "crates/notedeck_chrome" } notedeck_columns = { path = "crates/notedeck_columns" } notedeck_dave = { path = "crates/notedeck_dave" } +notedeck_ui = { path = "crates/notedeck_ui" } tokenator = { path = "crates/tokenator" } open = "5.3.0" poll-promise = { version = "0.3.0", features = ["tokio"] } diff --git a/crates/notedeck_chrome/Cargo.toml b/crates/notedeck_chrome/Cargo.toml @@ -13,8 +13,10 @@ eframe = { workspace = true } egui_extras = { workspace = true } egui = { workspace = true } notedeck_columns = { workspace = true } +notedeck_ui = { workspace = true } notedeck_dave = { workspace = true } notedeck = { workspace = true } +nostrdb = { workspace = true } puffin = { workspace = true, optional = true } puffin_egui = { workspace = true, optional = true } serde_json = { workspace = true } diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -1,19 +1,31 @@ - // Entry point for wasm //#[cfg(target_arch = "wasm32")] //use wasm_bindgen::prelude::*; +use egui::{Button, Label, Layout, RichText, ThemePreference, Widget}; +use egui_extras::{Size, StripBuilder}; +use nostrdb::{ProfileRecord, Transaction}; +use notedeck::{AppContext, NotedeckTextStyle, UserAccount}; +use notedeck_ui::{profile::get_profile_url, AnimationHelper, ProfilePic}; + +static ICON_WIDTH: f32 = 40.0; +pub static ICON_EXPANSION_MULTIPLE: f32 = 1.2; +#[derive(Default)] pub struct Chrome { active: i32, apps: Vec<Box<dyn notedeck::App>>, } +pub enum ChromePanelAction { + Support, + Settings, + Account, + SaveTheme(ThemePreference), +} + impl Chrome { pub fn new() -> Self { - Chrome { - active: 0, - apps: vec![], - } + Chrome::default() } pub fn add_app(&mut self, app: impl notedeck::App + 'static) { @@ -23,15 +35,264 @@ impl Chrome { pub fn set_active(&mut self, app: i32) { self.active = app; } + + /// Show the side menu or bar, depending on if we're on a narrow + /// or wide screen. + /// + /// The side menu should hover over the screen, while the side bar + /// is collapsible but persistent on the screen. + fn show(&mut self, ctx: &mut AppContext, ui: &mut egui::Ui) { + ui.spacing_mut().item_spacing.x = 0.0; + + let side_panel_width: f32 = 68.0; + StripBuilder::new(ui) + .size(Size::exact(side_panel_width)) // collapsible sidebar + .size(Size::remainder()) // the main app contents + .clip(true) + .horizontal(|mut strip| { + strip.cell(|ui| { + let rect = ui.available_rect_before_wrap(); + if !ui.visuals().dark_mode { + let rect = ui.available_rect_before_wrap(); + ui.painter().rect( + rect, + 0, + notedeck_ui::colors::ALMOST_WHITE, + egui::Stroke::new(0.0, egui::Color32::TRANSPARENT), + egui::StrokeKind::Inside, + ); + } + + ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { + self.topdown_sidebar(ui); + }); + + ui.with_layout(Layout::bottom_up(egui::Align::Center), |ui| { + self.bottomup_sidebar(ctx, ui); + }); + + // vertical sidebar line + ui.painter().vline( + rect.right(), + rect.y_range(), + ui.visuals().widgets.noninteractive.bg_stroke, + ); + }); + + strip.cell(|ui| { + /* + let rect = ui.available_rect_before_wrap(); + ui.painter().rect( + rect, + 0, + egui::Color32::RED, + egui::Stroke::new(1.0, egui::Color32::BLUE), + egui::StrokeKind::Inside, + ); + */ + + self.apps[self.active as usize].update(ctx, ui); + }); + }); + } + + /// The section of the chrome sidebar that starts at the + /// bottom and goes up + fn bottomup_sidebar( + &mut self, + ctx: &mut AppContext, + ui: &mut egui::Ui, + ) -> Option<ChromePanelAction> { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let pfp_resp = self.pfp_button(ctx, ui); + let settings_resp = ui.add(settings_button(dark_mode)); + + let theme_action = match ui.ctx().theme() { + egui::Theme::Dark => { + let resp = ui + .add(Button::new("☀").frame(false)) + .on_hover_text("Switch to light mode"); + if resp.clicked() { + Some(ChromePanelAction::SaveTheme(ThemePreference::Light)) + } else { + None + } + } + egui::Theme::Light => { + let resp = ui + .add(Button::new("🌙").frame(false)) + .on_hover_text("Switch to dark mode"); + if resp.clicked() { + Some(ChromePanelAction::SaveTheme(ThemePreference::Light)) + } else { + None + } + } + }; + + if ui.add(support_button()).clicked() { + return Some(ChromePanelAction::Support); + } + + if theme_action.is_some() { + return theme_action; + } + + if pfp_resp.clicked() { + Some(ChromePanelAction::Account) + } else if settings_resp.clicked() || settings_resp.hovered() { + Some(ChromePanelAction::Settings) + } else { + None + } + } + + fn pfp_button(&mut self, ctx: &mut AppContext, ui: &mut egui::Ui) -> egui::Response { + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget + let helper = AnimationHelper::new(ui, "pfp-button", egui::vec2(max_size, max_size)); + + let min_pfp_size = ICON_WIDTH; + let cur_pfp_size = helper.scale_1d_pos(min_pfp_size); + + let txn = Transaction::new(ctx.ndb).expect("should be able to create txn"); + let profile_url = get_account_url(&txn, ctx.ndb, ctx.accounts.get_selected_account()); + + let widget = ProfilePic::new(ctx.img_cache, profile_url).size(cur_pfp_size); + + ui.put(helper.get_animation_rect(), widget); + + helper.take_animation_response() + } + + fn topdown_sidebar(&mut self, ui: &mut egui::Ui) { + // macos needs a bit of space to make room for window + // minimize/close buttons + if cfg!(target_os = "macos") { + ui.add_space(28.0); + } + + if ui.add(expand_side_panel_button()).clicked() { + self.active = (self.active + 1) % (self.apps.len() as i32); + } + + ui.add_space(4.0); + ui.add(milestone_name()); + ui.add_space(16.0); + //let dark_mode = ui.ctx().style().visuals.dark_mode; + //ui.add(add_column_button(dark_mode)) + } } impl notedeck::App for Chrome { fn update(&mut self, ctx: &mut notedeck::AppContext, ui: &mut egui::Ui) { - let active = self.active; - self.apps[active as usize].update(ctx, ui); - //for i in 0..self.apps.len() { - // self.apps[i].update(ctx, ui); - //} + self.show(ctx, ui); + // TODO: unify this constant with the columns side panel width. ui crate? + } +} + +fn milestone_name() -> impl Widget { + |ui: &mut egui::Ui| -> egui::Response { + ui.vertical_centered(|ui| { + let font = egui::FontId::new( + notedeck::fonts::get_font_size( + ui.ctx(), + &NotedeckTextStyle::Tiny, + ), + egui::FontFamily::Name(notedeck::fonts::NamedFontFamily::Bold.as_str().into()), + ); + ui.add(Label::new( + RichText::new("ALPHA") + .color( ui.style().visuals.noninteractive().fg_stroke.color) + .font(font), + ).selectable(false)).on_hover_text("Notedeck is an alpha product. Expect bugs and contact us when you run into issues.").on_hover_cursor(egui::CursorIcon::Help) + }) + .inner + } +} + +fn expand_side_panel_button() -> impl Widget { + |ui: &mut egui::Ui| -> egui::Response { + let img_size = 40.0; + let img_data = egui::include_image!("../../../assets/damus_rounded_80.png"); + let img = egui::Image::new(img_data) + .max_width(img_size) + .sense(egui::Sense::click()); + + ui.add(img) + } +} + +fn support_button() -> impl Widget { + |ui: &mut egui::Ui| -> egui::Response { + let img_size = 16.0; + + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget + let img_data = if ui.visuals().dark_mode { + egui::include_image!("../../../assets/icons/help_icon_dark_4x.png") + } else { + egui::include_image!("../../../assets/icons/help_icon_inverted_4x.png") + }; + let img = egui::Image::new(img_data).max_width(img_size); + + let helper = AnimationHelper::new(ui, "help-button", egui::vec2(max_size, max_size)); + + let cur_img_size = helper.scale_1d_pos(img_size); + img.paint_at( + ui, + helper + .get_animation_rect() + .shrink((max_size - cur_img_size) / 2.0), + ); + + helper.take_animation_response() } } +fn settings_button(dark_mode: bool) -> impl Widget { + move |ui: &mut egui::Ui| { + let img_size = 24.0; + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget + let img_data = if dark_mode { + egui::include_image!("../../../assets/icons/settings_dark_4x.png") + } else { + egui::include_image!("../../../assets/icons/settings_light_4x.png") + }; + let img = egui::Image::new(img_data).max_width(img_size); + + let helper = AnimationHelper::new(ui, "settings-button", egui::vec2(max_size, max_size)); + + let cur_img_size = helper.scale_1d_pos(img_size); + img.paint_at( + ui, + helper + .get_animation_rect() + .shrink((max_size - cur_img_size) / 2.0), + ); + + helper.take_animation_response() + } +} + +pub fn get_profile_url_owned(profile: Option<ProfileRecord<'_>>) -> &str { + if let Some(url) = profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())) { + url + } else { + ProfilePic::no_pfp_url() + } +} + +pub fn get_account_url<'a>( + txn: &'a nostrdb::Transaction, + ndb: &nostrdb::Ndb, + account: Option<&UserAccount>, +) -> &'a str { + if let Some(selected_account) = account { + if let Ok(profile) = ndb.get_profile_by_pubkey(txn, selected_account.key.pubkey.bytes()) { + get_profile_url_owned(Some(profile)) + } else { + get_profile_url_owned(None) + } + } else { + get_profile_url(None) + } +} diff --git a/crates/notedeck_chrome/src/preview.rs b/crates/notedeck_chrome/src/preview.rs @@ -4,7 +4,7 @@ use notedeck_columns::ui::configure_deck::ConfigureDeckView; use notedeck_columns::ui::edit_deck::EditDeckView; use notedeck_columns::ui::profile::EditProfileView; use notedeck_columns::ui::{ - account_login_view::AccountLoginView, PostView, Preview, PreviewApp, PreviewConfig, ProfilePic, + account_login_view::AccountLoginView, PostView, Preview, PreviewApp, PreviewConfig, ProfilePreview, RelayView, }; use std::env; @@ -99,7 +99,6 @@ async fn main() { RelayView, AccountLoginView, ProfilePreview, - ProfilePic, PostView, ConfigureDeckView, EditDeckView, diff --git a/crates/notedeck_columns/Cargo.toml b/crates/notedeck_columns/Cargo.toml @@ -30,6 +30,7 @@ hex = { workspace = true } image = { workspace = true } indexmap = { workspace = true } nostrdb = { workspace = true } +notedeck_ui = { workspace = true } open = { workspace = true } poll-promise = { workspace = true } puffin = { workspace = true, optional = true } diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -554,26 +554,33 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, ctx: &mut App let mut side_panel_action: Option<nav::SwitchingAction> = None; strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); - let side_panel = DesktopSidePanel::new( - ctx.ndb, - ctx.img_cache, - ctx.accounts.get_selected_account(), - &app.decks_cache, - ) - .show(ui); - - if side_panel.response.clicked() || side_panel.response.secondary_clicked() { - if let Some(action) = DesktopSidePanel::perform_action( - &mut app.decks_cache, - ctx.accounts, - &mut app.support, - ctx.theme, - side_panel.action, - ) { - side_panel_action = Some(action); + let side_panel = + DesktopSidePanel::new(ctx.accounts.get_selected_account(), &app.decks_cache) + .show(ui); + + if let Some(side_panel) = side_panel { + if side_panel.response.clicked() || side_panel.response.secondary_clicked() { + if let Some(action) = DesktopSidePanel::perform_action( + &mut app.decks_cache, + ctx.accounts, + side_panel.action, + ) { + side_panel_action = Some(action); + } } } + // debug + /* + ui.painter().rect( + rect, + 0, + egui::Color32::RED, + egui::Stroke::new(1.0, egui::Color32::BLUE), + egui::StrokeKind::Inside, + ); + */ + // vertical sidebar line ui.painter().vline( rect.right(), diff --git a/crates/notedeck_columns/src/images.rs b/crates/notedeck_columns/src/images.rs @@ -1,419 +0,0 @@ -use egui::{pos2, Color32, ColorImage, Rect, Sense, SizeHint}; -use image::codecs::gif::GifDecoder; -use image::imageops::FilterType; -use image::AnimationDecoder; -use image::DynamicImage; -use image::FlatSamples; -use image::Frame; -use notedeck::Animation; -use notedeck::ImageFrame; -use notedeck::MediaCache; -use notedeck::MediaCacheType; -use notedeck::Result; -use notedeck::TextureFrame; -use notedeck::TexturedImage; -use poll_promise::Promise; -use std::collections::VecDeque; -use std::io::Cursor; -use std::path; -use std::path::PathBuf; -use std::sync::mpsc; -use std::sync::mpsc::SyncSender; -use std::thread; -use std::time::Duration; -use tokio::fs; - -// NOTE(jb55): chatgpt wrote this because I was too dumb to -pub fn aspect_fill( - ui: &mut egui::Ui, - sense: Sense, - texture_id: egui::TextureId, - aspect_ratio: f32, -) -> egui::Response { - let frame = ui.available_rect_before_wrap(); // Get the available frame space in the current layout - let frame_ratio = frame.width() / frame.height(); - - let (width, height) = if frame_ratio > aspect_ratio { - // Frame is wider than the content - (frame.width(), frame.width() / aspect_ratio) - } else { - // Frame is taller than the content - (frame.height() * aspect_ratio, frame.height()) - }; - - let content_rect = Rect::from_min_size( - frame.min - + egui::vec2( - (frame.width() - width) / 2.0, - (frame.height() - height) / 2.0, - ), - egui::vec2(width, height), - ); - - // Set the clipping rectangle to the frame - //let clip_rect = ui.clip_rect(); // Preserve the original clipping rectangle - //ui.set_clip_rect(frame); - - let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); - - let (response, painter) = ui.allocate_painter(ui.available_size(), sense); - - // Draw the texture within the calculated rect, potentially clipping it - painter.rect_filled(content_rect, 0.0, ui.ctx().style().visuals.window_fill()); - painter.image(texture_id, content_rect, uv, Color32::WHITE); - - // Restore the original clipping rectangle - //ui.set_clip_rect(clip_rect); - response -} - -#[profiling::function] -pub fn round_image(image: &mut ColorImage) { - // The radius to the edge of of the avatar circle - let edge_radius = image.size[0] as f32 / 2.0; - let edge_radius_squared = edge_radius * edge_radius; - - for (pixnum, pixel) in image.pixels.iter_mut().enumerate() { - // y coordinate - let uy = pixnum / image.size[0]; - let y = uy as f32; - let y_offset = edge_radius - y; - - // x coordinate - let ux = pixnum % image.size[0]; - let x = ux as f32; - let x_offset = edge_radius - x; - - // The radius to this pixel (may be inside or outside the circle) - let pixel_radius_squared: f32 = x_offset * x_offset + y_offset * y_offset; - - // If inside of the avatar circle - if pixel_radius_squared <= edge_radius_squared { - // squareroot to find how many pixels we are from the edge - let pixel_radius: f32 = pixel_radius_squared.sqrt(); - let distance = edge_radius - pixel_radius; - - // If we are within 1 pixel of the edge, we should fade, to - // antialias the edge of the circle. 1 pixel from the edge should - // be 100% of the original color, and right on the edge should be - // 0% of the original color. - if distance <= 1.0 { - *pixel = Color32::from_rgba_premultiplied( - (pixel.r() as f32 * distance) as u8, - (pixel.g() as f32 * distance) as u8, - (pixel.b() as f32 * distance) as u8, - (pixel.a() as f32 * distance) as u8, - ); - } - } else { - // Outside of the avatar circle - *pixel = Color32::TRANSPARENT; - } - } -} - -#[profiling::function] -fn process_pfp_bitmap(imgtyp: ImageType, mut image: image::DynamicImage) -> ColorImage { - match imgtyp { - ImageType::Content => { - let image_buffer = image.clone().into_rgba8(); - let color_image = ColorImage::from_rgba_unmultiplied( - [ - image_buffer.width() as usize, - image_buffer.height() as usize, - ], - image_buffer.as_flat_samples().as_slice(), - ); - color_image - } - ImageType::Profile(size) => { - // Crop square - let smaller = image.width().min(image.height()); - - if image.width() > smaller { - let excess = image.width() - smaller; - image = image.crop_imm(excess / 2, 0, image.width() - excess, image.height()); - } else if image.height() > smaller { - let excess = image.height() - smaller; - image = image.crop_imm(0, excess / 2, image.width(), image.height() - excess); - } - let image = image.resize(size, size, FilterType::CatmullRom); // DynamicImage - let image_buffer = image.into_rgba8(); // RgbaImage (ImageBuffer) - let mut color_image = ColorImage::from_rgba_unmultiplied( - [ - image_buffer.width() as usize, - image_buffer.height() as usize, - ], - image_buffer.as_flat_samples().as_slice(), - ); - round_image(&mut color_image); - color_image - } - } -} - -#[profiling::function] -fn parse_img_response(response: ehttp::Response, imgtyp: ImageType) -> Result<ColorImage> { - let content_type = response.content_type().unwrap_or_default(); - let size_hint = match imgtyp { - ImageType::Profile(size) => SizeHint::Size(size, size), - ImageType::Content => SizeHint::default(), - }; - - if content_type.starts_with("image/svg") { - profiling::scope!("load_svg"); - - let mut color_image = - egui_extras::image::load_svg_bytes_with_size(&response.bytes, Some(size_hint))?; - round_image(&mut color_image); - Ok(color_image) - } else if content_type.starts_with("image/") { - profiling::scope!("load_from_memory"); - let dyn_image = image::load_from_memory(&response.bytes)?; - Ok(process_pfp_bitmap(imgtyp, dyn_image)) - } else { - Err(format!("Expected image, found content-type {:?}", content_type).into()) - } -} - -fn fetch_img_from_disk( - ctx: &egui::Context, - url: &str, - path: &path::Path, - cache_type: MediaCacheType, -) -> Promise<Result<TexturedImage>> { - let ctx = ctx.clone(); - let url = url.to_owned(); - let path = path.to_owned(); - Promise::spawn_async(async move { - match cache_type { - MediaCacheType::Image => { - let data = fs::read(path).await?; - let image_buffer = - image::load_from_memory(&data).map_err(notedeck::Error::Image)?; - - let img = buffer_to_color_image( - image_buffer.as_flat_samples_u8(), - image_buffer.width(), - image_buffer.height(), - ); - Ok(TexturedImage::Static(ctx.load_texture( - &url, - img, - Default::default(), - ))) - } - MediaCacheType::Gif => { - let gif_bytes = fs::read(path.clone()).await?; // Read entire file into a Vec<u8> - generate_gif(ctx, url, &path, gif_bytes, false, |i| { - buffer_to_color_image(i.as_flat_samples_u8(), i.width(), i.height()) - }) - } - } - }) -} - -fn generate_gif( - ctx: egui::Context, - url: String, - path: &path::Path, - data: Vec<u8>, - write_to_disk: bool, - process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + Copy + 'static, -) -> Result<TexturedImage> { - let decoder = { - let reader = Cursor::new(data.as_slice()); - GifDecoder::new(reader)? - }; - let (tex_input, tex_output) = mpsc::sync_channel(4); - let (maybe_encoder_input, maybe_encoder_output) = if write_to_disk { - let (inp, out) = mpsc::sync_channel(4); - (Some(inp), Some(out)) - } else { - (None, None) - }; - - let mut frames: VecDeque<Frame> = decoder - .into_frames() - .collect::<std::result::Result<VecDeque<_>, image::ImageError>>() - .map_err(|e| notedeck::Error::Generic(e.to_string()))?; - - let first_frame = frames.pop_front().map(|frame| { - generate_animation_frame( - &ctx, - &url, - 0, - frame, - maybe_encoder_input.as_ref(), - process_to_egui, - ) - }); - - let cur_url = url.clone(); - thread::spawn(move || { - for (index, frame) in frames.into_iter().enumerate() { - let texture_frame = generate_animation_frame( - &ctx, - &cur_url, - index, - frame, - maybe_encoder_input.as_ref(), - process_to_egui, - ); - - if tex_input.send(texture_frame).is_err() { - tracing::error!("AnimationTextureFrame mpsc stopped abruptly"); - break; - } - } - }); - - if let Some(encoder_output) = maybe_encoder_output { - let path = path.to_owned(); - - thread::spawn(move || { - let mut imgs = Vec::new(); - while let Ok(img) = encoder_output.recv() { - imgs.push(img); - } - - if let Err(e) = MediaCache::write_gif(&path, &url, imgs) { - tracing::error!("Could not write gif to disk: {e}"); - } - }); - } - - first_frame.map_or_else( - || { - Err(notedeck::Error::Generic( - "first frame not found for gif".to_owned(), - )) - }, - |first_frame| { - Ok(TexturedImage::Animated(Animation { - other_frames: Default::default(), - receiver: Some(tex_output), - first_frame, - })) - }, - ) -} - -fn generate_animation_frame( - ctx: &egui::Context, - url: &str, - index: usize, - frame: image::Frame, - maybe_encoder_input: Option<&SyncSender<ImageFrame>>, - process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + 'static, -) -> TextureFrame { - let delay = Duration::from(frame.delay()); - let img = DynamicImage::ImageRgba8(frame.into_buffer()); - let color_img = process_to_egui(img); - - if let Some(sender) = maybe_encoder_input { - if let Err(e) = sender.send(ImageFrame { - delay, - image: color_img.clone(), - }) { - tracing::error!("ImageFrame mpsc unexpectedly closed: {e}"); - } - } - - TextureFrame { - delay, - texture: ctx.load_texture(format!("{}{}", url, index), color_img, Default::default()), - } -} - -fn buffer_to_color_image( - samples: Option<FlatSamples<&[u8]>>, - width: u32, - height: u32, -) -> ColorImage { - // TODO(jb55): remove unwrap here - let flat_samples = samples.unwrap(); - ColorImage::from_rgba_unmultiplied([width as usize, height as usize], flat_samples.as_slice()) -} - -pub fn fetch_binary_from_disk(path: PathBuf) -> Result<Vec<u8>> { - std::fs::read(path).map_err(|e| notedeck::Error::Generic(e.to_string())) -} - -/// Controls type-specific handling -#[derive(Debug, Clone, Copy)] -pub enum ImageType { - /// Profile Image (size) - Profile(u32), - /// Content Image - Content, -} - -pub fn fetch_img( - img_cache: &MediaCache, - ctx: &egui::Context, - url: &str, - imgtyp: ImageType, - cache_type: MediaCacheType, -) -> Promise<Result<TexturedImage>> { - let key = MediaCache::key(url); - let path = img_cache.cache_dir.join(key); - - if path.exists() { - fetch_img_from_disk(ctx, url, &path, cache_type) - } else { - fetch_img_from_net(&img_cache.cache_dir, ctx, url, imgtyp, cache_type) - } - - // TODO: fetch image from local cache -} - -fn fetch_img_from_net( - cache_path: &path::Path, - ctx: &egui::Context, - url: &str, - imgtyp: ImageType, - cache_type: MediaCacheType, -) -> Promise<Result<TexturedImage>> { - let (sender, promise) = Promise::new(); - let request = ehttp::Request::get(url); - let ctx = ctx.clone(); - let cloned_url = url.to_owned(); - let cache_path = cache_path.to_owned(); - ehttp::fetch(request, move |response| { - let handle = response.map_err(notedeck::Error::Generic).and_then(|resp| { - match cache_type { - MediaCacheType::Image => { - let img = parse_img_response(resp, imgtyp); - img.map(|img| { - let texture_handle = - ctx.load_texture(&cloned_url, img.clone(), Default::default()); - - // write to disk - std::thread::spawn(move || { - MediaCache::write(&cache_path, &cloned_url, img) - }); - - TexturedImage::Static(texture_handle) - }) - } - MediaCacheType::Gif => { - let gif_bytes = resp.bytes; - generate_gif( - ctx.clone(), - cloned_url, - &cache_path, - gif_bytes, - true, - move |img| process_pfp_bitmap(imgtyp, img), - ) - } - } - }); - - sender.send(handle); // send the results back to the UI thread. - ctx.request_repaint(); - }); - - promise -} diff --git a/crates/notedeck_columns/src/lib.rs b/crates/notedeck_columns/src/lib.rs @@ -9,14 +9,11 @@ mod actionbar; pub mod app_creation; mod app_style; mod args; -mod colors; mod column; mod deck_state; mod decks; mod draft; mod frame_history; -mod gif; -mod images; mod key_parsing; pub mod login_manager; mod media_upload; diff --git a/crates/notedeck_columns/src/media_upload.rs b/crates/notedeck_columns/src/media_upload.rs @@ -8,7 +8,8 @@ use poll_promise::Promise; use sha2::{Digest, Sha256}; use url::Url; -use crate::{images::fetch_binary_from_disk, Error}; +use crate::Error; +use notedeck_ui::images::fetch_binary_from_disk; pub const NOSTR_BUILD_URL: fn() -> Url = || Url::parse("http://nostr.build").unwrap(); const NIP96_WELL_KNOWN: &str = ".well-known/nostr/nip96.json"; diff --git a/crates/notedeck_columns/src/post.rs b/crates/notedeck_columns/src/post.rs @@ -369,7 +369,7 @@ impl PostBuffer { pub fn to_layout_job(&self, ui: &egui::Ui) -> LayoutJob { let mut job = LayoutJob::default(); - let colored_fmt = default_text_format_colored(ui, crate::colors::PINK); + let colored_fmt = default_text_format_colored(ui, notedeck_ui::colors::PINK); let mut prev_text_char_index = 0; let mut prev_text_byte_index = 0; diff --git a/crates/notedeck_columns/src/ui/accounts.rs b/crates/notedeck_columns/src/ui/accounts.rs @@ -1,4 +1,4 @@ -use crate::colors::PINK; +use notedeck_ui::colors::PINK; use egui::{ Align, Button, Frame, Image, InnerResponse, Layout, RichText, ScrollArea, Ui, UiBuilder, Vec2, }; diff --git a/crates/notedeck_columns/src/ui/add_column.rs b/crates/notedeck_columns/src/ui/add_column.rs @@ -554,13 +554,34 @@ impl<'a> AddColumnView<'a> { } fn find_user_button() -> impl Widget { - styled_button("Find User", crate::colors::PINK) + styled_button("Find User", notedeck_ui::colors::PINK) } fn add_column_button() -> impl Widget { - styled_button("Add", crate::colors::PINK) + styled_button("Add", notedeck_ui::colors::PINK) } +/* +pub(crate) fn sized_button(text: &str) -> impl Widget + '_ { + move |ui: &mut egui::Ui| -> egui::Response { + let painter = ui.painter(); + let galley = painter.layout( + text.to_owned(), + NotedeckTextStyle::Body.get_font_id(ui.ctx()), + Color32::WHITE, + ui.available_width(), + ); + + ui.add_sized( + galley.rect.expand2(vec2(16.0, 8.0)).size(), + egui::Button::new(galley) + .corner_radius(8.0) + .fill(notedeck_ui::colors::PINK), + ) + } +} +*/ + struct ColumnOptionData { title: &'static str, description: &'static str, diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs @@ -1,4 +1,3 @@ -use crate::colors; use crate::column::ColumnsAction; use crate::nav::RenderNavAction; use crate::nav::SwitchingAction; @@ -302,7 +301,7 @@ impl<'a> NavTitle<'a> { let col_resp = if col == self.col_id { ui.dnd_drag_source(item_id, col, |ui| { item_frame - .stroke(egui::Stroke::new(2.0, colors::PINK)) + .stroke(egui::Stroke::new(2.0, notedeck_ui::colors::PINK)) .fill(ui.visuals().widgets.noninteractive.bg_stroke.color) .show(ui, |ui| self.move_tooltip_col_presentation(ui, col)); }) diff --git a/crates/notedeck_columns/src/ui/configure_deck.rs b/crates/notedeck_columns/src/ui/configure_deck.rs @@ -1,6 +1,7 @@ -use crate::{app_style::deck_icon_font_sized, colors::PINK, deck_state::DeckState}; +use crate::{app_style::deck_icon_font_sized, deck_state::DeckState}; use egui::{vec2, Button, Color32, Label, RichText, Stroke, Ui, Widget}; use notedeck::{NamedFontFamily, NotedeckTextStyle}; +use notedeck_ui::colors::PINK; use super::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, diff --git a/crates/notedeck_columns/src/ui/images.rs b/crates/notedeck_columns/src/ui/images.rs @@ -1,75 +0,0 @@ -use notedeck::{GifStateMap, Images, MediaCache, MediaCacheType, TexturedImage}; - -use crate::images::ImageType; - -use super::ProfilePic; - -#[allow(clippy::too_many_arguments)] -pub fn render_images( - ui: &mut egui::Ui, - images: &mut Images, - url: &str, - img_type: ImageType, - cache_type: MediaCacheType, - show_waiting: impl FnOnce(&mut egui::Ui), - show_error: impl FnOnce(&mut egui::Ui, String), - show_success: impl FnOnce(&mut egui::Ui, &str, &mut TexturedImage, &mut GifStateMap), -) -> egui::Response { - let cache = match cache_type { - MediaCacheType::Image => &mut images.static_imgs, - MediaCacheType::Gif => &mut images.gifs, - }; - - render_media_cache( - ui, - cache, - &mut images.gif_states, - url, - img_type, - cache_type, - show_waiting, - show_error, - show_success, - ) -} - -#[allow(clippy::too_many_arguments)] -fn render_media_cache( - ui: &mut egui::Ui, - cache: &mut MediaCache, - gif_states: &mut GifStateMap, - url: &str, - img_type: ImageType, - cache_type: MediaCacheType, - show_waiting: impl FnOnce(&mut egui::Ui), - show_error: impl FnOnce(&mut egui::Ui, String), - show_success: impl FnOnce(&mut egui::Ui, &str, &mut TexturedImage, &mut GifStateMap), -) -> egui::Response { - let m_cached_promise = cache.map().get(url); - - if m_cached_promise.is_none() { - let res = crate::images::fetch_img(cache, ui.ctx(), url, img_type, cache_type.clone()); - cache.map_mut().insert(url.to_owned(), res); - } - - egui::Frame::NONE - .show(ui, |ui| { - match cache.map_mut().get_mut(url).and_then(|p| p.ready_mut()) { - None => show_waiting(ui), - Some(Err(err)) => { - let err = err.to_string(); - let no_pfp = crate::images::fetch_img( - cache, - ui.ctx(), - ProfilePic::no_pfp_url(), - ImageType::Profile(128), - cache_type, - ); - cache.map_mut().insert(url.to_owned(), no_pfp); - show_error(ui, err) - } - Some(Ok(renderable_media)) => show_success(ui, url, renderable_media, gif_states), - } - }) - .response -} diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs @@ -24,8 +24,9 @@ pub mod widgets; pub use accounts::AccountsView; pub use mention::Mention; pub use note::{NoteResponse, NoteView, PostReplyView, PostView}; +pub use notedeck_ui::ProfilePic; pub use preview::{Preview, PreviewApp, PreviewConfig}; -pub use profile::{ProfilePic, ProfilePreview}; +pub use profile::ProfilePreview; pub use relay::RelayView; pub use side_panel::{DesktopSidePanel, SidePanelAction}; pub use thread::ThreadView; diff --git a/crates/notedeck_columns/src/ui/note/contents.rs b/crates/notedeck_columns/src/ui/note/contents.rs @@ -1,13 +1,16 @@ -use crate::gif::{handle_repaint, retrieve_latest_texture}; -use crate::ui::images::render_images; use crate::ui::{ self, note::{NoteOptions, NoteResponse}, }; -use crate::{actionbar::NoteAction, images::ImageType, timeline::TimelineKind}; +use crate::{actionbar::NoteAction, timeline::TimelineKind}; use egui::{Button, Color32, Hyperlink, Image, Response, RichText, Sense, Window}; use enostr::KeypairUnowned; use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction}; +use notedeck_ui::images::ImageType; +use notedeck_ui::{ + gif::{handle_repaint, retrieve_latest_texture}, + images::render_images, +}; use tracing::warn; use notedeck::{supported_mime_hosted_at_url, Images, MediaCacheType, NoteCache, Zaps}; diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -1,9 +1,7 @@ use crate::draft::{Draft, Drafts, MentionHint}; -use crate::gif::{handle_repaint, retrieve_latest_texture}; use crate::media_upload::{nostrbuild_nip96_upload, MediaPath}; use crate::post::{downcast_post_buffer, MentionType, NewPost}; use crate::profile::get_display_name; -use crate::ui::images::render_images; use crate::ui::search_results::SearchResultsView; use crate::ui::{self, Preview, PreviewConfig}; use crate::Result; @@ -13,6 +11,10 @@ use egui::widgets::text_edit::TextEdit; use egui::{vec2, Frame, Layout, Margin, Pos2, ScrollArea, Sense, TextBuffer}; use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, Transaction}; +use notedeck_ui::{ + gif::{handle_repaint, retrieve_latest_texture}, + images::render_images, +}; use notedeck::supported_mime_hosted_at_url; use tracing::error; @@ -428,7 +430,7 @@ impl<'a, 'd> PostView<'a, 'd> { ui, self.note_context.img_cache, &media.url, - crate::images::ImageType::Content, + notedeck_ui::images::ImageType::Content, cache_type, |ui| { ui.spinner(); diff --git a/crates/notedeck_columns/src/ui/profile/edit.rs b/crates/notedeck_columns/src/ui/profile/edit.rs @@ -3,9 +3,11 @@ use core::f32; use egui::{vec2, Button, CornerRadius, Layout, Margin, RichText, ScrollArea, TextEdit}; use notedeck::{Images, NotedeckTextStyle}; -use crate::{colors, profile_state::ProfileState}; +use crate::profile_state::ProfileState; -use super::{banner, unwrap_profile_url, ProfilePic}; +use super::banner; + +use notedeck_ui::{profile::unwrap_profile_url, ProfilePic}; pub struct EditProfileView<'a> { state: &'a mut ProfileState, @@ -34,7 +36,7 @@ impl<'a> EditProfileView<'a> { crate::ui::padding(padding, ui, |ui| { ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { if ui - .add(button("Save changes", 119.0).fill(colors::PINK)) + .add(button("Save changes", 119.0).fill(notedeck_ui::colors::PINK)) .clicked() { save = true; diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -1,5 +1,4 @@ pub mod edit; -pub mod picture; pub mod preview; pub use edit::EditProfileView; @@ -7,13 +6,11 @@ use egui::load::TexturePoll; use egui::{vec2, Color32, CornerRadius, Label, Layout, Rect, RichText, ScrollArea, Sense, Stroke}; use enostr::Pubkey; use nostrdb::{ProfileRecord, Transaction}; -pub use picture::ProfilePic; pub use preview::ProfilePreview; use tracing::error; use crate::{ actionbar::NoteAction, - colors, images, profile::get_display_name, timeline::{TimelineCache, TimelineKind}, ui::timeline::{tabs_ui, TimelineTabView}, @@ -21,6 +18,7 @@ use crate::{ }; use notedeck::{Accounts, MuteFun, NotedeckTextStyle, UnknownIds}; +use notedeck_ui::{images, profile::get_profile_url, ProfilePic}; use super::note::contents::NoteContext; use super::note::NoteOptions; @@ -215,7 +213,7 @@ fn handle_link(ui: &mut egui::Ui, website_url: &str) { "../../../../../assets/icons/links_4x.png" )); if ui - .label(RichText::new(website_url).color(colors::PINK)) + .label(RichText::new(website_url).color(notedeck_ui::colors::PINK)) .on_hover_cursor(egui::CursorIcon::PointingHand) .interact(Sense::click()) .clicked() @@ -231,7 +229,7 @@ fn handle_lud16(ui: &mut egui::Ui, lud16: &str) { "../../../../../assets/icons/zap_4x.png" )); - let _ = ui.label(RichText::new(lud16).color(colors::PINK)); + let _ = ui.label(RichText::new(lud16).color(notedeck_ui::colors::PINK)); } fn copy_key_widget(pfp_rect: &egui::Rect) -> impl egui::Widget + '_ { @@ -360,7 +358,7 @@ fn display_name_widget(name: NostrName<'_>, add_placeholder_space: bool) -> impl Label::new( RichText::new(format!("@{}", username)) .size(16.0) - .color(colors::MID_GRAY), + .color(notedeck_ui::colors::MID_GRAY), ) .selectable(false), ) @@ -371,7 +369,9 @@ fn display_name_widget(name: NostrName<'_>, add_placeholder_space: bool) -> impl "../../../../../assets/icons/verified_4x.png" )); ui.add(Label::new( - RichText::new(nip05).size(16.0).color(colors::TEAL), + RichText::new(nip05) + .size(16.0) + .color(notedeck_ui::colors::TEAL), )) }); @@ -396,18 +396,6 @@ fn display_name_widget(name: NostrName<'_>, add_placeholder_space: bool) -> impl } } -pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str { - unwrap_profile_url(profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture()))) -} - -pub fn unwrap_profile_url(maybe_url: Option<&str>) -> &str { - if let Some(url) = maybe_url { - url - } else { - ProfilePic::no_pfp_url() - } -} - fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b where 'b: 'a, diff --git a/crates/notedeck_columns/src/ui/profile/picture.rs b/crates/notedeck_columns/src/ui/profile/picture.rs @@ -1,265 +0,0 @@ -use crate::gif::{handle_repaint, retrieve_latest_texture}; -use crate::images::ImageType; -use crate::ui::images::render_images; -use crate::ui::{Preview, PreviewConfig}; -use egui::{vec2, Sense, Stroke, TextureHandle}; -use nostrdb::{Ndb, Transaction}; -use tracing::info; - -use notedeck::{supported_mime_hosted_at_url, AppContext, Images}; - -pub struct ProfilePic<'cache, 'url> { - cache: &'cache mut Images, - url: &'url str, - size: f32, - border: Option<Stroke>, -} - -impl egui::Widget for ProfilePic<'_, '_> { - fn ui(self, ui: &mut egui::Ui) -> egui::Response { - render_pfp(ui, self.cache, self.url, self.size, self.border) - } -} - -impl<'cache, 'url> ProfilePic<'cache, 'url> { - pub fn new(cache: &'cache mut Images, url: &'url str) -> Self { - let size = Self::default_size() as f32; - ProfilePic { - cache, - url, - size, - border: None, - } - } - - pub fn border_stroke(ui: &egui::Ui) -> Stroke { - Stroke::new(4.0, ui.visuals().panel_fill) - } - - pub fn from_profile( - cache: &'cache mut Images, - profile: &nostrdb::ProfileRecord<'url>, - ) -> Option<Self> { - profile - .record() - .profile() - .and_then(|p| p.picture()) - .map(|url| ProfilePic::new(cache, url)) - } - - #[inline] - pub fn default_size() -> i8 { - 38 - } - - #[inline] - pub fn medium_size() -> i8 { - 32 - } - - #[inline] - pub fn small_size() -> i8 { - 24 - } - - #[inline] - pub fn no_pfp_url() -> &'static str { - "https://damus.io/img/no-profile.svg" - } - - #[inline] - pub fn size(mut self, size: f32) -> Self { - self.size = size; - self - } - - #[inline] - pub fn border(mut self, stroke: Stroke) -> Self { - self.border = Some(stroke); - self - } -} - -#[profiling::function] -fn render_pfp( - ui: &mut egui::Ui, - img_cache: &mut Images, - url: &str, - ui_size: f32, - border: Option<Stroke>, -) -> egui::Response { - // We will want to downsample these so it's not blurry on hi res displays - let img_size = 128u32; - - let cache_type = supported_mime_hosted_at_url(&mut img_cache.urls, url) - .unwrap_or(notedeck::MediaCacheType::Image); - - render_images( - ui, - img_cache, - url, - ImageType::Profile(img_size), - cache_type, - |ui| { - paint_circle(ui, ui_size, border); - }, - |ui, _| { - paint_circle(ui, ui_size, border); - }, - |ui, url, renderable_media, gifs| { - let texture_handle = - handle_repaint(ui, retrieve_latest_texture(url, gifs, renderable_media)); - pfp_image(ui, texture_handle, ui_size, border); - }, - ) -} - -#[profiling::function] -fn pfp_image( - ui: &mut egui::Ui, - img: &TextureHandle, - size: f32, - border: Option<Stroke>, -) -> egui::Response { - let (rect, response) = ui.allocate_at_least(vec2(size, size), Sense::hover()); - if let Some(stroke) = border { - draw_bg_border(ui, rect.center(), size, stroke); - } - ui.put(rect, egui::Image::new(img).max_width(size)); - - response -} - -fn paint_circle(ui: &mut egui::Ui, size: f32, border: Option<Stroke>) -> egui::Response { - let (rect, response) = ui.allocate_at_least(vec2(size, size), Sense::hover()); - - if let Some(stroke) = border { - draw_bg_border(ui, rect.center(), size, stroke); - } - - ui.painter() - .circle_filled(rect.center(), size / 2.0, ui.visuals().weak_text_color()); - - response -} - -fn draw_bg_border(ui: &mut egui::Ui, center: egui::Pos2, size: f32, stroke: Stroke) { - let border_size = size + (stroke.width * 2.0); - ui.painter() - .circle_filled(center, border_size / 2.0, stroke.color); -} - -mod preview { - use super::*; - use crate::ui; - use nostrdb::*; - use std::collections::HashSet; - - pub struct ProfilePicPreview { - keys: Option<Vec<ProfileKey>>, - } - - impl ProfilePicPreview { - fn new() -> Self { - ProfilePicPreview { keys: None } - } - - fn show(&mut self, app: &mut AppContext<'_>, ui: &mut egui::Ui) { - egui::ScrollArea::both().show(ui, |ui| { - ui.horizontal_wrapped(|ui| { - let txn = Transaction::new(app.ndb).unwrap(); - - let keys = if let Some(keys) = &self.keys { - keys - } else { - return; - }; - - for key in keys { - let profile = app.ndb.get_profile_by_key(&txn, *key).unwrap(); - let url = profile - .record() - .profile() - .expect("should have profile") - .picture() - .expect("should have picture"); - - let expand_size = 10.0; - let anim_speed = 0.05; - - let (rect, size, _resp) = ui::anim::hover_expand( - ui, - egui::Id::new(profile.key().unwrap()), - ui::ProfilePic::default_size() as f32, - expand_size, - anim_speed, - ); - - ui.put( - rect, - ui::ProfilePic::new(app.img_cache, url) - .size(size) - .border(ui::ProfilePic::border_stroke(ui)), - ) - .on_hover_ui_at_pointer(|ui| { - ui.set_max_width(300.0); - ui.add(ui::ProfilePreview::new(&profile, app.img_cache)); - }); - } - }); - }); - } - - fn setup(&mut self, ndb: &Ndb) { - let txn = Transaction::new(ndb).unwrap(); - let filters = vec![Filter::new().kinds(vec![0]).build()]; - let mut pks = HashSet::new(); - let mut keys = HashSet::new(); - - for query_result in ndb.query(&txn, &filters, 20000).unwrap() { - pks.insert(query_result.note.pubkey()); - } - - for pk in pks { - let profile = if let Ok(profile) = ndb.get_profile_by_pubkey(&txn, pk) { - profile - } else { - continue; - }; - - if profile - .record() - .profile() - .and_then(|p| p.picture()) - .is_none() - { - continue; - } - - keys.insert(profile.key().expect("should not be owned")); - } - - let keys: Vec<ProfileKey> = keys.into_iter().collect(); - info!("Loaded {} profiles", keys.len()); - self.keys = Some(keys); - } - } - - impl notedeck::App for ProfilePicPreview { - fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) { - if self.keys.is_none() { - self.setup(ctx.ndb); - } - - self.show(ctx, ui) - } - } - - impl Preview for ProfilePic<'_, '_> { - type Prev = ProfilePicPreview; - - fn preview(_cfg: PreviewConfig) -> Self::Prev { - ProfilePicPreview::new() - } - } -} diff --git a/crates/notedeck_columns/src/ui/profile/preview.rs b/crates/notedeck_columns/src/ui/profile/preview.rs @@ -4,9 +4,10 @@ use egui::{Frame, Label, RichText, Widget}; use egui_extras::Size; use nostrdb::ProfileRecord; -use notedeck::{Images, NotedeckTextStyle, UserAccount}; +use notedeck::{Images, NotedeckTextStyle}; -use super::{about_section_widget, banner, display_name_widget, get_display_name, get_profile_url}; +use super::{about_section_widget, banner, display_name_widget, get_display_name}; +use notedeck_ui::profile::get_profile_url; pub struct ProfilePreview<'a, 'cache> { profile: &'a ProfileRecord<'a>, @@ -152,30 +153,6 @@ mod previews { } } -pub fn get_profile_url_owned(profile: Option<ProfileRecord<'_>>) -> &str { - if let Some(url) = profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())) { - url - } else { - ProfilePic::no_pfp_url() - } -} - -pub fn get_account_url<'a>( - txn: &'a nostrdb::Transaction, - ndb: &nostrdb::Ndb, - account: Option<&UserAccount>, -) -> &'a str { - if let Some(selected_account) = account { - if let Ok(profile) = ndb.get_profile_by_pubkey(txn, selected_account.key.pubkey.bytes()) { - get_profile_url_owned(Some(profile)) - } else { - get_profile_url_owned(None) - } - } else { - get_profile_url(None) - } -} - pub fn one_line_display_name_widget<'a>( visuals: &egui::Visuals, display_name: NostrName<'a>, diff --git a/crates/notedeck_columns/src/ui/relay.rs b/crates/notedeck_columns/src/ui/relay.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::colors::PINK; +use notedeck_ui::colors::PINK; use crate::relay_pool_manager::{RelayPoolManager, RelayStatus}; use crate::ui::{Preview, PreviewConfig, View}; use egui::{ @@ -197,7 +197,7 @@ fn add_relay_button() -> Button<'static> { fn add_relay_button2(is_enabled: bool) -> impl egui::Widget + 'static { move |ui: &mut egui::Ui| -> egui::Response { - let button_widget = styled_button("Add", crate::colors::PINK); + let button_widget = styled_button("Add", notedeck_ui::colors::PINK); ui.add_enabled(is_enabled, button_widget) } } diff --git a/crates/notedeck_columns/src/ui/search_results.rs b/crates/notedeck_columns/src/ui/search_results.rs @@ -8,8 +8,8 @@ use crate::{ ui::anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, }; -use super::widgets::x_button; -use super::{profile::get_profile_url, ProfilePic}; +use super::{widgets::x_button, ProfilePic}; +use notedeck_ui::profile::get_profile_url; pub struct SearchResultsView<'a> { ndb: &'a Ndb, diff --git a/crates/notedeck_columns/src/ui/side_panel.rs b/crates/notedeck_columns/src/ui/side_panel.rs @@ -1,35 +1,29 @@ use egui::{ - vec2, Button, Color32, InnerResponse, Label, Layout, Margin, RichText, ScrollArea, Separator, - Stroke, ThemePreference, Widget, + vec2, Color32, InnerResponse, Layout, Margin, RichText, ScrollArea, Separator, Stroke, Widget, }; use tracing::{error, info}; use crate::{ - accounts::AccountsRoute, app::{get_active_columns_mut, get_decks_mut}, app_style::DECK_ICON_SIZE, - colors, decks::{DecksAction, DecksCache}, nav::SwitchingAction, route::Route, - support::Support, }; -use notedeck::{Accounts, Images, NotedeckTextStyle, ThemeHandler, UserAccount}; +use notedeck::{Accounts, UserAccount}; +use notedeck_ui::colors; use super::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, configure_deck::deck_icon, - profile::preview::get_account_url, - ProfilePic, View, + View, }; pub static SIDE_PANEL_WIDTH: f32 = 68.0; static ICON_WIDTH: f32 = 40.0; pub struct DesktopSidePanel<'a> { - ndb: &'a nostrdb::Ndb, - img_cache: &'a mut Images, selected_account: Option<&'a UserAccount>, decks_cache: &'a DecksCache, } @@ -42,18 +36,13 @@ impl View for DesktopSidePanel<'_> { #[derive(Debug, Eq, PartialEq, Clone, Copy)] pub enum SidePanelAction { - Panel, - Account, - Settings, Columns, ComposeNote, Search, ExpandSidePanel, - Support, NewDeck, SwitchDeck(usize), EditDeck(usize), - SaveTheme(ThemePreference), Wallet, } @@ -69,228 +58,133 @@ impl SidePanelResponse { } impl<'a> DesktopSidePanel<'a> { - pub fn new( - ndb: &'a nostrdb::Ndb, - img_cache: &'a mut Images, - selected_account: Option<&'a UserAccount>, - decks_cache: &'a DecksCache, - ) -> Self { + pub fn new(selected_account: Option<&'a UserAccount>, decks_cache: &'a DecksCache) -> Self { Self { - ndb, - img_cache, selected_account, decks_cache, } } - pub fn show(&mut self, ui: &mut egui::Ui) -> SidePanelResponse { - let mut frame = egui::Frame::new().inner_margin(Margin::same(8)); + pub fn show(&mut self, ui: &mut egui::Ui) -> Option<SidePanelResponse> { + let frame = egui::Frame::new().inner_margin(Margin::same(8)); if !ui.visuals().dark_mode { - frame = frame.fill(colors::ALMOST_WHITE); + let rect = ui.available_rect_before_wrap(); + ui.painter().rect( + rect, + 0, + colors::ALMOST_WHITE, + egui::Stroke::new(0.0, egui::Color32::TRANSPARENT), + egui::StrokeKind::Inside, + ); } frame.show(ui, |ui| self.show_inner(ui)).inner } - fn show_inner(&mut self, ui: &mut egui::Ui) -> SidePanelResponse { + fn show_inner(&mut self, ui: &mut egui::Ui) -> Option<SidePanelResponse> { let dark_mode = ui.ctx().style().visuals.dark_mode; let inner = ui .vertical(|ui| { - let top_resp = ui - .with_layout(Layout::top_down(egui::Align::Center), |ui| { - // macos needs a bit of space to make room for window - // minimize/close buttons - if cfg!(target_os = "macos") { - ui.add_space(24.0); - } - - let expand_resp = ui.add(expand_side_panel_button()); - ui.add_space(4.0); - ui.add(milestone_name()); - ui.add_space(16.0); - let is_interactive = self - .selected_account - .is_some_and(|s| s.key.secret_key.is_some()); - let compose_resp = ui.add(compose_note_button(is_interactive, dark_mode)); - let compose_resp = if is_interactive { - compose_resp - } else { - compose_resp.on_hover_cursor(egui::CursorIcon::NotAllowed) - }; - let search_resp = ui.add(search_button()); - let column_resp = ui.add(add_column_button(dark_mode)); - - ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); - - ui.add_space(8.0); - ui.add(egui::Label::new( - RichText::new("DECKS") - .size(11.0) - .color(ui.visuals().noninteractive().fg_stroke.color), - )); - ui.add_space(8.0); - let add_deck_resp = ui.add(add_deck_button()); - - let decks_inner = ScrollArea::vertical() - .max_height(ui.available_height() - (3.0 * (ICON_WIDTH + 12.0))) - .show(ui, |ui| { - show_decks(ui, self.decks_cache, self.selected_account) - }) - .inner; - if expand_resp.clicked() { - Some(InnerResponse::new( - SidePanelAction::ExpandSidePanel, - expand_resp, - )) - } else if compose_resp.clicked() { + ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { + // macos needs a bit of space to make room for window + // minimize/close buttons + //if cfg!(target_os = "macos") { + // ui.add_space(24.0); + //} + + let is_interactive = self + .selected_account + .is_some_and(|s| s.key.secret_key.is_some()); + let compose_resp = ui.add(compose_note_button(is_interactive, dark_mode)); + let compose_resp = if is_interactive { + compose_resp + } else { + compose_resp.on_hover_cursor(egui::CursorIcon::NotAllowed) + }; + let search_resp = ui.add(search_button()); + let column_resp = ui.add(add_column_button(dark_mode)); + + ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); + + ui.add_space(8.0); + ui.add(egui::Label::new( + RichText::new("DECKS") + .size(11.0) + .color(ui.visuals().noninteractive().fg_stroke.color), + )); + ui.add_space(8.0); + let add_deck_resp = ui.add(add_deck_button()); + + let decks_inner = ScrollArea::vertical() + .max_height(ui.available_height() - (3.0 * (ICON_WIDTH + 12.0))) + .show(ui, |ui| { + show_decks(ui, self.decks_cache, self.selected_account) + }) + .inner; + + /* + if expand_resp.clicked() { + Some(InnerResponse::new( + SidePanelAction::ExpandSidePanel, + expand_resp, + )) + */ + if compose_resp.clicked() { + Some(InnerResponse::new( + SidePanelAction::ComposeNote, + compose_resp, + )) + } else if search_resp.clicked() { + Some(InnerResponse::new(SidePanelAction::Search, search_resp)) + } else if column_resp.clicked() { + Some(InnerResponse::new(SidePanelAction::Columns, column_resp)) + } else if add_deck_resp.clicked() { + Some(InnerResponse::new(SidePanelAction::NewDeck, add_deck_resp)) + } else if decks_inner.response.secondary_clicked() { + info!("decks inner secondary click"); + if let Some(clicked_index) = decks_inner.inner { Some(InnerResponse::new( - SidePanelAction::ComposeNote, - compose_resp, + SidePanelAction::EditDeck(clicked_index), + decks_inner.response, )) - } else if search_resp.clicked() { - Some(InnerResponse::new(SidePanelAction::Search, search_resp)) - } else if column_resp.clicked() { - Some(InnerResponse::new(SidePanelAction::Columns, column_resp)) - } else if add_deck_resp.clicked() { - Some(InnerResponse::new(SidePanelAction::NewDeck, add_deck_resp)) - } else if decks_inner.response.secondary_clicked() { - info!("decks inner secondary click"); - if let Some(clicked_index) = decks_inner.inner { - Some(InnerResponse::new( - SidePanelAction::EditDeck(clicked_index), - decks_inner.response, - )) - } else { - None - } - } else if decks_inner.response.clicked() { - if let Some(clicked_index) = decks_inner.inner { - Some(InnerResponse::new( - SidePanelAction::SwitchDeck(clicked_index), - decks_inner.response, - )) - } else { - None - } } else { None } - }) - .inner; - - ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); - let (pfp_resp, bottom_resp) = ui - .with_layout(Layout::bottom_up(egui::Align::Center), |ui| { - let pfp_resp = self.pfp_button(ui); - let settings_resp = ui.add(settings_button(dark_mode)); - - let save_theme = if let Some((theme, resp)) = match ui.ctx().theme() { - egui::Theme::Dark => { - let resp = ui - .add(Button::new("☀").frame(false)) - .on_hover_text("Switch to light mode"); - if resp.clicked() { - Some((ThemePreference::Light, resp)) - } else { - None - } - } - egui::Theme::Light => { - let resp = ui - .add(Button::new("🌙").frame(false)) - .on_hover_text("Switch to dark mode"); - if resp.clicked() { - Some((ThemePreference::Dark, resp)) - } else { - None - } - } - } { - ui.ctx().set_theme(theme); - Some((theme, resp)) - } else { - None - }; - - let support_resp = ui.add(support_button()); - - let wallet_resp = ui.add(wallet_button()); - - let optional_inner = if pfp_resp.clicked() { - Some(egui::InnerResponse::new( - SidePanelAction::Account, - pfp_resp.clone(), - )) - } else if settings_resp.clicked() || settings_resp.hovered() { - Some(egui::InnerResponse::new( - SidePanelAction::Settings, - settings_resp, - )) - } else if support_resp.clicked() { - Some(egui::InnerResponse::new( - SidePanelAction::Support, - support_resp, - )) - } else if let Some((theme, resp)) = save_theme { - Some(egui::InnerResponse::new( - SidePanelAction::SaveTheme(theme), - resp, - )) - } else if wallet_resp.clicked() { - Some(egui::InnerResponse::new( - SidePanelAction::Wallet, - wallet_resp, + } else if decks_inner.response.clicked() { + if let Some(clicked_index) = decks_inner.inner { + Some(InnerResponse::new( + SidePanelAction::SwitchDeck(clicked_index), + decks_inner.response, )) } else { None - }; - - (pfp_resp, optional_inner) - }) - .inner; - - if let Some(bottom_inner) = bottom_resp { - bottom_inner - } else if let Some(top_inner) = top_resp { - top_inner - } else { - egui::InnerResponse::new(SidePanelAction::Panel, pfp_resp) - } + } + } else { + None + } + }) + .inner }) .inner; - SidePanelResponse::new(inner.inner, inner.response) - } - - fn pfp_button(&mut self, ui: &mut egui::Ui) -> egui::Response { - let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget - let helper = AnimationHelper::new(ui, "pfp-button", vec2(max_size, max_size)); - - let min_pfp_size = ICON_WIDTH; - let cur_pfp_size = helper.scale_1d_pos(min_pfp_size); - - let txn = nostrdb::Transaction::new(self.ndb).expect("should be able to create txn"); - let profile_url = get_account_url(&txn, self.ndb, self.selected_account); - - let widget = ProfilePic::new(self.img_cache, profile_url).size(cur_pfp_size); - - ui.put(helper.get_animation_rect(), widget); - - helper.take_animation_response() + if let Some(inner) = inner { + Some(SidePanelResponse::new(inner.inner, inner.response)) + } else { + None + } } pub fn perform_action( decks_cache: &mut DecksCache, accounts: &Accounts, - support: &mut Support, - theme_handler: &mut ThemeHandler, action: SidePanelAction, ) -> Option<SwitchingAction> { let router = get_active_columns_mut(accounts, decks_cache).get_first_router(); let mut switching_response = None; match action { + /* SidePanelAction::Panel => {} // TODO SidePanelAction::Account => { if router @@ -312,6 +206,15 @@ impl<'a> DesktopSidePanel<'a> { router.route_to(Route::relays()); } } + SidePanelAction::Support => { + if router.routes().iter().any(|r| r == &Route::Support) { + router.go_back(); + } else { + support.refresh(); + router.route_to(Route::Support); + } + } + */ SidePanelAction::Columns => { if router .routes() @@ -342,14 +245,6 @@ impl<'a> DesktopSidePanel<'a> { // TODO info!("Clicked expand side panel button"); } - SidePanelAction::Support => { - if router.routes().iter().any(|r| r == &Route::Support) { - router.go_back(); - } else { - support.refresh(); - router.route_to(Route::Support); - } - } SidePanelAction::NewDeck => { if router.routes().iter().any(|r| r == &Route::NewDeck) { router.go_back(); @@ -382,9 +277,6 @@ impl<'a> DesktopSidePanel<'a> { } } } - SidePanelAction::SaveTheme(theme) => { - theme_handler.save(theme); - } SidePanelAction::Wallet => 's: { if router .routes() @@ -402,31 +294,6 @@ impl<'a> DesktopSidePanel<'a> { } } -fn settings_button(dark_mode: bool) -> impl Widget { - move |ui: &mut egui::Ui| { - let img_size = 24.0; - let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget - let img_data = if dark_mode { - egui::include_image!("../../../../assets/icons/settings_dark_4x.png") - } else { - egui::include_image!("../../../../assets/icons/settings_light_4x.png") - }; - let img = egui::Image::new(img_data).max_width(img_size); - - let helper = AnimationHelper::new(ui, "settings-button", vec2(max_size, max_size)); - - let cur_img_size = helper.scale_1d_pos(img_size); - img.paint_at( - ui, - helper - .get_animation_rect() - .shrink((max_size - cur_img_size) / 2.0), - ); - - helper.take_animation_response() - } -} - fn add_column_button(dark_mode: bool) -> impl Widget { move |ui: &mut egui::Ui| { let img_size = 24.0; @@ -554,41 +421,6 @@ pub fn search_button() -> impl Widget { } // TODO: convert to responsive button when expanded side panel impl is finished -fn expand_side_panel_button() -> impl Widget { - |ui: &mut egui::Ui| -> egui::Response { - let img_size = 40.0; - let img_data = egui::include_image!("../../../../assets/damus_rounded_80.png"); - let img = egui::Image::new(img_data).max_width(img_size); - - ui.add(img) - } -} - -fn support_button() -> impl Widget { - |ui: &mut egui::Ui| -> egui::Response { - let img_size = 16.0; - - let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget - let img_data = if ui.visuals().dark_mode { - egui::include_image!("../../../../assets/icons/help_icon_dark_4x.png") - } else { - egui::include_image!("../../../../assets/icons/help_icon_inverted_4x.png") - }; - let img = egui::Image::new(img_data).max_width(img_size); - - let helper = AnimationHelper::new(ui, "help-button", vec2(max_size, max_size)); - - let cur_img_size = helper.scale_1d_pos(img_size); - img.paint_at( - ui, - helper - .get_animation_rect() - .shrink((max_size - cur_img_size) / 2.0), - ); - - helper.take_animation_response() - } -} fn add_deck_button() -> impl Widget { |ui: &mut egui::Ui| -> egui::Response { @@ -676,23 +508,3 @@ fn show_decks<'a>( } InnerResponse::new(clicked_index, resp) } - -fn milestone_name() -> impl Widget { - |ui: &mut egui::Ui| -> egui::Response { - ui.vertical_centered(|ui| { - let font = egui::FontId::new( - notedeck::fonts::get_font_size( - ui.ctx(), - &NotedeckTextStyle::Tiny, - ), - egui::FontFamily::Name(notedeck::fonts::NamedFontFamily::Bold.as_str().into()), - ); - ui.add(Label::new( - RichText::new("ALPHA") - .color( ui.style().visuals.noninteractive().fg_stroke.color) - .font(font), - ).selectable(false)).on_hover_text("Notedeck is an alpha product. Expect bugs and contact us when you run into issues.").on_hover_cursor(egui::CursorIcon::Help) - }) - .inner - } -} diff --git a/crates/notedeck_columns/src/ui/support.rs b/crates/notedeck_columns/src/ui/support.rs @@ -1,7 +1,8 @@ use egui::{vec2, Button, Label, Layout, RichText}; use tracing::error; -use crate::{colors::PINK, support::Support}; +use crate::support::Support; +use notedeck_ui::colors::PINK; use super::padding; use notedeck::{NamedFontFamily, NotedeckTextStyle}; diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs @@ -193,7 +193,7 @@ fn goto_top_button(center: Pos2) -> impl egui::Widget { }); let painter = ui.painter(); - painter.circle_filled(center, helper.scale_1d_pos(radius), crate::colors::PINK); + painter.circle_filled(center, helper.scale_1d_pos(radius), notedeck_ui::colors::PINK); let create_pt = |angle: f32| { let side = radius / 2.0; diff --git a/crates/notedeck_columns/src/ui/wallet.rs b/crates/notedeck_columns/src/ui/wallet.rs @@ -156,7 +156,7 @@ fn show_no_wallet( } ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { - ui.add(styled_button("Add Wallet", crate::colors::PINK)) + ui.add(styled_button("Add Wallet", notedeck_ui::colors::PINK)) .clicked() .then_some(WalletAction::SaveURI) }) diff --git a/crates/notedeck_dave/src/avatar.rs b/crates/notedeck_dave/src/avatar.rs @@ -1,6 +1,6 @@ use std::num::NonZeroU64; -use crate::{Vec3, Quaternion}; +use crate::{Quaternion, Vec3}; use eframe::egui_wgpu::{self, wgpu}; use egui::{Rect, Response}; use rand::Rng; diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -25,12 +25,12 @@ use avatar::DaveAvatar; use egui::{Rect, Vec2}; use egui_wgpu::RenderState; -pub use vec3::Vec3; pub use quaternion::Quaternion; +pub use vec3::Vec3; mod avatar; -mod vec3; mod quaternion; +mod vec3; #[derive(Debug, Clone)] pub enum Message { @@ -608,7 +608,7 @@ impl notedeck::App for Dave { #[derive(Debug, Clone)] enum ArgType { String, - Number, + //Number, Enum(Vec<&'static str>), } @@ -616,7 +616,7 @@ impl ArgType { pub fn type_string(&self) -> &'static str { match self { Self::String => "string", - Self::Number => "number", + //Self::Number => "number", Self::Enum(_) => "string", } } diff --git a/crates/notedeck_ui/Cargo.toml b/crates/notedeck_ui/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "notedeck_ui" +edition = "2021" +version.workspace = true + +[dependencies] +egui = { workspace = true } +egui_extras = { workspace = true } +ehttp = { workspace = true } +nostrdb = { workspace = true } +tracing = { workspace = true } +poll-promise = { workspace = true } +profiling = { workspace = true } +tokio = { workspace = true } +notedeck = { workspace = true } +image = { workspace = true } diff --git a/crates/notedeck_ui/src/anim.rs b/crates/notedeck_ui/src/anim.rs @@ -0,0 +1,140 @@ +use egui::{Pos2, Rect, Response, Sense}; + +/* +pub fn hover_expand( + ui: &mut egui::Ui, + id: egui::Id, + size: f32, + expand_size: f32, + anim_speed: f32, +) -> (egui::Rect, f32, egui::Response) { + // Allocate space for the profile picture with a fixed size + let default_size = size + expand_size; + let (rect, response) = + ui.allocate_exact_size(egui::vec2(default_size, default_size), egui::Sense::click()); + + let val = ui + .ctx() + .animate_bool_with_time(id, response.hovered(), anim_speed); + + let size = size + val * expand_size; + (rect, size, response) +} + +pub fn hover_expand_small(ui: &mut egui::Ui, id: egui::Id) -> (egui::Rect, f32, egui::Response) { + let size = 10.0; + let expand_size = 5.0; + let anim_speed = 0.05; + + hover_expand(ui, id, size, expand_size, anim_speed) +} +*/ + +pub static ICON_EXPANSION_MULTIPLE: f32 = 1.2; +pub static ANIM_SPEED: f32 = 0.05; +pub struct AnimationHelper { + rect: Rect, + center: Pos2, + response: Response, + animation_progress: f32, + expansion_multiple: f32, +} + +impl AnimationHelper { + pub fn new( + ui: &mut egui::Ui, + animation_name: impl std::hash::Hash, + max_size: egui::Vec2, + ) -> Self { + let id = ui.id().with(animation_name); + let (rect, response) = ui.allocate_exact_size(max_size, Sense::click()); + + let animation_progress = + ui.ctx() + .animate_bool_with_time(id, response.hovered(), ANIM_SPEED); + + Self { + rect, + center: rect.center(), + response, + animation_progress, + expansion_multiple: ICON_EXPANSION_MULTIPLE, + } + } + + pub fn no_animation(ui: &mut egui::Ui, size: egui::Vec2) -> Self { + let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); + + Self { + rect, + center: rect.center(), + response, + animation_progress: 0.0, + expansion_multiple: ICON_EXPANSION_MULTIPLE, + } + } + + pub fn new_from_rect( + ui: &mut egui::Ui, + animation_name: impl std::hash::Hash, + animation_rect: egui::Rect, + ) -> Self { + let id = ui.id().with(animation_name); + let response = ui.allocate_rect(animation_rect, Sense::click()); + + let animation_progress = + ui.ctx() + .animate_bool_with_time(id, response.hovered(), ANIM_SPEED); + + Self { + rect: animation_rect, + center: animation_rect.center(), + response, + animation_progress, + expansion_multiple: ICON_EXPANSION_MULTIPLE, + } + } + + pub fn scale_1d_pos(&self, min_object_size: f32) -> f32 { + let max_object_size = min_object_size * self.expansion_multiple; + + if self.response.is_pointer_button_down_on() { + min_object_size + } else { + min_object_size + ((max_object_size - min_object_size) * self.animation_progress) + } + } + + pub fn scale_radius(&self, min_diameter: f32) -> f32 { + self.scale_1d_pos((min_diameter - 1.0) / 2.0) + } + + pub fn get_animation_rect(&self) -> egui::Rect { + self.rect + } + + pub fn center(&self) -> Pos2 { + self.rect.center() + } + + pub fn take_animation_response(self) -> egui::Response { + self.response + } + + // Scale a minimum position from center to the current animation position + pub fn scale_from_center(&self, x_min: f32, y_min: f32) -> Pos2 { + Pos2::new( + self.center.x + self.scale_1d_pos(x_min), + self.center.y + self.scale_1d_pos(y_min), + ) + } + + pub fn scale_pos_from_center(&self, min_pos: Pos2) -> Pos2 { + self.scale_from_center(min_pos.x, min_pos.y) + } + + /// New method for min/max scaling when needed + pub fn scale_1d_pos_min_max(&self, min_object_size: f32, max_object_size: f32) -> f32 { + min_object_size + ((max_object_size - min_object_size) * self.animation_progress) + } +} diff --git a/crates/notedeck_columns/src/colors.rs b/crates/notedeck_ui/src/colors.rs diff --git a/crates/notedeck_columns/src/gif.rs b/crates/notedeck_ui/src/gif.rs diff --git a/crates/notedeck_ui/src/images.rs b/crates/notedeck_ui/src/images.rs @@ -0,0 +1,488 @@ +use crate::ProfilePic; +use egui::{pos2, Color32, ColorImage, Rect, Sense, SizeHint}; +use image::codecs::gif::GifDecoder; +use image::imageops::FilterType; +use image::{AnimationDecoder, DynamicImage, FlatSamples, Frame}; +use notedeck::{ + Animation, GifStateMap, ImageFrame, Images, MediaCache, MediaCacheType, TextureFrame, + TexturedImage, +}; +use poll_promise::Promise; +use std::collections::VecDeque; +use std::io::Cursor; +use std::path; +use std::path::PathBuf; +use std::sync::mpsc; +use std::sync::mpsc::SyncSender; +use std::thread; +use std::time::Duration; +use tokio::fs; + +// NOTE(jb55): chatgpt wrote this because I was too dumb to +pub fn aspect_fill( + ui: &mut egui::Ui, + sense: Sense, + texture_id: egui::TextureId, + aspect_ratio: f32, +) -> egui::Response { + let frame = ui.available_rect_before_wrap(); // Get the available frame space in the current layout + let frame_ratio = frame.width() / frame.height(); + + let (width, height) = if frame_ratio > aspect_ratio { + // Frame is wider than the content + (frame.width(), frame.width() / aspect_ratio) + } else { + // Frame is taller than the content + (frame.height() * aspect_ratio, frame.height()) + }; + + let content_rect = Rect::from_min_size( + frame.min + + egui::vec2( + (frame.width() - width) / 2.0, + (frame.height() - height) / 2.0, + ), + egui::vec2(width, height), + ); + + // Set the clipping rectangle to the frame + //let clip_rect = ui.clip_rect(); // Preserve the original clipping rectangle + //ui.set_clip_rect(frame); + + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + + let (response, painter) = ui.allocate_painter(ui.available_size(), sense); + + // Draw the texture within the calculated rect, potentially clipping it + painter.rect_filled(content_rect, 0.0, ui.ctx().style().visuals.window_fill()); + painter.image(texture_id, content_rect, uv, Color32::WHITE); + + // Restore the original clipping rectangle + //ui.set_clip_rect(clip_rect); + response +} + +#[profiling::function] +pub fn round_image(image: &mut ColorImage) { + // The radius to the edge of of the avatar circle + let edge_radius = image.size[0] as f32 / 2.0; + let edge_radius_squared = edge_radius * edge_radius; + + for (pixnum, pixel) in image.pixels.iter_mut().enumerate() { + // y coordinate + let uy = pixnum / image.size[0]; + let y = uy as f32; + let y_offset = edge_radius - y; + + // x coordinate + let ux = pixnum % image.size[0]; + let x = ux as f32; + let x_offset = edge_radius - x; + + // The radius to this pixel (may be inside or outside the circle) + let pixel_radius_squared: f32 = x_offset * x_offset + y_offset * y_offset; + + // If inside of the avatar circle + if pixel_radius_squared <= edge_radius_squared { + // squareroot to find how many pixels we are from the edge + let pixel_radius: f32 = pixel_radius_squared.sqrt(); + let distance = edge_radius - pixel_radius; + + // If we are within 1 pixel of the edge, we should fade, to + // antialias the edge of the circle. 1 pixel from the edge should + // be 100% of the original color, and right on the edge should be + // 0% of the original color. + if distance <= 1.0 { + *pixel = Color32::from_rgba_premultiplied( + (pixel.r() as f32 * distance) as u8, + (pixel.g() as f32 * distance) as u8, + (pixel.b() as f32 * distance) as u8, + (pixel.a() as f32 * distance) as u8, + ); + } + } else { + // Outside of the avatar circle + *pixel = Color32::TRANSPARENT; + } + } +} + +#[profiling::function] +fn process_pfp_bitmap(imgtyp: ImageType, mut image: image::DynamicImage) -> ColorImage { + match imgtyp { + ImageType::Content => { + let image_buffer = image.clone().into_rgba8(); + let color_image = ColorImage::from_rgba_unmultiplied( + [ + image_buffer.width() as usize, + image_buffer.height() as usize, + ], + image_buffer.as_flat_samples().as_slice(), + ); + color_image + } + ImageType::Profile(size) => { + // Crop square + let smaller = image.width().min(image.height()); + + if image.width() > smaller { + let excess = image.width() - smaller; + image = image.crop_imm(excess / 2, 0, image.width() - excess, image.height()); + } else if image.height() > smaller { + let excess = image.height() - smaller; + image = image.crop_imm(0, excess / 2, image.width(), image.height() - excess); + } + let image = image.resize(size, size, FilterType::CatmullRom); // DynamicImage + let image_buffer = image.into_rgba8(); // RgbaImage (ImageBuffer) + let mut color_image = ColorImage::from_rgba_unmultiplied( + [ + image_buffer.width() as usize, + image_buffer.height() as usize, + ], + image_buffer.as_flat_samples().as_slice(), + ); + round_image(&mut color_image); + color_image + } + } +} + +#[profiling::function] +fn parse_img_response( + response: ehttp::Response, + imgtyp: ImageType, +) -> Result<ColorImage, notedeck::Error> { + let content_type = response.content_type().unwrap_or_default(); + let size_hint = match imgtyp { + ImageType::Profile(size) => SizeHint::Size(size, size), + ImageType::Content => SizeHint::default(), + }; + + if content_type.starts_with("image/svg") { + profiling::scope!("load_svg"); + + let mut color_image = + egui_extras::image::load_svg_bytes_with_size(&response.bytes, Some(size_hint))?; + round_image(&mut color_image); + Ok(color_image) + } else if content_type.starts_with("image/") { + profiling::scope!("load_from_memory"); + let dyn_image = image::load_from_memory(&response.bytes)?; + Ok(process_pfp_bitmap(imgtyp, dyn_image)) + } else { + Err(format!("Expected image, found content-type {:?}", content_type).into()) + } +} + +fn fetch_img_from_disk( + ctx: &egui::Context, + url: &str, + path: &path::Path, + cache_type: MediaCacheType, +) -> Promise<Result<TexturedImage, notedeck::Error>> { + let ctx = ctx.clone(); + let url = url.to_owned(); + let path = path.to_owned(); + + Promise::spawn_async(async move { + match cache_type { + MediaCacheType::Image => { + let data = fs::read(path).await?; + let image_buffer = + image::load_from_memory(&data).map_err(notedeck::Error::Image)?; + + let img = buffer_to_color_image( + image_buffer.as_flat_samples_u8(), + image_buffer.width(), + image_buffer.height(), + ); + Ok(TexturedImage::Static(ctx.load_texture( + &url, + img, + Default::default(), + ))) + } + MediaCacheType::Gif => { + let gif_bytes = fs::read(path.clone()).await?; // Read entire file into a Vec<u8> + generate_gif(ctx, url, &path, gif_bytes, false, |i| { + buffer_to_color_image(i.as_flat_samples_u8(), i.width(), i.height()) + }) + } + } + }) +} + +fn generate_gif( + ctx: egui::Context, + url: String, + path: &path::Path, + data: Vec<u8>, + write_to_disk: bool, + process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + Copy + 'static, +) -> Result<TexturedImage, notedeck::Error> { + let decoder = { + let reader = Cursor::new(data.as_slice()); + GifDecoder::new(reader)? + }; + let (tex_input, tex_output) = mpsc::sync_channel(4); + let (maybe_encoder_input, maybe_encoder_output) = if write_to_disk { + let (inp, out) = mpsc::sync_channel(4); + (Some(inp), Some(out)) + } else { + (None, None) + }; + + let mut frames: VecDeque<Frame> = decoder + .into_frames() + .collect::<std::result::Result<VecDeque<_>, image::ImageError>>() + .map_err(|e| notedeck::Error::Generic(e.to_string()))?; + + let first_frame = frames.pop_front().map(|frame| { + generate_animation_frame( + &ctx, + &url, + 0, + frame, + maybe_encoder_input.as_ref(), + process_to_egui, + ) + }); + + let cur_url = url.clone(); + thread::spawn(move || { + for (index, frame) in frames.into_iter().enumerate() { + let texture_frame = generate_animation_frame( + &ctx, + &cur_url, + index, + frame, + maybe_encoder_input.as_ref(), + process_to_egui, + ); + + if tex_input.send(texture_frame).is_err() { + tracing::error!("AnimationTextureFrame mpsc stopped abruptly"); + break; + } + } + }); + + if let Some(encoder_output) = maybe_encoder_output { + let path = path.to_owned(); + + thread::spawn(move || { + let mut imgs = Vec::new(); + while let Ok(img) = encoder_output.recv() { + imgs.push(img); + } + + if let Err(e) = MediaCache::write_gif(&path, &url, imgs) { + tracing::error!("Could not write gif to disk: {e}"); + } + }); + } + + first_frame.map_or_else( + || { + Err(notedeck::Error::Generic( + "first frame not found for gif".to_owned(), + )) + }, + |first_frame| { + Ok(TexturedImage::Animated(Animation { + other_frames: Default::default(), + receiver: Some(tex_output), + first_frame, + })) + }, + ) +} + +fn generate_animation_frame( + ctx: &egui::Context, + url: &str, + index: usize, + frame: image::Frame, + maybe_encoder_input: Option<&SyncSender<ImageFrame>>, + process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + 'static, +) -> TextureFrame { + let delay = Duration::from(frame.delay()); + let img = DynamicImage::ImageRgba8(frame.into_buffer()); + let color_img = process_to_egui(img); + + if let Some(sender) = maybe_encoder_input { + if let Err(e) = sender.send(ImageFrame { + delay, + image: color_img.clone(), + }) { + tracing::error!("ImageFrame mpsc unexpectedly closed: {e}"); + } + } + + TextureFrame { + delay, + texture: ctx.load_texture(format!("{}{}", url, index), color_img, Default::default()), + } +} + +fn buffer_to_color_image( + samples: Option<FlatSamples<&[u8]>>, + width: u32, + height: u32, +) -> ColorImage { + // TODO(jb55): remove unwrap here + let flat_samples = samples.unwrap(); + ColorImage::from_rgba_unmultiplied([width as usize, height as usize], flat_samples.as_slice()) +} + +pub fn fetch_binary_from_disk(path: PathBuf) -> Result<Vec<u8>, notedeck::Error> { + std::fs::read(path).map_err(|e| notedeck::Error::Generic(e.to_string())) +} + +/// Controls type-specific handling +#[derive(Debug, Clone, Copy)] +pub enum ImageType { + /// Profile Image (size) + Profile(u32), + /// Content Image + Content, +} + +pub fn fetch_img( + img_cache: &MediaCache, + ctx: &egui::Context, + url: &str, + imgtyp: ImageType, + cache_type: MediaCacheType, +) -> Promise<Result<TexturedImage, notedeck::Error>> { + let key = MediaCache::key(url); + let path = img_cache.cache_dir.join(key); + + if path.exists() { + fetch_img_from_disk(ctx, url, &path, cache_type) + } else { + fetch_img_from_net(&img_cache.cache_dir, ctx, url, imgtyp, cache_type) + } + + // TODO: fetch image from local cache +} + +fn fetch_img_from_net( + cache_path: &path::Path, + ctx: &egui::Context, + url: &str, + imgtyp: ImageType, + cache_type: MediaCacheType, +) -> Promise<Result<TexturedImage, notedeck::Error>> { + let (sender, promise) = Promise::new(); + let request = ehttp::Request::get(url); + let ctx = ctx.clone(); + let cloned_url = url.to_owned(); + let cache_path = cache_path.to_owned(); + ehttp::fetch(request, move |response| { + let handle = response.map_err(notedeck::Error::Generic).and_then(|resp| { + match cache_type { + MediaCacheType::Image => { + let img = parse_img_response(resp, imgtyp); + img.map(|img| { + let texture_handle = + ctx.load_texture(&cloned_url, img.clone(), Default::default()); + + // write to disk + std::thread::spawn(move || { + MediaCache::write(&cache_path, &cloned_url, img) + }); + + TexturedImage::Static(texture_handle) + }) + } + MediaCacheType::Gif => { + let gif_bytes = resp.bytes; + generate_gif( + ctx.clone(), + cloned_url, + &cache_path, + gif_bytes, + true, + move |img| process_pfp_bitmap(imgtyp, img), + ) + } + } + }); + + sender.send(handle); // send the results back to the UI thread. + ctx.request_repaint(); + }); + + promise +} + +#[allow(clippy::too_many_arguments)] +pub fn render_images( + ui: &mut egui::Ui, + images: &mut Images, + url: &str, + img_type: ImageType, + cache_type: MediaCacheType, + show_waiting: impl FnOnce(&mut egui::Ui), + show_error: impl FnOnce(&mut egui::Ui, String), + show_success: impl FnOnce(&mut egui::Ui, &str, &mut TexturedImage, &mut GifStateMap), +) -> egui::Response { + let cache = match cache_type { + MediaCacheType::Image => &mut images.static_imgs, + MediaCacheType::Gif => &mut images.gifs, + }; + + render_media_cache( + ui, + cache, + &mut images.gif_states, + url, + img_type, + cache_type, + show_waiting, + show_error, + show_success, + ) +} + +#[allow(clippy::too_many_arguments)] +fn render_media_cache( + ui: &mut egui::Ui, + cache: &mut MediaCache, + gif_states: &mut GifStateMap, + url: &str, + img_type: ImageType, + cache_type: MediaCacheType, + show_waiting: impl FnOnce(&mut egui::Ui), + show_error: impl FnOnce(&mut egui::Ui, String), + show_success: impl FnOnce(&mut egui::Ui, &str, &mut TexturedImage, &mut GifStateMap), +) -> egui::Response { + let m_cached_promise = cache.map().get(url); + + if m_cached_promise.is_none() { + let res = crate::images::fetch_img(cache, ui.ctx(), url, img_type, cache_type.clone()); + cache.map_mut().insert(url.to_owned(), res); + } + + egui::Frame::NONE + .show(ui, |ui| { + match cache.map_mut().get_mut(url).and_then(|p| p.ready_mut()) { + None => show_waiting(ui), + Some(Err(err)) => { + let err = err.to_string(); + let no_pfp = crate::images::fetch_img( + cache, + ui.ctx(), + ProfilePic::no_pfp_url(), + ImageType::Profile(128), + cache_type, + ); + cache.map_mut().insert(url.to_owned(), no_pfp); + show_error(ui, err) + } + Some(Ok(renderable_media)) => show_success(ui, url, renderable_media, gif_states), + } + }) + .response +} diff --git a/crates/notedeck_ui/src/lib.rs b/crates/notedeck_ui/src/lib.rs @@ -0,0 +1,8 @@ +mod anim; +pub mod colors; +pub mod gif; +pub mod images; +pub mod profile; + +pub use anim::AnimationHelper; +pub use profile::ProfilePic; diff --git a/crates/notedeck_ui/src/profile/mod.rs b/crates/notedeck_ui/src/profile/mod.rs @@ -0,0 +1,17 @@ +use nostrdb::ProfileRecord; + +pub mod picture; + +pub use picture::ProfilePic; + +pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str { + unwrap_profile_url(profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture()))) +} + +pub fn unwrap_profile_url(maybe_url: Option<&str>) -> &str { + if let Some(url) = maybe_url { + url + } else { + ProfilePic::no_pfp_url() + } +} diff --git a/crates/notedeck_ui/src/profile/picture.rs b/crates/notedeck_ui/src/profile/picture.rs @@ -0,0 +1,146 @@ +use crate::gif::{handle_repaint, retrieve_latest_texture}; +use crate::images::{render_images, ImageType}; +use egui::{vec2, Sense, Stroke, TextureHandle}; + +use notedeck::{supported_mime_hosted_at_url, Images}; + +pub struct ProfilePic<'cache, 'url> { + cache: &'cache mut Images, + url: &'url str, + size: f32, + border: Option<Stroke>, +} + +impl egui::Widget for ProfilePic<'_, '_> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + render_pfp(ui, self.cache, self.url, self.size, self.border) + } +} + +impl<'cache, 'url> ProfilePic<'cache, 'url> { + pub fn new(cache: &'cache mut Images, url: &'url str) -> Self { + let size = Self::default_size() as f32; + ProfilePic { + cache, + url, + size, + border: None, + } + } + + pub fn border_stroke(ui: &egui::Ui) -> Stroke { + Stroke::new(4.0, ui.visuals().panel_fill) + } + + pub fn from_profile( + cache: &'cache mut Images, + profile: &nostrdb::ProfileRecord<'url>, + ) -> Option<Self> { + profile + .record() + .profile() + .and_then(|p| p.picture()) + .map(|url| ProfilePic::new(cache, url)) + } + + #[inline] + pub fn default_size() -> i8 { + 38 + } + + #[inline] + pub fn medium_size() -> i8 { + 32 + } + + #[inline] + pub fn small_size() -> i8 { + 24 + } + + #[inline] + pub fn no_pfp_url() -> &'static str { + "https://damus.io/img/no-profile.svg" + } + + #[inline] + pub fn size(mut self, size: f32) -> Self { + self.size = size; + self + } + + #[inline] + pub fn border(mut self, stroke: Stroke) -> Self { + self.border = Some(stroke); + self + } +} + +#[profiling::function] +fn render_pfp( + ui: &mut egui::Ui, + img_cache: &mut Images, + url: &str, + ui_size: f32, + border: Option<Stroke>, +) -> egui::Response { + // We will want to downsample these so it's not blurry on hi res displays + let img_size = 128u32; + + let cache_type = supported_mime_hosted_at_url(&mut img_cache.urls, url) + .unwrap_or(notedeck::MediaCacheType::Image); + + render_images( + ui, + img_cache, + url, + ImageType::Profile(img_size), + cache_type, + |ui| { + paint_circle(ui, ui_size, border); + }, + |ui, _| { + paint_circle(ui, ui_size, border); + }, + |ui, url, renderable_media, gifs| { + let texture_handle = + handle_repaint(ui, retrieve_latest_texture(url, gifs, renderable_media)); + pfp_image(ui, texture_handle, ui_size, border); + }, + ) +} + +#[profiling::function] +fn pfp_image( + ui: &mut egui::Ui, + img: &TextureHandle, + size: f32, + border: Option<Stroke>, +) -> egui::Response { + let (rect, response) = ui.allocate_at_least(vec2(size, size), Sense::hover()); + if let Some(stroke) = border { + draw_bg_border(ui, rect.center(), size, stroke); + } + ui.put(rect, egui::Image::new(img).max_width(size)); + + response +} + +fn paint_circle(ui: &mut egui::Ui, size: f32, border: Option<Stroke>) -> egui::Response { + let (rect, response) = ui.allocate_at_least(vec2(size, size), Sense::hover()); + + if let Some(stroke) = border { + draw_bg_border(ui, rect.center(), size, stroke); + } + + ui.painter() + .circle_filled(rect.center(), size / 2.0, ui.visuals().weak_text_color()); + + response +} + +fn draw_bg_border(ui: &mut egui::Ui, center: egui::Pos2, size: f32, stroke: Stroke) { + let border_size = size + (stroke.width * 2.0); + ui.painter() + .circle_filled(center, border_size / 2.0, stroke.color); +}