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
This commit is contained in:
William Casarin
2024-09-26 13:17:59 -07:00
13 changed files with 738 additions and 239 deletions

View File

@@ -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,

View File

@@ -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),
)
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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));
.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));
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)
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(

View File

@@ -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;