notedeck

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

commit 7fe3d5e99f25d45bcd8c359d2f50ddd646de9614
parent 7e4bddcacbfa468b7b55d195d25a34ae0e343c4c
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 26 Sep 2024 13:17:59 -0700

Merge side panel update #327

A few merge conflicts resolved, and changes the image to svg

kernelkind (7):
      initial compose note view
      change side panel width to 64.0
      Add AnimationHelper
      update sidebar to match new design
      remove app from sidebar
      remove profile_preview_controller
      add logo to side panel

Closes: https://github.com/damus-io/notedeck/pull/327

Diffstat:
Aassets/damus_rounded.svg | 334+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app.rs | 27+++++++++------------------
Msrc/nav.rs | 28+++++++++++++++++++++++++---
Msrc/route.rs | 2++
Msrc/timeline/route.rs | 12------------
Msrc/ui/account_management.rs | 20++++++++++++++------
Msrc/ui/anim.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ui/mod.rs | 2+-
Msrc/ui/profile/mod.rs | 2--
Msrc/ui/profile/preview.rs | 25+++++++++++++++++++++++++
Dsrc/ui/profile/profile_preview_controller.rs | 126-------------------------------------------------------------------------------
Msrc/ui/side_panel.rs | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Msrc/ui/timeline.rs | 25-------------------------
13 files changed, 739 insertions(+), 240 deletions(-)

diff --git a/assets/damus_rounded.svg b/assets/damus_rounded.svg @@ -0,0 +1,334 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + width="256mm" + height="256mm" + viewBox="0 0 256 256" + version="1.1" + id="svg5" + inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)" + sodipodi:docname="damus_rounded.svg" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <sodipodi:namedview + id="namedview7" + pagecolor="#ffffff" + bordercolor="#000000" + borderopacity="0.25" + inkscape:pageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + inkscape:blackoutopacity="0.0" + inkscape:document-units="mm" + showgrid="false" + inkscape:zoom="0.5946522" + inkscape:cx="405.27892" + inkscape:cy="543.17465" + inkscape:window-width="1920" + inkscape:window-height="1080" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="0" + inkscape:current-layer="layer1" + inkscape:showpageshadow="2" + inkscape:deskcolor="#d1d1d1" /> + <defs + id="defs2"> + <inkscape:path-effect + effect="fillet_chamfer" + id="path-effect9" + is_visible="true" + lpeversion="1" + nodesatellites_param="F,0,0,1,0,80,0,1 @ F,0,1,1,0,80,0,1 @ F,0,0,1,0,80,0,1 @ F,0,0,1,0,80,0,1" + radius="0" + unit="px" + method="auto" + mode="F" + chamfer_steps="1" + flexible="false" + use_knot_distance="true" + apply_no_radius="true" + apply_with_radius="true" + only_selected="false" + hide_knots="false" /> + <inkscape:path-effect + effect="fillet_chamfer" + id="path-effect8" + is_visible="true" + lpeversion="1" + nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1" + radius="0" + unit="px" + method="auto" + mode="F" + chamfer_steps="1" + flexible="false" + use_knot_distance="true" + apply_no_radius="true" + apply_with_radius="true" + only_selected="false" + hide_knots="false" /> + <inkscape:path-effect + effect="fillet_chamfer" + id="path-effect7" + is_visible="true" + lpeversion="1" + nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1" + radius="0" + unit="px" + method="auto" + mode="F" + chamfer_steps="1" + flexible="false" + use_knot_distance="true" + apply_no_radius="true" + apply_with_radius="true" + only_selected="false" + hide_knots="false" /> + <inkscape:path-effect + effect="fillet_chamfer" + id="path-effect6" + is_visible="true" + lpeversion="1" + nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1" + radius="0" + unit="px" + method="auto" + mode="F" + chamfer_steps="1" + flexible="false" + use_knot_distance="true" + apply_no_radius="true" + apply_with_radius="true" + only_selected="false" + hide_knots="false" /> + <inkscape:path-effect + effect="fillet_chamfer" + id="path-effect5" + is_visible="true" + lpeversion="1" + nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1" + radius="0" + unit="px" + method="auto" + mode="F" + chamfer_steps="1" + flexible="false" + use_knot_distance="true" + apply_no_radius="true" + apply_with_radius="true" + only_selected="false" + hide_knots="false" /> + <inkscape:path-effect + effect="fillet_chamfer" + id="path-effect4" + is_visible="true" + lpeversion="1" + nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1" + radius="0" + unit="px" + method="auto" + mode="F" + chamfer_steps="1" + flexible="false" + use_knot_distance="true" + apply_no_radius="true" + apply_with_radius="true" + only_selected="false" + hide_knots="false" /> + <inkscape:path-effect + effect="fillet_chamfer" + id="path-effect3" + is_visible="true" + lpeversion="1" + nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1" + radius="0" + unit="px" + method="auto" + mode="F" + chamfer_steps="1" + flexible="false" + use_knot_distance="true" + apply_no_radius="true" + apply_with_radius="true" + only_selected="false" + hide_knots="false" /> + <inkscape:path-effect + effect="fillet_chamfer" + id="path-effect2" + is_visible="true" + lpeversion="1" + nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1" + radius="0" + unit="px" + method="auto" + mode="F" + chamfer_steps="1" + flexible="false" + use_knot_distance="true" + apply_no_radius="true" + apply_with_radius="true" + only_selected="false" + hide_knots="false" /> + <inkscape:path-effect + effect="fillet_chamfer" + id="path-effect1" + is_visible="true" + lpeversion="1" + nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1" + radius="0" + unit="px" + method="auto" + mode="F" + chamfer_steps="1" + flexible="false" + use_knot_distance="true" + apply_no_radius="true" + apply_with_radius="true" + only_selected="false" + hide_knots="false" /> + <linearGradient + inkscape:collect="always" + id="linearGradient39361"> + <stop + style="stop-color:#0de8ff;stop-opacity:0.78082192;" + offset="0" + id="stop39357" /> + <stop + style="stop-color:#d600fc;stop-opacity:0.95433789;" + offset="1" + id="stop39359" /> + </linearGradient> + <inkscape:path-effect + effect="bspline" + id="path-effect255" + is_visible="true" + lpeversion="1" + weight="33.333333" + steps="2" + helper_size="0" + apply_no_weight="true" + apply_with_weight="true" + only_selected="false" /> + <linearGradient + inkscape:collect="always" + id="linearGradient2119"> + <stop + style="stop-color:#1c55ff;stop-opacity:1;" + offset="0" + id="stop2115" /> + <stop + style="stop-color:#7f35ab;stop-opacity:1;" + offset="0.5" + id="stop2123" /> + <stop + style="stop-color:#ff0bd6;stop-opacity:1;" + offset="1" + id="stop2117" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient2119" + id="linearGradient2121" + x1="10.067794" + y1="248.81357" + x2="246.56145" + y2="7.1864405" + gradientUnits="userSpaceOnUse" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient39361" + id="linearGradient39367" + x1="62.104473" + y1="128.78963" + x2="208.25758" + y2="128.78963" + gradientUnits="userSpaceOnUse" /> + </defs> + <g + inkscape:label="Background" + inkscape:groupmode="layer" + id="layer1" + sodipodi:insensitive="true"> + <path + id="rect61" + style="fill:url(#linearGradient2121);stroke-width:0.264583;opacity:1" + inkscape:label="Gradient" + d="m 80,-1.0775033e-7 h 96 A 80,80 45 0 1 256,80 v 96 a 80,80 135 0 1 -80,80 H 80 A 80,80 45 0 1 -5.3875166e-8,176 V 80 A 80,80 135 0 1 80,-1.0775033e-7 Z" + inkscape:path-effect="#path-effect9" + inkscape:original-d="M -5.3875166e-8,-1.0775033e-7 H 256 V 256 H -5.3875166e-8 Z" /> + </g> + <g + id="g407" + inkscape:label="Logo"> + <g + id="layer2" + inkscape:label="LogoStroke" + style="display:inline"> + <path + style="fill:url(#linearGradient39367);fill-opacity:1;stroke:#ffffff;stroke-width:10;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="M 101.1429,213.87373 C 67.104473,239.1681 67.104473,42.67112 67.104473,42.67112 135.18122,57.58146 203.25844,72.491904 203.25758,105.24181 c -8.6e-4,32.74991 -68.07625,83.33755 -102.11468,108.63192 z" + id="path253" /> + </g> + <g + inkscape:groupmode="layer" + id="layer3" + inkscape:label="Poly"> + <path + style="fill:#ffffff;fill-opacity:0.325424;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="M 67.32839,76.766948 112.00424,99.41949 100.04873,52.226693 Z" + id="path4648" /> + <path + style="fill:#ffffff;fill-opacity:0.274576;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="M 111.45696,98.998695 107.00758,142.60261 70.077729,105.67276 Z" + id="path9299" /> + <path + style="fill:#ffffff;fill-opacity:0.379661;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 111.01202,99.221164 29.14343,-37.15232 25.80641,39.377006 z" + id="path9301" /> + <path + style="fill:#ffffff;fill-opacity:0.447458;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 111.45696,99.443631 57.17452,55.172309 -2.89209,-53.17009 z" + id="path9368" /> + <path + style="fill:#ffffff;fill-opacity:0.20678;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 106.78511,142.38015 62.06884,12.68073 -57.17452,-55.617249 z" + id="path9370" /> + <path + style="fill:#ffffff;fill-opacity:0.244068;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 106.78511,142.38015 -28.47603,32.9254 62.51378,7.56395 z" + id="path9372" /> + <path + style="fill:#ffffff;fill-opacity:0.216949;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="M 165.96186,101.44585 195.7727,125.02756 182.64703,78.754017 Z" + id="path9374" /> + </g> + <g + inkscape:groupmode="layer" + id="layer4" + inkscape:label="Vertices"> + <circle + style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path27764" + cx="106.86934" + cy="142.38014" + r="2.0022209" /> + <circle + style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="circle28773" + cx="111.54119" + cy="99.221161" + r="2.0022209" /> + <circle + style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="circle29091" + cx="165.90784" + cy="101.36163" + r="2.0022209" /> + </g> + </g> +</svg> diff --git a/src/app.rs b/src/app.rs @@ -903,7 +903,7 @@ fn render_damus_mobile(ctx: &egui::Context, app: &mut Damus) { main_panel(&ctx.style(), ui::is_narrow(ctx)).show(ctx, |ui| { if !app.columns.columns().is_empty() { - nav::render_nav(false, 0, app, ui); + nav::render_nav(0, app, ui); } }); } @@ -951,13 +951,18 @@ fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, columns: usize) { StripBuilder::new(ui) - .size(Size::exact(40.0)) + .size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH)) .sizes(sizes, columns) .clip(true) .horizontal(|mut strip| { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); - let side_panel = DesktopSidePanel::new(app).show(ui); + let side_panel = DesktopSidePanel::new( + &app.ndb, + &mut app.img_cache, + app.accounts.get_selected_account(), + ) + .show(ui); let router = if let Some(router) = app .columns @@ -986,24 +991,10 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, columns: usiz }); let n_cols = app.columns.columns().len(); - let mut first = true; for column_ind in 0..n_cols { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); - let show_postbox = first - && app - .columns - .column(column_ind) - .router() - .routes() - .iter() - .find_map(|r| r.timeline_id()) - .is_some(); - if show_postbox { - first = false - } - - nav::render_nav(show_postbox, column_ind, app, ui); + nav::render_nav(column_ind, app, ui); // vertical line ui.painter().vline( diff --git a/src/nav.rs b/src/nav.rs @@ -4,13 +4,13 @@ use crate::{ route::Route, thread::thread_unsubscribe, timeline::route::{render_timeline_route, TimelineRoute, TimelineRouteResponse}, - ui::{note::PostAction, RelayView, View}, + ui::{self, note::PostAction, RelayView, View}, Damus, }; use egui_nav::{Nav, NavAction}; -pub fn render_nav(show_postbox: bool, col: usize, app: &mut Damus, ui: &mut egui::Ui) { +pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { // TODO(jb55): clean up this router_mut mess by using Router<R> in egui-nav directly let nav_response = Nav::new(app.columns().column(col).router().routes().clone()) .navigating(app.columns_mut().column_mut(col).router_mut().navigating) @@ -28,7 +28,6 @@ pub fn render_nav(show_postbox: bool, col: usize, app: &mut Damus, ui: &mut egui &mut app.accounts, *tlr, col, - show_postbox, app.textmode, ui, ), @@ -50,6 +49,29 @@ pub fn render_nav(show_postbox: bool, col: usize, app: &mut Damus, ui: &mut egui RelayView::new(manager).ui(ui); None } + Route::ComposeNote => { + let kp = app.accounts.selected_or_first_nsec()?; + let draft = app.drafts.compose_mut(); + + let txn = nostrdb::Transaction::new(&app.ndb).expect("txn"); + let post_response = ui::PostView::new( + &app.ndb, + draft, + crate::draft::DraftSource::Compose, + &mut app.img_cache, + &mut app.note_cache, + kp, + ) + .ui(&txn, ui); + + if let Some(action) = post_response.action { + PostAction::execute(kp, &action, &mut app.pool, draft, |np, seckey| { + np.to_note(seckey) + }); + } + + None + } }); if let Some(reply_response) = nav_response.inner { diff --git a/src/route.rs b/src/route.rs @@ -12,6 +12,7 @@ pub enum Route { Timeline(TimelineRoute), Accounts(AccountsRoute), Relays, + ComposeNote, } impl Route { @@ -123,6 +124,7 @@ impl fmt::Display for Route { AccountsRoute::Accounts => write!(f, "Accounts"), AccountsRoute::AddAccount => write!(f, "Add Account"), }, + Route::ComposeNote => write!(f, "Compose Note"), } } } diff --git a/src/timeline/route.rs b/src/timeline/route.rs @@ -48,23 +48,11 @@ pub fn render_timeline_route( accounts: &mut AccountManager, route: TimelineRoute, col: usize, - show_postbox: bool, textmode: bool, ui: &mut egui::Ui, ) -> Option<TimelineRouteResponse> { match route { TimelineRoute::Timeline(timeline_id) => { - if show_postbox { - let kp = accounts.selected_or_first_nsec()?; - let draft = drafts.compose_mut(); - let response = - ui::timeline::postbox_view(ndb, kp, draft, img_cache, note_cache, ui); - - if let Some(action) = response.action { - PostAction::execute(kp, &action, pool, draft, |np, seckey| np.to_note(seckey)); - } - } - if let Some(bar_action) = ui::TimelineView::new(timeline_id, columns, ndb, note_cache, img_cache, textmode) .ui(ui) diff --git a/src/ui/account_management.rs b/src/ui/account_management.rs @@ -10,8 +10,6 @@ use egui::{Align, Button, Frame, Image, InnerResponse, Layout, RichText, ScrollA use nostrdb::{Ndb, Transaction}; use super::profile::preview::SimpleProfilePreview; -use super::profile::ProfilePreviewOp; -use super::profile_preview_controller::profile_preview_view; pub struct AccountsView<'a> { ndb: &'a Ndb, @@ -26,6 +24,12 @@ pub enum AccountsViewResponse { RouteToLogin, } +#[derive(Debug)] +enum ProfilePreviewOp { + RemoveAccount, + SwitchTo, +} + impl<'a> AccountsView<'a> { pub fn new(ndb: &'a Ndb, accounts: &'a AccountManager, img_cache: &'a mut ImageCache) -> Self { AccountsView { @@ -86,9 +90,13 @@ impl<'a> AccountsView<'a> { false }; - if let Some(op) = - profile_preview_view(ui, profile.as_ref(), img_cache, is_selected) - { + let profile_peview_view = { + let width = ui.available_width(); + let preview = SimpleProfilePreview::new(profile.as_ref(), img_cache); + show_profile_card(ui, preview, width, is_selected) + }; + + if let Some(op) = profile_peview_view { return_op = Some(match op { ProfilePreviewOp::SwitchTo => AccountsViewResponse::SelectAccount(i), ProfilePreviewOp::RemoveAccount => { @@ -119,7 +127,7 @@ impl<'a> AccountsView<'a> { } } -pub fn show_profile_card( +fn show_profile_card( ui: &mut egui::Ui, preview: SimpleProfilePreview, width: f32, diff --git a/src/ui/anim.rs b/src/ui/anim.rs @@ -1,3 +1,5 @@ +use egui::{Pos2, Rect, Response, Sense}; + pub fn hover_expand( ui: &mut egui::Ui, id: egui::Id, @@ -25,3 +27,70 @@ pub fn hover_expand_small(ui: &mut egui::Ui, id: egui::Id) -> (egui::Rect, f32, 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 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), + ) + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs @@ -15,7 +15,7 @@ pub use account_management::AccountsView; pub use mention::Mention; pub use note::{NoteResponse, NoteView, PostReplyView, PostView}; pub use preview::{Preview, PreviewApp, PreviewConfig}; -pub use profile::{profile_preview_controller, ProfilePic, ProfilePreview}; +pub use profile::{ProfilePic, ProfilePreview}; pub use relay::RelayView; pub use side_panel::{DesktopSidePanel, SidePanelAction}; pub use thread::ThreadView; diff --git a/src/ui/profile/mod.rs b/src/ui/profile/mod.rs @@ -1,7 +1,5 @@ pub mod picture; pub mod preview; -pub mod profile_preview_controller; pub use picture::ProfilePic; pub use preview::ProfilePreview; -pub use profile_preview_controller::ProfilePreviewOp; diff --git a/src/ui/profile/preview.rs b/src/ui/profile/preview.rs @@ -1,6 +1,7 @@ use crate::app_style::NotedeckTextStyle; use crate::imgcache::ImageCache; use crate::ui::ProfilePic; +use crate::user_account::UserAccount; use crate::{colors, images, DisplayName}; use egui::load::TexturePoll; use egui::{Frame, RichText, Sense, Widget}; @@ -167,6 +168,30 @@ pub fn get_profile_url<'a>(profile: Option<&'a ProfileRecord<'a>>) -> &'a str { } } +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.pubkey.bytes()) { + get_profile_url_owned(Some(profile)) + } else { + get_profile_url_owned(None) + } + } else { + get_profile_url(None) + } +} + fn display_name_widget( display_name: DisplayName<'_>, add_placeholder_space: bool, diff --git a/src/ui/profile/profile_preview_controller.rs b/src/ui/profile/profile_preview_controller.rs @@ -1,126 +0,0 @@ -use egui::Ui; -use nostrdb::{Ndb, ProfileRecord, Transaction}; - -use crate::{ - imgcache::ImageCache, ui::account_management::show_profile_card, Damus, DisplayName, Result, -}; - -use super::{ - preview::{get_display_name, get_profile_url, SimpleProfilePreview}, - ProfilePic, -}; - -#[derive(Debug)] -pub enum ProfilePreviewOp { - RemoveAccount, - SwitchTo, -} - -pub fn profile_preview_view( - ui: &mut Ui, - profile: Option<&'_ ProfileRecord<'_>>, - img_cache: &mut ImageCache, - is_selected: bool, -) -> Option<ProfilePreviewOp> { - let width = ui.available_width(); - - let preview = SimpleProfilePreview::new(profile, img_cache); - show_profile_card(ui, preview, width, is_selected) -} - -pub fn view_profile_previews( - app: &mut Damus, - ui: &mut egui::Ui, - add_preview_ui: fn( - ui: &mut egui::Ui, - preview: SimpleProfilePreview, - width: f32, - is_selected: bool, - index: usize, - ) -> bool, -) -> Option<usize> { - let width = ui.available_width(); - - let txn = if let Ok(txn) = Transaction::new(app.ndb()) { - txn - } else { - return None; - }; - - for i in 0..app.accounts().num_accounts() { - let account = if let Some(account) = app.accounts().get_account(i) { - account - } else { - continue; - }; - - let profile = app - .ndb() - .get_profile_by_pubkey(&txn, account.pubkey.bytes()) - .ok(); - - let is_selected = if let Some(selected) = app.accounts().get_selected_account_index() { - i == selected - } else { - false - }; - - let preview = SimpleProfilePreview::new(profile.as_ref(), app.img_cache_mut()); - - if add_preview_ui(ui, preview, width, is_selected, i) { - return Some(i); - } - } - - None -} - -pub fn show_with_nickname( - ndb: &Ndb, - ui: &mut egui::Ui, - key: &[u8; 32], - ui_element: fn(ui: &mut egui::Ui, username: &DisplayName) -> egui::Response, -) -> Result<egui::Response> { - let txn = Transaction::new(ndb)?; - let profile = ndb.get_profile_by_pubkey(&txn, key)?; - Ok(ui_element(ui, &get_display_name(Some(&profile)))) -} - -pub fn show_with_selected_pfp( - app: &mut Damus, - ui: &mut egui::Ui, - ui_element: fn(ui: &mut egui::Ui, pfp: ProfilePic) -> egui::Response, -) -> Option<egui::Response> { - let selected_account = app.accounts().get_selected_account(); - if let Some(selected_account) = selected_account { - if let Ok(txn) = Transaction::new(app.ndb()) { - let profile = app - .ndb() - .get_profile_by_pubkey(&txn, selected_account.pubkey.bytes()); - - return Some(ui_element( - ui, - ProfilePic::new(app.img_cache_mut(), get_profile_url(profile.ok().as_ref())), - )); - } - } - - None -} - -pub fn show_with_pfp( - app: &mut Damus, - ui: &mut egui::Ui, - key: &[u8; 32], - ui_element: fn(ui: &mut egui::Ui, pfp: ProfilePic) -> egui::Response, -) -> Option<egui::Response> { - if let Ok(txn) = Transaction::new(app.ndb()) { - let profile = app.ndb().get_profile_by_pubkey(&txn, key); - - return Some(ui_element( - ui, - ProfilePic::new(app.img_cache_mut(), get_profile_url(profile.ok().as_ref())), - )); - } - None -} diff --git a/src/ui/side_panel.rs b/src/ui/side_panel.rs @@ -1,17 +1,29 @@ -use egui::{Button, Layout, SidePanel, Vec2, Widget}; +use egui::{vec2, Color32, InnerResponse, Layout, Margin, Separator, SidePanel, Stroke, Widget}; +use tracing::info; use crate::{ account_manager::AccountsRoute, + colors, column::Column, + imgcache::ImageCache, route::{Route, Router}, - ui::profile_preview_controller, + user_account::UserAccount, Damus, }; -use super::{ProfilePic, View}; +use super::{ + anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, + profile::preview::get_account_url, + ProfilePic, View, +}; + +pub static SIDE_PANEL_WIDTH: f32 = 64.0; +static ICON_WIDTH: f32 = 40.0; pub struct DesktopSidePanel<'a> { - app: &'a mut Damus, + ndb: &'a nostrdb::Ndb, + img_cache: &'a mut ImageCache, + selected_account: Option<&'a UserAccount>, } impl<'a> View for DesktopSidePanel<'a> { @@ -26,6 +38,9 @@ pub enum SidePanelAction { Account, Settings, Columns, + ComposeNote, + Search, + ExpandSidePanel, } pub struct SidePanelResponse { @@ -40,33 +55,93 @@ impl SidePanelResponse { } impl<'a> DesktopSidePanel<'a> { - pub fn new(app: &'a mut Damus) -> Self { - DesktopSidePanel { app } + pub fn new( + ndb: &'a nostrdb::Ndb, + img_cache: &'a mut ImageCache, + selected_account: Option<&'a UserAccount>, + ) -> Self { + Self { + ndb, + img_cache, + selected_account, + } } pub fn panel() -> SidePanel { egui::SidePanel::left("side_panel") .resizable(false) - .exact_width(40.0) + .exact_width(SIDE_PANEL_WIDTH) } pub fn show(&mut self, ui: &mut egui::Ui) -> SidePanelResponse { + egui::Frame::none() + .inner_margin(Margin::same(8.0)) + .show(ui, |ui| self.show_inner(ui)) + .inner + } + + fn show_inner(&mut self, ui: &mut egui::Ui) -> SidePanelResponse { let dark_mode = ui.ctx().style().visuals.dark_mode; - let spacing_amt = 16.0; let inner = ui - .with_layout(Layout::bottom_up(egui::Align::Center), |ui| { - ui.spacing_mut().item_spacing.y = spacing_amt; - let pfp_resp = self.pfp_button(ui); - let settings_resp = ui.add(settings_button(dark_mode)); - let column_resp = ui.add(add_column_button(dark_mode)); - - if pfp_resp.clicked() { - egui::InnerResponse::new(SidePanelAction::Account, pfp_resp) - } else if settings_resp.clicked() || settings_resp.hovered() { - egui::InnerResponse::new(SidePanelAction::Settings, settings_resp) - } else if column_resp.clicked() || column_resp.hovered() { - egui::InnerResponse::new(SidePanelAction::Columns, column_resp) + .vertical(|ui| { + let top_resp = ui + .with_layout(Layout::top_down(egui::Align::Center), |ui| { + let expand_resp = ui.add(expand_side_panel_button()); + ui.add_space(28.0); + let compose_resp = ui.add(compose_note_button()); + 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)); + + if expand_resp.clicked() { + Some(InnerResponse::new( + SidePanelAction::ExpandSidePanel, + expand_resp, + )) + } else 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 { + None + } + }) + .inner; + + 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 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 { + 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) } @@ -77,13 +152,20 @@ impl<'a> DesktopSidePanel<'a> { } fn pfp_button(&mut self, ui: &mut egui::Ui) -> egui::Response { - if let Some(resp) = - profile_preview_controller::show_with_selected_pfp(self.app, ui, show_pfp()) - { - resp - } else { - add_button_to_ui(ui, no_account_pfp()) - } + 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() } pub fn perform_action(router: &mut Router<Route>, action: SidePanelAction) { @@ -109,40 +191,167 @@ impl<'a> DesktopSidePanel<'a> { router.route_to(Route::relays()); } } - SidePanelAction::Columns => (), // TODO + SidePanelAction::Columns => { + // TODO + info!("Clicked columns button"); + } + SidePanelAction::ComposeNote => { + if router.routes().iter().any(|&r| r == Route::ComposeNote) { + router.go_back(); + } else { + router.route_to(Route::ComposeNote); + } + } + SidePanelAction::Search => { + // TODO + info!("Clicked search button"); + } + SidePanelAction::ExpandSidePanel => { + // TODO + info!("Clicked expand side panel button"); + } } } } -fn show_pfp() -> fn(ui: &mut egui::Ui, pfp: ProfilePic) -> egui::Response { - |ui, pfp| { - let response = pfp.ui(ui); - ui.allocate_rect(response.rect, egui::Sense::click()) +fn settings_button(dark_mode: bool) -> impl Widget { + let _ = dark_mode; + |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 = egui::include_image!("../../assets/icons/settings_dark_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 settings_button(dark_mode: bool) -> egui::Button<'static> { +fn add_column_button(dark_mode: bool) -> impl Widget { let _ = dark_mode; - let img_data = egui::include_image!("../../assets/icons/settings_dark_4x.png"); + move |ui: &mut egui::Ui| { + let img_size = 24.0; + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget - egui::Button::image(egui::Image::new(img_data).max_width(32.0)).frame(false) + let img_data = egui::include_image!("../../assets/icons/add_column_dark_4x.png"); + + let img = egui::Image::new(img_data).max_width(img_size); + + let helper = AnimationHelper::new(ui, "add-column-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_button_to_ui(ui: &mut egui::Ui, button: Button) -> egui::Response { - ui.add_sized(Vec2::new(32.0, 32.0), button) +fn compose_note_button() -> impl Widget { + |ui: &mut egui::Ui| -> egui::Response { + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget + + let min_outer_circle_diameter = 40.0; + let min_plus_sign_size = 14.0; // length of the plus sign + let min_line_width = 2.25; // width of the plus sign + + let helper = AnimationHelper::new(ui, "note-compose-button", vec2(max_size, max_size)); + + let painter = ui.painter_at(helper.get_animation_rect()); + + let use_background_radius = helper.scale_radius(min_outer_circle_diameter); + let use_line_width = helper.scale_1d_pos(min_line_width); + let use_edge_circle_radius = helper.scale_radius(min_line_width); + + painter.circle_filled(helper.center(), use_background_radius, colors::PINK); + + let min_half_plus_sign_size = min_plus_sign_size / 2.0; + let north_edge = helper.scale_from_center(0.0, min_half_plus_sign_size); + let south_edge = helper.scale_from_center(0.0, -min_half_plus_sign_size); + let west_edge = helper.scale_from_center(-min_half_plus_sign_size, 0.0); + let east_edge = helper.scale_from_center(min_half_plus_sign_size, 0.0); + + painter.line_segment( + [north_edge, south_edge], + Stroke::new(use_line_width, Color32::WHITE), + ); + painter.line_segment( + [west_edge, east_edge], + Stroke::new(use_line_width, Color32::WHITE), + ); + painter.circle_filled(north_edge, use_edge_circle_radius, Color32::WHITE); + painter.circle_filled(south_edge, use_edge_circle_radius, Color32::WHITE); + painter.circle_filled(west_edge, use_edge_circle_radius, Color32::WHITE); + painter.circle_filled(east_edge, use_edge_circle_radius, Color32::WHITE); + + helper.take_animation_response() + } } -fn no_account_pfp() -> Button<'static> { - Button::new("A") - .rounding(20.0) - .min_size(Vec2::new(38.0, 38.0)) +fn search_button() -> impl Widget { + |ui: &mut egui::Ui| -> egui::Response { + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget + let min_line_width_circle = 1.5; // width of the magnifying glass + let min_line_width_handle = 1.5; + let helper = AnimationHelper::new(ui, "search-button", vec2(max_size, max_size)); + + let painter = ui.painter_at(helper.get_animation_rect()); + + let cur_line_width_circle = helper.scale_1d_pos(min_line_width_circle); + let cur_line_width_handle = helper.scale_1d_pos(min_line_width_handle); + let min_outer_circle_radius = helper.scale_radius(15.0); + let cur_outer_circle_radius = helper.scale_1d_pos(min_outer_circle_radius); + let min_handle_length = 7.0; + let cur_handle_length = helper.scale_1d_pos(min_handle_length); + + let circle_center = helper.scale_from_center(-2.0, -2.0); + + let handle_vec = vec2( + std::f32::consts::FRAC_1_SQRT_2, + std::f32::consts::FRAC_1_SQRT_2, + ); + + let handle_pos_1 = circle_center + (handle_vec * (cur_outer_circle_radius - 3.0)); + let handle_pos_2 = + circle_center + (handle_vec * (cur_outer_circle_radius + cur_handle_length)); + + let circle_stroke = Stroke::new(cur_line_width_circle, colors::MID_GRAY); + let handle_stroke = Stroke::new(cur_line_width_handle, colors::MID_GRAY); + + painter.line_segment([handle_pos_1, handle_pos_2], handle_stroke); + painter.circle( + circle_center, + min_outer_circle_radius, + ui.style().visuals.widgets.inactive.weak_bg_fill, + circle_stroke, + ); + + helper.take_animation_response() + } } -fn add_column_button(dark_mode: bool) -> egui::Button<'static> { - let _ = dark_mode; - let img_data = egui::include_image!("../../assets/icons/add_column_dark_4x.png"); +// 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.svg"); + let img = egui::Image::new(img_data).max_width(img_size); - egui::Button::image(egui::Image::new(img_data).max_width(32.0)).frame(false) + ui.add(img) + } } mod preview { @@ -173,12 +382,16 @@ mod preview { impl View for DesktopSidePanelPreview { fn ui(&mut self, ui: &mut egui::Ui) { StripBuilder::new(ui) - .size(Size::exact(40.0)) + .size(Size::exact(SIDE_PANEL_WIDTH)) .sizes(Size::remainder(), 0) .clip(true) .horizontal(|mut strip| { strip.cell(|ui| { - let mut panel = DesktopSidePanel::new(&mut self.app); + let mut panel = DesktopSidePanel::new( + &self.app.ndb, + &mut self.app.img_cache, + self.app.accounts.get_selected_account(), + ); let response = panel.show(ui); DesktopSidePanel::perform_action( diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs @@ -1,4 +1,3 @@ -use crate::draft::Draft; use crate::{ actionbar::BarAction, column::Columns, imgcache::ImageCache, notecache::NoteCache, timeline::TimelineId, ui, @@ -6,12 +5,9 @@ use crate::{ use egui::containers::scroll_area::ScrollBarVisibility; use egui::{Direction, Layout}; use egui_tabs::TabColor; -use enostr::FilledKeypair; use nostrdb::{Ndb, Transaction}; use tracing::{debug, error, warn}; -use super::note::PostResponse; - pub struct TimelineView<'a> { timeline_id: TimelineId, columns: &'a mut Columns, @@ -175,27 +171,6 @@ fn timeline_ui( bar_action } -pub fn postbox_view<'a>( - ndb: &'a Ndb, - key: FilledKeypair<'a>, - draft: &'a mut Draft, - img_cache: &'a mut ImageCache, - note_cache: &'a mut NoteCache, - ui: &'a mut egui::Ui, -) -> PostResponse { - // show a postbox in the first timeline - let txn = Transaction::new(ndb).expect("txn"); - ui::PostView::new( - ndb, - draft, - crate::draft::DraftSource::Compose, - img_cache, - note_cache, - key, - ) - .ui(&txn, ui) -} - fn tabs_ui(ui: &mut egui::Ui) -> i32 { ui.spacing_mut().item_spacing.y = 0.0;