commit d81243f05538efcf42d307f14f336275fdc2bb69 parent a6d91c43e46a22db155ae0f7d5eff346676dc1fa Author: kernelkind <kernelkind@gmail.com> Date: Thu, 25 Sep 2025 13:33:52 -0400 prop drag id through responses instead of manual wiring Signed-off-by: kernelkind <kernelkind@gmail.com> Diffstat:
16 files changed, 395 insertions(+), 202 deletions(-)
diff --git a/crates/notedeck_columns/src/accounts/mod.rs b/crates/notedeck_columns/src/accounts/mod.rs @@ -7,6 +7,7 @@ use notedeck_ui::nip51_set::Nip51SetUiCache; pub use crate::accounts::route::AccountsResponse; use crate::app::get_active_columns_mut; use crate::decks::DecksCache; +use crate::nav::BodyResponse; use crate::onboarding::Onboarding; use crate::profile::send_new_contact_list; use crate::subscriptions::Subscriptions; @@ -81,7 +82,7 @@ pub fn render_accounts_route( onboarding: &mut Onboarding, follow_packs_ui: &mut Nip51SetUiCache, route: AccountsRoute, -) -> Option<AccountsResponse> { +) -> BodyResponse<AccountsResponse> { match route { AccountsRoute::Accounts => AccountsView::new( app_ctx.ndb, @@ -90,15 +91,15 @@ pub fn render_accounts_route( app_ctx.i18n, ) .ui(ui) - .inner - .map(AccountsRouteResponse::Accounts) - .map(AccountsResponse::Account), + .map_output(AccountsRouteResponse::Accounts) + .map_output(AccountsResponse::Account), AccountsRoute::AddAccount => { - AccountLoginView::new(login_state, app_ctx.clipboard, app_ctx.i18n) + let action = AccountLoginView::new(login_state, app_ctx.clipboard, app_ctx.i18n) .ui(ui) .inner .map(AccountsRouteResponse::AddAccount) - .map(AccountsResponse::Account) + .map(AccountsResponse::Account); + BodyResponse::output(action) } AccountsRoute::Onboarding => FollowPackOnboardingView::new( onboarding, @@ -110,7 +111,7 @@ pub fn render_accounts_route( jobs, ) .ui(ui) - .map(|r| match r { + .map_output(|r| match r { OnboardingResponse::FollowPacks(follow_packs_response) => { AccountsResponse::Account(AccountsRouteResponse::AddAccount( AccountLoginResponse::Onboarding(follow_packs_response), diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -33,6 +33,7 @@ use crate::{ Damus, }; +use egui::scroll_area::ScrollAreaOutput; use egui_nav::{Nav, NavAction, NavResponse, NavUiType, Percent, PopupResponse, PopupSheet}; use enostr::ProfileState; use nostrdb::{Filter, Ndb, Transaction}; @@ -532,7 +533,7 @@ fn render_nav_body( depth: usize, col: usize, inner_rect: egui::Rect, -) -> Option<RenderNavAction> { +) -> BodyResponse<RenderNavAction> { let mut note_context = NoteContext { ndb: ctx.ndb, accounts: ctx.accounts, @@ -555,7 +556,7 @@ fn render_nav_body( .is_some_and(|ind| ind == col) && app.options.contains(AppOptions::ScrollToTop); - let nav_action = render_timeline_route( + let resp = render_timeline_route( &mut app.timeline_cache, kind, col, @@ -574,7 +575,7 @@ fn render_nav_body( app.options.remove(AppOptions::ScrollToTop); } - nav_action + resp } Route::Thread(selection) => render_thread_route( &mut app.threads, @@ -585,8 +586,8 @@ fn render_nav_body( &mut note_context, &mut app.jobs, ), - Route::Accounts(amr) => 's: { - let Some(action) = render_accounts_route( + Route::Accounts(amr) => { + let resp = render_accounts_route( ui, ctx, &mut app.jobs, @@ -594,11 +595,9 @@ fn render_nav_body( &mut app.onboarding, &mut app.view_state.follow_packs, *amr, - ) else { - break 's None; - }; + ); - match action { + resp.map_output_maybe(|action| match action { AccountsResponse::ViewProfile(pubkey) => { Some(RenderNavAction::NoteAction(NoteAction::Profile(pubkey))) } @@ -611,11 +610,11 @@ fn render_nav_body( .accounts_action .map(|f| RenderNavAction::SwitchingAction(SwitchingAction::Accounts(f))) } - } + }) } Route::Relays => RelayView::new(ctx.pool, &mut app.view_state.id_string_map, ctx.i18n) .ui(ui) - .map(RenderNavAction::RelayAction), + .map_output(RenderNavAction::RelayAction), Route::Settings => SettingsView::new( ctx.settings.get_settings_mut(), @@ -624,7 +623,7 @@ fn render_nav_body( &mut app.jobs, ) .ui(ui) - .map(RenderNavAction::SettingsAction), + .map_output(RenderNavAction::SettingsAction), Route::Reply(id) => { let txn = if let Ok(txn) = Transaction::new(ctx.ndb) { @@ -635,7 +634,7 @@ fn render_nav_body( "Reply to unknown note", "Error message when reply note cannot be found" )); - return None; + return BodyResponse::none(); }; let note = if let Ok(note) = ctx.ndb.get_note_by_id(&txn, id.bytes()) { @@ -646,18 +645,20 @@ fn render_nav_body( "Reply to unknown note", "Error message when reply note cannot be found" )); - return None; + return BodyResponse::none(); }; - let poster = ctx.accounts.selected_filled()?; + let Some(poster) = ctx.accounts.selected_filled() else { + return BodyResponse::none(); + }; - let action = { + let resp = { let draft = app.drafts.reply_mut(note.id()); let mut options = app.note_options; options.set(NoteOptions::Wide, false); - let response = ui::PostReplyView::new( + ui::PostReplyView::new( &mut note_context, poster, draft, @@ -667,12 +668,10 @@ fn render_nav_body( &mut app.jobs, col, ) - .show(ui); - - response.action + .show(ui) }; - action.map(Into::into) + resp.map_output_maybe(|o| Some(o.action?.into())) } Route::Quote(id) => { let txn = Transaction::new(ctx.ndb).expect("txn"); @@ -685,10 +684,13 @@ fn render_nav_body( "Quote of unknown note", "Error message when quote note cannot be found" )); - return None; + return BodyResponse::none(); + }; + + let Some(poster) = ctx.accounts.selected_filled() else { + return BodyResponse::none(); }; - let poster = ctx.accounts.selected_filled()?; let draft = app.drafts.quote_mut(note.id()); let response = crate::ui::note::QuoteRepostView::new( @@ -703,10 +705,12 @@ fn render_nav_body( ) .show(ui); - response.action.map(Into::into) + response.map_output_maybe(|o| Some(o.action?.into())) } Route::ComposeNote => { - let kp = ctx.accounts.get_selected_account().key.to_full()?; + let Some(kp) = ctx.accounts.get_selected_account().key.to_full() else { + return BodyResponse::none(); + }; let draft = app.drafts.compose_mut(); let txn = Transaction::new(ctx.ndb).expect("txn"); @@ -721,16 +725,16 @@ fn render_nav_body( ) .ui(&txn, ui); - post_response.action.map(Into::into) + post_response.map_output_maybe(|o| Some(o.action?.into())) } Route::AddColumn(route) => { render_add_column_routes(ui, app, ctx, col, route); - None + BodyResponse::none() } Route::Support => { SupportView::new(&mut app.support, ctx.i18n).show(ui); - None + BodyResponse::none() } Route::Search => { let id = ui.id().with(("search", depth, col)); @@ -759,7 +763,7 @@ fn render_nav_body( &mut app.jobs, ) .show(ui) - .map(RenderNavAction::NoteAction) + .map_output(RenderNavAction::NoteAction) } Route::NewDeck => { let id = ui.id().with("new-deck"); @@ -784,7 +788,8 @@ fn render_nav_body( .get_selected_router() .go_back(); } - resp + + BodyResponse::output(resp) } Route::EditDeck(index) => { let mut action = None; @@ -816,31 +821,37 @@ fn render_nav_body( .go_back(); } - action + BodyResponse::output(action) } Route::EditProfile(pubkey) => { - let mut action = None; let Some(kp) = ctx.accounts.get_full(pubkey) else { error!("Pubkey in EditProfile route did not have an nsec attached in Accounts"); - return None; + return BodyResponse::none(); }; let Some(state) = app.view_state.pubkey_to_profile_state.get_mut(kp.pubkey) else { tracing::error!( "No profile state when navigating to EditProfile... was handle_navigating_edit_profile not called?" ); - return action; + return BodyResponse::none(); }; - if EditProfileView::new(ctx.i18n, state, ctx.img_cache, ctx.clipboard).ui(ui) { - if let Some(state) = app.view_state.pubkey_to_profile_state.get(kp.pubkey) { - action = Some(RenderNavAction::ProfileAction(ProfileAction::SaveChanges( - SaveProfileChanges::new(kp.to_full(), state.clone()), - ))) - } - } - - action + EditProfileView::new(ctx.i18n, state, ctx.img_cache, ctx.clipboard) + .ui(ui) + .map_output_maybe(|save| { + if save { + app.view_state + .pubkey_to_profile_state + .get(kp.pubkey) + .map(|state| { + RenderNavAction::ProfileAction(ProfileAction::SaveChanges( + SaveProfileChanges::new(kp.to_full(), state.clone()), + )) + }) + } else { + None + } + }) } Route::Wallet(wallet_type) => { let state = match wallet_type { @@ -887,23 +898,24 @@ fn render_nav_body( } }; - WalletView::new(state, ctx.i18n, ctx.clipboard) - .ui(ui) - .map(RenderNavAction::WalletAction) + BodyResponse::output(WalletView::new(state, ctx.i18n, ctx.clipboard).ui(ui)) + .map_output(RenderNavAction::WalletAction) } Route::CustomizeZapAmount(target) => { let txn = Transaction::new(ctx.ndb).expect("txn"); let default_msats = get_current_default_msats(ctx.accounts, ctx.global_wallet); - CustomZapView::new( - ctx.i18n, - ctx.img_cache, - ctx.ndb, - &txn, - &target.zap_recipient, - default_msats, + BodyResponse::output( + CustomZapView::new( + ctx.i18n, + ctx.img_cache, + ctx.ndb, + &txn, + &target.zap_recipient, + default_msats, + ) + .ui(ui), ) - .ui(ui) - .map(|msats| { + .map_output(|msats| { get_active_columns_mut(ctx.i18n, ctx.accounts, &mut app.decks_cache) .column_mut(col) .router_mut() @@ -919,6 +931,87 @@ fn render_nav_body( } } +pub struct BodyResponse<R> { + pub drag_id: Option<egui::Id>, // the id which was used for dragging. + pub output: Option<R>, +} + +impl<R> BodyResponse<R> { + pub fn none() -> Self { + Self { + drag_id: None, + output: None, + } + } + + pub fn scroll(output: ScrollAreaOutput<Option<R>>) -> Self { + Self { + drag_id: Some(Self::scroll_output_to_drag_id(output.id)), + output: output.inner, + } + } + + pub fn set_scroll_id(&mut self, output: &ScrollAreaOutput<Option<R>>) { + self.drag_id = Some(Self::scroll_output_to_drag_id(output.id)); + } + + pub fn output(output: Option<R>) -> Self { + Self { + drag_id: None, + output, + } + } + + pub fn set_output(&mut self, output: R) { + self.output = Some(output); + } + + /// The id of an `egui::ScrollAreaOutput` + /// Should use `Self::scroll` when possible + pub fn scroll_raw(mut self, id: egui::Id) -> Self { + self.drag_id = Some(Self::scroll_output_to_drag_id(id)); + self + } + + /// The id which is directly used for dragging + pub fn set_drag_id_raw(&mut self, id: egui::Id) { + self.drag_id = Some(id); + } + + fn scroll_output_to_drag_id(id: egui::Id) -> egui::Id { + id.with("area") + } + + pub fn map_output<S>(self, f: impl FnOnce(R) -> S) -> BodyResponse<S> { + BodyResponse { + drag_id: self.drag_id, + output: self.output.map(f), + } + } + + pub fn map_output_maybe<S>(self, f: impl FnOnce(R) -> Option<S>) -> BodyResponse<S> { + BodyResponse { + drag_id: self.drag_id, + output: self.output.and_then(f), + } + } + + pub fn maybe_map_output<S>(self, f: impl FnOnce(Option<R>) -> S) -> BodyResponse<S> { + BodyResponse { + drag_id: self.drag_id, + output: Some(f(self.output)), + } + } + + /// insert the contents of the new BodyResponse if they are empty in Self + pub fn insert(&mut self, body: BodyResponse<R>) { + self.drag_id = self.drag_id.or(body.drag_id); + if self.output.is_none() { + self.output = body.output; + } + } +} + #[must_use = "RenderNavResponse must be handled by calling .process_render_nav_response(..)"] pub fn render_nav( col: usize, @@ -967,7 +1060,9 @@ pub fn render_nav( .show_move_button(!narrow) .show_delete_button(!narrow) .show(ui), - NavUiType::Body => render_nav_body(ui, app, ctx, route, 1, col, inner_rect), + NavUiType::Body => { + render_nav_body(ui, app, ctx, route, 1, col, inner_rect).output + } }); return RenderNavResponse::new(col, NotedeckNavResponse::Popup(Box::new(resp))); @@ -1047,8 +1142,9 @@ pub fn render_nav( if let Some(top) = nav.routes().last() { render_nav_body(ui, app, ctx, top, nav.routes().len(), col, inner_rect) } else { - None + BodyResponse::none() } + .output } }); diff --git a/crates/notedeck_columns/src/timeline/route.rs b/crates/notedeck_columns/src/timeline/route.rs @@ -1,5 +1,5 @@ use crate::{ - nav::RenderNavAction, + nav::{BodyResponse, RenderNavAction}, profile::ProfileAction, timeline::{thread::Threads, ThreadSelection, TimelineCache, TimelineKind}, ui::{self, ProfileView}, @@ -20,7 +20,7 @@ pub fn render_timeline_route( note_context: &mut NoteContext, jobs: &mut JobsCache, scroll_to_top: bool, -) -> Option<RenderNavAction> { +) -> BodyResponse<RenderNavAction> { match kind { TimelineKind::List(_) | TimelineKind::Search(_) @@ -29,11 +29,11 @@ pub fn render_timeline_route( | TimelineKind::Universe | TimelineKind::Hashtag(_) | TimelineKind::Generic(_) => { - let note_action = + let resp = ui::TimelineView::new(kind, timeline_cache, note_context, note_options, jobs, col) .ui(ui); - note_action.map(RenderNavAction::NoteAction) + resp.map_output(RenderNavAction::NoteAction) } TimelineKind::Profile(pubkey) => { @@ -49,7 +49,7 @@ pub fn render_timeline_route( ) } else { // we render profiles like timelines if they are at the root - let note_action = ui::TimelineView::new( + let resp = ui::TimelineView::new( kind, timeline_cache, note_context, @@ -60,7 +60,7 @@ pub fn render_timeline_route( .scroll_to_top(scroll_to_top) .ui(ui); - note_action.map(RenderNavAction::NoteAction) + resp.map_output(RenderNavAction::NoteAction) } } } @@ -75,7 +75,7 @@ pub fn render_thread_route( ui: &mut egui::Ui, note_context: &mut NoteContext, jobs: &mut JobsCache, -) -> Option<RenderNavAction> { +) -> BodyResponse<RenderNavAction> { // don't truncate thread notes for now, since they are // default truncated everywher eelse note_options.set(NoteOptions::Truncate, false); @@ -92,7 +92,7 @@ pub fn render_thread_route( col, ) .ui(ui) - .map(Into::into) + .map_output(RenderNavAction::NoteAction) } #[allow(clippy::too_many_arguments)] @@ -104,7 +104,7 @@ pub fn render_profile_route( note_options: NoteOptions, note_context: &mut NoteContext, jobs: &mut JobsCache, -) -> Option<RenderNavAction> { +) -> BodyResponse<RenderNavAction> { let profile_view = ProfileView::new( pubkey, col, @@ -115,23 +115,19 @@ pub fn render_profile_route( ) .ui(ui); - if let Some(action) = profile_view { - match action { - ui::profile::ProfileViewAction::EditProfile => note_context - .accounts - .get_full(pubkey) - .map(|kp| RenderNavAction::ProfileAction(ProfileAction::Edit(kp.to_full()))), - ui::profile::ProfileViewAction::Note(note_action) => { - Some(RenderNavAction::NoteAction(note_action)) - } - ui::profile::ProfileViewAction::Follow(target_key) => Some( - RenderNavAction::ProfileAction(ProfileAction::Follow(target_key)), - ), - ui::profile::ProfileViewAction::Unfollow(target_key) => Some( - RenderNavAction::ProfileAction(ProfileAction::Unfollow(target_key)), - ), + profile_view.map_output_maybe(|action| match action { + ui::profile::ProfileViewAction::EditProfile => note_context + .accounts + .get_full(pubkey) + .map(|kp| RenderNavAction::ProfileAction(ProfileAction::Edit(kp.to_full()))), + ui::profile::ProfileViewAction::Note(note_action) => { + Some(RenderNavAction::NoteAction(note_action)) } - } else { - None - } + ui::profile::ProfileViewAction::Follow(target_key) => Some(RenderNavAction::ProfileAction( + ProfileAction::Follow(target_key), + )), + ui::profile::ProfileViewAction::Unfollow(target_key) => Some( + RenderNavAction::ProfileAction(ProfileAction::Unfollow(target_key)), + ), + }) } diff --git a/crates/notedeck_columns/src/ui/accounts.rs b/crates/notedeck_columns/src/ui/accounts.rs @@ -9,6 +9,8 @@ use notedeck_ui::profile::preview::SimpleProfilePreview; use notedeck_ui::app_images; +use crate::nav::BodyResponse; + pub struct AccountsView<'a> { ndb: &'a Ndb, accounts: &'a Accounts, @@ -44,20 +46,26 @@ impl<'a> AccountsView<'a> { } } - pub fn ui(&mut self, ui: &mut Ui) -> InnerResponse<Option<AccountsViewResponse>> { + pub fn ui(&mut self, ui: &mut Ui) -> BodyResponse<AccountsViewResponse> { + let mut out = BodyResponse::none(); Frame::new().outer_margin(12.0).show(ui, |ui| { if let Some(resp) = Self::top_section_buttons_widget(ui, self.i18n).inner { - return Some(resp); + out.set_output(resp); } ui.add_space(8.0); - scroll_area() + let scroll_out = scroll_area() .id_salt(AccountsView::scroll_id()) .show(ui, |ui| { Self::show_accounts(ui, self.accounts, self.ndb, self.img_cache, self.i18n) - }) - .inner - }) + }); + + out.set_scroll_id(&scroll_out); + if let Some(scroll_output) = scroll_out.inner { + out.set_output(scroll_output); + } + }); + out } pub fn scroll_id() -> egui::Id { diff --git a/crates/notedeck_columns/src/ui/mentions_picker.rs b/crates/notedeck_columns/src/ui/mentions_picker.rs @@ -11,6 +11,8 @@ use notedeck_ui::{ }; use tracing::error; +use crate::nav::BodyResponse; + /// Displays user profiles for the user to pick from. /// Useful for manually typing a username and selecting the profile desired pub struct MentionPickerView<'a> { @@ -64,7 +66,11 @@ impl<'a> MentionPickerView<'a> { MentionPickerResponse::SelectResult(selection) } - pub fn show_in_rect(&mut self, rect: egui::Rect, ui: &mut egui::Ui) -> MentionPickerResponse { + pub fn show_in_rect( + &mut self, + rect: egui::Rect, + ui: &mut egui::Ui, + ) -> BodyResponse<MentionPickerResponse> { let widget_id = ui.id().with("mention_results"); let area_resp = egui::Area::new(widget_id) .order(egui::Order::Foreground) @@ -102,14 +108,16 @@ impl<'a> MentionPickerView<'a> { let scroll_resp = ScrollArea::vertical() .max_width(rect.width()) .auto_shrink(Vec2b::FALSE) - .show(ui, |ui| self.show(ui, width)); + .show(ui, |ui| Some(self.show(ui, width))); ui.advance_cursor_after_rect(rect); - if close_button_resp { - MentionPickerResponse::DeleteMention - } else { - scroll_resp.inner - } + BodyResponse::scroll(scroll_resp).map_output(|o| { + if close_button_resp { + MentionPickerResponse::DeleteMention + } else { + o + } + }) }) .inner }); diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -1,5 +1,6 @@ use crate::draft::{Draft, Drafts, MentionHint}; use crate::media_upload::nostrbuild_nip96_upload; +use crate::nav::BodyResponse; use crate::post::{downcast_post_buffer, MentionType, NewPost}; use crate::ui::mentions_picker::MentionPickerView; use crate::ui::{self, Preview, PreviewConfig}; @@ -143,7 +144,7 @@ impl<'a, 'd> PostView<'a, 'd> { self } - fn editbox(&mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui) -> egui::Response { + fn editbox(&mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui) -> EditBoxResponse { ui.spacing_mut().item_spacing.x = 12.0; let pfp_size = 24.0; @@ -221,37 +222,42 @@ impl<'a, 'd> PostView<'a, 'd> { self.draft.buffer.selected_mention = false; } - if let Some(cursor_index) = get_cursor_index(&out.state.cursor.char_range()) { - self.show_mention_hints(txn, ui, cursor_index, &out); - } + let mention_hints_drag_id = + if let Some(cursor_index) = get_cursor_index(&out.state.cursor.char_range()) { + self.show_mention_hints(txn, ui, cursor_index, &out) + } else { + None + }; let focused = out.response.has_focus(); ui.ctx() .data_mut(|d| d.insert_temp(PostView::id(), focused)); - out.response + EditBoxResponse { + resp: out.response, + mention_hints_drag_id, + } } // Displays the mention picker and handles when one is selected. + // returns the drag id of the mention hint widget fn show_mention_hints( &mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui, cursor_index: usize, textedit_output: &TextEditOutput, - ) { - let Some(mention) = self.draft.buffer.get_mention(cursor_index) else { - return; - }; + ) -> Option<egui::Id> { + let mention = self.draft.buffer.get_mention(cursor_index)?; if mention.info.mention_type != MentionType::Pending { - return; + return None; } if ui.ctx().input(|r| r.key_pressed(egui::Key::Escape)) { self.draft.buffer.delete_mention(mention.index); - return; + return None; } let mention_str = self.draft.buffer.get_mention_string(&mention); @@ -274,10 +280,8 @@ impl<'a, 'd> PostView<'a, 'd> { } let hint_rect = { - let hint = if let Some(hint) = &self.draft.cur_mention_hint { - hint - } else { - return; + let Some(hint) = &self.draft.cur_mention_hint else { + return None; }; let mut hint_rect = self.inner_rect; @@ -285,9 +289,11 @@ impl<'a, 'd> PostView<'a, 'd> { hint_rect }; - let Ok(res) = self.note_context.ndb.search_profile(txn, mention_str, 10) else { - return; - }; + let res = self + .note_context + .ndb + .search_profile(txn, mention_str, 10) + .ok()?; let resp = MentionPickerView::new( self.note_context.img_cache, @@ -298,7 +304,12 @@ impl<'a, 'd> PostView<'a, 'd> { .show_in_rect(hint_rect, ui); let mut selection_made = None; - match resp { + + let Some(out) = resp.output else { + return resp.drag_id; + }; + + match out { ui::mentions_picker::MentionPickerResponse::SelectResult(selection) => { if let Some(hint_index) = selection { if let Some(pk) = res.get(hint_index) { @@ -326,6 +337,8 @@ impl<'a, 'd> PostView<'a, 'd> { if let Some(selection) = selection_made { selection.process(ui.ctx(), textedit_output); } + + resp.drag_id } fn focused(&self, ui: &egui::Ui) -> bool { @@ -341,14 +354,25 @@ impl<'a, 'd> PostView<'a, 'd> { 12 } - pub fn ui(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> PostResponse { - ScrollArea::vertical() + pub fn ui(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> BodyResponse<PostResponse> { + let scroll_out = ScrollArea::vertical() .id_salt(PostView::scroll_id()) - .show(ui, |ui| self.ui_no_scroll(txn, ui)) - .inner + .show(ui, |ui| Some(self.ui_no_scroll(txn, ui))); + + let scroll_id = scroll_out.id; + if let Some(inner) = scroll_out.inner { + inner // should override the PostView scroll for the mention scroll + } else { + BodyResponse::none() + } + .scroll_raw(scroll_id) } - pub fn ui_no_scroll(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> PostResponse { + pub fn ui_no_scroll( + &mut self, + txn: &Transaction, + ui: &mut egui::Ui, + ) -> BodyResponse<PostResponse> { while let Some(selected_file) = get_next_selected_file() { match selected_file { Ok(selected_media) => { @@ -393,7 +417,7 @@ impl<'a, 'd> PostView<'a, 'd> { .inner } - fn input_ui(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> PostResponse { + fn input_ui(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> BodyResponse<PostResponse> { let edit_response = ui.horizontal(|ui| self.editbox(txn, ui)).inner; let note_response = if let PostType::Quote(id) = self.post_type { @@ -445,10 +469,14 @@ impl<'a, 'd> PostView<'a, 'd> { .and_then(|nr| nr.action.map(PostAction::QuotedNoteAction)) .or(post_action.map(PostAction::NewPostAction)); - PostResponse { - action, - edit_response, + let mut resp = BodyResponse::output(action); + if let Some(drag_id) = edit_response.mention_hints_drag_id { + resp.set_drag_id_raw(drag_id); } + resp.maybe_map_output(|action| PostResponse { + action, + edit_response: edit_response.resp, + }) } fn input_buttons(&mut self, ui: &mut egui::Ui) -> Option<NewPostAction> { @@ -596,6 +624,11 @@ impl<'a, 'd> PostView<'a, 'd> { } } +struct EditBoxResponse { + resp: egui::Response, + mention_hints_drag_id: Option<egui::Id>, +} + #[allow(clippy::too_many_arguments)] fn render_post_view_media( ui: &mut egui::Ui, diff --git a/crates/notedeck_columns/src/ui/note/quote_repost.rs b/crates/notedeck_columns/src/ui/note/quote_repost.rs @@ -1,6 +1,7 @@ use super::{PostResponse, PostType}; use crate::{ draft::Draft, + nav::BodyResponse, ui::{self}, }; @@ -52,14 +53,22 @@ impl<'a, 'd> QuoteRepostView<'a, 'd> { QuoteRepostView::id(col, note_id).with("scroll") } - pub fn show(&mut self, ui: &mut egui::Ui) -> PostResponse { - ScrollArea::vertical() + pub fn show(&mut self, ui: &mut egui::Ui) -> BodyResponse<PostResponse> { + let scroll_out = ScrollArea::vertical() .id_salt(self.scroll_id) - .show(ui, |ui| self.show_internal(ui)) - .inner + .show(ui, |ui| Some(self.show_internal(ui))); + + let scroll_id = scroll_out.id; + + if let Some(inner) = scroll_out.inner { + inner + } else { + BodyResponse::none() + } + .scroll_raw(scroll_id) } - fn show_internal(&mut self, ui: &mut egui::Ui) -> PostResponse { + fn show_internal(&mut self, ui: &mut egui::Ui) -> BodyResponse<PostResponse> { let quoting_note_id = self.quoting_note.id(); let post_resp = ui::PostView::new( diff --git a/crates/notedeck_columns/src/ui/note/reply.rs b/crates/notedeck_columns/src/ui/note/reply.rs @@ -1,4 +1,5 @@ use crate::draft::Draft; +use crate::nav::BodyResponse; use crate::ui::{ self, note::{PostAction, PostResponse, PostType}, @@ -52,16 +53,23 @@ impl<'a, 'd> PostReplyView<'a, 'd> { PostReplyView::id(col, note_id).with("scroll") } - pub fn show(&mut self, ui: &mut egui::Ui) -> PostResponse { - ScrollArea::vertical() + pub fn show(&mut self, ui: &mut egui::Ui) -> BodyResponse<PostResponse> { + let scroll_out = ScrollArea::vertical() .id_salt(self.scroll_id) .stick_to_bottom(true) - .show(ui, |ui| self.show_internal(ui)) - .inner + .show(ui, |ui| Some(self.show_internal(ui))); + + let scroll_id = scroll_out.id; + if let Some(inner) = scroll_out.inner { + inner + } else { + BodyResponse::none() + } + .scroll_raw(scroll_id) } // no scroll - fn show_internal(&mut self, ui: &mut egui::Ui) -> PostResponse { + fn show_internal(&mut self, ui: &mut egui::Ui) -> BodyResponse<PostResponse> { ui.vertical(|ui| { let avail_rect = ui.available_rect_before_wrap(); @@ -103,17 +111,22 @@ impl<'a, 'd> PostReplyView<'a, 'd> { .ui_no_scroll(self.note.txn().unwrap(), ui) }; - post_response.action = post_response - .action - .or(quoted_note.action.map(PostAction::QuotedNoteAction)); - - reply_line_ui( - &rect_before_post, - &post_response.edit_response, - pfp_offset as f32, - &avail_rect, - ui, - ); + post_response = post_response.map_output(|mut o| { + o.action = o + .action + .or(quoted_note.action.map(PostAction::QuotedNoteAction)); + o + }); + + if let Some(p) = &post_response.output { + reply_line_ui( + &rect_before_post, + &p.edit_response, + pfp_offset as f32, + &avail_rect, + ui, + ); + } // // NOTE(jb55): We add some space so that you can scroll to diff --git a/crates/notedeck_columns/src/ui/onboarding.rs b/crates/notedeck_columns/src/ui/onboarding.rs @@ -8,7 +8,7 @@ use notedeck_ui::{ nip51_set::{Nip51SetUiCache, Nip51SetWidget, Nip51SetWidgetAction, Nip51SetWidgetFlags}, }; -use crate::{onboarding::Onboarding, ui::widgets::styled_button}; +use crate::{nav::BodyResponse, onboarding::Onboarding, ui::widgets::styled_button}; /// Display Follow Packs for the user to choose from authors trusted by the Damus team pub struct FollowPackOnboardingView<'a> { @@ -56,17 +56,17 @@ impl<'a> FollowPackOnboardingView<'a> { egui::Id::new("follow_pack_onboarding") } - pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<OnboardingResponse> { + pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<OnboardingResponse> { let Some(follow_pack_state) = self.onboarding.get_follow_packs() else { - return Some(OnboardingResponse::FollowPacks( + return BodyResponse::output(Some(OnboardingResponse::FollowPacks( FollowPacksResponse::NoFollowPacks, - )); + ))); }; let max_height = ui.available_height() - 48.0; let mut action = None; - ScrollArea::vertical() + let scroll_out = ScrollArea::vertical() .id_salt(Self::scroll_id()) .max_height(max_height) .show(ui, |ui| { @@ -114,6 +114,6 @@ impl<'a> FollowPackOnboardingView<'a> { } }); - action + BodyResponse::output(action).scroll_raw(scroll_out.id) } } diff --git a/crates/notedeck_columns/src/ui/profile/edit.rs b/crates/notedeck_columns/src/ui/profile/edit.rs @@ -7,6 +7,8 @@ use notedeck::{profile::unwrap_profile_url, tr, Images, Localization, NotedeckTe use notedeck_ui::context_menu::{input_context, PasteBehavior}; use notedeck_ui::{profile::banner, ProfilePic}; +use crate::nav::BodyResponse; + pub struct EditProfileView<'a> { state: &'a mut ProfileState, clipboard: &'a mut Clipboard, @@ -34,8 +36,8 @@ impl<'a> EditProfileView<'a> { } // return true to save - pub fn ui(&mut self, ui: &mut egui::Ui) -> bool { - ScrollArea::vertical() + pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<bool> { + let scroll_out = ScrollArea::vertical() .id_salt(EditProfileView::scroll_id()) .stick_to_bottom(true) .show(ui, |ui| { @@ -71,9 +73,9 @@ impl<'a> EditProfileView<'a> { }); }); - save - }) - .inner + Some(save) + }); + BodyResponse::scroll(scroll_out) } fn inner(&mut self, ui: &mut egui::Ui, padding: f32) { diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -10,6 +10,7 @@ use robius_open::Uri; use tracing::error; use crate::{ + nav::BodyResponse, timeline::{TimelineCache, TimelineKind}, ui::timeline::{tabs_ui, TimelineTabView}, }; @@ -68,13 +69,16 @@ impl<'a, 'd> ProfileView<'a, 'd> { egui::Id::new(("profile_scroll", col_id, profile_pubkey)) } - pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<ProfileViewAction> { + pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<ProfileViewAction> { let scroll_id = ProfileView::scroll_id(self.col_id, self.pubkey); let scroll_area = ScrollArea::vertical().id_salt(scroll_id).animated(false); - let profile_timeline = self + let Some(profile_timeline) = self .timeline_cache - .get_mut(&TimelineKind::Profile(*self.pubkey))?; + .get_mut(&TimelineKind::Profile(*self.pubkey)) + else { + return BodyResponse::none(); + }; let output = scroll_area.show(ui, |ui| { let mut action = None; @@ -132,7 +136,7 @@ impl<'a, 'd> ProfileView<'a, 'd> { // only allow front insert when the profile body is fully obstructed profile_timeline.enable_front_insert = output.inner.body_end_pos < ui.clip_rect().top(); - output.inner.action + BodyResponse::output(output.inner.action).scroll_raw(output.id) } } diff --git a/crates/notedeck_columns/src/ui/relay.rs b/crates/notedeck_columns/src/ui/relay.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use crate::nav::BodyResponse; use crate::ui::{Preview, PreviewConfig}; use egui::{Align, Button, CornerRadius, Frame, Id, Layout, Margin, Rgba, RichText, Ui, Vec2}; use enostr::{RelayPool, RelayStatus}; @@ -17,9 +18,8 @@ pub struct RelayView<'a> { } impl RelayView<'_> { - pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<RelayAction> { - let mut action = None; - Frame::new() + pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<RelayAction> { + let scroll_out = Frame::new() .inner_margin(Margin::symmetric(10, 0)) .show(ui, |ui| { ui.add_space(24.0); @@ -40,6 +40,7 @@ impl RelayView<'_> { .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) .auto_shrink([false; 2]) .show(ui, |ui| { + let mut action = None; if let Some(relay_to_remove) = self.show_relays(ui) { action = Some(RelayAction::Remove(relay_to_remove)); } @@ -47,10 +48,12 @@ impl RelayView<'_> { if let Some(relay_to_add) = self.show_add_relay_ui(ui) { action = Some(RelayAction::Add(relay_to_add)); } - }); - }); + action + }) + }) + .inner; - action + BodyResponse::scroll(scroll_out) } pub fn scroll_id() -> egui::Id { diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs @@ -3,6 +3,7 @@ use enostr::{NoteId, Pubkey}; use state::TypingType; use crate::{ + nav::BodyResponse, timeline::{TimelineTab, TimelineUnits}, ui::timeline::TimelineTabView, }; @@ -49,11 +50,11 @@ impl<'a, 'd> SearchView<'a, 'd> { } } - pub fn show(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> { + pub fn show(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> { padding(8.0, ui, |ui| self.show_impl(ui)).inner } - pub fn show_impl(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> { + pub fn show_impl(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> { ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0); let search_resp = search_box( @@ -67,7 +68,7 @@ impl<'a, 'd> SearchView<'a, 'd> { search_resp.process(self.query); let mut search_action = None; - let mut note_action = None; + let mut body_resp = BodyResponse::none(); match &self.query.state { SearchState::New | SearchState::Navigating => {} SearchState::Typing(TypingType::Mention(mention_name)) => 's: { @@ -87,7 +88,11 @@ impl<'a, 'd> SearchView<'a, 'd> { ) .show_in_rect(ui.available_rect_before_wrap(), ui); - search_action = match search_res { + let Some(res) = search_res.output else { + break 's; + }; + + search_action = match res { MentionPickerResponse::SelectResult(Some(index)) => { let Some(pk_bytes) = results.get(index) else { break 's; @@ -120,7 +125,7 @@ impl<'a, 'd> SearchView<'a, 'd> { &mut self.query.notes, ); search_action = Some(SearchAction::Searched); - note_action = self.show_search_results(ui); + body_resp.insert(self.show_search_results(ui)); } SearchState::Searched => { ui.label(tr_plural!( @@ -131,7 +136,7 @@ impl<'a, 'd> SearchView<'a, 'd> { self.query.notes.units.len(), // count query = &self.query.string )); - note_action = self.show_search_results(ui); + body_resp.insert(self.show_search_results(ui)); } SearchState::Typing(TypingType::AutoSearch) => { ui.label(tr!( @@ -141,7 +146,7 @@ impl<'a, 'd> SearchView<'a, 'd> { query = &self.query.string )); - note_action = self.show_search_results(ui); + body_resp.insert(self.show_search_results(ui)); } }; @@ -149,11 +154,11 @@ impl<'a, 'd> SearchView<'a, 'd> { resp.process(self.query); } - note_action + body_resp } - fn show_search_results(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> { - egui::ScrollArea::vertical() + fn show_search_results(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> { + let scroll_out = egui::ScrollArea::vertical() .id_salt(SearchView::scroll_id()) .show(ui, |ui| { TimelineTabView::new( @@ -164,8 +169,9 @@ impl<'a, 'd> SearchView<'a, 'd> { self.jobs, ) .show(ui) - }) - .inner + }); + + BodyResponse::scroll(scroll_out) } pub fn scroll_id() -> egui::Id { diff --git a/crates/notedeck_columns/src/ui/settings.rs b/crates/notedeck_columns/src/ui/settings.rs @@ -16,7 +16,11 @@ use notedeck_ui::{ AnimationHelper, NoteOptions, NoteView, }; -use crate::{nav::RouterAction, ui::account_login_view::eye_button, Damus, Route}; +use crate::{ + nav::{BodyResponse, RouterAction}, + ui::account_login_view::eye_button, + Damus, Route, +}; const PREVIEW_NOTE_ID: &str = "note1edjc8ggj07hwv77g2405uh6j2jkk5aud22gktxrvc2wnre4vdwgqzlv2gw"; @@ -638,13 +642,12 @@ impl<'a> SettingsView<'a> { action } - pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { - let mut action: Option<SettingsAction> = None; - - Frame::default() + pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<SettingsAction> { + let scroll_out = Frame::default() .inner_margin(Margin::symmetric(10, 10)) .show(ui, |ui| { ScrollArea::vertical().show(ui, |ui| { + let mut action = None; if let Some(new_action) = self.appearance_section(ui) { action = Some(new_action); } @@ -670,10 +673,12 @@ impl<'a> SettingsView<'a> { if let Some(new_action) = self.manage_relays_section(ui) { action = Some(new_action); } - }); - }); + action + }) + }) + .inner; - action + BodyResponse::scroll(scroll_out) } } diff --git a/crates/notedeck_columns/src/ui/thread.rs b/crates/notedeck_columns/src/ui/thread.rs @@ -7,6 +7,7 @@ use notedeck::{NoteAction, NoteContext}; use notedeck_ui::note::NoteResponse; use notedeck_ui::{NoteOptions, NoteView}; +use crate::nav::BodyResponse; use crate::timeline::thread::{NoteSeenFlags, ParentState, Threads}; pub struct ThreadView<'a, 'd> { @@ -42,7 +43,7 @@ impl<'a, 'd> ThreadView<'a, 'd> { egui::Id::new(("threadscroll", selected_note_id, col)) } - pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> { + pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> { let txn = Transaction::new(self.note_context.ndb).expect("txn"); let scroll_id = ThreadView::scroll_id(self.selected_note_id, self.col); @@ -60,6 +61,7 @@ impl<'a, 'd> ThreadView<'a, 'd> { let output = scroll_area.show(ui, |ui| self.notes(ui, &txn)); + let out_id = output.id; let mut resp = output.inner; if let Some(NoteAction::Note { @@ -71,7 +73,7 @@ impl<'a, 'd> ThreadView<'a, 'd> { *scroll_offset = output.state.offset.y; } - resp + BodyResponse::output(resp).scroll_raw(out_id) } fn notes(&mut self, ui: &mut egui::Ui, txn: &Transaction) -> Option<NoteAction> { diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs @@ -12,6 +12,7 @@ use notedeck_ui::{ProfilePic, ProfilePreview}; use std::f32::consts::PI; use tracing::{error, warn}; +use crate::nav::BodyResponse; use crate::timeline::{ CompositeType, CompositeUnit, NoteUnit, ReactionUnit, RepostUnit, TimelineCache, TimelineKind, TimelineTab, ViewFilter, @@ -56,7 +57,7 @@ impl<'a, 'd> TimelineView<'a, 'd> { } } - pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> { + pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> { timeline_ui( ui, self.timeline_id, @@ -94,7 +95,7 @@ fn timeline_ui( jobs: &mut JobsCache, col: usize, scroll_to_top: bool, -) -> Option<NoteAction> { +) -> BodyResponse<NoteAction> { //padding(4.0, ui, |ui| ui.heading("Notifications")); /* let font_id = egui::TextStyle::Body.resolve(ui.style()); @@ -102,7 +103,9 @@ fn timeline_ui( */ - let scroll_id = TimelineView::scroll_id(timeline_cache, timeline_id, col)?; + let Some(scroll_id) = TimelineView::scroll_id(timeline_cache, timeline_id, col) else { + return BodyResponse::none(); + }; { let timeline = if let Some(timeline) = timeline_cache.get_mut(timeline_id) { @@ -111,7 +114,7 @@ fn timeline_ui( error!("tried to render timeline in column, but timeline was missing"); // TODO (jb55): render error when timeline is missing? // this shouldn't happen... - return None; + return BodyResponse::none(); }; timeline.selected_view = tabs_ui( @@ -204,7 +207,9 @@ fn timeline_ui( .data_mut(|d| d.insert_temp(show_top_button_id, true)); } - scroll_output.inner.or_else(|| { + let scroll_id = scroll_output.id; + + let action = scroll_output.inner.or_else(|| { // if we're scrolling, return that as a response. We need this // for auto-closing the side menu @@ -215,7 +220,9 @@ fn timeline_ui( } else { None } - }) + }); + + BodyResponse::output(action).scroll_raw(scroll_id) } fn goto_top_button(center: Pos2) -> impl egui::Widget {