From c1c4c1cc7a2d53ddfec8d9f7214b52a9fbde94b0 Mon Sep 17 00:00:00 2001 From: kernelkind Date: Sun, 2 Feb 2025 16:52:10 -0500 Subject: [PATCH 01/10] supply inner_rect for PostView Signed-off-by: kernelkind --- crates/notedeck_columns/src/app.rs | 18 +++++++++++------- crates/notedeck_columns/src/nav.rs | 7 ++++++- crates/notedeck_columns/src/ui/note/post.rs | 4 ++++ .../src/ui/note/quote_repost.rs | 4 ++++ crates/notedeck_columns/src/ui/note/reply.rs | 4 ++++ 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs index 8e0bc852..8f97210d 100644 --- a/crates/notedeck_columns/src/app.rs +++ b/crates/notedeck_columns/src/app.rs @@ -500,7 +500,8 @@ fn render_damus_mobile(app: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut e //let routes = app.timelines[0].routes.clone(); if !app.columns(app_ctx.accounts).columns().is_empty() - && nav::render_nav(0, app, app_ctx, ui).process_render_nav_response(app, app_ctx) + && nav::render_nav(0, ui.available_rect_before_wrap(), app, app_ctx, ui) + .process_render_nav_response(app, app_ctx) && !app.tmp_columns { storage::save_decks_cache(app_ctx.path, &app.decks_cache); @@ -584,14 +585,17 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, ctx: &mut App 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, ctx, ui)); + let v_line_stroke = ui.visuals().widgets.noninteractive.bg_stroke; + let inner_rect = { + let mut inner = rect; + inner.set_right(rect.right() - v_line_stroke.width); + inner + }; + responses.push(nav::render_nav(col_index, inner_rect, app, ctx, ui)); // vertical line - ui.painter().vline( - rect.right(), - rect.y_range(), - ui.visuals().widgets.noninteractive.bg_stroke, - ); + ui.painter() + .vline(rect.right(), rect.y_range(), v_line_stroke); }); //strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind)); diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs index 8b82ca08..3f496a72 100644 --- a/crates/notedeck_columns/src/nav.rs +++ b/crates/notedeck_columns/src/nav.rs @@ -241,6 +241,7 @@ fn render_nav_body( top: &Route, depth: usize, col: usize, + inner_rect: egui::Rect, ) -> Option { match top { Route::Timeline(kind) => render_timeline_route( @@ -308,6 +309,7 @@ fn render_nav_body( ctx.note_cache, ctx.img_cache, ¬e, + inner_rect, ) .id_source(id) .show(ui) @@ -342,6 +344,7 @@ fn render_nav_body( ctx.img_cache, draft, ¬e, + inner_rect, ) .id_source(id) .show(ui) @@ -362,6 +365,7 @@ fn render_nav_body( ctx.img_cache, ctx.note_cache, kp, + inner_rect, ) .ui(&txn, ui); @@ -473,6 +477,7 @@ fn render_nav_body( #[must_use = "RenderNavResponse must be handled by calling .process_render_nav_response(..)"] pub fn render_nav( col: usize, + inner_rect: egui::Rect, app: &mut Damus, ctx: &mut AppContext<'_>, ui: &mut egui::Ui, @@ -508,7 +513,7 @@ pub fn render_nav( .show(ui), NavUiType::Body => { if let Some(top) = nav.routes().last() { - render_nav_body(ui, app, ctx, top, nav.routes().len(), col) + render_nav_body(ui, app, ctx, top, nav.routes().len(), col, inner_rect) } else { None } diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs index 020677b1..1053b298 100644 --- a/crates/notedeck_columns/src/ui/note/post.rs +++ b/crates/notedeck_columns/src/ui/note/post.rs @@ -22,6 +22,7 @@ pub struct PostView<'a> { note_cache: &'a mut NoteCache, poster: FilledKeypair<'a>, id_source: Option, + inner_rect: egui::Rect, } #[derive(Clone)] @@ -84,6 +85,7 @@ impl<'a> PostView<'a> { img_cache: &'a mut ImageCache, note_cache: &'a mut NoteCache, poster: FilledKeypair<'a>, + inner_rect: egui::Rect, ) -> Self { let id_source: Option = None; PostView { @@ -94,6 +96,7 @@ impl<'a> PostView<'a> { poster, id_source, post_type, + inner_rect, } } @@ -535,6 +538,7 @@ mod preview { app.img_cache, app.note_cache, self.poster.to_filled(), + ui.available_rect_before_wrap(), ) .ui(&txn, ui); } diff --git a/crates/notedeck_columns/src/ui/note/quote_repost.rs b/crates/notedeck_columns/src/ui/note/quote_repost.rs index 1bb671d8..f5b4baf1 100644 --- a/crates/notedeck_columns/src/ui/note/quote_repost.rs +++ b/crates/notedeck_columns/src/ui/note/quote_repost.rs @@ -14,6 +14,7 @@ pub struct QuoteRepostView<'a> { draft: &'a mut Draft, quoting_note: &'a nostrdb::Note<'a>, id_source: Option, + inner_rect: egui::Rect, } impl<'a> QuoteRepostView<'a> { @@ -24,6 +25,7 @@ impl<'a> QuoteRepostView<'a> { img_cache: &'a mut ImageCache, draft: &'a mut Draft, quoting_note: &'a nostrdb::Note<'a>, + inner_rect: egui::Rect, ) -> Self { let id_source: Option = None; QuoteRepostView { @@ -34,6 +36,7 @@ impl<'a> QuoteRepostView<'a> { draft, quoting_note, id_source, + inner_rect, } } @@ -48,6 +51,7 @@ impl<'a> QuoteRepostView<'a> { self.img_cache, self.note_cache, self.poster, + self.inner_rect, ) .id_source(id) .ui(self.quoting_note.txn().unwrap(), ui) diff --git a/crates/notedeck_columns/src/ui/note/reply.rs b/crates/notedeck_columns/src/ui/note/reply.rs index 48600287..a6280bd6 100644 --- a/crates/notedeck_columns/src/ui/note/reply.rs +++ b/crates/notedeck_columns/src/ui/note/reply.rs @@ -14,6 +14,7 @@ pub struct PostReplyView<'a> { draft: &'a mut Draft, note: &'a nostrdb::Note<'a>, id_source: Option, + inner_rect: egui::Rect, } impl<'a> PostReplyView<'a> { @@ -24,6 +25,7 @@ impl<'a> PostReplyView<'a> { note_cache: &'a mut NoteCache, img_cache: &'a mut ImageCache, note: &'a nostrdb::Note<'a>, + inner_rect: egui::Rect, ) -> Self { let id_source: Option = None; PostReplyView { @@ -34,6 +36,7 @@ impl<'a> PostReplyView<'a> { note_cache, img_cache, id_source, + inner_rect, } } @@ -83,6 +86,7 @@ impl<'a> PostReplyView<'a> { self.img_cache, self.note_cache, self.poster, + self.inner_rect, ) .id_source(id) .ui(self.note.txn().unwrap(), ui) From c375146658a7aefe049e8c93a4312c0253ec7ecd Mon Sep 17 00:00:00 2001 From: kernelkind Date: Sun, 2 Feb 2025 17:03:26 -0500 Subject: [PATCH 02/10] add SearchResultsView impl Signed-off-by: kernelkind --- crates/notedeck_columns/src/ui/mod.rs | 1 + .../notedeck_columns/src/ui/search_results.rs | 135 ++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 crates/notedeck_columns/src/ui/search_results.rs diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs index dc228566..2735beda 100644 --- a/crates/notedeck_columns/src/ui/mod.rs +++ b/crates/notedeck_columns/src/ui/mod.rs @@ -11,6 +11,7 @@ pub mod preview; pub mod profile; pub mod relay; pub mod relay_debug; +pub mod search_results; pub mod side_panel; pub mod support; pub mod thread; diff --git a/crates/notedeck_columns/src/ui/search_results.rs b/crates/notedeck_columns/src/ui/search_results.rs new file mode 100644 index 00000000..5c70c2bf --- /dev/null +++ b/crates/notedeck_columns/src/ui/search_results.rs @@ -0,0 +1,135 @@ +use egui::{vec2, FontId, Pos2, Rect, ScrollArea, Vec2b}; +use nostrdb::{Ndb, ProfileRecord, Transaction}; +use notedeck::{fonts::get_font_size, ImageCache, NotedeckTextStyle}; +use tracing::error; + +use crate::{ + profile::get_display_name, + ui::anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, +}; + +use super::{profile::get_profile_url, ProfilePic}; + +pub struct SearchResultsView<'a> { + ndb: &'a Ndb, + txn: &'a Transaction, + img_cache: &'a mut ImageCache, + results: &'a Vec<&'a [u8; 32]>, +} + +impl<'a> SearchResultsView<'a> { + pub fn new( + img_cache: &'a mut ImageCache, + ndb: &'a Ndb, + txn: &'a Transaction, + results: &'a Vec<&'a [u8; 32]>, + ) -> Self { + Self { + ndb, + txn, + img_cache, + results, + } + } + + fn show(&mut self, ui: &mut egui::Ui, width: f32) -> Option { + let mut selection = None; + ui.vertical(|ui| { + for (i, res) in self.results.iter().enumerate() { + let profile = match self.ndb.get_profile_by_pubkey(self.txn, res) { + Ok(rec) => rec, + Err(e) => { + error!("Error fetching profile for pubkey {:?}: {e}", res); + return; + } + }; + + if ui + .add(user_result(&profile, self.img_cache, i, width)) + .clicked() + { + selection = Some(i) + } + } + }); + + selection + } + + pub fn show_in_rect(&mut self, rect: egui::Rect, ui: &mut egui::Ui) -> Option { + let widget_id = ui.id().with("search_results"); + let area_resp = egui::Area::new(widget_id) + .order(egui::Order::Foreground) + .fixed_pos(rect.left_top()) + .constrain_to(rect) + .show(ui.ctx(), |ui| { + egui::Frame::none() + .fill(ui.visuals().panel_fill) + .show(ui, |ui| { + let width = rect.width(); + let scroll_resp = ScrollArea::vertical() + .max_width(width) + .auto_shrink(Vec2b::FALSE) + .show(ui, |ui| self.show(ui, width)); + ui.advance_cursor_after_rect(rect); + scroll_resp.inner + }) + .inner + }); + + area_resp.inner + } +} + +fn user_result<'a>( + profile: &'a ProfileRecord<'_>, + cache: &'a mut ImageCache, + index: usize, + width: f32, +) -> impl egui::Widget + use<'a> { + move |ui: &mut egui::Ui| -> egui::Response { + let min_img_size = 48.0; + let max_image = min_img_size * ICON_EXPANSION_MULTIPLE; + let spacing = 8.0; + let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body); + + let helper = AnimationHelper::new(ui, ("user_result", index), vec2(width, max_image)); + + let icon_rect = { + let r = helper.get_animation_rect(); + let mut center = r.center(); + center.x = r.left() + (max_image / 2.0); + let size = helper.scale_1d_pos(min_img_size); + Rect::from_center_size(center, vec2(size, size)) + }; + + let pfp_resp = ui.put( + icon_rect, + ProfilePic::new(cache, get_profile_url(Some(profile))) + .size(helper.scale_1d_pos(min_img_size)), + ); + + let name_font = FontId::new( + helper.scale_1d_pos(body_font_size), + NotedeckTextStyle::Body.font_family(), + ); + let painter = ui.painter_at(helper.get_animation_rect()); + let name_galley = painter.layout( + get_display_name(Some(profile)).name().to_owned(), + name_font, + ui.visuals().text_color(), + width, + ); + + let galley_pos = { + let right_top = pfp_resp.rect.right_top(); + let galley_pos_y = pfp_resp.rect.center().y - (name_galley.rect.height() / 2.0); + Pos2::new(right_top.x + spacing, galley_pos_y) + }; + + painter.galley(galley_pos, name_galley, ui.visuals().text_color()); + ui.advance_cursor_after_rect(helper.get_animation_rect()); + + pfp_resp.union(helper.take_animation_response()) + } +} From 48933c24881aeafa9828eeb8923d7e6a78a46a5e Mon Sep 17 00:00:00 2001 From: kernelkind Date: Wed, 5 Feb 2025 15:59:53 -0500 Subject: [PATCH 03/10] use dev dep pretty assertions Signed-off-by: kernelkind --- Cargo.lock | 25 ++++++++++++++++++++++++- crates/notedeck_columns/Cargo.toml | 1 + 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index bdc4813a..efa187be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1077,6 +1077,12 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -2816,6 +2822,7 @@ dependencies = [ "notedeck", "open", "poll-promise", + "pretty_assertions", "puffin 0.19.1 (git+https://github.com/jb55/puffin?rev=70ff86d5503815219b01a009afd3669b7903a057)", "puffin_egui", "rfd", @@ -2961,7 +2968,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 3.2.0", "proc-macro2", "quote", "syn 2.0.90", @@ -3457,6 +3464,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.25" @@ -5735,6 +5752,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.7.5" diff --git a/crates/notedeck_columns/Cargo.toml b/crates/notedeck_columns/Cargo.toml index 6a9fe900..99f04906 100644 --- a/crates/notedeck_columns/Cargo.toml +++ b/crates/notedeck_columns/Cargo.toml @@ -52,6 +52,7 @@ rfd = "0.15" [dev-dependencies] tempfile = { workspace = true } +pretty_assertions = "1.4.1" [target.'cfg(target_os = "macos")'.dependencies] security-framework = "2.11.0" From e7ada80876d4e69b5659e1865d6018e690236741 Mon Sep 17 00:00:00 2001 From: kernelkind Date: Sun, 2 Feb 2025 17:43:14 -0500 Subject: [PATCH 04/10] mentions logic Signed-off-by: kernelkind --- crates/notedeck_columns/src/post.rs | 817 +++++++++++++++++++++++++++- 1 file changed, 815 insertions(+), 2 deletions(-) diff --git a/crates/notedeck_columns/src/post.rs b/crates/notedeck_columns/src/post.rs index 51a03311..840052bd 100644 --- a/crates/notedeck_columns/src/post.rs +++ b/crates/notedeck_columns/src/post.rs @@ -1,6 +1,11 @@ -use enostr::FullKeypair; +use egui::TextBuffer; +use enostr::{FullKeypair, Pubkey}; use nostrdb::{Note, NoteBuilder, NoteReply}; -use std::collections::HashSet; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + ops::Range, +}; +use tracing::error; use crate::media_upload::Nip94Event; @@ -203,9 +208,450 @@ fn add_imeta_tags<'a>(builder: NoteBuilder<'a>, media: &Vec) -> Note builder } +type MentionKey = usize; + +#[derive(Debug, Clone)] +pub struct PostBuffer { + pub text_buffer: String, + pub mention_indicator: char, + pub mentions: HashMap, + mentions_key: MentionKey, + + // the start index of a mention is inclusive + pub mention_starts: BTreeMap, // maps the mention start index with the correct `MentionKey` + + // the end index of a mention is exclusive + pub mention_ends: BTreeMap, // maps the mention end index with the correct `MentionKey` +} + +impl Default for PostBuffer { + fn default() -> Self { + Self { + mention_indicator: '@', + mentions_key: 0, + text_buffer: Default::default(), + mentions: Default::default(), + mention_starts: Default::default(), + mention_ends: Default::default(), + } + } +} + +impl PostBuffer { + pub fn get_new_mentions_key(&mut self) -> usize { + let prev = self.mentions_key; + self.mentions_key += 1; + prev + } + + pub fn get_mention(&self, cursor_index: usize) -> Option> { + self.mention_ends + .range(cursor_index..) + .next() + .and_then(|(_, mention_key)| { + self.mentions + .get(mention_key) + .filter(|info| { + if let MentionType::Finalized(_) = info.mention_type { + // should exclude the last character if we're finalized + info.start_index <= cursor_index && cursor_index < info.end_index + } else { + info.start_index <= cursor_index && cursor_index <= info.end_index + } + }) + .map(|info| MentionIndex { + index: *mention_key, + info, + }) + }) + } + + pub fn get_mention_string<'a>(&'a self, mention_key: &MentionIndex<'a>) -> &'a str { + self.text_buffer + .char_range(mention_key.info.start_index + 1..mention_key.info.end_index) + // don't include the delim + } + + pub fn select_full_mention(&mut self, mention_key: usize, pk: Pubkey) { + if let Some(info) = self.mentions.get_mut(&mention_key) { + info.mention_type = MentionType::Finalized(pk); + } else { + error!("Error selecting mention for index: {mention_key}. Have the following mentions: {:?}", self.mentions); + } + } + + pub fn select_mention_and_replace_name( + &mut self, + mention_key: usize, + full_name: &str, + pk: Pubkey, + ) { + if let Some(info) = self.mentions.get(&mention_key) { + let text_start_index = info.start_index + 1; + self.delete_char_range(text_start_index..info.end_index); + self.insert_text(full_name, text_start_index); + self.select_full_mention(mention_key, pk); + } else { + error!("Error selecting mention for index: {mention_key}. Have the following mentions: {:?}", self.mentions); + } + } + + pub fn is_empty(&self) -> bool { + self.text_buffer.is_empty() + } + + pub fn output(&self) -> PostOutput { + let mut out = self.text_buffer.clone(); + let mut mentions = Vec::new(); + for (cur_end_ind, mention_ind) in self.mention_ends.iter().rev() { + if let Some(info) = self.mentions.get(mention_ind) { + if let MentionType::Finalized(pk) = info.mention_type { + if let Some(bech) = pk.to_bech() { + out.replace_range(info.start_index..*cur_end_ind, &format!("nostr:{bech}")); + mentions.push(pk); + } + } + } + } + mentions.reverse(); + + PostOutput { + text: out, + mentions, + } + } +} + +pub struct PostOutput { + pub text: String, + pub mentions: Vec, +} + +#[derive(Debug)] +pub struct MentionIndex<'a> { + pub index: usize, + pub info: &'a MentionInfo, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum MentionType { + Pending, + Finalized(Pubkey), +} + +impl TextBuffer for PostBuffer { + fn is_mutable(&self) -> bool { + true + } + + fn as_str(&self) -> &str { + self.text_buffer.as_str() + } + + fn insert_text(&mut self, text: &str, char_index: usize) -> usize { + if text.is_empty() { + return 0; + } + let text_num_chars = text.chars().count(); + self.text_buffer.insert_text(text, char_index); + + // the text was inserted before or inside these mentions. We need to at least move their ends + let pending_ends_to_update: Vec = self + .mention_ends + .range(char_index..) + .filter(|(k, v)| { + let is_last = **k == char_index; + let is_finalized = if let Some(info) = self.mentions.get(*v) { + matches!(info.mention_type, MentionType::Finalized(_)) + } else { + false + }; + !(is_last && is_finalized) + }) + .map(|(&k, _)| k) + .collect(); + + for cur_end in pending_ends_to_update { + let mention_key = if let Some(mention_key) = self.mention_ends.get(&cur_end) { + *mention_key + } else { + continue; + }; + + self.mention_ends.remove(&cur_end); + + let new_end = cur_end + text_num_chars; + self.mention_ends.insert(new_end, mention_key); + // replaced the current end with the new value + + if let Some(mention_info) = self.mentions.get_mut(&mention_key) { + if mention_info.start_index >= char_index { + // the text is being inserted before this mention. move the start index as well + self.mention_starts.remove(&mention_info.start_index); + let new_start = mention_info.start_index + text_num_chars; + self.mention_starts.insert(new_start, mention_key); + mention_info.start_index = new_start; + } else { + // text is being inserted inside this mention. Make sure it is in the pending state + mention_info.mention_type = MentionType::Pending; + } + + mention_info.end_index = new_end; + } else { + error!("Could not find mention at index {}", mention_key); + } + } + + if first_is_desired_char(text, self.mention_indicator) { + // if a mention already exists where we're inserting the delim, remove it + let to_remove = self.get_mention(char_index).map(|old_mention| { + ( + old_mention.index, + old_mention.info.start_index..old_mention.info.end_index, + ) + }); + + if let Some((key, range)) = to_remove { + self.mention_ends.remove(&range.end); + self.mention_starts.remove(&range.start); + self.mentions.remove(&key); + } + + let start_index = char_index; + let end_index = char_index + text_num_chars; + let mention_key = self.get_new_mentions_key(); + self.mentions.insert( + mention_key, + MentionInfo { + start_index, + end_index, + mention_type: MentionType::Pending, + }, + ); + self.mention_starts.insert(start_index, mention_key); + self.mention_ends.insert(end_index, mention_key); + } + + text_num_chars + } + + fn delete_char_range(&mut self, char_range: Range) { + let deletion_num_chars = char_range.len(); + let Range { + start: deletion_start, + end: deletion_end, + } = char_range; + + self.text_buffer.delete_char_range(char_range); + + // these mentions will be affected by the deletion + let ends_to_update: Vec = self + .mention_ends + .range(deletion_start..) + .map(|(&k, _)| k) + .collect(); + + for cur_mention_end in ends_to_update { + let mention_key = match &self.mention_ends.get(&cur_mention_end) { + Some(ind) => **ind, + None => continue, + }; + let cur_mention_start = match self.mentions.get(&mention_key) { + Some(i) => i.start_index, + None => { + error!("Could not find mention at index {}", mention_key); + continue; + } + }; + + if cur_mention_end <= deletion_start { + // nothing happens to this mention + continue; + } + + let status = if cur_mention_start >= deletion_start { + if cur_mention_start >= deletion_end { + // mention falls after the range + // need to shift both start and end + + DeletionStatus::ShiftStartAndEnd( + cur_mention_start - deletion_num_chars, + cur_mention_end - deletion_num_chars, + ) + } else { + // fully delete mention + + DeletionStatus::FullyRemove + } + } else if cur_mention_end > deletion_end { + // inner partial delete + + DeletionStatus::ShiftEnd(cur_mention_end - deletion_num_chars) + } else { + // outer partial delete + + DeletionStatus::ShiftEnd(deletion_start) + }; + + match status { + DeletionStatus::FullyRemove => { + self.mention_starts.remove(&cur_mention_start); + self.mention_ends.remove(&cur_mention_end); + self.mentions.remove(&mention_key); + } + DeletionStatus::ShiftEnd(new_end) + | DeletionStatus::ShiftStartAndEnd(_, new_end) => { + let mention_info = match self.mentions.get_mut(&mention_key) { + Some(i) => i, + None => { + error!("Could not find mention at index {}", mention_key); + continue; + } + }; + + self.mention_ends.remove(&cur_mention_end); + self.mention_ends.insert(new_end, mention_key); + mention_info.end_index = new_end; + + if let DeletionStatus::ShiftStartAndEnd(new_start, _) = status { + self.mention_starts.remove(&cur_mention_start); + self.mention_starts.insert(new_start, mention_key); + mention_info.start_index = new_start; + } + + if let DeletionStatus::ShiftEnd(_) = status { + mention_info.mention_type = MentionType::Pending; + } + } + } + } + } +} + +fn first_is_desired_char(text: &str, desired: char) -> bool { + if let Some(char) = text.chars().next() { + char == desired + } else { + false + } +} + +#[derive(Debug)] +enum DeletionStatus { + FullyRemove, + ShiftEnd(usize), + ShiftStartAndEnd(usize, usize), +} + +#[derive(Debug, PartialEq, Clone)] +pub struct MentionInfo { + pub start_index: usize, + pub end_index: usize, + pub mention_type: MentionType, +} + #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; + + impl MentionInfo { + pub fn bounds(&self) -> Range { + self.start_index..self.end_index + } + } + + const JB55: fn() -> Pubkey = || { + Pubkey::from_hex("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245") + .unwrap() + }; + const KK: fn() -> Pubkey = || { + Pubkey::from_hex("4a0510f26880d40e432f4865cb5714d9d3c200ca6ebb16b418ae6c555f574967") + .unwrap() + }; + + #[derive(PartialEq, Clone, Debug)] + struct MentionExample { + text: String, + mention1: Option, + mention2: Option, + mention3: Option, + mention4: Option, + } + + fn apply_mention_example(buf: &mut PostBuffer) -> MentionExample { + buf.insert_text("test ", 0); + buf.insert_text("@jb55", 5); + buf.select_full_mention(0, JB55()); + buf.insert_text(" test ", 10); + buf.insert_text("@vrod", 16); + buf.select_full_mention(1, JB55()); + buf.insert_text(" test ", 21); + buf.insert_text("@elsat", 27); + buf.select_full_mention(2, JB55()); + buf.insert_text(" test ", 33); + buf.insert_text("@kernelkind", 39); + buf.select_full_mention(3, KK()); + buf.insert_text(" test", 50); + + let mention1_bounds = 5..10; + let mention2_bounds = 16..21; + let mention3_bounds = 27..33; + let mention4_bounds = 39..50; + + let text = "test @jb55 test @vrod test @elsat test @kernelkind test"; + + assert_eq!(buf.as_str(), text); + assert_eq!(buf.mentions.len(), 4); + + let mention1 = buf.mentions.get(&0).unwrap(); + assert_eq!(mention1.bounds(), mention1_bounds); + assert_eq!(mention1.mention_type, MentionType::Finalized(JB55())); + let mention2 = buf.mentions.get(&1).unwrap(); + assert_eq!(mention2.bounds(), mention2_bounds); + assert_eq!(mention2.mention_type, MentionType::Finalized(JB55())); + let mention3 = buf.mentions.get(&2).unwrap(); + assert_eq!(mention3.bounds(), mention3_bounds); + assert_eq!(mention3.mention_type, MentionType::Finalized(JB55())); + let mention4 = buf.mentions.get(&3).unwrap(); + assert_eq!(mention4.bounds(), mention4_bounds); + assert_eq!(mention4.mention_type, MentionType::Finalized(KK())); + + let text = text.to_owned(); + MentionExample { + text, + mention1: Some(mention1.clone()), + mention2: Some(mention2.clone()), + mention3: Some(mention3.clone()), + mention4: Some(mention4.clone()), + } + } + + impl PostBuffer { + fn to_example(&self) -> MentionExample { + let mention1 = self.mentions.get(&0).cloned(); + let mention2 = self.mentions.get(&1).cloned(); + let mention3 = self.mentions.get(&2).cloned(); + let mention4 = self.mentions.get(&3).cloned(); + + MentionExample { + text: self.text_buffer.clone(), + mention1, + mention2, + mention3, + mention4, + } + } + } + + impl MentionInfo { + fn shifted(mut self, offset: usize) -> Self { + self.end_index -= offset; + self.start_index -= offset; + + self + } + } #[test] fn test_extract_hashtags() { @@ -234,4 +680,371 @@ mod tests { assert_eq!(result, expected, "Failed for input: {}", input); } } + + #[test] + fn test_insert_single_mention() { + let mut buf = PostBuffer::default(); + buf.insert_text("test ", 0); + buf.insert_text("@", 5); + assert!(buf.get_mention(5).is_some()); + buf.insert_text("jb55", 6); + assert_eq!(buf.as_str(), "test @jb55"); + assert_eq!(buf.mentions.len(), 1); + assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 5..10); + + buf.select_full_mention(0, JB55()); + + assert_eq!( + buf.mentions.get(&0).unwrap().mention_type, + MentionType::Finalized(JB55()) + ); + } + + #[test] + fn test_insert_mention_with_space() { + let mut buf = PostBuffer::default(); + buf.insert_text("@", 0); + buf.insert_text("jb", 1); + buf.insert_text("55", 3); + assert!(buf.get_mention(1).is_some()); + assert_eq!(buf.mentions.len(), 1); + assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..5); + buf.insert_text(" test", 5); + assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..10); + assert_eq!(buf.as_str(), "@jb55 test"); + + buf.select_full_mention(0, JB55()); + + assert_eq!( + buf.mentions.get(&0).unwrap().mention_type, + MentionType::Finalized(JB55()) + ); + } + + #[test] + fn test_insert_mention_with_emojis() { + let mut buf = PostBuffer::default(); + buf.insert_text("test ", 0); + buf.insert_text("@test😀 🏴‍☠️ :D", 5); + buf.select_full_mention(0, JB55()); + buf.insert_text(" test", 19); + + assert_eq!(buf.as_str(), "test @test😀 🏴‍☠️ :D test"); + let mention = buf.mentions.get(&0).unwrap(); + assert_eq!( + *mention, + MentionInfo { + start_index: 5, + end_index: 19, + mention_type: MentionType::Finalized(JB55()) + } + ); + } + + #[test] + fn test_insert_partial_to_full() { + let mut buf = PostBuffer::default(); + buf.insert_text("@jb", 0); + assert_eq!(buf.mentions.len(), 1); + assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..3); + buf.select_mention_and_replace_name(0, "jb55", JB55()); + assert_eq!(buf.as_str(), "@jb55"); + + buf.insert_text(" test", 5); + assert_eq!(buf.as_str(), "@jb55 test"); + + assert_eq!(buf.mentions.len(), 1); + let mention = buf.mentions.get(&0).unwrap(); + assert_eq!(mention.bounds(), 0..5); + assert_eq!(mention.mention_type, MentionType::Finalized(JB55())); + } + + #[test] + fn test_insert_mention_after() { + let mut buf = PostBuffer::default(); + buf.insert_text("test text here", 0); + buf.insert_text("@jb55", 4); + + assert!(buf.get_mention(4).is_some()); + assert_eq!(buf.mentions.len(), 1); + assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 4..9); + assert_eq!("test@jb55 text here", buf.as_str()); + + buf.select_full_mention(0, JB55()); + + assert_eq!( + buf.mentions.get(&0).unwrap().mention_type, + MentionType::Finalized(JB55()) + ); + } + + #[test] + fn test_insert_mention_then_text() { + let mut buf = PostBuffer::default(); + + buf.insert_text("@jb55", 0); + buf.select_full_mention(0, JB55()); + + buf.insert_text(" test", 5); + assert_eq!(buf.as_str(), "@jb55 test"); + assert_eq!(buf.mentions.len(), 1); + assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..5); + assert!(buf.get_mention(6).is_none()); + } + + #[test] + fn test_insert_two_mentions() { + let mut buf = PostBuffer::default(); + + buf.insert_text("@jb55", 0); + buf.select_full_mention(0, JB55()); + buf.insert_text(" test ", 5); + buf.insert_text("@kernelkind", 11); + buf.select_full_mention(1, KK()); + buf.insert_text(" test", 22); + + assert_eq!(buf.as_str(), "@jb55 test @kernelkind test"); + assert_eq!(buf.mentions.len(), 2); + assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..5); + assert_eq!(buf.mentions.get(&1).unwrap().bounds(), 11..22); + } + + #[test] + fn test_insert_into_mention() { + let mut buf = PostBuffer::default(); + + buf.insert_text("@jb55", 0); + buf.select_full_mention(0, JB55()); + buf.insert_text(" test", 5); + + assert_eq!(buf.mentions.len(), 1); + let mention = buf.mentions.get(&0).unwrap(); + assert_eq!(mention.bounds(), 0..5); + assert_eq!(mention.mention_type, MentionType::Finalized(JB55())); + + buf.insert_text("oops", 2); + assert_eq!(buf.as_str(), "@joopsb55 test"); + assert_eq!(buf.mentions.len(), 1); + let mention = buf.mentions.get(&0).unwrap(); + assert_eq!(mention.bounds(), 0..9); + assert_eq!(mention.mention_type, MentionType::Pending); + } + + #[test] + fn test_insert_mention_inside_mention() { + let mut buf = PostBuffer::default(); + + buf.insert_text("@jb55", 0); + buf.select_full_mention(0, JB55()); + buf.insert_text(" test", 5); + + assert_eq!(buf.mentions.len(), 1); + let mention = buf.mentions.get(&0).unwrap(); + assert_eq!(mention.bounds(), 0..5); + assert_eq!(mention.mention_type, MentionType::Finalized(JB55())); + + buf.insert_text("@oops", 3); + assert_eq!(buf.as_str(), "@jb@oops55 test"); + assert_eq!(buf.mentions.len(), 1); + assert_eq!(buf.mention_ends.len(), 1); + assert_eq!(buf.mention_starts.len(), 1); + let mention = buf.mentions.get(&1).unwrap(); + assert_eq!(mention.bounds(), 3..8); + assert_eq!(mention.mention_type, MentionType::Pending); + } + + #[test] + fn test_delete_before_mention() { + let mut buf = PostBuffer::default(); + let before = apply_mention_example(&mut buf); + + let range = 1..5; + let len = range.len(); + buf.delete_char_range(range); + + assert_eq!( + MentionExample { + text: "t@jb55 test @vrod test @elsat test @kernelkind test".to_owned(), + mention1: Some(before.mention1.clone().unwrap().shifted(len)), + mention2: Some(before.mention2.clone().unwrap().shifted(len)), + mention3: Some(before.mention3.clone().unwrap().shifted(len)), + mention4: Some(before.mention4.clone().unwrap().shifted(len)), + }, + buf.to_example(), + ); + } + + #[test] + fn test_delete_after_mention() { + let mut buf = PostBuffer::default(); + let before = apply_mention_example(&mut buf); + + let range = 11..16; + let len = range.len(); + buf.delete_char_range(range); + + assert_eq!( + MentionExample { + text: "test @jb55 @vrod test @elsat test @kernelkind test".to_owned(), + mention2: Some(before.mention2.clone().unwrap().shifted(len)), + mention3: Some(before.mention3.clone().unwrap().shifted(len)), + mention4: Some(before.mention4.clone().unwrap().shifted(len)), + ..before.clone() + }, + buf.to_example(), + ); + } + + #[test] + fn test_delete_mention_partial_inner() { + let mut buf = PostBuffer::default(); + let before = apply_mention_example(&mut buf); + + let range = 17..20; + let len = range.len(); + buf.delete_char_range(range); + + assert_eq!( + MentionExample { + text: "test @jb55 test @d test @elsat test @kernelkind test".to_owned(), + mention2: Some(MentionInfo { + start_index: 16, + end_index: 18, + mention_type: MentionType::Pending, + }), + mention3: Some(before.mention3.clone().unwrap().shifted(len)), + mention4: Some(before.mention4.clone().unwrap().shifted(len)), + ..before.clone() + }, + buf.to_example(), + ); + } + + #[test] + fn test_delete_mention_partial_outer() { + let mut buf = PostBuffer::default(); + let before = apply_mention_example(&mut buf); + + let range = 17..27; + let len = range.len(); + buf.delete_char_range(range); + + assert_eq!( + MentionExample { + text: "test @jb55 test @@elsat test @kernelkind test".to_owned(), + mention2: Some(MentionInfo { + start_index: 16, + end_index: 17, + mention_type: MentionType::Pending + }), + mention3: Some(before.mention3.clone().unwrap().shifted(len)), + mention4: Some(before.mention4.clone().unwrap().shifted(len)), + ..before.clone() + }, + buf.to_example(), + ); + } + + #[test] + fn test_delete_mention_partial_and_full() { + let mut buf = PostBuffer::default(); + let before = apply_mention_example(&mut buf); + + buf.delete_char_range(17..28); + + assert_eq!( + MentionExample { + text: "test @jb55 test @elsat test @kernelkind test".to_owned(), + mention2: Some(MentionInfo { + end_index: 17, + mention_type: MentionType::Pending, + ..before.mention2.clone().unwrap() + }), + mention3: None, + mention4: Some(MentionInfo { + start_index: 28, + end_index: 39, + ..before.mention4.clone().unwrap() + }), + ..before.clone() + }, + buf.to_example() + ) + } + + #[test] + fn test_delete_mention_full_one() { + let mut buf = PostBuffer::default(); + let before = apply_mention_example(&mut buf); + + let range = 10..26; + let len = range.len(); + buf.delete_char_range(range); + + assert_eq!( + MentionExample { + text: "test @jb55 @elsat test @kernelkind test".to_owned(), + mention2: None, + mention3: Some(before.mention3.clone().unwrap().shifted(len)), + mention4: Some(before.mention4.clone().unwrap().shifted(len)), + ..before.clone() + }, + buf.to_example() + ); + } + + #[test] + fn test_delete_mention_full_two() { + let mut buf = PostBuffer::default(); + let before = apply_mention_example(&mut buf); + + buf.delete_char_range(11..28); + + assert_eq!( + MentionExample { + text: "test @jb55 elsat test @kernelkind test".to_owned(), + mention2: None, + mention3: None, + mention4: Some(MentionInfo { + start_index: 22, + end_index: 33, + ..before.mention4.clone().unwrap() + }), + ..before.clone() + }, + buf.to_example() + ) + } + + #[test] + fn test_two_then_one_between() { + let mut buf = PostBuffer::default(); + + buf.insert_text("@jb", 0); + buf.select_mention_and_replace_name(0, "jb55", JB55()); + buf.insert_text(" test ", 5); + buf.insert_text("@kernel", 11); + buf.select_mention_and_replace_name(1, "KernelKind", KK()); + buf.insert_text(" test", 22); + + assert_eq!(buf.as_str(), "@jb55 test @KernelKind test"); + assert_eq!(buf.mentions.len(), 2); + + buf.insert_text(" ", 5); + buf.insert_text("@els", 6); + assert_eq!(buf.mentions.len(), 3); + assert_eq!(buf.mentions.get(&2).unwrap().bounds(), 6..10); + buf.select_mention_and_replace_name(2, "elsat", JB55()); + assert_eq!(buf.as_str(), "@jb55 @elsat test @KernelKind test"); + + let jb_mention = buf.mentions.get(&0).unwrap(); + let kk_mention = buf.mentions.get(&1).unwrap(); + let el_mention = buf.mentions.get(&2).unwrap(); + assert_eq!(jb_mention.bounds(), 0..5); + assert_eq!(jb_mention.mention_type, MentionType::Finalized(JB55())); + assert_eq!(kk_mention.bounds(), 18..29); + assert_eq!(kk_mention.mention_type, MentionType::Finalized(KK())); + assert_eq!(el_mention.bounds(), 6..12); + assert_eq!(el_mention.mention_type, MentionType::Finalized(JB55())); + } } From c0662798a2667cd887d0c022221dcd6e23837430 Mon Sep 17 00:00:00 2001 From: kernelkind Date: Sun, 2 Feb 2025 17:51:40 -0500 Subject: [PATCH 05/10] add PostView mentions UI Signed-off-by: kernelkind --- crates/notedeck_columns/src/draft.rs | 13 ++- crates/notedeck_columns/src/post.rs | 9 +- crates/notedeck_columns/src/ui/note/post.rs | 123 ++++++++++++++++++-- 3 files changed, 129 insertions(+), 16 deletions(-) diff --git a/crates/notedeck_columns/src/draft.rs b/crates/notedeck_columns/src/draft.rs index 9d667e0b..0bbf0afb 100644 --- a/crates/notedeck_columns/src/draft.rs +++ b/crates/notedeck_columns/src/draft.rs @@ -1,16 +1,23 @@ use poll_promise::Promise; -use crate::{media_upload::Nip94Event, ui::note::PostType, Error}; +use crate::{media_upload::Nip94Event, post::PostBuffer, ui::note::PostType, Error}; use std::collections::HashMap; #[derive(Default)] pub struct Draft { - pub buffer: String, + pub buffer: PostBuffer, + pub cur_mention_hint: Option, pub uploaded_media: Vec, // media uploads to include pub uploading_media: Vec>>, // promises that aren't ready yet pub upload_errors: Vec, // media upload errors to show the user } +pub struct MentionHint { + pub index: usize, + pub pos: egui::Pos2, + pub text: String, +} + #[derive(Default)] pub struct Drafts { replies: HashMap<[u8; 32], Draft>, @@ -46,7 +53,7 @@ impl Draft { } pub fn clear(&mut self) { - self.buffer = "".to_string(); + self.buffer = PostBuffer::default(); self.upload_errors = Vec::new(); self.uploaded_media = Vec::new(); self.uploading_media = Vec::new(); diff --git a/crates/notedeck_columns/src/post.rs b/crates/notedeck_columns/src/post.rs index 840052bd..5965e8a7 100644 --- a/crates/notedeck_columns/src/post.rs +++ b/crates/notedeck_columns/src/post.rs @@ -13,6 +13,7 @@ pub struct NewPost { pub content: String, pub account: FullKeypair, pub media: Vec, + pub mentions: Vec, } fn add_client_tag(builder: NoteBuilder<'_>) -> NoteBuilder<'_> { @@ -23,11 +24,17 @@ fn add_client_tag(builder: NoteBuilder<'_>) -> NoteBuilder<'_> { } impl NewPost { - pub fn new(content: String, account: FullKeypair, media: Vec) -> Self { + pub fn new( + content: String, + account: enostr::FullKeypair, + media: Vec, + mentions: Vec, + ) -> Self { NewPost { content, account, media, + mentions, } } diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs index 1053b298..3df851b0 100644 --- a/crates/notedeck_columns/src/ui/note/post.rs +++ b/crates/notedeck_columns/src/ui/note/post.rs @@ -1,12 +1,16 @@ -use crate::draft::{Draft, Drafts}; +use crate::draft::{Draft, Drafts, MentionHint}; use crate::images::fetch_img; use crate::media_upload::{nostrbuild_nip96_upload, MediaPath}; -use crate::post::NewPost; +use crate::post::{MentionType, NewPost}; +use crate::profile::get_display_name; +use crate::ui::search_results::SearchResultsView; use crate::ui::{self, Preview, PreviewConfig}; use crate::Result; +use egui::text::CCursorRange; +use egui::text_edit::TextEditOutput; use egui::widgets::text_edit::TextEdit; use egui::{vec2, Frame, Layout, Margin, Pos2, ScrollArea, Sense}; -use enostr::{FilledKeypair, FullKeypair, NoteId, RelayPool}; +use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, Transaction}; use notedeck::{ImageCache, NoteCache}; @@ -126,18 +130,85 @@ impl<'a> PostView<'a> { ); } - let response = ui.add_sized( - ui.available_size(), - TextEdit::multiline(&mut self.draft.buffer) - .hint_text(egui::RichText::new("Write a banger note here...").weak()) - .frame(false), - ); + let textedit = TextEdit::multiline(&mut self.draft.buffer) + .hint_text(egui::RichText::new("Write a banger note here...").weak()) + .frame(false) + .desired_width(ui.available_width()); - let focused = response.has_focus(); + let out = textedit.show(ui); + + if let Some(cursor_index) = get_cursor_index(&out.state.cursor.char_range()) { + self.show_mention_hints(txn, ui, cursor_index, &out); + } + + let focused = out.response.has_focus(); ui.ctx().data_mut(|d| d.insert_temp(self.id(), focused)); - response + out.response + } + + fn show_mention_hints( + &mut self, + txn: &nostrdb::Transaction, + ui: &mut egui::Ui, + cursor_index: usize, + textedit_output: &TextEditOutput, + ) { + if let Some(mention) = &self.draft.buffer.get_mention(cursor_index) { + if mention.info.mention_type == MentionType::Pending { + let mention_str = self.draft.buffer.get_mention_string(mention); + + if !mention_str.is_empty() { + if let Some(mention_hint) = &mut self.draft.cur_mention_hint { + if mention_hint.index != mention.index { + mention_hint.index = mention.index; + mention_hint.pos = calculate_mention_hints_pos( + textedit_output, + mention.info.start_index, + ); + } + mention_hint.text = mention_str.to_owned(); + } else { + self.draft.cur_mention_hint = Some(MentionHint { + index: mention.index, + text: mention_str.to_owned(), + pos: calculate_mention_hints_pos( + textedit_output, + mention.info.start_index, + ), + }); + } + } + + if let Some(hint) = &self.draft.cur_mention_hint { + let hint_rect = { + let mut hint_rect = self.inner_rect; + hint_rect.set_top(hint.pos.y); + hint_rect + }; + + if let Ok(res) = self.ndb.search_profile(txn, mention_str, 10) { + let hint_selection = + SearchResultsView::new(self.img_cache, self.ndb, txn, &res) + .show_in_rect(hint_rect, ui); + + if let Some(hint_index) = hint_selection { + if let Some(pk) = res.get(hint_index) { + let record = self.ndb.get_profile_by_pubkey(txn, pk); + + self.draft.buffer.select_mention_and_replace_name( + mention.index, + get_display_name(record.ok().as_ref()).name(), + Pubkey::new(**pk), + ); + self.draft.cur_mention_hint = None; + } + } + } + } + } + } } fn focused(&self, ui: &egui::Ui) -> bool { @@ -237,10 +308,12 @@ impl<'a> PostView<'a> { ) .clicked() { + let output = self.draft.buffer.output(); let new_post = NewPost::new( - self.draft.buffer.clone(), + output.text, self.poster.to_full(), self.draft.uploaded_media.clone(), + output.mentions, ); Some(PostAction::new(self.post_type.clone(), new_post)) } else { @@ -485,6 +558,32 @@ fn show_remove_upload_button(ui: &mut egui::Ui, desired_rect: egui::Rect) -> egu resp } +fn get_cursor_index(cursor: &Option) -> Option { + let range = cursor.as_ref()?; + + if range.primary.index == range.secondary.index { + Some(range.primary.index) + } else { + None + } +} + +fn calculate_mention_hints_pos(out: &TextEditOutput, char_pos: usize) -> egui::Pos2 { + let mut cur_pos = 0; + + for row in &out.galley.rows { + if cur_pos + row.glyphs.len() <= char_pos { + cur_pos += row.glyphs.len(); + } else if let Some(glyph) = row.glyphs.get(char_pos - cur_pos) { + let mut pos = glyph.pos + out.galley_pos.to_vec2(); + pos.y += row.rect.height(); + return pos; + } + } + + out.text_clip_rect.left_bottom() +} + mod preview { use crate::media_upload::Nip94Event; From b9501ad57242c41a275ca0c61c2770e803cf3bee Mon Sep 17 00:00:00 2001 From: kernelkind Date: Sun, 2 Feb 2025 18:59:36 -0500 Subject: [PATCH 06/10] add mention tags to post note Signed-off-by: kernelkind --- crates/notedeck_columns/src/post.rs | 98 +++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/crates/notedeck_columns/src/post.rs b/crates/notedeck_columns/src/post.rs index 5965e8a7..7988964b 100644 --- a/crates/notedeck_columns/src/post.rs +++ b/crates/notedeck_columns/src/post.rs @@ -52,6 +52,10 @@ impl NewPost { builder = add_imeta_tags(builder, &self.media); } + if !self.mentions.is_empty() { + builder = add_mention_tags(builder, &self.mentions); + } + builder.sign(seckey).build().expect("note should be ok") } @@ -125,6 +129,10 @@ impl NewPost { builder = add_imeta_tags(builder, &self.media); } + if !self.mentions.is_empty() { + builder = add_mention_tags(builder, &self.mentions); + } + builder .sign(seckey) .build() @@ -150,6 +158,10 @@ impl NewPost { builder = add_imeta_tags(builder, &self.media); } + if !self.mentions.is_empty() { + builder = add_mention_tags(builder, &self.mentions); + } + builder .start_tag() .tag_str("q") @@ -185,6 +197,16 @@ fn append_urls(content: &mut String, media: &Vec) { } } +fn add_mention_tags<'a>(builder: NoteBuilder<'a>, mentions: &Vec) -> NoteBuilder<'a> { + let mut builder = builder; + + for mention in mentions { + builder = builder.start_tag().tag_str("p").tag_str(&mention.hex()); + } + + builder +} + fn add_imeta_tags<'a>(builder: NoteBuilder<'a>, media: &Vec) -> NoteBuilder<'a> { let mut builder = builder; for item in media { @@ -1054,4 +1076,80 @@ mod tests { assert_eq!(el_mention.bounds(), 6..12); assert_eq!(el_mention.mention_type, MentionType::Finalized(JB55())); } + + #[test] + fn note_single_mention() { + let mut buf = PostBuffer::default(); + buf.insert_text("@jb55", 0); + buf.select_full_mention(0, JB55()); + + let out = buf.output(); + let kp = FullKeypair::generate(); + let post = NewPost::new(out.text, kp.clone(), Vec::new(), out.mentions); + let note = post.to_note(&kp.pubkey); + + let mut tags_iter = note.tags().iter(); + tags_iter.next(); //ignore the first one, the client tag + let tag = tags_iter.next().unwrap(); + assert_eq!(tag.count(), 2); + assert_eq!(tag.get(0).unwrap().str().unwrap(), "p"); + assert_eq!(tag.get(1).unwrap().id().unwrap(), JB55().bytes()); + assert!(tags_iter.next().is_none()); + assert_eq!( + note.content(), + "nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s" + ); + } + + #[test] + fn note_two_mentions() { + let mut buf = PostBuffer::default(); + + buf.insert_text("@jb55", 0); + buf.select_full_mention(0, JB55()); + buf.insert_text(" test ", 5); + buf.insert_text("@KernelKind", 11); + buf.select_full_mention(1, KK()); + buf.insert_text(" test", 22); + assert_eq!(buf.as_str(), "@jb55 test @KernelKind test"); + + let out = buf.output(); + let kp = FullKeypair::generate(); + let post = NewPost::new(out.text, kp.clone(), Vec::new(), out.mentions); + let note = post.to_note(&kp.pubkey); + + let mut tags_iter = note.tags().iter(); + tags_iter.next(); //ignore the first one, the client tag + let jb_tag = tags_iter.next().unwrap(); + assert_eq!(jb_tag.count(), 2); + assert_eq!(jb_tag.get(0).unwrap().str().unwrap(), "p"); + assert_eq!(jb_tag.get(1).unwrap().id().unwrap(), JB55().bytes()); + + let kk_tag = tags_iter.next().unwrap(); + assert_eq!(kk_tag.count(), 2); + assert_eq!(kk_tag.get(0).unwrap().str().unwrap(), "p"); + assert_eq!(kk_tag.get(1).unwrap().id().unwrap(), KK().bytes()); + + assert!(tags_iter.next().is_none()); + + assert_eq!(note.content(), "nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s test nostr:npub1fgz3pungsr2quse0fpjuk4c5m8fuyqx2d6a3ddqc4ek92h6hf9ns0mjeck test"); + } + + #[test] + fn note_one_pending() { + let mut buf = PostBuffer::default(); + + buf.insert_text("test ", 0); + buf.insert_text("@jb55 test", 5); + + let out = buf.output(); + let kp = FullKeypair::generate(); + let post = NewPost::new(out.text, kp.clone(), Vec::new(), out.mentions); + let note = post.to_note(&kp.pubkey); + + let mut tags_iter = note.tags().iter(); + tags_iter.next(); //ignore the first one, the client tag + assert!(tags_iter.next().is_none()); + assert_eq!(note.content(), "test @jb55 test"); + } } From 07c6b27493560c81b925172a769be3229bae4b1d Mon Sep 17 00:00:00 2001 From: kernelkind Date: Mon, 3 Feb 2025 16:29:16 -0500 Subject: [PATCH 07/10] use updated TextEdit::layouter in egui Signed-off-by: kernelkind --- Cargo.lock | 17 ++++++----------- Cargo.toml | 4 ++++ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index efa187be..799736fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1165,8 +1165,7 @@ checksum = "f25c0e292a7ca6d6498557ff1df68f32c99850012b6ea401cf8daf771f22ff53" [[package]] name = "ecolor" version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "775cfde491852059e386c4e1deb4aef381c617dc364184c6f6afee99b87c402b" +source = "git+https://github.com/kernelkind/egui?branch=update_layouter_0.29.1#4b19e72384345078581f940c9cd17dcb2f43d124" dependencies = [ "bytemuck", "emath", @@ -1211,8 +1210,7 @@ dependencies = [ [[package]] name = "egui" version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53eafabcce0cb2325a59a98736efe0bf060585b437763f8c476957fb274bb974" +source = "git+https://github.com/kernelkind/egui?branch=update_layouter_0.29.1#4b19e72384345078581f940c9cd17dcb2f43d124" dependencies = [ "accesskit", "ahash", @@ -1364,8 +1362,7 @@ checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "emath" version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1fe0049ce51d0fb414d029e668dd72eb30bc2b739bf34296ed97bd33df544f3" +source = "git+https://github.com/kernelkind/egui?branch=update_layouter_0.29.1#4b19e72384345078581f940c9cd17dcb2f43d124" dependencies = [ "bytemuck", "serde", @@ -1452,8 +1449,7 @@ dependencies = [ [[package]] name = "epaint" version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a32af8da821bd4f43f2c137e295459ee2e1661d87ca8779dfa0eaf45d870e20f" +source = "git+https://github.com/kernelkind/egui?branch=update_layouter_0.29.1#4b19e72384345078581f940c9cd17dcb2f43d124" dependencies = [ "ab_glyph", "ahash", @@ -1471,8 +1467,7 @@ dependencies = [ [[package]] name = "epaint_default_fonts" version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "483440db0b7993cf77a20314f08311dbe95675092405518c0677aa08c151a3ea" +source = "git+https://github.com/kernelkind/egui?branch=update_layouter_0.29.1#4b19e72384345078581f940c9cd17dcb2f43d124" [[package]] name = "equivalent" @@ -2389,7 +2384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fbbc5881..88e8060c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,10 @@ uuid = { version = "1.10.0", features = ["v4"] } security-framework = "2.11.0" sha2 = "0.10.8" +[patch.crates-io] +egui = { git = "https://github.com/kernelkind/egui", branch = "update_layouter_0.29.1" } +epaint = { git = "https://github.com/kernelkind/egui", branch = "update_layouter_0.29.1" } + [profile.small] inherits = 'release' opt-level = 'z' # Optimize for size From a3e975d133de5204307c3879636adcbb092d0b1b Mon Sep 17 00:00:00 2001 From: kernelkind Date: Mon, 3 Feb 2025 17:29:36 -0500 Subject: [PATCH 08/10] implement TextBuffer -> PostBuffer downcasting Signed-off-by: kernelkind --- crates/notedeck_columns/src/post.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/notedeck_columns/src/post.rs b/crates/notedeck_columns/src/post.rs index 7988964b..42322598 100644 --- a/crates/notedeck_columns/src/post.rs +++ b/crates/notedeck_columns/src/post.rs @@ -2,7 +2,9 @@ use egui::TextBuffer; use enostr::{FullKeypair, Pubkey}; use nostrdb::{Note, NoteBuilder, NoteReply}; use std::{ + any::TypeId, collections::{BTreeMap, HashMap, HashSet}, + hash::{DefaultHasher, Hash, Hasher}, ops::Range, }; use tracing::error; @@ -351,6 +353,18 @@ impl PostBuffer { } } +pub fn downcast_post_buffer(buffer: &dyn TextBuffer) -> Option<&PostBuffer> { + let mut hasher = DefaultHasher::new(); + TypeId::of::().hash(&mut hasher); + let post_id = hasher.finish() as usize; + + if buffer.type_id() == post_id { + unsafe { Some(&*(buffer as *const dyn TextBuffer as *const PostBuffer)) } + } else { + None + } +} + pub struct PostOutput { pub text: String, pub mentions: Vec, @@ -555,6 +569,12 @@ impl TextBuffer for PostBuffer { } } } + + fn type_id(&self) -> usize { + let mut hasher = DefaultHasher::new(); + TypeId::of::().hash(&mut hasher); + hasher.finish() as usize + } } fn first_is_desired_char(text: &str, desired: char) -> bool { From bc8ed2c6428277f38608e8220dc617ebef62e3a2 Mon Sep 17 00:00:00 2001 From: kernelkind Date: Mon, 3 Feb 2025 17:30:01 -0500 Subject: [PATCH 09/10] color mentions in PostView Signed-off-by: kernelkind --- crates/notedeck_columns/src/post.rs | 79 ++++++++++++++++++++- crates/notedeck_columns/src/ui/note/post.rs | 31 ++++++-- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/crates/notedeck_columns/src/post.rs b/crates/notedeck_columns/src/post.rs index 42322598..20f3b6ee 100644 --- a/crates/notedeck_columns/src/post.rs +++ b/crates/notedeck_columns/src/post.rs @@ -1,4 +1,4 @@ -use egui::TextBuffer; +use egui::{text::LayoutJob, TextBuffer, TextFormat}; use enostr::{FullKeypair, Pubkey}; use nostrdb::{Note, NoteBuilder, NoteReply}; use std::{ @@ -351,6 +351,70 @@ impl PostBuffer { mentions, } } + + pub fn to_layout_job(&self, ui: &egui::Ui) -> LayoutJob { + let mut job = LayoutJob::default(); + let colored_fmt = default_text_format_colored(ui, crate::colors::PINK); + + let mut prev_text_char_index = 0; + let mut prev_text_byte_index = 0; + for (start_char_index, mention_ind) in &self.mention_starts { + if let Some(info) = self.mentions.get(mention_ind) { + if matches!(info.mention_type, MentionType::Finalized(_)) { + let end_char_index = info.end_index; + + let char_indices = prev_text_char_index..*start_char_index; + if let Some(byte_indicies) = + char_indices_to_byte(&self.text_buffer, char_indices.clone()) + { + if let Some(prev_text) = self.text_buffer.get(byte_indicies.clone()) { + job.append(prev_text, 0.0, default_text_format(ui)); + prev_text_char_index = *start_char_index; + prev_text_byte_index = byte_indicies.end; + } + } + + let char_indices = *start_char_index..end_char_index; + if let Some(byte_indicies) = + char_indices_to_byte(&self.text_buffer, char_indices.clone()) + { + if let Some(cur_text) = self.text_buffer.get(byte_indicies.clone()) { + job.append(cur_text, 0.0, colored_fmt.clone()); + prev_text_char_index = end_char_index; + prev_text_byte_index = byte_indicies.end; + } + } + } + } + } + + if prev_text_byte_index < self.text_buffer.len() { + if let Some(cur_text) = self.text_buffer.get(prev_text_byte_index..) { + job.append(cur_text, 0.0, default_text_format(ui)); + } else { + error!( + "could not retrieve substring from [{} to {}) in PostBuffer::text_buffer", + prev_text_byte_index, + self.text_buffer.len() + ); + } + } + + job + } +} + +fn char_indices_to_byte(text: &str, char_range: Range) -> Option> { + let mut char_indices = text.char_indices(); + + let start = char_indices.nth(char_range.start)?.0; + let end = if char_range.end < text.chars().count() { + char_indices.nth(char_range.end - char_range.start - 1)?.0 + } else { + text.len() + }; + + Some(start..end) } pub fn downcast_post_buffer(buffer: &dyn TextBuffer) -> Option<&PostBuffer> { @@ -365,6 +429,19 @@ pub fn downcast_post_buffer(buffer: &dyn TextBuffer) -> Option<&PostBuffer> { } } +fn default_text_format(ui: &egui::Ui) -> TextFormat { + default_text_format_colored( + ui, + ui.visuals() + .override_text_color + .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()), + ) +} + +fn default_text_format_colored(ui: &egui::Ui, color: egui::Color32) -> TextFormat { + TextFormat::simple(egui::FontSelection::default().resolve(ui.style()), color) +} + pub struct PostOutput { pub text: String, pub mentions: Vec, diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs index 3df851b0..d57f7c6a 100644 --- a/crates/notedeck_columns/src/ui/note/post.rs +++ b/crates/notedeck_columns/src/ui/note/post.rs @@ -1,15 +1,15 @@ use crate::draft::{Draft, Drafts, MentionHint}; use crate::images::fetch_img; use crate::media_upload::{nostrbuild_nip96_upload, MediaPath}; -use crate::post::{MentionType, NewPost}; +use crate::post::{downcast_post_buffer, MentionType, NewPost}; use crate::profile::get_display_name; use crate::ui::search_results::SearchResultsView; use crate::ui::{self, Preview, PreviewConfig}; use crate::Result; -use egui::text::CCursorRange; +use egui::text::{CCursorRange, LayoutJob}; use egui::text_edit::TextEditOutput; use egui::widgets::text_edit::TextEdit; -use egui::{vec2, Frame, Layout, Margin, Pos2, ScrollArea, Sense}; +use egui::{vec2, Frame, Layout, Margin, Pos2, ScrollArea, Sense, TextBuffer}; use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, Transaction}; @@ -130,10 +130,22 @@ impl<'a> PostView<'a> { ); } + let mut layouter = |ui: &egui::Ui, buf: &dyn TextBuffer, wrap_width: f32| { + let mut layout_job = if let Some(post_buffer) = downcast_post_buffer(buf) { + post_buffer.to_layout_job(ui) + } else { + error!("Failed to get custom mentions layouter"); + text_edit_default_layout(ui, buf.as_str().to_owned(), wrap_width) + }; + layout_job.wrap.max_width = wrap_width; + ui.fonts(|f| f.layout_job(layout_job)) + }; + let textedit = TextEdit::multiline(&mut self.draft.buffer) .hint_text(egui::RichText::new("Write a banger note here...").weak()) .frame(false) - .desired_width(ui.available_width()); + .desired_width(ui.available_width()) + .layouter(&mut layouter); let out = textedit.show(ui); @@ -584,6 +596,17 @@ fn calculate_mention_hints_pos(out: &TextEditOutput, char_pos: usize) -> egui::P out.text_clip_rect.left_bottom() } +fn text_edit_default_layout(ui: &egui::Ui, text: String, wrap_width: f32) -> LayoutJob { + LayoutJob::simple( + text, + egui::FontSelection::default().resolve(ui.style()), + ui.visuals() + .override_text_color + .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()), + wrap_width, + ) +} + mod preview { use crate::media_upload::Nip94Event; From 0e21611645016b560f294386f2f279b2505531f1 Mon Sep 17 00:00:00 2001 From: kernelkind Date: Wed, 5 Feb 2025 16:55:00 -0500 Subject: [PATCH 10/10] cache LayoutJob Signed-off-by: kernelkind --- crates/notedeck_columns/src/draft.rs | 2 ++ crates/notedeck_columns/src/post.rs | 15 ++++++++++++++ crates/notedeck_columns/src/ui/note/post.rs | 23 +++++++++++++++++++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/notedeck_columns/src/draft.rs b/crates/notedeck_columns/src/draft.rs index 0bbf0afb..6ee7aed1 100644 --- a/crates/notedeck_columns/src/draft.rs +++ b/crates/notedeck_columns/src/draft.rs @@ -1,3 +1,4 @@ +use egui::text::LayoutJob; use poll_promise::Promise; use crate::{media_upload::Nip94Event, post::PostBuffer, ui::note::PostType, Error}; @@ -6,6 +7,7 @@ use std::collections::HashMap; #[derive(Default)] pub struct Draft { pub buffer: PostBuffer, + pub cur_layout: Option<(String, LayoutJob)>, // `PostBuffer::text_buffer` to current `LayoutJob` pub cur_mention_hint: Option, pub uploaded_media: Vec, // media uploads to include pub uploading_media: Vec>>, // promises that aren't ready yet diff --git a/crates/notedeck_columns/src/post.rs b/crates/notedeck_columns/src/post.rs index 20f3b6ee..9003644f 100644 --- a/crates/notedeck_columns/src/post.rs +++ b/crates/notedeck_columns/src/post.rs @@ -247,6 +247,7 @@ pub struct PostBuffer { pub mention_indicator: char, pub mentions: HashMap, mentions_key: MentionKey, + pub selected_mention: bool, // the start index of a mention is inclusive pub mention_starts: BTreeMap, // maps the mention start index with the correct `MentionKey` @@ -260,6 +261,7 @@ impl Default for PostBuffer { Self { mention_indicator: '@', mentions_key: 0, + selected_mention: false, text_buffer: Default::default(), mentions: Default::default(), mention_starts: Default::default(), @@ -306,6 +308,7 @@ impl PostBuffer { pub fn select_full_mention(&mut self, mention_key: usize, pk: Pubkey) { if let Some(info) = self.mentions.get_mut(&mention_key) { info.mention_type = MentionType::Finalized(pk); + self.selected_mention = true; } else { error!("Error selecting mention for index: {mention_key}. Have the following mentions: {:?}", self.mentions); } @@ -402,6 +405,18 @@ impl PostBuffer { job } + + pub fn need_new_layout(&self, cache: Option<&(String, LayoutJob)>) -> bool { + if let Some((text, _)) = cache { + if self.selected_mention { + return true; + } + + self.text_buffer != *text + } else { + true + } + } } fn char_indices_to_byte(text: &str, char_range: Range) -> Option> { diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs index d57f7c6a..c5fc5a9e 100644 --- a/crates/notedeck_columns/src/ui/note/post.rs +++ b/crates/notedeck_columns/src/ui/note/post.rs @@ -130,13 +130,28 @@ impl<'a> PostView<'a> { ); } + let mut updated_layout = false; let mut layouter = |ui: &egui::Ui, buf: &dyn TextBuffer, wrap_width: f32| { - let mut layout_job = if let Some(post_buffer) = downcast_post_buffer(buf) { - post_buffer.to_layout_job(ui) + if let Some(post_buffer) = downcast_post_buffer(buf) { + let maybe_job = if post_buffer.need_new_layout(self.draft.cur_layout.as_ref()) { + Some(post_buffer.to_layout_job(ui)) + } else { + None + }; + + if let Some(job) = maybe_job { + self.draft.cur_layout = Some((post_buffer.text_buffer.clone(), job)); + updated_layout = true; + } + }; + + let mut layout_job = if let Some((_, job)) = &self.draft.cur_layout { + job.clone() } else { error!("Failed to get custom mentions layouter"); text_edit_default_layout(ui, buf.as_str().to_owned(), wrap_width) }; + layout_job.wrap.max_width = wrap_width; ui.fonts(|f| f.layout_job(layout_job)) }; @@ -149,6 +164,10 @@ impl<'a> PostView<'a> { let out = textedit.show(ui); + if updated_layout { + 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); }