diff --git a/assets/icons/new_deck_icon_4x_dark.png b/assets/icons/new_deck_icon_4x_dark.png new file mode 100644 index 00000000..e69de29b diff --git a/src/accounts/mod.rs b/src/accounts/mod.rs index 37634a52..da2a1cf2 100644 --- a/src/accounts/mod.rs +++ b/src/accounts/mod.rs @@ -9,11 +9,12 @@ use enostr::{ClientMessage, FilledKeypair, FullKeypair, Keypair, RelayPool}; use nostrdb::{Filter, Ndb, Note, NoteKey, Subscription, Transaction}; use crate::{ - column::Columns, + app::get_active_columns_mut, + decks::DecksCache, imgcache::ImageCache, login_manager::AcquireKeyState, muted::Muted, - route::{Route, Router}, + route::Route, storage::{KeyStorageResponse, KeyStorageType}, ui::{ account_login_view::{AccountLoginResponse, AccountLoginView}, @@ -27,7 +28,7 @@ use tracing::{debug, error, info}; mod route; -pub use route::{AccountsRoute, AccountsRouteResponse}; +pub use route::{AccountsAction, AccountsRoute, AccountsRouteResponse}; pub struct AccountRelayData { filter: Filter, @@ -224,19 +225,34 @@ pub struct Accounts { needs_relay_config: bool, } +#[must_use = "You must call process_login_action on this to handle unknown ids"] +pub struct RenderAccountAction { + pub accounts_action: Option, + pub unk_id_action: SingleUnkIdAction, +} + +impl RenderAccountAction { + // Simple wrapper around processing the unknown action to expose too + // much internal logic. This allows us to have a must_use on our + // LoginAction type, otherwise the SingleUnkIdAction's must_use will + // be lost when returned in the login action + pub fn process_action(&mut self, ids: &mut UnknownIds, ndb: &Ndb, txn: &Transaction) { + self.unk_id_action.process_action(ids, ndb, txn); + } +} + /// Render account management views from a route #[allow(clippy::too_many_arguments)] pub fn render_accounts_route( ui: &mut egui::Ui, ndb: &Ndb, col: usize, - columns: &mut Columns, img_cache: &mut ImageCache, accounts: &mut Accounts, + decks: &mut DecksCache, login_state: &mut AcquireKeyState, route: AccountsRoute, -) -> SingleUnkIdAction { - let router = columns.column_mut(col).router_mut(); +) -> RenderAccountAction { let resp = match route { AccountsRoute::Accounts => AccountsView::new(ndb, accounts, img_cache) .ui(ui) @@ -252,37 +268,57 @@ pub fn render_accounts_route( if let Some(resp) = resp { match resp { AccountsRouteResponse::Accounts(response) => { - process_accounts_view_response(accounts, response, router); - SingleUnkIdAction::no_action() + let action = process_accounts_view_response(accounts, decks, col, response); + RenderAccountAction { + accounts_action: action, + unk_id_action: SingleUnkIdAction::no_action(), + } } AccountsRouteResponse::AddAccount(response) => { - let action = process_login_view_response(accounts, response); + let action = process_login_view_response(accounts, decks, response); *login_state = Default::default(); + let router = get_active_columns_mut(accounts, decks) + .column_mut(col) + .router_mut(); router.go_back(); action } } } else { - SingleUnkIdAction::no_action() + RenderAccountAction { + accounts_action: None, + unk_id_action: SingleUnkIdAction::no_action(), + } } } pub fn process_accounts_view_response( - manager: &mut Accounts, + accounts: &mut Accounts, + decks: &mut DecksCache, + col: usize, response: AccountsViewResponse, - router: &mut Router, -) { +) -> Option { + let router = get_active_columns_mut(accounts, decks) + .column_mut(col) + .router_mut(); + let mut selection = None; match response { AccountsViewResponse::RemoveAccount(index) => { - manager.remove_account(index); + let acc_sel = AccountsAction::Remove(index); + info!("account selection: {:?}", acc_sel); + selection = Some(acc_sel); } AccountsViewResponse::SelectAccount(index) => { - manager.select_account(index); + let acc_sel = AccountsAction::Switch(index); + info!("account selection: {:?}", acc_sel); + selection = Some(acc_sel); } AccountsViewResponse::RouteToLogin => { router.route_to(Route::add_account()); } } + + selection } impl Accounts { @@ -375,7 +411,7 @@ impl Accounts { } #[must_use = "UnknownIdAction's must be handled. Use .process_unknown_id_action()"] - pub fn add_account(&mut self, account: Keypair) -> LoginAction { + pub fn add_account(&mut self, account: Keypair) -> RenderAccountAction { let pubkey = account.pubkey; let switch_to_index = if let Some(contains_acc) = self.contains_account(pubkey.bytes()) { if account.secret_key.is_some() && !contains_acc.has_nsec { @@ -397,9 +433,9 @@ impl Accounts { self.accounts.len() - 1 }; - LoginAction { - unk: SingleUnkIdAction::pubkey(pubkey), - switch_to_index, + RenderAccountAction { + accounts_action: Some(AccountsAction::Switch(switch_to_index)), + unk_id_action: SingleUnkIdAction::pubkey(pubkey), } } @@ -429,6 +465,7 @@ impl Accounts { } } + /// Select the account at the index pub fn select_account(&mut self, index: usize) { if let Some(account) = self.accounts.get(index) { self.currently_selected_account = Some(index); @@ -619,32 +656,24 @@ fn get_selected_index(accounts: &[UserAccount], keystore: &KeyStorageType) -> Op pub fn process_login_view_response( manager: &mut Accounts, + decks: &mut DecksCache, response: AccountLoginResponse, -) -> SingleUnkIdAction { - let login_action = match response { +) -> RenderAccountAction { + let (r, pubkey) = match response { AccountLoginResponse::CreateNew => { - manager.add_account(FullKeypair::generate().to_keypair()) + let kp = FullKeypair::generate().to_keypair(); + let pubkey = kp.pubkey; + (manager.add_account(kp), pubkey) + } + AccountLoginResponse::LoginWith(keypair) => { + let pubkey = keypair.pubkey; + (manager.add_account(keypair), pubkey) } - AccountLoginResponse::LoginWith(keypair) => manager.add_account(keypair), }; - manager.select_account(login_action.switch_to_index); - login_action.unk -} -#[must_use = "You must call process_login_action on this to handle unknown ids"] -pub struct LoginAction { - unk: SingleUnkIdAction, - pub switch_to_index: usize, -} + decks.add_deck_default(pubkey); -impl LoginAction { - // Simple wrapper around processing the unknown action to expose too - // much internal logic. This allows us to have a must_use on our - // LoginAction type, otherwise the SingleUnkIdAction's must_use will - // be lost when returned in the login action - pub fn process_action(&mut self, ids: &mut UnknownIds, ndb: &Ndb, txn: &Transaction) { - self.unk.process_action(ids, ndb, txn); - } + r } #[derive(Default)] diff --git a/src/accounts/route.rs b/src/accounts/route.rs index 69ce1279..b446d463 100644 --- a/src/accounts/route.rs +++ b/src/accounts/route.rs @@ -11,3 +11,9 @@ pub enum AccountsRoute { Accounts, AddAccount, } + +#[derive(Debug)] +pub enum AccountsAction { + Switch(usize), + Remove(usize), +} diff --git a/src/app.rs b/src/app.rs index b7ea27db..88106bef 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,9 +1,10 @@ use crate::{ - accounts::{Accounts, AccountsRoute}, + accounts::Accounts, app_creation::setup_cc, app_size_handler::AppSizeHandler, args::Args, - column::{Column, Columns}, + column::Columns, + decks::{Decks, DecksCache}, draft::Drafts, filter::FilterState, frame_history::FrameHistory, @@ -12,13 +13,12 @@ use crate::{ notecache::NoteCache, notes_holder::NotesHolderStorage, profile::Profile, - route::Route, storage::{self, DataPath, DataPathType, Directory, FileKeyStorage, KeyStorageType}, subscriptions::{SubKind, Subscriptions}, support::Support, thread::Thread, - timeline::{self, Timeline, TimelineKind}, - ui::{self, add_column::AddColumnRoute, DesktopSidePanel}, + timeline::{self, Timeline}, + ui::{self, DesktopSidePanel}, unknowns::UnknownIds, view_state::ViewState, Result, @@ -30,7 +30,7 @@ use uuid::Uuid; use egui::{Context, Frame, Style}; use egui_extras::{Size, StripBuilder}; -use nostrdb::{Config, Filter, Ndb, Transaction}; +use nostrdb::{Config, Ndb, Transaction}; use std::collections::HashMap; use std::path::Path; @@ -49,7 +49,7 @@ pub struct Damus { pub note_cache: NoteCache, pub pool: RelayPool, - pub columns: Columns, + pub decks_cache: DecksCache, pub ndb: Ndb, pub view_state: ViewState, pub unknown_ids: UnknownIds, @@ -98,7 +98,8 @@ fn handle_key_events(input: &egui::InputState, _pixels_per_point: f32, columns: fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { let ppp = ctx.pixels_per_point(); - ctx.input(|i| handle_key_events(i, ppp, &mut damus.columns)); + let current_columns = get_active_columns_mut(&damus.accounts, &mut damus.decks_cache); + ctx.input(|i| handle_key_events(i, ppp, current_columns)); let ctx2 = ctx.clone(); let wakeup = move || { @@ -124,7 +125,7 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { timeline::send_initial_timeline_filters( &damus.ndb, damus.since_optimize, - &mut damus.columns, + get_active_columns_mut(&damus.accounts, &mut damus.decks_cache), &mut damus.subscriptions, &mut damus.pool, &ev.relay, @@ -138,10 +139,11 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { } } - let n_timelines = damus.columns.timelines().len(); + let current_columns = get_active_columns_mut(&damus.accounts, &mut damus.decks_cache); + let n_timelines = current_columns.timelines().len(); for timeline_ind in 0..n_timelines { let is_ready = { - let timeline = &mut damus.columns.timelines[timeline_ind]; + let timeline = &mut current_columns.timelines[timeline_ind]; timeline::is_timeline_ready( &damus.ndb, &mut damus.pool, @@ -156,7 +158,7 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { if let Err(err) = Timeline::poll_notes_into_view( timeline_ind, - damus.columns.timelines_mut(), + current_columns.timelines_mut(), &damus.ndb, &txn, &mut damus.unknown_ids, @@ -206,11 +208,12 @@ fn update_damus(damus: &mut Damus, ctx: &egui::Context) { damus .subscriptions() .insert("unknownids".to_string(), SubKind::OneShot); + if let Err(err) = timeline::setup_initial_nostrdb_subs( &damus.ndb, &mut damus.note_cache, - &mut damus.columns, &damus.accounts.mutefun(), + &mut damus.decks_cache, ) { warn!("update_damus init: {err}"); } @@ -257,7 +260,7 @@ fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) -> Result<()> { UnknownIds::update( &txn, &mut damus.unknown_ids, - &damus.columns, + get_active_columns(&damus.accounts, &damus.decks_cache), &damus.ndb, &mut damus.note_cache, ); @@ -274,7 +277,10 @@ fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) -> Result<()> { } SubKind::FetchingContactList(timeline_uid) => { - let timeline = if let Some(tl) = damus.columns.find_timeline_mut(timeline_uid) { + let timeline = if let Some(tl) = + get_active_columns_mut(&damus.accounts, &mut damus.decks_cache) + .find_timeline_mut(timeline_uid) + { tl } else { error!( @@ -435,19 +441,21 @@ impl Damus { .as_ref() .map(|a| a.pubkey.bytes()); - let mut columns = if parsed_args.columns.is_empty() { - if let Some(serializable_columns) = storage::load_columns(&path) { - info!("Using columns from disk"); - serializable_columns.into_columns(&ndb, account) + let decks_cache = if parsed_args.columns.is_empty() { + if let Some(serializable_decks_cache) = storage::load_decks_cache(&path) { + info!("Using decks cache from disk"); + serializable_decks_cache.into_decks_cache(&ndb) } else { - info!("Could not load columns from disk"); - Columns::new() + info!("Could read not decks cache from disk"); + let mut cache = DecksCache::new_with_demo_config(&ndb); + for account in accounts.get_accounts() { + cache.add_deck_default(account.pubkey); + } + set_demo(&mut cache, &ndb, &mut accounts, &mut unknown_ids); + + cache } } else { - info!( - "Using columns from command line arguments: {:?}", - parsed_args.columns - ); let mut columns: Columns = Columns::new(); for col in parsed_args.columns { if let Some(timeline) = col.into_timeline(&ndb, account) { @@ -455,19 +463,18 @@ impl Damus { } } - columns + let mut decks_cache = DecksCache::default(); + let mut decks = Decks::default(); + *decks.active_mut().columns_mut() = columns; + + if let Some(acc) = account { + decks_cache.add_decks(Pubkey::new(*acc), decks); + } + decks_cache }; let debug = parsed_args.debug; - if columns.columns().is_empty() { - if accounts.get_accounts().is_empty() { - set_demo(&path, &ndb, &mut accounts, &mut columns, &mut unknown_ids); - } else { - columns.new_column_picker(); - } - } - let app_rect_handler = AppSizeHandler::new(&path); let support = Support::new(&path); @@ -483,7 +490,6 @@ impl Damus { state: DamusState::Initializing, img_cache: ImageCache::new(imgcache_dir), note_cache: NoteCache::default(), - columns, textmode: parsed_args.textmode, ndb, accounts, @@ -492,6 +498,7 @@ impl Damus { path, app_rect_handler, support, + decks_cache, } } @@ -524,11 +531,11 @@ impl Damus { } pub fn columns_mut(&mut self) -> &mut Columns { - &mut self.columns + get_active_columns_mut(&self.accounts, &mut self.decks_cache) } pub fn columns(&self) -> &Columns { - &self.columns + get_active_columns(&self.accounts, &self.decks_cache) } pub fn gen_subid(&self, kind: &SubKind) -> String { @@ -540,12 +547,7 @@ impl Damus { } pub fn mock>(data_path: P) -> Self { - let mut columns = Columns::new(); - let filter = Filter::from_json(include_str!("../queries/global.json")).unwrap(); - - let timeline = Timeline::new(TimelineKind::Universe, FilterState::ready(vec![filter])); - - columns.add_new_timeline_column(timeline); + let decks_cache = DecksCache::default(); let path = DataPath::new(&data_path); let imgcache_dir = path.path(DataPathType::Cache).join(ImageCache::rel_dir()); @@ -569,7 +571,6 @@ impl Damus { pool: RelayPool::new(), img_cache: ImageCache::new(imgcache_dir), note_cache: NoteCache::default(), - columns, textmode: false, ndb: Ndb::new( path.path(DataPathType::Db) @@ -581,10 +582,10 @@ impl Damus { accounts: Accounts::new(KeyStorageType::None, vec![]), frame_history: FrameHistory::default(), view_state: ViewState::default(), - path, app_rect_handler, support, + decks_cache, } } @@ -629,10 +630,10 @@ fn render_damus_mobile(ctx: &egui::Context, app: &mut Damus) { //let routes = app.timelines[0].routes.clone(); main_panel(&ctx.style(), ui::is_narrow(ctx)).show(ctx, |ui| { - if !app.columns.columns().is_empty() + if !app.columns().columns().is_empty() && nav::render_nav(0, app, ui).process_render_nav_response(app) { - storage::save_columns(&app.path, app.columns().as_serializable_columns()); + storage::save_decks_cache(&app.path, &(&app.decks_cache).into()); } }); } @@ -656,7 +657,9 @@ 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.num_columns() as f32) - 30.0; + let calc_panel_width = (screen_size + / get_active_columns(&app.accounts, &app.decks_cache).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 { @@ -680,24 +683,32 @@ fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { 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, app.columns.num_columns()) + .sizes( + sizes, + get_active_columns(&app.accounts, &app.decks_cache).num_columns(), + ) .clip(true) .horizontal(|mut strip| { + let mut side_panel_action: Option = None; strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); let side_panel = DesktopSidePanel::new( &app.ndb, &mut app.img_cache, app.accounts.get_selected_account(), + &app.decks_cache, ) .show(ui); - if side_panel.response.clicked() { - DesktopSidePanel::perform_action( - &mut app.columns, + if side_panel.response.clicked() || side_panel.response.secondary_clicked() { + if let Some(action) = DesktopSidePanel::perform_action( + &mut app.decks_cache, + &app.accounts, &mut app.support, side_panel.action, - ); + ) { + side_panel_action = Some(action); + } } // vertical sidebar line @@ -708,8 +719,9 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus) { ); }); - let mut responses = Vec::with_capacity(app.columns.num_columns()); - for col_index in 0..app.columns.num_columns() { + let num_cols = app.columns().num_columns(); + let mut responses = Vec::with_capacity(num_cols); + for col_index in 0..num_cols { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); responses.push(nav::render_nav(col_index, app, ui)); @@ -726,13 +738,17 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus) { } let mut save_cols = false; + if let Some(action) = side_panel_action { + save_cols = save_cols || action.process(app); + } + for response in responses { let save = response.process_render_nav_response(app); save_cols = save_cols || save; } if save_cols { - storage::save_columns(&app.path, app.columns().as_serializable_columns()); + storage::save_decks_cache(&app.path, &(&app.decks_cache).into()); } }); } @@ -756,37 +772,45 @@ impl eframe::App for Damus { } } -fn set_demo( - data_path: &DataPath, - ndb: &Ndb, - accounts: &mut Accounts, - columns: &mut Columns, - unk_ids: &mut UnknownIds, -) { - let demo_pubkey = - Pubkey::from_hex("aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe") - .unwrap(); - { - let txn = Transaction::new(ndb).expect("txn"); - accounts - .add_account(Keypair::only_pubkey(demo_pubkey)) - .process_action(unk_ids, ndb, &txn); - accounts.select_account(0); - } +pub fn get_active_columns<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Columns { + get_decks(accounts, decks_cache).active().columns() +} - columns.add_column(Column::new(vec![ - Route::AddColumn(AddColumnRoute::Base), - Route::Accounts(AccountsRoute::Accounts), - ])); +pub fn get_decks<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Decks { + let key = if let Some(acc) = accounts.get_selected_account() { + &acc.pubkey + } else { + &decks_cache.fallback_pubkey + }; + decks_cache.decks(key) +} - if let Some(timeline) = - TimelineKind::contact_list(timeline::PubkeySource::Explicit(demo_pubkey)) - .into_timeline(ndb, Some(demo_pubkey.bytes())) - { - columns.add_new_timeline_column(timeline); - } +pub fn get_active_columns_mut<'a>( + accounts: &Accounts, + decks_cache: &'a mut DecksCache, +) -> &'a mut Columns { + get_decks_mut(accounts, decks_cache) + .active_mut() + .columns_mut() +} - columns.add_new_timeline_column(Timeline::hashtag("introductions".to_string())); +pub fn get_decks_mut<'a>(accounts: &Accounts, decks_cache: &'a mut DecksCache) -> &'a mut Decks { + if let Some(acc) = accounts.get_selected_account() { + decks_cache.decks_mut(&acc.pubkey) + } else { + decks_cache.fallback_mut() + } +} - storage::save_columns(data_path, columns.as_serializable_columns()); +pub fn set_demo( + decks_cache: &mut DecksCache, + ndb: &Ndb, + accounts: &mut Accounts, + unk_ids: &mut UnknownIds, +) { + let txn = Transaction::new(ndb).expect("txn"); + accounts + .add_account(Keypair::only_pubkey(decks_cache.fallback_pubkey)) + .process_action(unk_ids, ndb, &txn); + accounts.select_account(accounts.num_accounts() - 1); } diff --git a/src/app_style.rs b/src/app_style.rs index 6387c9bc..7763d176 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}, + fonts::NamedFontFamily, ui::is_narrow, }; use egui::{ @@ -77,6 +78,7 @@ pub fn desktop_font_size(text_style: &NotedeckTextStyle) -> f32 { NotedeckTextStyle::Heading => 48.0, NotedeckTextStyle::Heading2 => 24.0, NotedeckTextStyle::Heading3 => 20.0, + NotedeckTextStyle::Heading4 => 14.0, NotedeckTextStyle::Body => 16.0, NotedeckTextStyle::Monospace => 13.0, NotedeckTextStyle::Button => 13.0, @@ -91,6 +93,7 @@ pub fn mobile_font_size(text_style: &NotedeckTextStyle) -> f32 { NotedeckTextStyle::Heading => 48.0, NotedeckTextStyle::Heading2 => 24.0, NotedeckTextStyle::Heading3 => 20.0, + NotedeckTextStyle::Heading4 => 14.0, NotedeckTextStyle::Body => 13.0, NotedeckTextStyle::Monospace => 13.0, NotedeckTextStyle::Button => 13.0, @@ -112,6 +115,7 @@ pub enum NotedeckTextStyle { Heading, Heading2, Heading3, + Heading4, Body, Monospace, Button, @@ -125,6 +129,7 @@ impl NotedeckTextStyle { Self::Heading => TextStyle::Heading, Self::Heading2 => TextStyle::Name("Heading2".into()), Self::Heading3 => TextStyle::Name("Heading3".into()), + Self::Heading4 => TextStyle::Name("Heading4".into()), Self::Body => TextStyle::Body, Self::Monospace => TextStyle::Monospace, Self::Button => TextStyle::Button, @@ -138,6 +143,7 @@ impl NotedeckTextStyle { Self::Heading => FontFamily::Proportional, Self::Heading2 => FontFamily::Proportional, Self::Heading3 => FontFamily::Proportional, + Self::Heading4 => FontFamily::Proportional, Self::Body => FontFamily::Proportional, Self::Monospace => FontFamily::Monospace, Self::Button => FontFamily::Proportional, @@ -215,3 +221,13 @@ pub fn create_themed_visuals(theme: ColorTheme, default: Visuals) -> Visuals { ..default } } + +pub static DECK_ICON_SIZE: f32 = 24.0; + +pub fn deck_icon_font_sized(size: f32) -> FontId { + egui::FontId::new(size, emoji_font_family()) +} + +pub fn emoji_font_family() -> FontFamily { + egui::FontFamily::Name(NamedFontFamily::Emoji.as_str().into()) +} diff --git a/src/args.rs b/src/args.rs index 99d8630e..43a39f1f 100644 --- a/src/args.rs +++ b/src/args.rs @@ -306,15 +306,15 @@ mod tests { let ctx = egui::Context::default(); let app = Damus::new(&ctx, &tmpdir, args); - assert_eq!(app.columns.columns().len(), 2); + assert_eq!(app.columns().columns().len(), 2); - let tl1 = app.columns.column(0).router().top().timeline_id(); - let tl2 = app.columns.column(1).router().top().timeline_id(); + let tl1 = app.columns().column(0).router().top().timeline_id(); + let tl2 = app.columns().column(1).router().top().timeline_id(); assert_eq!(tl1.is_some(), true); assert_eq!(tl2.is_some(), true); - let timelines = app.columns.timelines(); + let timelines = app.columns().timelines(); assert!(timelines[0].kind.is_notifications()); assert!(timelines[1].kind.is_contacts()); diff --git a/src/column.rs b/src/column.rs index 042f283b..a813e12d 100644 --- a/src/column.rs +++ b/src/column.rs @@ -236,6 +236,19 @@ pub struct SerializableColumns { pub timelines: Vec, } +impl From<&Columns> for SerializableColumns { + fn from(columns: &Columns) -> Self { + SerializableColumns { + columns: columns.columns.values().cloned().collect(), + timelines: columns + .timelines + .values() + .map(|t| t.as_serializable_timeline()) + .collect(), + } + } +} + impl SerializableColumns { pub fn into_columns(self, ndb: &Ndb, deck_pubkey: Option<&[u8; 32]>) -> Columns { let mut columns = Columns::default(); @@ -270,3 +283,8 @@ impl SerializableColumns { columns } } + +pub enum ColumnsAction { + // Switch(usize), TODO: could use for keyboard selection + Remove(usize), +} diff --git a/src/deck_state.rs b/src/deck_state.rs new file mode 100644 index 00000000..2574b540 --- /dev/null +++ b/src/deck_state.rs @@ -0,0 +1,65 @@ +use crate::{app_style::emoji_font_family, decks::Deck}; + +/// State for UI creating/editing deck +pub struct DeckState { + pub deck_name: String, + pub selected_glyph: Option, + pub deleting: bool, + pub selecting_glyph: bool, + pub warn_no_title: bool, + pub warn_no_icon: bool, + glyph_options: Option>, +} + +impl DeckState { + pub fn load(&mut self, deck: &Deck) { + self.deck_name = deck.name.clone(); + self.selected_glyph = Some(deck.icon); + } + + pub fn from_deck(deck: &Deck) -> Self { + let deck_name = deck.name.clone(); + let selected_glyph = Some(deck.icon); + Self { + deck_name, + selected_glyph, + ..Default::default() + } + } + + pub fn clear(&mut self) { + *self = Default::default(); + } + + pub fn get_glyph_options(&mut self, ui: &egui::Ui) -> &Vec { + self.glyph_options + .get_or_insert_with(|| available_characters(ui, emoji_font_family())) + } +} + +impl Default for DeckState { + fn default() -> Self { + Self { + deck_name: Default::default(), + selected_glyph: Default::default(), + deleting: Default::default(), + selecting_glyph: true, + warn_no_icon: Default::default(), + warn_no_title: Default::default(), + glyph_options: Default::default(), + } + } +} + +fn available_characters(ui: &egui::Ui, family: egui::FontFamily) -> Vec { + ui.fonts(|f| { + f.lock() + .fonts + .font(&egui::FontId::new(10.0, family)) // size is arbitrary for getting the characters + .characters() + .iter() + .filter(|chr| !chr.is_whitespace() && !chr.is_ascii_control()) + .copied() + .collect() + }) +} diff --git a/src/decks.rs b/src/decks.rs new file mode 100644 index 00000000..f8ce0102 --- /dev/null +++ b/src/decks.rs @@ -0,0 +1,424 @@ +use std::collections::HashMap; + +use enostr::Pubkey; +use nostrdb::Ndb; +use serde::{Deserialize, Serialize}; +use tracing::{error, info}; + +use crate::{ + accounts::AccountsRoute, + column::{Column, Columns, SerializableColumns}, + route::Route, + timeline::{self, Timeline, TimelineKind}, + ui::{add_column::AddColumnRoute, configure_deck::ConfigureDeckResponse}, +}; + +static FALLBACK_PUBKEY: &str = "aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe"; + +pub enum DecksAction { + Switch(usize), + Removing(usize), +} + +#[derive(Serialize)] +pub struct DecksCache { + pub account_to_decks: HashMap, + pub fallback_pubkey: Pubkey, +} + +impl Default for DecksCache { + fn default() -> Self { + let mut account_to_decks: HashMap = Default::default(); + account_to_decks.insert(Pubkey::from_hex(FALLBACK_PUBKEY).unwrap(), Decks::default()); + DecksCache::new(account_to_decks) + } +} + +impl DecksCache { + pub fn new(account_to_decks: HashMap) -> Self { + let fallback_pubkey = Pubkey::from_hex(FALLBACK_PUBKEY).unwrap(); + + Self { + account_to_decks, + fallback_pubkey, + } + } + + pub fn new_with_demo_config(ndb: &Ndb) -> Self { + let mut account_to_decks: HashMap = Default::default(); + let fallback_pubkey = Pubkey::from_hex(FALLBACK_PUBKEY).unwrap(); + account_to_decks.insert(fallback_pubkey, demo_decks(fallback_pubkey, ndb)); + DecksCache::new(account_to_decks) + } + + pub fn decks(&self, key: &Pubkey) -> &Decks { + self.account_to_decks + .get(key) + .unwrap_or_else(|| panic!("{:?} not found", key)) + } + + pub fn decks_mut(&mut self, key: &Pubkey) -> &mut Decks { + self.account_to_decks + .get_mut(key) + .unwrap_or_else(|| panic!("{:?} not found", key)) + } + + pub fn fallback_mut(&mut self) -> &mut Decks { + self.account_to_decks + .get_mut(&self.fallback_pubkey) + .unwrap_or_else(|| panic!("fallback deck not found")) + } + + pub fn add_deck_default(&mut self, key: Pubkey) { + self.account_to_decks.insert(key, Decks::default()); + info!( + "Adding new default deck for {:?}. New decks size is {}", + key, + self.account_to_decks.get(&key).unwrap().decks.len() + ); + } + + pub fn add_decks(&mut self, key: Pubkey, decks: Decks) { + self.account_to_decks.insert(key, decks); + info!( + "Adding new deck for {:?}. New decks size is {}", + key, + self.account_to_decks.get(&key).unwrap().decks.len() + ); + } + + pub fn add_deck(&mut self, key: Pubkey, deck: Deck) { + match self.account_to_decks.entry(key) { + std::collections::hash_map::Entry::Occupied(mut entry) => { + let decks = entry.get_mut(); + decks.add_deck(deck); + info!( + "Created new deck for {:?}. New number of decks is {}", + key, + decks.decks.len() + ); + } + std::collections::hash_map::Entry::Vacant(entry) => { + info!("Created first deck for {:?}", key); + entry.insert(Decks::new(deck)); + } + } + } + + pub fn remove_for(&mut self, key: &Pubkey) { + info!("Removing decks for {:?}", key); + self.account_to_decks.remove(key); + } +} + +#[derive(Serialize, Deserialize)] +pub struct SerializableDecksCache { + #[serde(serialize_with = "serialize_map", deserialize_with = "deserialize_map")] + pub account_to_decks: HashMap, +} + +impl From<&DecksCache> for SerializableDecksCache { + fn from(value: &DecksCache) -> Self { + SerializableDecksCache { + account_to_decks: value + .account_to_decks + .iter() + .map(|(id, d)| (*id, d.into())) + .collect(), + } + } +} + +impl SerializableDecksCache { + pub fn into_decks_cache(self, ndb: &nostrdb::Ndb) -> DecksCache { + let account_to_decks = self + .account_to_decks + .into_iter() + .map(|(id, s)| (id, s.into_decks(ndb, Some(id.bytes())))) + .collect(); + DecksCache::new(account_to_decks) + } +} + +fn serialize_map( + map: &HashMap, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + let stringified_map: HashMap = + map.iter().map(|(k, v)| (k.hex(), v)).collect(); + stringified_map.serialize(serializer) +} + +fn deserialize_map<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let stringified_map: HashMap = HashMap::deserialize(deserializer)?; + + stringified_map + .into_iter() + .map(|(k, v)| { + let key = Pubkey::from_hex(&k).map_err(serde::de::Error::custom)?; + Ok((key, v)) + }) + .collect() +} + +#[derive(Serialize)] +pub struct Decks { + active_deck: usize, + removal_request: Option, + decks: Vec, +} + +impl Default for Decks { + fn default() -> Self { + Decks::new(Deck::default()) + } +} + +impl Decks { + pub fn new(deck: Deck) -> Self { + let decks = vec![deck]; + + Decks { + active_deck: 0, + removal_request: None, + decks, + } + } + + pub fn active(&self) -> &Deck { + self.decks + .get(self.active_deck) + .expect("active_deck index was invalid") + } + + pub fn active_mut(&mut self) -> &mut Deck { + self.decks + .get_mut(self.active_deck) + .expect("active_deck index was invalid") + } + + pub fn decks(&self) -> &Vec { + &self.decks + } + + pub fn decks_mut(&mut self) -> &mut Vec { + &mut self.decks + } + + pub fn add_deck(&mut self, deck: Deck) { + self.decks.push(deck); + } + + pub fn active_index(&self) -> usize { + self.active_deck + } + + pub fn set_active(&mut self, index: usize) { + if index < self.decks.len() { + self.active_deck = index; + } else { + error!( + "requested deck change that is invalid. decks len: {}, requested index: {}", + self.decks.len(), + index + ); + } + } + + pub fn remove_deck(&mut self, index: usize) { + if index < self.decks.len() { + if self.decks.len() > 1 { + self.decks.remove(index); + + let info_prefix = format!("Removed deck at index {}", index); + match index.cmp(&self.active_deck) { + std::cmp::Ordering::Less => { + info!( + "{}. The active deck was index {}, now it is {}", + info_prefix, + self.active_deck, + self.active_deck - 1 + ); + self.active_deck -= 1 + } + std::cmp::Ordering::Greater => { + info!( + "{}. Active deck remains at index {}.", + info_prefix, self.active_deck + ) + } + std::cmp::Ordering::Equal => { + if index != 0 { + info!( + "{}. Active deck was index {}, now it is {}", + info_prefix, + self.active_deck, + self.active_deck - 1 + ); + self.active_deck -= 1; + } else { + info!( + "{}. Active deck remains at index {}.", + info_prefix, self.active_deck + ) + } + } + } + self.removal_request = None; + } else { + error!("attempted unsucessfully to remove the last deck for this account"); + } + } else { + error!("index was out of bounds"); + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct SerializableDecks { + active_deck: usize, + removal_request: Option, + decks: Vec, +} + +impl SerializableDecks { + pub fn into_decks(self, ndb: &nostrdb::Ndb, deck_pubkey: Option<&[u8; 32]>) -> Decks { + Decks { + active_deck: self.active_deck, + removal_request: self.removal_request, + decks: self + .decks + .into_iter() + .map(|d| d.into_deck(ndb, deck_pubkey)) + .collect(), + } + } +} + +impl From<&Decks> for SerializableDecks { + fn from(value: &Decks) -> Self { + SerializableDecks { + active_deck: value.active_deck, + removal_request: value.removal_request, + decks: value.decks.iter().map(|d| d.into()).collect(), + } + } +} + +pub struct Deck { + pub icon: char, + pub name: String, + columns: Columns, +} + +impl Default for Deck { + fn default() -> Self { + let mut columns = Columns::default(); + columns.new_column_picker(); + Self { + icon: '🇩', + name: String::from("Default Deck"), + columns, + } + } +} + +impl Deck { + pub fn new(icon: char, name: String) -> Self { + let mut columns = Columns::default(); + columns.new_column_picker(); + Self { + icon, + name, + columns, + } + } + + pub fn columns(&self) -> &Columns { + &self.columns + } + + pub fn columns_mut(&mut self) -> &mut Columns { + &mut self.columns + } + + pub fn edit(&mut self, changes: ConfigureDeckResponse) { + self.name = changes.name; + self.icon = changes.icon; + } +} + +#[derive(Serialize, Deserialize)] +struct SerializableDeck { + pub icon: char, + pub name: String, + columns: SerializableColumns, +} + +impl Serialize for Deck { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let helper = SerializableDeck { + icon: self.icon, + name: self.name.to_owned(), + columns: (&self.columns).into(), + }; + + helper.serialize(serializer) + } +} + +impl From<&Deck> for SerializableDeck { + fn from(value: &Deck) -> Self { + SerializableDeck { + icon: value.icon, + name: value.name.to_owned(), + columns: (&value.columns).into(), + } + } +} + +impl SerializableDeck { + pub fn into_deck(self, ndb: &nostrdb::Ndb, deck_pubkey: Option<&[u8; 32]>) -> Deck { + Deck { + icon: self.icon, + name: self.name, + columns: self.columns.into_columns(ndb, deck_pubkey), + } + } +} + +pub fn demo_decks(demo_pubkey: Pubkey, ndb: &Ndb) -> Decks { + let deck = { + let mut columns = Columns::default(); + columns.add_column(Column::new(vec![ + Route::AddColumn(AddColumnRoute::Base), + Route::Accounts(AccountsRoute::Accounts), + ])); + + if let Some(timeline) = + TimelineKind::contact_list(timeline::PubkeySource::Explicit(demo_pubkey)) + .into_timeline(ndb, Some(demo_pubkey.bytes())) + { + columns.add_new_timeline_column(timeline); + } + + columns.add_new_timeline_column(Timeline::hashtag("introductions".to_string())); + + Deck { + icon: '🇩', + name: String::from("Demo Deck"), + columns, + } + }; + + Decks::new(deck) +} diff --git a/src/fonts.rs b/src/fonts.rs index 220e01d1..e5be4aa4 100644 --- a/src/fonts.rs +++ b/src/fonts.rs @@ -5,6 +5,7 @@ use tracing::debug; pub enum NamedFontFamily { Medium, Bold, + Emoji, } impl NamedFontFamily { @@ -12,6 +13,7 @@ impl NamedFontFamily { match self { Self::Bold => "bold", Self::Medium => "medium", + Self::Emoji => "emoji", } } @@ -124,7 +126,9 @@ pub fn setup_fonts(ctx: &egui::Context) { mono.extend(base_fonts.clone()); let mut bold = vec!["OnestBold".to_owned()]; - bold.extend(base_fonts); + bold.extend(base_fonts.clone()); + + let emoji = vec!["NotoEmoji".to_owned()]; families.insert(egui::FontFamily::Proportional, proportional); families.insert(egui::FontFamily::Monospace, mono); @@ -136,6 +140,10 @@ pub fn setup_fonts(ctx: &egui::Context) { egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()), bold, ); + families.insert( + egui::FontFamily::Name(NamedFontFamily::Emoji.as_str().into()), + emoji, + ); debug!("fonts: {:?}", families); diff --git a/src/lib.rs b/src/lib.rs index babfc3d9..5a0b7203 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,8 @@ mod app_style; mod args; mod colors; mod column; +mod deck_state; +mod decks; mod draft; mod filter; mod fonts; diff --git a/src/nav.rs b/src/nav.rs index e905fa60..4f63d29d 100644 --- a/src/nav.rs +++ b/src/nav.rs @@ -1,7 +1,11 @@ use crate::{ - accounts::render_accounts_route, + accounts::{render_accounts_route, AccountsAction}, actionbar::NoteAction, + app::{get_active_columns, get_active_columns_mut, get_decks_mut}, app_style::{get_font_size, NotedeckTextStyle}, + column::ColumnsAction, + deck_state::DeckState, + decks::{Deck, DecksAction}, fonts::NamedFontFamily, notes_holder::NotesHolder, profile::Profile, @@ -16,6 +20,8 @@ use crate::{ self, add_column::render_add_column_routes, anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, + configure_deck::ConfigureDeckView, + edit_deck::{EditDeckResponse, EditDeckView}, note::{PostAction, PostType}, support::SupportView, RelayView, View, @@ -28,9 +34,43 @@ use egui_nav::{Nav, NavAction, NavResponse, TitleBarResponse}; use nostrdb::{Ndb, Transaction}; use tracing::{error, info}; +#[allow(clippy::enum_variant_names)] pub enum RenderNavAction { PostAction(PostAction), NoteAction(NoteAction), + SwitchingAction(SwitchingAction), +} + +pub enum SwitchingAction { + Accounts(AccountsAction), + Columns(ColumnsAction), + Decks(crate::decks::DecksAction), +} + +impl SwitchingAction { + /// process the action, and return whether switching occured + pub fn process(&self, app: &mut Damus) -> bool { + match &self { + SwitchingAction::Accounts(account_action) => match *account_action { + AccountsAction::Switch(index) => app.accounts.select_account(index), + AccountsAction::Remove(index) => app.accounts.remove_account(index), + }, + SwitchingAction::Columns(columns_action) => match *columns_action { + ColumnsAction::Remove(index) => { + get_active_columns_mut(&app.accounts, &mut app.decks_cache).delete_column(index) + } + }, + SwitchingAction::Decks(decks_action) => match *decks_action { + DecksAction::Switch(index) => { + get_decks_mut(&app.accounts, &mut app.decks_cache).set_active(index) + } + DecksAction::Removing(index) => { + get_decks_mut(&app.accounts, &mut app.decks_cache).remove_deck(index) + } + }, + } + true + } } impl From for RenderNavAction { @@ -61,7 +101,7 @@ impl RenderNavResponse { #[must_use = "Make sure to save columns if result is true"] pub fn process_render_nav_response(&self, app: &mut Damus) -> bool { - let mut col_changed: bool = false; + let mut switching_occured: bool = false; let col = self.column; if let Some(action) = &self.response.inner { @@ -70,7 +110,10 @@ impl RenderNavResponse { RenderNavAction::PostAction(post_action) => { let txn = Transaction::new(&app.ndb).expect("txn"); let _ = post_action.execute(&app.ndb, &txn, &mut app.pool, &mut app.drafts); - app.columns_mut().column_mut(col).router_mut().go_back(); + get_active_columns_mut(&app.accounts, &mut app.decks_cache) + .column_mut(col) + .router_mut() + .go_back(); } RenderNavAction::NoteAction(note_action) => { @@ -78,7 +121,7 @@ impl RenderNavResponse { note_action.execute_and_process_result( &app.ndb, - &mut app.columns, + get_active_columns_mut(&app.accounts, &mut app.decks_cache), col, &mut app.threads, &mut app.profiles, @@ -88,6 +131,10 @@ impl RenderNavResponse { &app.accounts.mutefun(), ); } + + RenderNavAction::SwitchingAction(switching_action) => { + switching_occured = switching_action.process(app); + } } } @@ -125,14 +172,18 @@ impl RenderNavResponse { &app.accounts.mutefun(), ); } - col_changed = true; + + if let Some(Route::EditDeck(index)) = r { + SwitchingAction::Decks(DecksAction::Removing(index)).process(app); + } + switching_occured = true; } else if let Some(NavAction::Navigated) = self.response.action { let cur_router = app.columns_mut().column_mut(col).router_mut(); cur_router.navigating = false; if cur_router.is_replacing() { cur_router.remove_previous_routes(); } - col_changed = true; + switching_occured = true; } if let Some(title_response) = &self.response.title_response { @@ -144,26 +195,25 @@ impl RenderNavResponse { } app.columns_mut().delete_column(col); - col_changed = true; + switching_occured = true; } } } - col_changed + switching_occured } } #[must_use = "RenderNavResponse must be handled by calling .process_render_nav_response(..)"] pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) -> RenderNavResponse { - let col_id = app.columns.get_column_id_at_index(col); + let col_id = get_active_columns(&app.accounts, &app.decks_cache).get_column_id_at_index(col); // TODO(jb55): clean up this router_mut mess by using Router in egui-nav directly - let routes = app - .columns() + let routes = get_active_columns(&app.accounts, &app.decks_cache) .column(col) .router() .routes() .iter() - .map(|r| r.get_titled_route(&app.columns, &app.ndb)) + .map(|r| r.get_titled_route(&app.accounts, &app.decks_cache, &app.ndb)) .collect(); let nav_response = Nav::new(routes) @@ -174,7 +224,7 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) -> RenderNavRe .show_mut(ui, |ui, nav| match &nav.top().route { Route::Timeline(tlr) => render_timeline_route( &app.ndb, - &mut app.columns, + get_active_columns_mut(&app.accounts, &mut app.decks_cache), &mut app.drafts, &mut app.img_cache, &mut app.unknown_ids, @@ -188,19 +238,21 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) -> RenderNavRe ui, ), Route::Accounts(amr) => { - let action = render_accounts_route( + let resp = render_accounts_route( ui, &app.ndb, col, - &mut app.columns, &mut app.img_cache, &mut app.accounts, + &mut app.decks_cache, &mut app.view_state.login, *amr, ); let txn = Transaction::new(&app.ndb).expect("txn"); - action.process_action(&mut app.unknown_ids, &app.ndb, &txn); - None + resp.unk_id_action + .process_action(&mut app.unknown_ids, &app.ndb, &txn); + resp.accounts_action + .map(|f| RenderNavAction::SwitchingAction(SwitchingAction::Accounts(f))) } Route::Relays => { let manager = RelayPoolManager::new(app.pool_mut()); @@ -234,6 +286,72 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) -> RenderNavRe SupportView::new(&mut app.support).show(ui); None } + Route::NewDeck => { + let id = ui.id().with("new-deck"); + let new_deck_state = app.view_state.id_to_deck_state.entry(id).or_default(); + let mut resp = None; + if let Some(config_resp) = ConfigureDeckView::new(new_deck_state).ui(ui) { + if let Some(cur_acc) = app.accounts.get_selected_account() { + app.decks_cache.add_deck( + cur_acc.pubkey, + Deck::new(config_resp.icon, config_resp.name), + ); + + // set new deck as active + let cur_index = get_decks_mut(&app.accounts, &mut app.decks_cache) + .decks() + .len() + - 1; + resp = Some(RenderNavAction::SwitchingAction(SwitchingAction::Decks( + DecksAction::Switch(cur_index), + ))); + } + + new_deck_state.clear(); + get_active_columns_mut(&app.accounts, &mut app.decks_cache) + .get_first_router() + .go_back(); + } + resp + } + Route::EditDeck(index) => { + let cur_deck = get_decks_mut(&app.accounts, &mut app.decks_cache) + .decks_mut() + .get_mut(*index) + .expect("index wasn't valid"); + let id = ui.id().with(( + "edit-deck", + app.accounts.get_selected_account().map(|k| k.pubkey), + index, + )); + let deck_state = app + .view_state + .id_to_deck_state + .entry(id) + .or_insert_with(|| DeckState::from_deck(cur_deck)); + if let Some(resp) = EditDeckView::new(deck_state).ui(ui) { + match resp { + EditDeckResponse::Edit(configure_deck_response) => { + cur_deck.edit(configure_deck_response); + } + EditDeckResponse::Delete => { + deck_state.deleting = true; + } + } + get_active_columns_mut(&app.accounts, &mut app.decks_cache) + .get_first_router() + .go_back(); + } + + // if deck_state.deleting { + // Some(RenderNavAction::SwitchingAction(SwitchingAction::Decks( + // DecksAction::Removing(*index), + // ))) + // } else { + // None + // } + None + } }); RenderNavResponse::new(col, nav_response) diff --git a/src/route.rs b/src/route.rs index b102e25c..4688efe8 100644 --- a/src/route.rs +++ b/src/route.rs @@ -4,8 +4,9 @@ use serde::{Deserialize, Serialize}; use std::fmt::{self}; use crate::{ - accounts::AccountsRoute, - column::Columns, + accounts::{Accounts, AccountsRoute}, + app::{get_active_columns, get_decks}, + decks::DecksCache, timeline::{TimelineId, TimelineRoute}, ui::{ add_column::AddColumnRoute, @@ -22,6 +23,8 @@ pub enum Route { ComposeNote, AddColumn(AddColumnRoute), Support, + NewDeck, + EditDeck(usize), } #[derive(Clone)] @@ -77,10 +80,16 @@ impl Route { Route::Accounts(AccountsRoute::AddAccount) } - pub fn get_titled_route(&self, columns: &Columns, ndb: &Ndb) -> TitledRoute { + pub fn get_titled_route( + &self, + accounts: &Accounts, + decks_cache: &DecksCache, + ndb: &Ndb, + ) -> TitledRoute { let title = match self { Route::Timeline(tlr) => match tlr { TimelineRoute::Timeline(id) => { + let columns = get_active_columns(accounts, decks_cache); let timeline = columns .find_timeline(*id) .expect("expected to find timeline"); @@ -116,6 +125,16 @@ impl Route { AddColumnRoute::Hashtag => "Add Hashtag Column".to_owned(), }, Route::Support => "Damus Support".to_owned(), + Route::NewDeck => "Add Deck".to_owned(), + Route::EditDeck(index) => { + let deck_name = + if let Some(deck) = get_decks(accounts, decks_cache).decks().get(*index) { + &deck.name + } else { + &String::new() + }; + format!("Edit Deck: {}", deck_name) + } }; TitledRoute { @@ -226,6 +245,8 @@ impl fmt::Display for Route { Route::AddColumn(_) => write!(f, "Add Column"), Route::Support => write!(f, "Support"), + Route::NewDeck => write!(f, "Add Deck"), + Route::EditDeck(_) => write!(f, "Edit Deck"), } } } diff --git a/src/storage/columns.rs b/src/storage/columns.rs index 1b325c8c..86600d16 100644 --- a/src/storage/columns.rs +++ b/src/storage/columns.rs @@ -1,48 +1,58 @@ use tracing::{error, info}; -use crate::column::SerializableColumns; +use crate::decks::SerializableDecksCache; use super::{write_file, DataPath, DataPathType, Directory}; -static COLUMNS_FILE: &str = "columns.json"; +static DECKS_CACHE_FILE: &str = "decks_cache.json"; -pub fn save_columns(path: &DataPath, columns: SerializableColumns) { - let serialized_columns = match serde_json::to_string(&columns) { - Ok(s) => s, - Err(e) => { - error!("Could not serialize columns: {}", e); - return; - } - }; - - let data_path = path.path(DataPathType::Setting); - - if let Err(e) = write_file(&data_path, COLUMNS_FILE.to_string(), &serialized_columns) { - error!("Could not write columns to file {}: {}", COLUMNS_FILE, e); - } else { - info!("Successfully wrote columns to {}", COLUMNS_FILE); - } -} - -pub fn load_columns(path: &DataPath) -> Option { +pub fn load_decks_cache(path: &DataPath) -> Option { let data_path = path.path(DataPathType::Setting); - let columns_string = match Directory::new(data_path).get_file(COLUMNS_FILE.to_owned()) { + let decks_cache_str = match Directory::new(data_path).get_file(DECKS_CACHE_FILE.to_owned()) { Ok(s) => s, Err(e) => { - error!("Could not read columns from file {}: {}", COLUMNS_FILE, e); + error!( + "Could not read decks cache from file {}: {}", + DECKS_CACHE_FILE, e + ); return None; } }; - match serde_json::from_str::(&columns_string) { + match serde_json::from_str::(&decks_cache_str) { Ok(s) => { - info!("Successfully loaded columns from {}", COLUMNS_FILE); + info!("Successfully loaded decks cache from {}", DECKS_CACHE_FILE); Some(s) } Err(e) => { - error!("Could not deserialize columns: {}", e); + error!("Could not deserialize decks cache: {}", e); None } } } + +pub fn save_decks_cache(path: &DataPath, decks_cache: &SerializableDecksCache) { + let serialized_decks_cache = match serde_json::to_string(decks_cache) { + Ok(s) => s, + Err(e) => { + error!("Could not serialize decks cache: {}", e); + return; + } + }; + + let data_path = path.path(DataPathType::Setting); + + if let Err(e) = write_file( + &data_path, + DECKS_CACHE_FILE.to_string(), + &serialized_decks_cache, + ) { + error!( + "Could not write decks cache to file {}: {}", + DECKS_CACHE_FILE, e + ); + } else { + info!("Successfully wrote decks cache to {}", DECKS_CACHE_FILE); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 005902ed..73add08f 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -2,7 +2,7 @@ mod columns; mod file_key_storage; mod file_storage; -pub use columns::{load_columns, save_columns}; +pub use columns::{load_decks_cache, save_decks_cache}; pub use file_key_storage::FileKeyStorage; pub use file_storage::{delete_file, write_file, DataPath, DataPathType, Directory}; diff --git a/src/timeline/mod.rs b/src/timeline/mod.rs index 0b26ceb8..d61e8eb4 100644 --- a/src/timeline/mod.rs +++ b/src/timeline/mod.rs @@ -1,5 +1,6 @@ use crate::{ column::Columns, + decks::DecksCache, error::{Error, FilterError}, filter::{self, FilterState, FilterStates}, muted::MuteFun, @@ -601,12 +602,16 @@ pub fn copy_notes_into_timeline( pub fn setup_initial_nostrdb_subs( ndb: &Ndb, note_cache: &mut NoteCache, - columns: &mut Columns, is_muted: &MuteFun, + decks_cache: &mut DecksCache, ) -> Result<()> { - for timeline in columns.timelines_mut() { - if let Err(err) = setup_timeline_nostrdb_sub(ndb, note_cache, timeline, is_muted) { - error!("setup_initial_nostrdb_subs: {err}"); + for decks in decks_cache.account_to_decks.values_mut() { + for deck in decks.decks_mut() { + for timeline in deck.columns_mut().timelines_mut() { + if let Err(err) = setup_timeline_nostrdb_sub(ndb, note_cache, timeline, is_muted) { + error!("setup_initial_nostrdb_subs: {err}"); + } + } } } diff --git a/src/ui/accounts.rs b/src/ui/accounts.rs index f7fc7758..51751dbf 100644 --- a/src/ui/accounts.rs +++ b/src/ui/accounts.rs @@ -2,7 +2,6 @@ use crate::colors::PINK; use crate::imgcache::ImageCache; use crate::{ accounts::Accounts, - route::{Route, Router}, ui::{Preview, PreviewConfig, View}, Damus, }; @@ -203,15 +202,12 @@ mod preview { pub struct AccountsPreview { app: Damus, - router: Router, } impl AccountsPreview { fn new() -> Self { let app = test_data::test_app(); - let router = Router::new(vec![Route::accounts()]); - - AccountsPreview { app, router } + AccountsPreview { app } } } @@ -224,7 +220,12 @@ mod preview { .ui(ui) .inner { - process_accounts_view_response(self.app.accounts_mut(), response, &mut self.router); + process_accounts_view_response( + &mut self.app.accounts, + &mut self.app.decks_cache, + 0, + response, + ); } } } diff --git a/src/ui/add_column.rs b/src/ui/add_column.rs index b0acf580..6656314c 100644 --- a/src/ui/add_column.rs +++ b/src/ui/add_column.rs @@ -285,7 +285,11 @@ impl<'a> AddColumnView<'a> { }); if let Some(acc) = self.cur_account { - let source = PubkeySource::Explicit(acc.pubkey); + let source = if acc.secret_key.is_some() { + PubkeySource::DeckAuthor + } else { + PubkeySource::Explicit(acc.pubkey) + }; vec.push(ColumnOptionData { title: "Home timeline", diff --git a/src/ui/configure_deck.rs b/src/ui/configure_deck.rs new file mode 100644 index 00000000..b7cc8db1 --- /dev/null +++ b/src/ui/configure_deck.rs @@ -0,0 +1,324 @@ +use egui::{vec2, Button, Color32, Label, RichText, Stroke, Ui, Widget}; + +use crate::{ + app_style::{deck_icon_font_sized, get_font_size, NotedeckTextStyle}, + colors::PINK, + deck_state::DeckState, + fonts::NamedFontFamily, +}; + +use super::{ + anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, + padding, +}; + +pub struct ConfigureDeckView<'a> { + state: &'a mut DeckState, + create_button_text: String, +} + +pub struct ConfigureDeckResponse { + pub icon: char, + pub name: String, +} + +static CREATE_TEXT: &str = "Create Deck"; + +impl<'a> ConfigureDeckView<'a> { + pub fn new(state: &'a mut DeckState) -> Self { + Self { + state, + create_button_text: CREATE_TEXT.to_owned(), + } + } + + pub fn with_create_text(mut self, text: &str) -> Self { + self.create_button_text = text.to_owned(); + self + } + + pub fn ui(&mut self, ui: &mut Ui) -> Option { + let title_font = egui::FontId::new( + get_font_size(ui.ctx(), &NotedeckTextStyle::Heading4), + egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()), + ); + padding(16.0, ui, |ui| { + ui.add(Label::new( + RichText::new("Deck name").font(title_font.clone()), + )); + ui.add_space(8.0); + ui.text_edit_singleline(&mut self.state.deck_name); + ui.add_space(8.0); + ui.add(Label::new( + RichText::new("We recommend short names") + .color(ui.visuals().noninteractive().fg_stroke.color) + .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Small)), + )); + + ui.add_space(32.0); + ui.add(Label::new(RichText::new("Icon").font(title_font))); + + if ui + .add(deck_icon( + ui.id().with("config-deck"), + self.state.selected_glyph, + 38.0, + 64.0, + false, + )) + .clicked() + { + self.state.selecting_glyph = !self.state.selecting_glyph; + } + + if self.state.selecting_glyph { + let max_height = if ui.available_height() - 100.0 > 0.0 { + ui.available_height() - 100.0 + } else { + ui.available_height() + }; + egui::Frame::window(ui.style()).show(ui, |ui| { + let glyphs = self.state.get_glyph_options(ui); + if let Some(selected_glyph) = glyph_options_ui(ui, 16.0, max_height, glyphs) { + self.state.selected_glyph = Some(selected_glyph); + self.state.selecting_glyph = false; + } + }); + ui.add_space(16.0); + } + + if self.state.warn_no_icon && self.state.selected_glyph.is_some() { + self.state.warn_no_icon = false; + } + if self.state.warn_no_title && !self.state.deck_name.is_empty() { + self.state.warn_no_title = false; + } + + show_warnings(ui, self.state.warn_no_icon, self.state.warn_no_title); + + let mut resp = None; + if ui + .add(create_deck_button(&self.create_button_text)) + .clicked() + { + if self.state.deck_name.is_empty() { + self.state.warn_no_title = true; + } + if self.state.selected_glyph.is_none() { + self.state.warn_no_icon = true; + } + if !self.state.deck_name.is_empty() { + if let Some(glyph) = self.state.selected_glyph { + resp = Some(ConfigureDeckResponse { + icon: glyph, + name: self.state.deck_name.clone(), + }); + } + } + } + resp + }) + .inner + } +} + +fn show_warnings(ui: &mut Ui, warn_no_icon: bool, warn_no_title: bool) { + if warn_no_icon || warn_no_title { + let messages = [ + if warn_no_title { + "create a name for the deck" + } else { + "" + }, + if warn_no_icon { "select an icon" } else { "" }, + ]; + let message = messages + .iter() + .filter(|&&m| !m.is_empty()) + .copied() + .collect::>() + .join(" and "); + + ui.add( + egui::Label::new( + RichText::new(format!("Please {}.", message)).color(ui.visuals().error_fg_color), + ) + .wrap(), + ); + } +} + +fn create_deck_button(text: &str) -> impl Widget + use<'_> { + move |ui: &mut egui::Ui| { + let size = vec2(108.0, 40.0); + ui.allocate_ui_with_layout(size, egui::Layout::top_down(egui::Align::Center), |ui| { + ui.add(Button::new(text).fill(PINK).min_size(size)) + }) + .inner + } +} + +pub fn deck_icon( + id: egui::Id, + glyph: Option, + font_size: f32, + full_size: f32, + highlight: bool, +) -> impl Widget { + move |ui: &mut egui::Ui| -> egui::Response { + let max_size = full_size * ICON_EXPANSION_MULTIPLE; + + let helper = AnimationHelper::new(ui, id, vec2(max_size, max_size)); + let painter = ui.painter_at(helper.get_animation_rect()); + let bg_center = helper.get_animation_rect().center(); + + let (stroke, fill_color) = if highlight { + ( + ui.visuals().selection.stroke, + ui.visuals().widgets.noninteractive.weak_bg_fill, + ) + } else { + ( + Stroke::new( + ui.visuals().widgets.inactive.bg_stroke.width, + ui.visuals().widgets.inactive.weak_bg_fill, + ), + ui.visuals().widgets.noninteractive.weak_bg_fill, + ) + }; + + let radius = helper.scale_1d_pos((full_size / 2.0) - stroke.width); + painter.circle(bg_center, radius, fill_color, stroke); + + if let Some(glyph) = glyph { + let font = + deck_icon_font_sized(helper.scale_1d_pos(font_size / std::f32::consts::SQRT_2)); + let glyph_galley = + painter.layout_no_wrap(glyph.to_string(), font, ui.visuals().text_color()); + + let top_left = { + let mut glyph_rect = glyph_galley.rect; + glyph_rect.set_center(bg_center); + glyph_rect.left_top() + }; + + painter.galley(top_left, glyph_galley, Color32::WHITE); + } + + helper.take_animation_response() + } +} + +fn glyph_icon_max_size(ui: &egui::Ui, glyph: &char, font_size: f32) -> egui::Vec2 { + let painter = ui.painter(); + let font = deck_icon_font_sized(font_size * ICON_EXPANSION_MULTIPLE); + let glyph_galley = painter.layout_no_wrap(glyph.to_string(), font, Color32::WHITE); + glyph_galley.rect.size() +} + +fn glyph_icon(glyph: char, font_size: f32, max_size: egui::Vec2) -> impl Widget { + move |ui: &mut egui::Ui| { + let helper = AnimationHelper::new(ui, ("glyph", glyph), max_size); + let painter = ui.painter_at(helper.get_animation_rect()); + + let font = deck_icon_font_sized(helper.scale_1d_pos(font_size)); + let glyph_galley = painter.layout_no_wrap(glyph.to_string(), font, Color32::WHITE); + + let top_left = { + let mut glyph_rect = glyph_galley.rect; + glyph_rect.set_center(helper.get_animation_rect().center()); + glyph_rect.left_top() + }; + + painter.galley(top_left, glyph_galley, Color32::WHITE); + helper.take_animation_response() + } +} + +fn glyph_options_ui( + ui: &mut egui::Ui, + font_size: f32, + max_height: f32, + glyphs: &[char], +) -> Option { + let mut selected_glyph = None; + egui::ScrollArea::vertical() + .max_height(max_height) + .show(ui, |ui| { + let max_width = ui.available_width(); + let mut row_glyphs = Vec::new(); + let mut cur_width = 0.0; + let spacing = ui.spacing().item_spacing.x; + + for (index, glyph) in glyphs.iter().enumerate() { + let next_glyph_size = glyph_icon_max_size(ui, glyph, font_size); + + if cur_width + spacing + next_glyph_size.x > max_width { + if let Some(selected) = paint_row(ui, &row_glyphs, font_size) { + selected_glyph = Some(selected); + } + row_glyphs.clear(); + cur_width = 0.0; + } + + cur_width += spacing; + cur_width += next_glyph_size.x; + row_glyphs.push(*glyph); + + if index == glyphs.len() - 1 { + if let Some(selected) = paint_row(ui, &row_glyphs, font_size) { + selected_glyph = Some(selected); + } + } + } + }); + selected_glyph +} + +fn paint_row(ui: &mut egui::Ui, row_glyphs: &[char], font_size: f32) -> Option { + let mut selected_glyph = None; + ui.horizontal(|ui| { + for glyph in row_glyphs { + let glyph_size = glyph_icon_max_size(ui, glyph, font_size); + if ui.add(glyph_icon(*glyph, font_size, glyph_size)).clicked() { + selected_glyph = Some(*glyph); + } + } + }); + selected_glyph +} + +mod preview { + use crate::{ + deck_state::DeckState, + ui::{Preview, PreviewConfig, View}, + }; + + use super::ConfigureDeckView; + + pub struct ConfigureDeckPreview { + state: DeckState, + } + + impl ConfigureDeckPreview { + fn new() -> Self { + let state = DeckState::default(); + + ConfigureDeckPreview { state } + } + } + + impl View for ConfigureDeckPreview { + fn ui(&mut self, ui: &mut egui::Ui) { + ConfigureDeckView::new(&mut self.state).ui(ui); + } + } + + impl Preview for ConfigureDeckView<'_> { + type Prev = ConfigureDeckPreview; + + fn preview(_cfg: PreviewConfig) -> Self::Prev { + ConfigureDeckPreview::new() + } + } +} diff --git a/src/ui/edit_deck.rs b/src/ui/edit_deck.rs new file mode 100644 index 00000000..1e027542 --- /dev/null +++ b/src/ui/edit_deck.rs @@ -0,0 +1,91 @@ +use egui::Widget; + +use crate::deck_state::DeckState; + +use super::{ + configure_deck::{ConfigureDeckResponse, ConfigureDeckView}, + padding, +}; + +pub struct EditDeckView<'a> { + config_view: ConfigureDeckView<'a>, +} + +static EDIT_TEXT: &str = "Edit Deck"; + +pub enum EditDeckResponse { + Edit(ConfigureDeckResponse), + Delete, +} + +impl<'a> EditDeckView<'a> { + pub fn new(state: &'a mut DeckState) -> Self { + let config_view = ConfigureDeckView::new(state).with_create_text(EDIT_TEXT); + Self { config_view } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> Option { + let mut edit_deck_resp = None; + + padding(egui::Margin::symmetric(16.0, 4.0), ui, |ui| { + if ui.add(delete_button()).clicked() { + edit_deck_resp = Some(EditDeckResponse::Delete); + } + }); + + if let Some(config_resp) = self.config_view.ui(ui) { + edit_deck_resp = Some(EditDeckResponse::Edit(config_resp)) + } + + edit_deck_resp + } +} + +fn delete_button() -> impl Widget { + |ui: &mut egui::Ui| { + let size = egui::vec2(108.0, 40.0); + ui.allocate_ui_with_layout(size, egui::Layout::top_down(egui::Align::Center), |ui| { + ui.add( + egui::Button::new("Delete Deck") + .fill(ui.visuals().error_fg_color) + .min_size(size), + ) + }) + .inner + } +} + +mod preview { + use crate::{ + deck_state::DeckState, + ui::{Preview, PreviewConfig, View}, + }; + + use super::EditDeckView; + + pub struct EditDeckPreview { + state: DeckState, + } + + impl EditDeckPreview { + fn new() -> Self { + let state = DeckState::default(); + + EditDeckPreview { state } + } + } + + impl View for EditDeckPreview { + fn ui(&mut self, ui: &mut egui::Ui) { + EditDeckView::new(&mut self.state).ui(ui); + } + } + + impl Preview for EditDeckView<'_> { + type Prev = EditDeckPreview; + + fn preview(_cfg: PreviewConfig) -> Self::Prev { + EditDeckPreview::new() + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 95e4afcc..c3e40753 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2,6 +2,8 @@ pub mod account_login_view; pub mod accounts; pub mod add_column; pub mod anim; +pub mod configure_deck; +pub mod edit_deck; pub mod mention; pub mod note; pub mod preview; diff --git a/src/ui/side_panel.rs b/src/ui/side_panel.rs index cce3641e..46442e87 100644 --- a/src/ui/side_panel.rs +++ b/src/ui/side_panel.rs @@ -1,13 +1,18 @@ use egui::{ - vec2, Color32, InnerResponse, Label, Layout, Margin, RichText, Separator, Stroke, Widget, + vec2, Color32, InnerResponse, Label, Layout, Margin, RichText, ScrollArea, Separator, Stroke, + Widget, }; use tracing::info; use crate::{ - accounts::AccountsRoute, - app_style, colors, - column::{Column, Columns}, + accounts::{Accounts, AccountsRoute}, + app::get_active_columns_mut, + app_style::{self, DECK_ICON_SIZE}, + colors, + column::Column, + decks::{DecksAction, DecksCache}, imgcache::ImageCache, + nav::SwitchingAction, route::Route, support::Support, user_account::UserAccount, @@ -16,6 +21,7 @@ use crate::{ use super::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, + configure_deck::deck_icon, profile::preview::get_account_url, ProfilePic, View, }; @@ -27,6 +33,7 @@ pub struct DesktopSidePanel<'a> { ndb: &'a nostrdb::Ndb, img_cache: &'a mut ImageCache, selected_account: Option<&'a UserAccount>, + decks_cache: &'a DecksCache, } impl View for DesktopSidePanel<'_> { @@ -45,6 +52,9 @@ pub enum SidePanelAction { Search, ExpandSidePanel, Support, + NewDeck, + SwitchDeck(usize), + EditDeck(usize), } pub struct SidePanelResponse { @@ -63,11 +73,13 @@ impl<'a> DesktopSidePanel<'a> { ndb: &'a nostrdb::Ndb, img_cache: &'a mut ImageCache, selected_account: Option<&'a UserAccount>, + decks_cache: &'a DecksCache, ) -> Self { Self { ndb, img_cache, selected_account, + decks_cache, } } @@ -112,6 +124,21 @@ impl<'a> DesktopSidePanel<'a> { ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); + ui.add_space(8.0); + ui.add(egui::Label::new( + RichText::new("DECKS") + .size(11.0) + .color(ui.visuals().noninteractive().fg_stroke.color), + )); + ui.add_space(8.0); + let add_deck_resp = ui.add(add_deck_button()); + + let decks_inner = ScrollArea::vertical() + .max_height(ui.available_height() - (3.0 * (ICON_WIDTH + 12.0))) + .show(ui, |ui| { + show_decks(ui, self.decks_cache, self.selected_account) + }) + .inner; if expand_resp.clicked() { Some(InnerResponse::new( SidePanelAction::ExpandSidePanel, @@ -126,12 +153,34 @@ impl<'a> DesktopSidePanel<'a> { // Some(InnerResponse::new(SidePanelAction::Search, search_resp)) } else if column_resp.clicked() { Some(InnerResponse::new(SidePanelAction::Columns, column_resp)) + } else if add_deck_resp.clicked() { + Some(InnerResponse::new(SidePanelAction::NewDeck, add_deck_resp)) + } else if decks_inner.response.secondary_clicked() { + info!("decks inner secondary click"); + if let Some(clicked_index) = decks_inner.inner { + Some(InnerResponse::new( + SidePanelAction::EditDeck(clicked_index), + decks_inner.response, + )) + } else { + None + } + } else if decks_inner.response.clicked() { + if let Some(clicked_index) = decks_inner.inner { + Some(InnerResponse::new( + SidePanelAction::SwitchDeck(clicked_index), + decks_inner.response, + )) + } else { + None + } } else { None } }) .inner; + ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); let (pfp_resp, bottom_resp) = ui .with_layout(Layout::bottom_up(egui::Align::Center), |ui| { let pfp_resp = self.pfp_button(ui); @@ -200,8 +249,14 @@ impl<'a> DesktopSidePanel<'a> { helper.take_animation_response() } - pub fn perform_action(columns: &mut Columns, support: &mut Support, action: SidePanelAction) { - let router = columns.get_first_router(); + pub fn perform_action( + decks_cache: &mut DecksCache, + accounts: &Accounts, + support: &mut Support, + action: SidePanelAction, + ) -> Option { + let router = get_active_columns_mut(accounts, decks_cache).get_first_router(); + let mut switching_response = None; match action { SidePanelAction::Panel => {} // TODO SidePanelAction::Account => { @@ -232,7 +287,7 @@ impl<'a> DesktopSidePanel<'a> { { router.go_back(); } else { - columns.new_column_picker(); + get_active_columns_mut(accounts, decks_cache).new_column_picker(); } } SidePanelAction::ComposeNote => { @@ -258,7 +313,27 @@ impl<'a> DesktopSidePanel<'a> { router.route_to(Route::Support); } } + SidePanelAction::NewDeck => { + if router.routes().iter().any(|&r| r == Route::NewDeck) { + router.go_back(); + } else { + router.route_to(Route::NewDeck); + } + } + SidePanelAction::SwitchDeck(index) => { + switching_response = Some(crate::nav::SwitchingAction::Decks(DecksAction::Switch( + index, + ))) + } + SidePanelAction::EditDeck(index) => { + if router.routes().iter().any(|&r| r == Route::EditDeck(index)) { + router.go_back(); + } else { + router.route_to(Route::EditDeck(index)); + } + } } + switching_response } } @@ -445,6 +520,64 @@ fn support_button() -> impl Widget { } } +fn add_deck_button() -> impl Widget { + |ui: &mut egui::Ui| -> egui::Response { + let img_size = 40.0; + + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget + let img_data = egui::include_image!("../../assets/icons/new_deck_icon_4x_dark.png"); + let img = egui::Image::new(img_data).max_width(img_size); + + let helper = AnimationHelper::new(ui, "new-deck-icon", 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 show_decks<'a>( + ui: &mut egui::Ui, + decks_cache: &'a DecksCache, + selected_account: Option<&'a UserAccount>, +) -> InnerResponse> { + let show_decks_id = ui.id().with("show-decks"); + let account_id = if let Some(acc) = selected_account { + acc.pubkey + } else { + decks_cache.fallback_pubkey + }; + let (cur_decks, account_id) = ( + decks_cache.decks(&account_id), + show_decks_id.with(account_id), + ); + let active_index = cur_decks.active_index(); + + let (_, mut resp) = ui.allocate_exact_size(vec2(0.0, 0.0), egui::Sense::click()); + let mut clicked_index = None; + for (index, deck) in cur_decks.decks().iter().enumerate() { + let highlight = index == active_index; + let deck_icon_resp = ui.add(deck_icon( + account_id.with(index), + Some(deck.icon), + DECK_ICON_SIZE, + 40.0, + highlight, + )); + if deck_icon_resp.clicked() || deck_icon_resp.secondary_clicked() { + clicked_index = Some(index); + } + resp = resp.union(deck_icon_resp); + } + InnerResponse::new(clicked_index, resp) +} + fn milestone_name() -> impl Widget { |ui: &mut egui::Ui| -> egui::Response { ui.vertical_centered(|ui| { @@ -470,6 +603,7 @@ mod preview { use egui_extras::{Size, StripBuilder}; use crate::{ + app::get_active_columns_mut, test_data, ui::{Preview, PreviewConfig}, }; @@ -483,7 +617,8 @@ mod preview { impl DesktopSidePanelPreview { fn new() -> Self { let mut app = test_data::test_app(); - app.columns.add_column(Column::new(vec![Route::accounts()])); + get_active_columns_mut(&app.accounts, &mut app.decks_cache) + .add_column(Column::new(vec![Route::accounts()])); DesktopSidePanelPreview { app } } } @@ -500,11 +635,13 @@ mod preview { &self.app.ndb, &mut self.app.img_cache, self.app.accounts.get_selected_account(), + &self.app.decks_cache, ); let response = panel.show(ui); DesktopSidePanel::perform_action( - &mut self.app.columns, + &mut self.app.decks_cache, + &self.app.accounts, &mut self.app.support, response.action, ); diff --git a/src/ui_preview/main.rs b/src/ui_preview/main.rs index 835d012a..03c110ce 100644 --- a/src/ui_preview/main.rs +++ b/src/ui_preview/main.rs @@ -1,3 +1,5 @@ +use notedeck::ui::configure_deck::ConfigureDeckView; +use notedeck::ui::edit_deck::EditDeckView; use notedeck::ui::{ account_login_view::AccountLoginView, accounts::AccountsView, add_column::AddColumnView, DesktopSidePanel, PostView, Preview, PreviewApp, PreviewConfig, ProfilePic, ProfilePreview, @@ -106,5 +108,7 @@ async fn main() { DesktopSidePanel, PostView, AddColumnView, + ConfigureDeckView, + EditDeckView, ); } diff --git a/src/view_state.rs b/src/view_state.rs index 17aec246..c0a20b2c 100644 --- a/src/view_state.rs +++ b/src/view_state.rs @@ -1,11 +1,13 @@ use std::collections::HashMap; +use crate::deck_state::DeckState; use crate::login_manager::AcquireKeyState; /// Various state for views #[derive(Default)] pub struct ViewState { pub login: AcquireKeyState, + pub id_to_deck_state: HashMap, pub id_state_map: HashMap, pub id_string_map: HashMap, }