diff --git a/Cargo.lock b/Cargo.lock index ac55f09..8ca5953 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,7 +1148,7 @@ dependencies = [ [[package]] name = "egui_nav" version = "0.1.0" -source = "git+https://github.com/damus-io/egui-nav?rev=b19742503329a13df660ac8c5a3ada4a25b7cc53#b19742503329a13df660ac8c5a3ada4a25b7cc53" +source = "git+https://github.com/damus-io/egui-nav?rev=6ba42de2bae384d10e35c532f3856b81d2e9f645#6ba42de2bae384d10e35c532f3856b81d2e9f645" dependencies = [ "egui", "egui_extras", @@ -1660,7 +1660,7 @@ checksum = "9c08c1f623a8d0b722b8b99f821eb0ba672a1618f0d3b16ddbee1cedd2dd8557" dependencies = [ "bitflags 2.6.0", "gpu-descriptor-types", - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -1692,6 +1692,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" + [[package]] name = "hassle-rs" version = "0.11.0" @@ -1950,12 +1956,12 @@ checksum = "44feda355f4159a7c757171a77de25daf6411e217b4cabd03bd6650690468126" [[package]] name = "indexmap" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.0", "serde", ] @@ -2508,6 +2514,7 @@ dependencies = [ "env_logger 0.10.2", "hex", "image", + "indexmap", "log", "nostrdb", "poll-promise", diff --git a/Cargo.toml b/Cargo.toml index 75404f1..17e99ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ eframe = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da egui_extras = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", package = "egui_extras", features = ["all_loaders"] } ehttp = "0.2.0" egui_tabs = { git = "https://github.com/damus-io/egui-tabs", branch = "egui-0.28" } -egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "b19742503329a13df660ac8c5a3ada4a25b7cc53" } +egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "6ba42de2bae384d10e35c532f3856b81d2e9f645" } egui_virtual_list = { git = "https://github.com/jb55/hello_egui", branch = "egui-0.28", package = "egui_virtual_list" } reqwest = { version = "0.12.4", default-features = false, features = [ "rustls-tls-native-roots" ] } image = { version = "0.25", features = ["jpeg", "png", "webp"] } @@ -42,6 +42,7 @@ strum = "0.26" strum_macros = "0.26" bitflags = "2.5.0" uuid = { version = "1.10.0", features = ["v4"] } +indexmap = "2.6.0" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/assets/icons/column_delete_icon_4x.png b/assets/icons/column_delete_icon_4x.png new file mode 100644 index 0000000..ae8860b Binary files /dev/null and b/assets/icons/column_delete_icon_4x.png differ diff --git a/assets/icons/home_icon_dark_4x.png b/assets/icons/home_icon_dark_4x.png new file mode 100644 index 0000000..56cf957 Binary files /dev/null and b/assets/icons/home_icon_dark_4x.png differ diff --git a/assets/icons/notifications_icon_dark_4x.png b/assets/icons/notifications_icon_dark_4x.png new file mode 100644 index 0000000..cef65b7 Binary files /dev/null and b/assets/icons/notifications_icon_dark_4x.png differ diff --git a/assets/icons/universe_icon_dark_4x.png b/assets/icons/universe_icon_dark_4x.png new file mode 100644 index 0000000..c4a50f8 Binary files /dev/null and b/assets/icons/universe_icon_dark_4x.png differ diff --git a/src/app.rs b/src/app.rs index 5e7b0af..ce3c234 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,21 +3,19 @@ use crate::{ app_creation::setup_cc, app_style::user_requested_visuals_change, args::Args, - column::{Column, Columns}, + column::Columns, draft::Drafts, error::{Error, FilterError}, - filter, - filter::FilterState, + filter::{self, FilterState}, frame_history::FrameHistory, imgcache::ImageCache, key_storage::KeyStorageType, nav, note::NoteRef, notecache::{CachedNote, NoteCache}, - route::Route, subscriptions::{SubKind, Subscriptions}, thread::Threads, - timeline::{Timeline, TimelineKind, ViewFilter}, + timeline::{Timeline, TimelineId, TimelineKind, ViewFilter}, ui::{self, DesktopSidePanel}, unknowns::UnknownIds, view_state::ViewState, @@ -41,6 +39,7 @@ use tracing::{debug, error, info, trace, warn}; pub enum DamusState { Initializing, Initialized, + NewTimelineSub(TimelineId), } /// We derive Deserialize/Serialize so we can persist app state on shutdown. @@ -248,7 +247,7 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { if let Err(err) = Timeline::poll_notes_into_view( timeline_ind, - &mut damus.columns.timelines, + damus.columns.timelines_mut(), &damus.ndb, &txn, &mut damus.unknown_ids, @@ -394,47 +393,105 @@ fn setup_initial_nostrdb_subs( columns: &mut Columns, ) -> Result<()> { for timeline in columns.timelines_mut() { - match &timeline.filter { - FilterState::Ready(filters) => { - { setup_initial_timeline(ndb, timeline, note_cache, &filters.clone()) }? - } + setup_nostrdb_sub(ndb, note_cache, timeline)? + } - FilterState::Broken(err) => { - error!("FetchingRemote state broken in setup_initial_nostr_subs: {err}") - } - FilterState::FetchingRemote(_) => { - error!("FetchingRemote state in setup_initial_nostr_subs") - } - FilterState::GotRemote(_) => { - error!("GotRemote state in setup_initial_nostr_subs") - } - FilterState::NeedsRemote(_filters) => { - // can't do anything yet, we defer to first connect to send - // remote filters - } + Ok(()) +} + +fn setup_nostrdb_sub(ndb: &Ndb, note_cache: &mut NoteCache, timeline: &mut Timeline) -> Result<()> { + match &timeline.filter { + FilterState::Ready(filters) => { + { setup_initial_timeline(ndb, timeline, note_cache, &filters.clone()) }? + } + + FilterState::Broken(err) => { + error!("FetchingRemote state broken in setup_initial_nostr_subs: {err}") + } + FilterState::FetchingRemote(_) => { + error!("FetchingRemote state in setup_initial_nostr_subs") + } + FilterState::GotRemote(_) => { + error!("GotRemote state in setup_initial_nostr_subs") + } + FilterState::NeedsRemote(_filters) => { + // can't do anything yet, we defer to first connect to send + // remote filters } } Ok(()) } -fn update_damus(damus: &mut Damus, ctx: &egui::Context) { - if damus.state == DamusState::Initializing { - #[cfg(feature = "profiling")] - setup_profiling(); - - damus.state = DamusState::Initialized; - // this lets our eose handler know to close unknownids right away - damus - .subscriptions() - .insert("unknownids".to_string(), SubKind::OneShot); - setup_initial_nostrdb_subs(&damus.ndb, &mut damus.note_cache, &mut damus.columns) - .expect("home subscription failed"); +fn setup_new_nostrdb_sub( + ndb: &Ndb, + note_cache: &mut NoteCache, + columns: &mut Columns, + new_timeline_id: TimelineId, +) -> Result<()> { + if let Some(timeline) = columns.find_timeline_mut(new_timeline_id) { + info!("Setting up timeline sub for {}", timeline.id); + if let FilterState::Ready(filters) = &timeline.filter { + for filter in filters { + info!("Setting up filter {:?}", filter.json()); + } + } + setup_nostrdb_sub(ndb, note_cache, timeline)? } + Ok(()) +} + +fn update_damus(damus: &mut Damus, ctx: &egui::Context) { + match damus.state { + DamusState::Initializing => { + #[cfg(feature = "profiling")] + setup_profiling(); + + damus.state = DamusState::Initialized; + // this lets our eose handler know to close unknownids right away + damus + .subscriptions() + .insert("unknownids".to_string(), SubKind::OneShot); + setup_initial_nostrdb_subs(&damus.ndb, &mut damus.note_cache, &mut damus.columns) + .expect("home subscription failed"); + } + + DamusState::NewTimelineSub(new_timeline_id) => { + info!("adding new timeline {}", new_timeline_id); + setup_new_nostrdb_sub( + &damus.ndb, + &mut damus.note_cache, + &mut damus.columns, + new_timeline_id, + ) + .expect("new timeline subscription failed"); + + if let Some(filter) = { + let timeline = damus + .columns + .find_timeline(new_timeline_id) + .expect("timeline"); + match &timeline.filter { + FilterState::Ready(filters) => Some(filters.clone()), + _ => None, + } + } { + let subid = Uuid::new_v4().to_string(); + damus.pool.subscribe(subid, filter); + + damus.state = DamusState::Initialized; + } + } + + DamusState::Initialized => (), + }; + if let Err(err) = try_process_event(damus, ctx) { error!("error processing event: {}", err); } + + damus.columns.attempt_perform_deletion_request(); } fn process_event(damus: &mut Damus, _subid: &str, event: &str) { @@ -643,11 +700,7 @@ impl Damus { let debug = parsed_args.debug; if columns.columns().is_empty() { - let filter = Filter::from_json(include_str!("../queries/timeline.json")).unwrap(); - columns.add_timeline(Timeline::new( - TimelineKind::Generic, - FilterState::ready(vec![filter]), - )) + columns.new_column_picker(); } Self { @@ -714,6 +767,10 @@ impl Damus { } } + pub fn subscribe_new_timeline(&mut self, timeline_id: TimelineId) { + self.state = DamusState::NewTimelineSub(timeline_id); + } + pub fn mock>(data_path: P) -> Self { let mut columns = Columns::new(); let filter = Filter::from_json(include_str!("../queries/global.json")).unwrap(); @@ -897,7 +954,7 @@ fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { puffin::profile_function!(); let screen_size = ctx.screen_rect().width(); - let calc_panel_width = (screen_size / app.columns.columns().len() as f32) - 30.0; + let calc_panel_width = (screen_size / app.columns.num_columns() as f32) - 30.0; let min_width = 320.0; let need_scroll = calc_panel_width < min_width; let panel_sizes = if need_scroll { @@ -910,18 +967,18 @@ fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { ui.spacing_mut().item_spacing.x = 0.0; if need_scroll { egui::ScrollArea::horizontal().show(ui, |ui| { - timelines_view(ui, panel_sizes, app, app.columns.columns().len()); + timelines_view(ui, panel_sizes, app); }); } else { - timelines_view(ui, panel_sizes, app, app.columns.columns().len()); + timelines_view(ui, panel_sizes, app); } }); } -fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, columns: usize) { +fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus) { StripBuilder::new(ui) .size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH)) - .sizes(sizes, columns) + .sizes(sizes, app.columns.num_columns()) .clip(true) .horizontal(|mut strip| { strip.cell(|ui| { @@ -933,22 +990,8 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, columns: usiz ) .show(ui); - let router = if let Some(router) = app - .columns - .columns_mut() - .get_mut(0) - .map(|c: &mut Column| c.router_mut()) - { - router - } else { - // TODO(jb55): Maybe we should have an empty column route? - let columns = app.columns.columns_mut(); - columns.push(Column::new(vec![Route::accounts()])); - columns[0].router_mut() - }; - if side_panel.response.clicked() { - DesktopSidePanel::perform_action(router, side_panel.action); + DesktopSidePanel::perform_action(app.columns_mut(), side_panel.action); } // vertical sidebar line @@ -959,11 +1002,10 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, columns: usiz ); }); - let n_cols = app.columns.columns().len(); - for column_ind in 0..n_cols { + for col_index in 0..app.columns.num_columns() { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); - nav::render_nav(column_ind, app, ui); + nav::render_nav(col_index, app, ui); // vertical line ui.painter().vline( diff --git a/src/app_style.rs b/src/app_style.rs index 71a0b7d..6e9faf6 100644 --- a/src/app_style.rs +++ b/src/app_style.rs @@ -1,5 +1,6 @@ -use crate::colors::{ - desktop_dark_color_theme, light_color_theme, mobile_dark_color_theme, ColorTheme, +use crate::{ + colors::{desktop_dark_color_theme, light_color_theme, mobile_dark_color_theme, ColorTheme}, + ui::is_narrow, }; use egui::{ epaint::Shadow, @@ -96,6 +97,14 @@ pub fn mobile_font_size(text_style: &NotedeckTextStyle) -> f32 { } } +pub fn get_font_size(ctx: &egui::Context, text_style: &NotedeckTextStyle) -> f32 { + if is_narrow(ctx) { + mobile_font_size(text_style) + } else { + desktop_font_size(text_style) + } +} + #[derive(Copy, Clone, Eq, PartialEq, Debug, EnumIter)] pub enum NotedeckTextStyle { Heading, diff --git a/src/column.rs b/src/column.rs index 16e4d5b..62d45cd 100644 --- a/src/column.rs +++ b/src/column.rs @@ -1,6 +1,8 @@ use crate::route::{Route, Router}; use crate::timeline::{Timeline, TimelineId}; +use indexmap::IndexMap; use std::iter::Iterator; +use std::sync::atomic::{AtomicU32, Ordering}; use tracing::warn; pub struct Column { @@ -25,16 +27,18 @@ impl Column { #[derive(Default)] pub struct Columns { /// Columns are simply routers into settings, timelines, etc - columns: Vec, + columns: IndexMap, /// Timeline state is not tied to routing logic separately, so that /// different columns can navigate to and from settings to timelines, /// etc. - pub timelines: Vec, + pub timelines: IndexMap, /// The selected column for key navigation selected: i32, + should_delete_column_at_index: Option, } +static UIDS: AtomicU32 = AtomicU32::new(0); impl Columns { pub fn new() -> Self { @@ -42,49 +46,112 @@ impl Columns { } pub fn add_timeline(&mut self, timeline: Timeline) { + let id = Self::get_new_id(); let routes = vec![Route::timeline(timeline.id)]; - self.timelines.push(timeline); - self.columns.push(Column::new(routes)) + self.timelines.insert(id, timeline); + self.columns.insert(id, Column::new(routes)); } - pub fn columns_mut(&mut self) -> &mut Vec { - &mut self.columns + pub fn add_timeline_to_column(&mut self, col: usize, timeline: Timeline) { + let col_id = self.get_column_id_at_index(col); + self.column_mut(col) + .router_mut() + .route_to_replaced(Route::timeline(timeline.id)); + self.timelines.insert(col_id, timeline); + } + + pub fn new_column_picker(&mut self) { + self.add_column(Column::new(vec![Route::AddColumn])); + } + + fn get_new_id() -> u32 { + UIDS.fetch_add(1, Ordering::Relaxed) + } + + pub fn add_column(&mut self, column: Column) { + self.columns.insert(Self::get_new_id(), column); + } + + pub fn columns_mut(&mut self) -> Vec<&mut Column> { + self.columns.values_mut().collect() + } + + pub fn num_columns(&self) -> usize { + self.columns.len() + } + + // Get the first router in the columns if there are columns present. + // Otherwise, create a new column picker and return the router + pub fn get_first_router(&mut self) -> &mut Router { + if self.columns.is_empty() { + self.new_column_picker(); + } + self.columns + .get_index_mut(0) + .expect("There should be at least one column") + .1 + .router_mut() } pub fn timeline_mut(&mut self, timeline_ind: usize) -> &mut Timeline { - &mut self.timelines[timeline_ind] + self.timelines + .get_index_mut(timeline_ind) + .expect("expected index to be in bounds") + .1 } pub fn column(&self, ind: usize) -> &Column { - &self.columns()[ind] + self.columns + .get_index(ind) + .expect("Expected index to be in bounds") + .1 } - pub fn columns(&self) -> &Vec { - &self.columns + pub fn columns(&self) -> Vec<&Column> { + self.columns.values().collect() + } + + pub fn get_column_id_at_index(&self, ind: usize) -> u32 { + *self + .columns + .get_index(ind) + .expect("expected index to be within bounds") + .0 } pub fn selected(&mut self) -> &mut Column { - &mut self.columns[self.selected as usize] + self.columns + .get_index_mut(self.selected as usize) + .expect("Expected selected index to be in bounds") + .1 } - pub fn timelines_mut(&mut self) -> &mut Vec { - &mut self.timelines + pub fn timelines_mut(&mut self) -> Vec<&mut Timeline> { + self.timelines.values_mut().collect() } - pub fn timelines(&self) -> &Vec { - &self.timelines + pub fn timelines(&self) -> Vec<&Timeline> { + self.timelines.values().collect() } pub fn find_timeline_mut(&mut self, id: TimelineId) -> Option<&mut Timeline> { - self.timelines_mut().iter_mut().find(|tl| tl.id == id) + self.timelines_mut().into_iter().find(|tl| tl.id == id) } pub fn find_timeline(&self, id: TimelineId) -> Option<&Timeline> { - self.timelines().iter().find(|tl| tl.id == id) + self.timelines().into_iter().find(|tl| tl.id == id) } pub fn column_mut(&mut self, ind: usize) -> &mut Column { - &mut self.columns[ind] + self.columns + .get_index_mut(ind) + .expect("Expected index to be in bounds") + .1 + } + + pub fn find_timeline_for_column_index(&self, ind: usize) -> Option<&Timeline> { + let col_id = self.get_column_id_at_index(ind); + self.timelines.get(&col_id) } pub fn select_down(&mut self) { @@ -108,4 +175,23 @@ impl Columns { } self.selected += 1; } + + pub fn request_deletion_at_index(&mut self, index: usize) { + self.should_delete_column_at_index = Some(index); + } + + pub fn attempt_perform_deletion_request(&mut self) { + if let Some(index) = self.should_delete_column_at_index { + if let Some((key, _)) = self.columns.get_index_mut(index) { + self.timelines.shift_remove(key); + } + + self.columns.shift_remove_index(index); + self.should_delete_column_at_index = None; + + if self.columns.is_empty() { + self.new_column_picker(); + } + } + } } diff --git a/src/fonts.rs b/src/fonts.rs index 6ec5174..220e01d 100644 --- a/src/fonts.rs +++ b/src/fonts.rs @@ -4,12 +4,13 @@ use tracing::debug; pub enum NamedFontFamily { Medium, + Bold, } impl NamedFontFamily { pub fn as_str(&mut self) -> &'static str { match self { - //Self::Bold => "bold", + Self::Bold => "bold", Self::Medium => "medium", } } @@ -43,7 +44,7 @@ pub fn setup_fonts(ctx: &egui::Context) { "DejaVuSans".to_owned(), FontData::from_static(include_bytes!("../assets/fonts/DejaVuSansSansEmoji.ttf")), ); - /* + font_data.insert( "OnestBold".to_owned(), FontData::from_static(include_bytes!( @@ -51,6 +52,7 @@ pub fn setup_fonts(ctx: &egui::Context) { )), ); + /* font_data.insert( "DejaVuSansBold".to_owned(), FontData::from_static(include_bytes!( @@ -119,7 +121,10 @@ pub fn setup_fonts(ctx: &egui::Context) { medium.extend(base_fonts.clone()); let mut mono = vec!["Inconsolata".to_owned()]; - mono.extend(base_fonts); + mono.extend(base_fonts.clone()); + + let mut bold = vec!["OnestBold".to_owned()]; + bold.extend(base_fonts); families.insert(egui::FontFamily::Proportional, proportional); families.insert(egui::FontFamily::Monospace, mono); @@ -127,6 +132,10 @@ pub fn setup_fonts(ctx: &egui::Context) { egui::FontFamily::Name(NamedFontFamily::Medium.as_str().into()), medium, ); + families.insert( + egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()), + bold, + ); debug!("fonts: {:?}", families); diff --git a/src/nav.rs b/src/nav.rs index a0f3983..bca1d03 100644 --- a/src/nav.rs +++ b/src/nav.rs @@ -1,84 +1,121 @@ use crate::{ account_manager::render_accounts_route, + app_style::{get_font_size, NotedeckTextStyle}, + fonts::NamedFontFamily, relay_pool_manager::RelayPoolManager, route::Route, thread::thread_unsubscribe, - timeline::route::{render_timeline_route, TimelineRoute, TimelineRouteResponse}, - ui::{self, note::PostAction, RelayView, View}, + timeline::route::{render_timeline_route, AfterRouteExecution, TimelineRoute}, + ui::{ + self, + add_column::{AddColumnResponse, AddColumnView}, + anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, + note::PostAction, + RelayView, View, + }, Damus, }; -use egui_nav::{Nav, NavAction}; +use egui::{pos2, Color32, InnerResponse, Stroke}; +use egui_nav::{Nav, NavAction, TitleBarResponse}; +use tracing::{error, info}; pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { + let col_id = app.columns.get_column_id_at_index(col); // TODO(jb55): clean up this router_mut mess by using Router in egui-nav directly - let nav_response = Nav::new(app.columns().column(col).router().routes().clone()) + let routes = app + .columns() + .column(col) + .router() + .routes() + .iter() + .map(|r| r.get_titled_route(&app.columns, &app.ndb)) + .collect(); + let nav_response = Nav::new(routes) .navigating(app.columns_mut().column_mut(col).router_mut().navigating) .returning(app.columns_mut().column_mut(col).router_mut().returning) - .title(false) - .show_mut(ui, |ui, nav| match nav.top() { - Route::Timeline(tlr) => render_timeline_route( - &app.ndb, - &mut app.columns, - &mut app.pool, - &mut app.drafts, - &mut app.img_cache, - &mut app.note_cache, - &mut app.threads, - &mut app.accounts, - *tlr, - col, - app.textmode, - ui, - ), - Route::Accounts(amr) => { - render_accounts_route( - ui, + .title(48.0, title_bar) + .show_mut(col_id, ui, |ui, nav| { + let column = app.columns.column_mut(col); + match &nav.top().route { + Route::Timeline(tlr) => render_timeline_route( &app.ndb, - col, &mut app.columns, - &mut app.img_cache, - &mut app.accounts, - &mut app.view_state.login, - *amr, - ); - None - } - Route::Relays => { - let manager = RelayPoolManager::new(app.pool_mut()); - 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.pool, + &mut app.drafts, &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) - }); - app.columns_mut().column_mut(col).router_mut().go_back(); + &mut app.threads, + &mut app.accounts, + *tlr, + col, + app.textmode, + ui, + ), + Route::Accounts(amr) => { + render_accounts_route( + ui, + &app.ndb, + col, + &mut app.columns, + &mut app.img_cache, + &mut app.accounts, + &mut app.view_state.login, + *amr, + ); + None } + Route::Relays => { + let manager = RelayPoolManager::new(app.pool_mut()); + RelayView::new(manager).ui(ui); + None + } + Route::ComposeNote => { + let kp = app.accounts.selected_or_first_nsec()?; + let draft = app.drafts.compose_mut(); - None + 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) + }); + column.router_mut().go_back(); + } + + None + } + Route::AddColumn => { + let resp = + AddColumnView::new(&app.ndb, app.accounts.get_selected_account()).ui(ui); + + if let Some(resp) = resp { + match resp { + AddColumnResponse::Timeline(timeline) => { + let id = timeline.id; + app.columns_mut().add_timeline_to_column(col, timeline); + app.subscribe_new_timeline(id); + } + }; + } + None + } } }); - if let Some(reply_response) = nav_response.inner { + if let Some(after_route_execution) = nav_response.inner { // start returning when we're finished posting - match reply_response { - TimelineRouteResponse::Post(resp) => { + match after_route_execution { + AfterRouteExecution::Post(resp) => { if let Some(action) = resp.action { match action { PostAction::Post(_) => { @@ -102,6 +139,197 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { ); } } else if let Some(NavAction::Navigated) = nav_response.action { - app.columns_mut().column_mut(col).router_mut().navigating = false; + let cur_router = app.columns_mut().column_mut(col).router_mut(); + cur_router.navigating = false; + if cur_router.is_replacing() { + cur_router.remove_previous_route(); + } + } + + if let Some(title_response) = nav_response.title_response { + match title_response { + TitleResponse::RemoveColumn => { + app.columns_mut().request_deletion_at_index(col); + let tl = app.columns().find_timeline_for_column_index(col); + if let Some(timeline) = tl { + if let Some(sub_id) = timeline.subscription { + if let Err(e) = app.ndb.unsubscribe(sub_id) { + error!("unsubscribe error: {}", e); + } else { + info!( + "successfully unsubscribed from timeline {} with sub id {}", + timeline.id, + sub_id.id() + ); + } + } + } + } + } } } + +fn title_bar( + ui: &mut egui::Ui, + allocated_response: egui::Response, + title_name: String, + back_name: Option, +) -> egui::InnerResponse> { + let icon_width = 32.0; + let padding_external = 16.0; + let padding_internal = 8.0; + let has_back = back_name.is_some(); + + let (spacing_rect, titlebar_rect) = allocated_response + .rect + .split_left_right_at_x(allocated_response.rect.left() + padding_external); + ui.advance_cursor_after_rect(spacing_rect); + + let (titlebar_resp, maybe_button_resp) = if has_back { + let (button_rect, titlebar_rect) = titlebar_rect + .split_left_right_at_x(allocated_response.rect.left() + icon_width + padding_external); + ( + allocated_response.with_new_rect(titlebar_rect), + Some(back_button(ui, button_rect)), + ) + } else { + (allocated_response, None) + }; + + title( + ui, + title_name, + titlebar_resp.rect, + icon_width, + if has_back { + padding_internal + } else { + padding_external + }, + ); + + let delete_button_resp = delete_column_button(ui, titlebar_resp, icon_width, padding_external); + let title_response = if delete_button_resp.clicked() { + Some(TitleResponse::RemoveColumn) + } else { + None + }; + + let titlebar_resp = TitleBarResponse { + title_response, + go_back: maybe_button_resp.map_or(false, |r| r.clicked()), + }; + + InnerResponse::new(titlebar_resp, delete_button_resp) +} + +fn back_button(ui: &mut egui::Ui, button_rect: egui::Rect) -> egui::Response { + let horizontal_length = 10.0; + let arrow_length = 5.0; + + let helper = AnimationHelper::new_from_rect(ui, "note-compose-button", button_rect); + let painter = ui.painter_at(helper.get_animation_rect()); + let stroke = Stroke::new(1.5, ui.visuals().text_color()); + + // Horizontal segment + let left_horizontal_point = pos2(-horizontal_length / 2., 0.); + let right_horizontal_point = pos2(horizontal_length / 2., 0.); + let scaled_left_horizontal_point = helper.scale_pos_from_center(left_horizontal_point); + let scaled_right_horizontal_point = helper.scale_pos_from_center(right_horizontal_point); + + painter.line_segment( + [scaled_left_horizontal_point, scaled_right_horizontal_point], + stroke, + ); + + // Top Arrow + let sqrt_2_over_2 = std::f32::consts::SQRT_2 / 2.; + let right_top_arrow_point = helper.scale_pos_from_center(pos2( + left_horizontal_point.x + (sqrt_2_over_2 * arrow_length), + right_horizontal_point.y + sqrt_2_over_2 * arrow_length, + )); + + let scaled_left_arrow_point = scaled_left_horizontal_point; + painter.line_segment([scaled_left_arrow_point, right_top_arrow_point], stroke); + + let right_bottom_arrow_point = helper.scale_pos_from_center(pos2( + left_horizontal_point.x + (sqrt_2_over_2 * arrow_length), + right_horizontal_point.y - sqrt_2_over_2 * arrow_length, + )); + + painter.line_segment([scaled_left_arrow_point, right_bottom_arrow_point], stroke); + + helper.take_animation_response() +} + +fn delete_column_button( + ui: &mut egui::Ui, + allocation_response: egui::Response, + icon_width: f32, + padding: f32, +) -> egui::Response { + let img_size = 16.0; + let max_size = icon_width * ICON_EXPANSION_MULTIPLE; + + let img_data = egui::include_image!("../assets/icons/column_delete_icon_4x.png"); + let img = egui::Image::new(img_data).max_width(img_size); + + let button_rect = { + let titlebar_rect = allocation_response.rect; + let titlebar_width = titlebar_rect.width(); + let titlebar_center = titlebar_rect.center(); + let button_center_y = titlebar_center.y; + let button_center_x = + titlebar_center.x + (titlebar_width / 2.0) - (max_size / 2.0) - padding; + egui::Rect::from_center_size( + pos2(button_center_x, button_center_y), + egui::vec2(max_size, max_size), + ) + }; + + let helper = AnimationHelper::new_from_rect(ui, "delete-column-button", button_rect); + + let cur_img_size = helper.scale_1d_pos(img_size); + + let animation_rect = helper.get_animation_rect(); + let animation_resp = helper.take_animation_response(); + if allocation_response.union(animation_resp.clone()).hovered() { + img.paint_at(ui, animation_rect.shrink((max_size - cur_img_size) / 2.0)); + } + + animation_resp +} + +fn title( + ui: &mut egui::Ui, + title_name: String, + titlebar_rect: egui::Rect, + icon_width: f32, + padding: f32, +) { + let painter = ui.painter_at(titlebar_rect); + + let font = egui::FontId::new( + get_font_size(ui.ctx(), &NotedeckTextStyle::Body), + egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()), + ); + + let max_title_width = titlebar_rect.width() - icon_width - padding * 2.; + let title_galley = + ui.fonts(|f| f.layout(title_name, font, ui.visuals().text_color(), max_title_width)); + + let pos = { + let titlebar_center = titlebar_rect.center(); + let text_height = title_galley.rect.height(); + + let galley_pos_x = titlebar_rect.left() + padding; + let galley_pos_y = titlebar_center.y - (text_height / 2.); + pos2(galley_pos_x, galley_pos_y) + }; + + painter.galley(pos, title_galley, Color32::WHITE); +} + +enum TitleResponse { + RemoveColumn, +} diff --git a/src/route.rs b/src/route.rs index f9c3736..9b622c9 100644 --- a/src/route.rs +++ b/src/route.rs @@ -1,9 +1,12 @@ use enostr::NoteId; +use nostrdb::Ndb; use std::fmt::{self}; use crate::{ account_manager::AccountsRoute, + column::Columns, timeline::{TimelineId, TimelineRoute}, + ui::profile::preview::get_note_users_displayname_string, }; /// App routing. These describe different places you can go inside Notedeck. @@ -13,6 +16,19 @@ pub enum Route { Accounts(AccountsRoute), Relays, ComposeNote, + AddColumn, +} + +#[derive(Clone)] +pub struct TitledRoute { + pub route: Route, + pub title: String, +} + +impl fmt::Display for TitledRoute { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.title) + } } impl Route { @@ -51,6 +67,42 @@ impl Route { pub fn add_account() -> Self { Route::Accounts(AccountsRoute::AddAccount) } + + pub fn get_titled_route(&self, columns: &Columns, ndb: &Ndb) -> TitledRoute { + let title = match self { + Route::Timeline(tlr) => match tlr { + TimelineRoute::Timeline(id) => { + let timeline = columns + .find_timeline(*id) + .expect("expected to find timeline"); + timeline.kind.to_title(ndb) + } + TimelineRoute::Thread(id) => { + format!("{}'s Thread", get_note_users_displayname_string(ndb, id)) + } + TimelineRoute::Reply(id) => { + format!("{}'s Reply", get_note_users_displayname_string(ndb, id)) + } + TimelineRoute::Quote(id) => { + format!("{}'s Quote", get_note_users_displayname_string(ndb, id)) + } + }, + + Route::Relays => "Relays".to_owned(), + + Route::Accounts(amr) => match amr { + AccountsRoute::Accounts => "Accounts".to_owned(), + AccountsRoute::AddAccount => "Add Account".to_owned(), + }, + Route::ComposeNote => "Compose Note".to_owned(), + Route::AddColumn => "Add Column".to_owned(), + }; + + TitledRoute { + title, + route: *self, + } + } } // TODO: add this to egui-nav so we don't have to deal with returning @@ -60,6 +112,7 @@ pub struct Router { routes: Vec, pub returning: bool, pub navigating: bool, + replacing: bool, } impl Router { @@ -69,10 +122,12 @@ impl Router { } let returning = false; let navigating = false; + let replacing = false; Router { routes, returning, navigating, + replacing, } } @@ -81,6 +136,13 @@ impl Router { self.routes.push(route); } + // Route to R. Then when it is successfully placed, should call `remove_previous_route` + pub fn route_to_replaced(&mut self, route: R) { + self.navigating = true; + self.replacing = true; + self.routes.push(route); + } + /// Go back, start the returning process pub fn go_back(&mut self) -> Option { if self.returning || self.routes.len() == 1 { @@ -99,6 +161,20 @@ impl Router { self.routes.pop() } + pub fn remove_previous_route(&mut self) -> Option { + let num_routes = self.routes.len(); + if num_routes <= 1 { + return None; + } + self.returning = false; + self.replacing = false; + Some(self.routes.remove(num_routes - 2)) + } + + pub fn is_replacing(&self) -> bool { + self.replacing + } + pub fn top(&self) -> &R { self.routes.last().expect("routes can't be empty") } @@ -125,6 +201,8 @@ impl fmt::Display for Route { AccountsRoute::AddAccount => write!(f, "Add Account"), }, Route::ComposeNote => write!(f, "Compose Note"), + + Route::AddColumn => write!(f, "Add Column"), } } } diff --git a/src/timeline/kind.rs b/src/timeline/kind.rs index 016706e..c48a927 100644 --- a/src/timeline/kind.rs +++ b/src/timeline/kind.rs @@ -2,6 +2,7 @@ use crate::error::{Error, FilterError}; use crate::filter; use crate::filter::FilterState; use crate::timeline::Timeline; +use crate::ui::profile::preview::get_profile_displayname_string; use enostr::{Filter, Pubkey}; use nostrdb::{Ndb, Transaction}; use std::fmt::Display; @@ -136,7 +137,7 @@ impl TimelineKind { )); } - match Timeline::contact_list(&results[0].note) { + match Timeline::contact_list(&results[0].note, pk_src.clone()) { Err(Error::Filter(FilterError::EmptyContactList)) => Some(Timeline::new( TimelineKind::contact_list(pk_src), FilterState::needs_remote(vec![contact_filter]), @@ -150,4 +151,32 @@ impl TimelineKind { } } } + + pub fn to_title(&self, ndb: &Ndb) -> String { + match self { + TimelineKind::List(list_kind) => match list_kind { + ListKind::Contact(pubkey_source) => match pubkey_source { + PubkeySource::Explicit(pubkey) => { + format!("{}'s Contacts", get_profile_displayname_string(ndb, pubkey)) + } + PubkeySource::DeckAuthor => "Contacts".to_owned(), + }, + }, + TimelineKind::Notifications(pubkey_source) => match pubkey_source { + PubkeySource::DeckAuthor => "Notifications".to_owned(), + PubkeySource::Explicit(pk) => format!( + "{}'s Notifications", + get_profile_displayname_string(ndb, pk) + ), + }, + TimelineKind::Profile(pubkey_source) => match pubkey_source { + PubkeySource::DeckAuthor => "Profile".to_owned(), + PubkeySource::Explicit(pk) => { + format!("{}'s Profile", get_profile_displayname_string(ndb, pk)) + } + }, + TimelineKind::Universe => "Universe".to_owned(), + TimelineKind::Generic => "Custom Filter".to_owned(), + } + } } diff --git a/src/timeline/mod.rs b/src/timeline/mod.rs index f5365c8..7259337 100644 --- a/src/timeline/mod.rs +++ b/src/timeline/mod.rs @@ -8,7 +8,6 @@ use std::fmt; use std::sync::atomic::{AtomicU32, Ordering}; use egui_virtual_list::VirtualList; -use enostr::Pubkey; use nostrdb::{Ndb, Note, Subscription, Transaction}; use std::cell::RefCell; use std::hash::Hash; @@ -180,9 +179,8 @@ pub struct Timeline { impl Timeline { /// Create a timeline from a contact list - pub fn contact_list(contact_list: &Note) -> Result { + pub fn contact_list(contact_list: &Note, pk_src: PubkeySource) -> Result { let filter = filter::filter_from_tags(contact_list)?.into_follow_filter(); - let pk_src = PubkeySource::Explicit(Pubkey::new(*contact_list.pubkey())); Ok(Timeline::new( TimelineKind::contact_list(pk_src), @@ -241,13 +239,15 @@ impl Timeline { pub fn poll_notes_into_view( timeline_idx: usize, - timelines: &mut [Timeline], + mut timelines: Vec<&mut Timeline>, ndb: &Ndb, txn: &Transaction, unknown_ids: &mut UnknownIds, note_cache: &mut NoteCache, ) -> Result<()> { - let timeline = &mut timelines[timeline_idx]; + let timeline = timelines + .get_mut(timeline_idx) + .ok_or(Error::TimelineNotFound)?; let sub = timeline.subscription.ok_or(Error::no_active_sub())?; let new_note_ids = ndb.poll_for_notes(sub, 500); diff --git a/src/timeline/route.rs b/src/timeline/route.rs index 941002e..7c1367c 100644 --- a/src/timeline/route.rs +++ b/src/timeline/route.rs @@ -26,13 +26,13 @@ pub enum TimelineRoute { Quote(NoteId), } -pub enum TimelineRouteResponse { +pub enum AfterRouteExecution { Post(PostResponse), } -impl TimelineRouteResponse { +impl AfterRouteExecution { pub fn post(post: PostResponse) -> Self { - TimelineRouteResponse::Post(post) + AfterRouteExecution::Post(post) } } @@ -50,7 +50,7 @@ pub fn render_timeline_route( col: usize, textmode: bool, ui: &mut egui::Ui, -) -> Option { +) -> Option { match route { TimelineRoute::Timeline(timeline_id) => { if let Some(bar_action) = @@ -58,7 +58,8 @@ pub fn render_timeline_route( .ui(ui) { let txn = Transaction::new(ndb).expect("txn"); - let router = columns.columns_mut()[col].router_mut(); + let mut cur_column = columns.columns_mut(); + let router = cur_column[col].router_mut(); bar_action.execute_and_process_result(ndb, router, threads, note_cache, pool, &txn); } @@ -73,7 +74,8 @@ pub fn render_timeline_route( .ui(ui) { let txn = Transaction::new(ndb).expect("txn"); - let router = columns.columns_mut()[col].router_mut(); + let mut cur_column = columns.columns_mut(); + let router = cur_column[col].router_mut(); bar_action.execute_and_process_result(ndb, router, threads, note_cache, pool, &txn); } @@ -111,7 +113,7 @@ pub fn render_timeline_route( }); } - Some(TimelineRouteResponse::post(response.inner)) + Some(AfterRouteExecution::post(response.inner)) } TimelineRoute::Quote(id) => { @@ -140,7 +142,7 @@ pub fn render_timeline_route( np.to_quote(seckey, ¬e) }); } - Some(TimelineRouteResponse::post(response.inner)) + Some(AfterRouteExecution::post(response.inner)) } } } diff --git a/src/ui/add_column.rs b/src/ui/add_column.rs new file mode 100644 index 0000000..298addc --- /dev/null +++ b/src/ui/add_column.rs @@ -0,0 +1,246 @@ +use egui::{pos2, vec2, Color32, FontId, ImageSource, Pos2, Rect, Separator, Ui}; +use nostrdb::Ndb; + +use crate::{ + app_style::{get_font_size, NotedeckTextStyle}, + timeline::{PubkeySource, Timeline, TimelineKind}, + ui::anim::ICON_EXPANSION_MULTIPLE, + user_account::UserAccount, +}; + +use super::anim::AnimationHelper; + +pub enum AddColumnResponse { + Timeline(Timeline), +} + +#[derive(Clone, Debug)] +enum AddColumnOption { + Universe, + Notification(PubkeySource), + Home(PubkeySource), +} + +impl AddColumnOption { + pub fn take_as_response( + self, + ndb: &Ndb, + cur_account: Option<&UserAccount>, + ) -> Option { + match self { + AddColumnOption::Universe => TimelineKind::Universe + .into_timeline(ndb, None) + .map(AddColumnResponse::Timeline), + AddColumnOption::Notification(pubkey) => TimelineKind::Notifications(pubkey) + .into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes())) + .map(AddColumnResponse::Timeline), + AddColumnOption::Home(pubkey) => { + let tlk = TimelineKind::contact_list(pubkey); + tlk.into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes())) + .map(AddColumnResponse::Timeline) + } + } + } +} + +pub struct AddColumnView<'a> { + ndb: &'a Ndb, + cur_account: Option<&'a UserAccount>, +} + +impl<'a> AddColumnView<'a> { + pub fn new(ndb: &'a Ndb, cur_account: Option<&'a UserAccount>) -> Self { + Self { ndb, cur_account } + } + + pub fn ui(&mut self, ui: &mut Ui) -> Option { + let mut selected_option: Option = None; + for column_option_data in self.get_column_options() { + let option = column_option_data.option.clone(); + if self.column_option_ui(ui, column_option_data).clicked() { + selected_option = option.take_as_response(self.ndb, self.cur_account); + } + + ui.add(Separator::default().spacing(0.0)); + } + + selected_option + } + + fn column_option_ui(&mut self, ui: &mut Ui, data: ColumnOptionData) -> egui::Response { + let icon_padding = 8.0; + let min_icon_width = 32.0; + let height_padding = 12.0; + let max_width = ui.available_width(); + let title_style = NotedeckTextStyle::Body; + let desc_style = NotedeckTextStyle::Button; + let title_min_font_size = get_font_size(ui.ctx(), &title_style); + let desc_min_font_size = get_font_size(ui.ctx(), &desc_style); + + let max_height = { + let max_wrap_width = + max_width - ((icon_padding * 2.0) + (min_icon_width * ICON_EXPANSION_MULTIPLE)); + let title_max_font = FontId::new( + title_min_font_size * ICON_EXPANSION_MULTIPLE, + title_style.font_family(), + ); + let desc_max_font = FontId::new( + desc_min_font_size * ICON_EXPANSION_MULTIPLE, + desc_style.font_family(), + ); + let max_desc_galley = ui.fonts(|f| { + f.layout( + data.description.to_string(), + desc_max_font, + Color32::WHITE, + max_wrap_width, + ) + }); + + let max_title_galley = ui.fonts(|f| { + f.layout( + data.title.to_string(), + title_max_font, + Color32::WHITE, + max_wrap_width, + ) + }); + + let desc_font_max_size = max_desc_galley.rect.height(); + let title_font_max_size = max_title_galley.rect.height(); + title_font_max_size + desc_font_max_size + (2.0 * height_padding) + }; + + let helper = AnimationHelper::new(ui, data.title, vec2(max_width, max_height)); + let animation_rect = helper.get_animation_rect(); + + let cur_icon_width = helper.scale_1d_pos(min_icon_width); + let painter = ui.painter_at(animation_rect); + + let cur_icon_size = vec2(cur_icon_width, cur_icon_width); + let cur_icon_x_pos = animation_rect.left() + (icon_padding) + (cur_icon_width / 2.0); + + let title_cur_font = FontId::new( + helper.scale_1d_pos(title_min_font_size), + title_style.font_family(), + ); + + let desc_cur_font = FontId::new( + helper.scale_1d_pos(desc_min_font_size), + desc_style.font_family(), + ); + + let wrap_width = max_width - (cur_icon_width + (icon_padding * 2.0)); + let text_color = ui.ctx().style().visuals.text_color(); + let fallback_color = ui.ctx().style().visuals.weak_text_color(); + + let title_galley = painter.layout( + data.title.to_string(), + title_cur_font, + text_color, + wrap_width, + ); + let desc_galley = painter.layout( + data.description.to_string(), + desc_cur_font, + text_color, + wrap_width, + ); + + let galley_heights = title_galley.rect.height() + desc_galley.rect.height(); + + let cur_height_padding = (animation_rect.height() - galley_heights) / 2.0; + let corner_x_pos = cur_icon_x_pos + (cur_icon_width / 2.0) + icon_padding; + let title_corner_pos = Pos2::new(corner_x_pos, animation_rect.top() + cur_height_padding); + let desc_corner_pos = Pos2::new( + corner_x_pos, + title_corner_pos.y + title_galley.rect.height(), + ); + + let icon_cur_y = animation_rect.top() + cur_height_padding + (galley_heights / 2.0); + let icon_img = egui::Image::new(data.icon).fit_to_exact_size(cur_icon_size); + let icon_rect = Rect::from_center_size(pos2(cur_icon_x_pos, icon_cur_y), cur_icon_size); + + icon_img.paint_at(ui, icon_rect); + painter.galley(title_corner_pos, title_galley, fallback_color); + painter.galley(desc_corner_pos, desc_galley, fallback_color); + + helper.take_animation_response() + } + + fn get_column_options(&self) -> Vec { + let mut vec = Vec::new(); + vec.push(ColumnOptionData { + title: "Universe", + description: "See the whole nostr universe", + icon: egui::include_image!("../../assets/icons/universe_icon_dark_4x.png"), + option: AddColumnOption::Universe, + }); + + if let Some(acc) = self.cur_account { + let source = if acc.secret_key.is_some() { + PubkeySource::DeckAuthor + } else { + PubkeySource::Explicit(acc.pubkey) + }; + + vec.push(ColumnOptionData { + title: "Home timeline", + description: "See recommended notes first", + icon: egui::include_image!("../../assets/icons/home_icon_dark_4x.png"), + option: AddColumnOption::Home(source.clone()), + }); + vec.push(ColumnOptionData { + title: "Notifications", + description: "Stay up to date with notifications and mentions", + icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"), + option: AddColumnOption::Notification(source), + }); + } + + vec + } +} + +struct ColumnOptionData { + title: &'static str, + description: &'static str, + icon: ImageSource<'static>, + option: AddColumnOption, +} + +mod preview { + use crate::{ + test_data, + ui::{Preview, PreviewConfig, View}, + Damus, + }; + + use super::AddColumnView; + + pub struct AddColumnPreview { + app: Damus, + } + + impl AddColumnPreview { + fn new() -> Self { + let app = test_data::test_app(); + + AddColumnPreview { app } + } + } + + impl View for AddColumnPreview { + fn ui(&mut self, ui: &mut egui::Ui) { + AddColumnView::new(&self.app.ndb, self.app.accounts.get_selected_account()).ui(ui); + } + } + + impl<'a> Preview for AddColumnView<'a> { + type Prev = AddColumnPreview; + + fn preview(_cfg: PreviewConfig) -> Self::Prev { + AddColumnPreview::new() + } + } +} diff --git a/src/ui/anim.rs b/src/ui/anim.rs index 339e0dc..312ab83 100644 --- a/src/ui/anim.rs +++ b/src/ui/anim.rs @@ -60,6 +60,27 @@ impl AnimationHelper { } } + 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; @@ -93,4 +114,8 @@ impl AnimationHelper { 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) + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ac35e1c..08f239b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,6 @@ pub mod account_login_view; pub mod account_management; +pub mod add_column; pub mod anim; pub mod mention; pub mod note; diff --git a/src/ui/profile/preview.rs b/src/ui/profile/preview.rs index 25ef49e..223ae17 100644 --- a/src/ui/profile/preview.rs +++ b/src/ui/profile/preview.rs @@ -6,6 +6,7 @@ use crate::{colors, images, DisplayName}; use egui::load::TexturePoll; use egui::{Frame, RichText, Sense, Widget}; use egui_extras::Size; +use enostr::NoteId; use nostrdb::ProfileRecord; pub struct ProfilePreview<'a, 'cache> { @@ -256,3 +257,28 @@ fn about_section_widget<'a>(profile: &'a ProfileRecord<'a>) -> impl egui::Widget } } } + +fn get_display_name_as_string(profile: Option<&'_ ProfileRecord<'_>>) -> String { + let display_name = get_display_name(profile); + match display_name { + DisplayName::One(n) => n.to_string(), + DisplayName::Both { display_name, .. } => display_name.to_string(), + } +} + +pub fn get_profile_displayname_string(ndb: &nostrdb::Ndb, pk: &enostr::Pubkey) -> String { + let txn = nostrdb::Transaction::new(ndb).expect("Transaction should have worked"); + let profile = ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok(); + get_display_name_as_string(profile.as_ref()) +} + +pub fn get_note_users_displayname_string(ndb: &nostrdb::Ndb, id: &NoteId) -> String { + let txn = nostrdb::Transaction::new(ndb).expect("Transaction should have worked"); + let note = ndb.get_note_by_id(&txn, id.bytes()); + let profile = if let Ok(note) = note { + ndb.get_profile_by_pubkey(&txn, note.pubkey()).ok() + } else { + None + }; + get_display_name_as_string(profile.as_ref()) +} diff --git a/src/ui/side_panel.rs b/src/ui/side_panel.rs index cf93ff1..bb779ca 100644 --- a/src/ui/side_panel.rs +++ b/src/ui/side_panel.rs @@ -4,9 +4,9 @@ use tracing::info; use crate::{ account_manager::AccountsRoute, colors, - column::Column, + column::{Column, Columns}, imgcache::ImageCache, - route::{Route, Router}, + route::Route, user_account::UserAccount, Damus, }; @@ -162,7 +162,8 @@ impl<'a> DesktopSidePanel<'a> { helper.take_animation_response() } - pub fn perform_action(router: &mut Router, action: SidePanelAction) { + pub fn perform_action(columns: &mut Columns, action: SidePanelAction) { + let router = columns.get_first_router(); match action { SidePanelAction::Panel => {} // TODO SidePanelAction::Account => { @@ -186,8 +187,11 @@ impl<'a> DesktopSidePanel<'a> { } } SidePanelAction::Columns => { - // TODO - info!("Clicked columns button"); + if router.routes().iter().any(|&r| r == Route::AddColumn) { + router.go_back(); + } else { + columns.new_column_picker(); + } } SidePanelAction::ComposeNote => { if router.routes().iter().any(|&r| r == Route::ComposeNote) { @@ -366,9 +370,7 @@ mod preview { impl DesktopSidePanelPreview { fn new() -> Self { let mut app = test_data::test_app(); - app.columns - .columns_mut() - .push(Column::new(vec![Route::accounts()])); + app.columns.add_column(Column::new(vec![Route::accounts()])); DesktopSidePanelPreview { app } } } @@ -388,10 +390,7 @@ mod preview { ); let response = panel.show(ui); - DesktopSidePanel::perform_action( - self.app.columns.columns_mut()[0].router_mut(), - response.action, - ); + DesktopSidePanel::perform_action(&mut self.app.columns, response.action); }); }); } diff --git a/src/ui_preview/main.rs b/src/ui_preview/main.rs index a9804a6..9a0d6f9 100644 --- a/src/ui_preview/main.rs +++ b/src/ui_preview/main.rs @@ -1,6 +1,7 @@ use notedeck::app_creation::{ generate_mobile_emulator_native_options, generate_native_options, setup_cc, }; +use notedeck::ui::add_column::AddColumnView; use notedeck::ui::{ account_login_view::AccountLoginView, account_management::AccountsView, DesktopSidePanel, PostView, Preview, PreviewApp, PreviewConfig, ProfilePic, ProfilePreview, RelayView, @@ -102,5 +103,6 @@ async fn main() { AccountsView, DesktopSidePanel, PostView, + AddColumnView, ); }