commit 13d6873eb182474931bef36de3d355e171dbb4f7
parent 2208e68726f723944d01cb17b2e1fa5608728ad7
Author: William Casarin <jb55@jb55.com>
Date: Thu, 26 Sep 2024 12:33:43 -0700
Merge note context menu #328
This merges kernel's note context menu with a bunch of refactorings on
top, closing #328 and #318
William Casarin (7):
refactor: remove processs_note_selection
refactor: make options_button a NoteOptions
note: switch to muted menu_options_button color
context: move note context button to its own file
context: fix hitbox, float on far right
context: set cursor icon on hover
kernelkind (3):
Add 'more options' to each note
can left click note more options button
process 'more options' for previews
Diffstat:
9 files changed, 302 insertions(+), 50 deletions(-)
diff --git a/enostr/src/pubkey.rs b/enostr/src/pubkey.rs
@@ -68,6 +68,10 @@ impl Pubkey {
Ok(Pubkey(data.1.try_into().unwrap()))
}
}
+
+ pub fn to_bech(&self) -> Option<String> {
+ nostr::bech32::encode::<nostr::bech32::Bech32>(HRP_NPUB, &self.0).ok()
+ }
}
impl fmt::Display for Pubkey {
diff --git a/src/notecache.rs b/src/notecache.rs
@@ -35,7 +35,6 @@ impl NoteCache {
pub struct CachedNote {
reltime: TimeCached<String>,
pub reply: NoteReplyBuf,
- pub bar_open: bool,
}
impl CachedNote {
@@ -46,12 +45,7 @@ impl CachedNote {
Box::new(move || time_ago_since(created_at)),
);
let reply = NoteReply::new(note.tags()).to_owned();
- let bar_open = false;
- CachedNote {
- reltime,
- reply,
- bar_open,
- }
+ CachedNote { reltime, reply }
}
pub fn reltime_str_mut(&mut self) -> &str {
diff --git a/src/ui/note/contents.rs b/src/ui/note/contents.rs
@@ -103,12 +103,17 @@ pub fn render_note_preview(
ui.visuals().noninteractive().bg_stroke.color,
))
.show(ui, |ui| {
- ui::NoteView::new(ndb, note_cache, img_cache, ¬e)
+ let resp = ui::NoteView::new(ndb, note_cache, img_cache, ¬e)
.actionbar(false)
.small_pfp(true)
.wide(true)
.note_previews(false)
+ .options_button(true)
.show(ui);
+
+ if let Some(context) = resp.context_selection {
+ context.process(ui, ¬e);
+ }
})
.response
}
diff --git a/src/ui/note/context.rs b/src/ui/note/context.rs
@@ -0,0 +1,177 @@
+use crate::colors;
+use egui::{Rect, Vec2};
+use enostr::{NoteId, Pubkey};
+use nostrdb::{Note, NoteKey};
+
+#[derive(Clone)]
+#[allow(clippy::enum_variant_names)]
+pub enum NoteContextSelection {
+ CopyText,
+ CopyPubkey,
+ CopyNoteId,
+}
+
+impl NoteContextSelection {
+ pub fn process(&self, ui: &mut egui::Ui, note: &Note<'_>) {
+ match self {
+ NoteContextSelection::CopyText => {
+ ui.output_mut(|w| {
+ w.copied_text = note.content().to_string();
+ });
+ }
+ NoteContextSelection::CopyPubkey => {
+ ui.output_mut(|w| {
+ if let Some(bech) = Pubkey::new(*note.pubkey()).to_bech() {
+ w.copied_text = bech;
+ }
+ });
+ }
+ NoteContextSelection::CopyNoteId => {
+ ui.output_mut(|w| {
+ if let Some(bech) = NoteId::new(*note.id()).to_bech() {
+ w.copied_text = bech;
+ }
+ });
+ }
+ }
+ }
+}
+
+pub struct NoteContextButton {
+ put_at: Option<Rect>,
+ note_key: NoteKey,
+}
+
+impl egui::Widget for NoteContextButton {
+ fn ui(self, ui: &mut egui::Ui) -> egui::Response {
+ let r = if let Some(r) = self.put_at {
+ r
+ } else {
+ let mut place = ui.available_rect_before_wrap();
+ let size = Self::max_width();
+ place.set_width(size);
+ place.set_height(size);
+ place
+ };
+
+ Self::show(ui, self.note_key, r)
+ }
+}
+
+impl NoteContextButton {
+ pub fn new(note_key: NoteKey) -> Self {
+ let put_at: Option<Rect> = None;
+ NoteContextButton { note_key, put_at }
+ }
+
+ pub fn place_at(mut self, rect: Rect) -> Self {
+ self.put_at = Some(rect);
+ self
+ }
+
+ pub fn max_width() -> f32 {
+ Self::max_radius() * 3.0 + Self::max_distance_between_circles() * 2.0
+ }
+
+ pub fn size() -> Vec2 {
+ let width = Self::max_width();
+ egui::vec2(width, width)
+ }
+
+ fn max_radius() -> f32 {
+ 8.0
+ }
+
+ fn min_radius() -> f32 {
+ Self::max_radius() / Self::expansion_multiple()
+ }
+
+ fn max_distance_between_circles() -> f32 {
+ 2.0
+ }
+
+ fn expansion_multiple() -> f32 {
+ 2.0
+ }
+
+ fn min_distance_between_circles() -> f32 {
+ Self::max_distance_between_circles() / Self::expansion_multiple()
+ }
+
+ pub fn show(ui: &mut egui::Ui, note_key: NoteKey, put_at: Rect) -> egui::Response {
+ let id = ui.id().with(("more_options_anim", note_key));
+
+ let min_radius = Self::min_radius();
+ let anim_speed = 0.05;
+ let response = ui.interact(put_at, id, egui::Sense::click());
+
+ let hovered = response.hovered();
+ let animation_progress = ui.ctx().animate_bool_with_time(id, hovered, anim_speed);
+
+ if hovered {
+ ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
+ }
+
+ let min_distance = Self::min_distance_between_circles();
+ let cur_distance = min_distance
+ + (Self::max_distance_between_circles() - min_distance) * animation_progress;
+
+ let cur_radius = min_radius + (Self::max_radius() - min_radius) * animation_progress;
+
+ let center = put_at.center();
+ let left_circle_center = center - egui::vec2(cur_distance + cur_radius, 0.0);
+ let right_circle_center = center + egui::vec2(cur_distance + cur_radius, 0.0);
+
+ let translated_radius = (cur_radius - 1.0) / 2.0;
+
+ // This works in both themes
+ let color = colors::GRAY_SECONDARY;
+
+ // Draw circles
+ let painter = ui.painter_at(put_at);
+ painter.circle_filled(left_circle_center, translated_radius, color);
+ painter.circle_filled(center, translated_radius, color);
+ painter.circle_filled(right_circle_center, translated_radius, color);
+
+ response
+ }
+
+ pub fn menu(
+ ui: &mut egui::Ui,
+ button_response: egui::Response,
+ ) -> Option<NoteContextSelection> {
+ let mut context_selection: Option<NoteContextSelection> = None;
+
+ stationary_arbitrary_menu_button(ui, button_response, |ui| {
+ ui.set_max_width(200.0);
+ if ui.button("Copy text").clicked() {
+ context_selection = Some(NoteContextSelection::CopyText);
+ ui.close_menu();
+ }
+ if ui.button("Copy user public key").clicked() {
+ context_selection = Some(NoteContextSelection::CopyPubkey);
+ ui.close_menu();
+ }
+ if ui.button("Copy note id").clicked() {
+ context_selection = Some(NoteContextSelection::CopyNoteId);
+ ui.close_menu();
+ }
+ });
+
+ context_selection
+ }
+}
+
+fn stationary_arbitrary_menu_button<R>(
+ ui: &mut egui::Ui,
+ button_response: egui::Response,
+ add_contents: impl FnOnce(&mut egui::Ui) -> R,
+) -> egui::InnerResponse<Option<R>> {
+ let bar_id = ui.id();
+ let mut bar_state = egui::menu::BarState::load(ui.ctx(), bar_id);
+
+ let inner = bar_state.bar_menu(&button_response, add_contents);
+
+ bar_state.store(ui.ctx(), bar_id);
+ egui::InnerResponse::new(inner.map(|r| r.inner), button_response)
+}
diff --git a/src/ui/note/mod.rs b/src/ui/note/mod.rs
@@ -1,10 +1,12 @@
pub mod contents;
+pub mod context;
pub mod options;
pub mod post;
pub mod quote_repost;
pub mod reply;
pub use contents::NoteContents;
+pub use context::{NoteContextButton, NoteContextSelection};
pub use options::NoteOptions;
pub use post::{PostAction, PostResponse, PostView};
pub use quote_repost::QuoteRepostView;
@@ -18,7 +20,7 @@ use crate::{
notecache::{CachedNote, NoteCache},
ui::{self, View},
};
-use egui::{Id, Label, Response, RichText, Sense};
+use egui::{Id, Label, Pos2, Rect, Response, RichText, Sense};
use enostr::NoteId;
use nostrdb::{Ndb, Note, NoteKey, NoteReply, Transaction};
@@ -35,6 +37,28 @@ pub struct NoteView<'a> {
pub struct NoteResponse {
pub response: egui::Response,
pub action: Option<BarAction>,
+ pub context_selection: Option<NoteContextSelection>,
+}
+
+impl NoteResponse {
+ pub fn new(response: egui::Response) -> Self {
+ Self {
+ response,
+ action: None,
+ context_selection: None,
+ }
+ }
+
+ pub fn with_action(self, action: Option<BarAction>) -> Self {
+ Self { action, ..self }
+ }
+
+ pub fn select_option(self, context_selection: Option<NoteContextSelection>) -> Self {
+ Self {
+ context_selection,
+ ..self
+ }
+ }
}
impl<'a> View for NoteView<'a> {
@@ -215,6 +239,11 @@ impl<'a> NoteView<'a> {
self
}
+ pub fn options_button(mut self, enable: bool) -> Self {
+ self.options_mut().set_options_button(enable);
+ self
+ }
+
pub fn options(&self) -> NoteOptions {
self.flags
}
@@ -324,10 +353,7 @@ impl<'a> NoteView<'a> {
pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse {
if self.options().has_textmode() {
- NoteResponse {
- response: self.textmode_ui(ui),
- action: None,
- }
+ NoteResponse::new(self.textmode_ui(ui))
} else {
let txn = self.note.txn().expect("txn");
if let Some(note_to_repost) = get_reposted_note(self.ndb, txn, self.note) {
@@ -369,17 +395,33 @@ impl<'a> NoteView<'a> {
note_cache: &mut NoteCache,
note: &Note,
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
- ) -> egui::Response {
+ options: NoteOptions,
+ container_right: Pos2,
+ ) -> NoteResponse {
let note_key = note.key().unwrap();
- ui.horizontal(|ui| {
+ let inner_response = ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.add(ui::Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20));
let cached_note = note_cache.cached_note_or_insert_mut(note_key, note);
render_reltime(ui, cached_note, true);
- })
- .response
+
+ if options.has_options_button() {
+ let context_pos = {
+ let size = NoteContextButton::max_width();
+ let min = Pos2::new(container_right.x - size, container_right.y);
+ Rect::from_min_size(min, egui::vec2(size, size))
+ };
+
+ let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos));
+ NoteContextButton::menu(ui, resp.clone())
+ } else {
+ None
+ }
+ });
+
+ NoteResponse::new(inner_response.response).select_option(inner_response.inner)
}
fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse {
@@ -388,8 +430,15 @@ impl<'a> NoteView<'a> {
let note_key = self.note.key().expect("todo: support non-db notes");
let txn = self.note.txn().expect("todo: support non-db notes");
let mut note_action: Option<BarAction> = None;
+ let mut selected_option: Option<NoteContextSelection> = None;
let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey());
let maybe_hitbox = maybe_note_hitbox(ui, note_key);
+ let container_right = {
+ let r = ui.available_rect_before_wrap();
+ let x = r.max.x;
+ let y = r.min.y;
+ Pos2::new(x, y)
+ };
// wide design
let response = if self.options().has_wide() {
@@ -400,7 +449,15 @@ impl<'a> NoteView<'a> {
ui.vertical(|ui| {
ui.add_sized([size.x, self.options().pfp_size()], |ui: &mut egui::Ui| {
ui.horizontal_centered(|ui| {
- NoteView::note_header(ui, self.note_cache, self.note, &profile);
+ selected_option = NoteView::note_header(
+ ui,
+ self.note_cache,
+ self.note,
+ &profile,
+ self.options(),
+ container_right,
+ )
+ .context_selection;
})
.response
});
@@ -440,8 +497,15 @@ impl<'a> NoteView<'a> {
self.pfp(note_key, &profile, ui);
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
- NoteView::note_header(ui, self.note_cache, self.note, &profile);
-
+ selected_option = NoteView::note_header(
+ ui,
+ self.note_cache,
+ self.note,
+ &profile,
+ self.options(),
+ container_right,
+ )
+ .context_selection;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
@@ -483,10 +547,9 @@ impl<'a> NoteView<'a> {
note_action,
);
- NoteResponse {
- response,
- action: note_action,
- }
+ NoteResponse::new(response)
+ .with_action(note_action)
+ .select_option(selected_option)
}
}
diff --git a/src/ui/note/options.rs b/src/ui/note/options.rs
@@ -5,14 +5,15 @@ bitflags! {
// Attributes can be applied to flags types
#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
- pub struct NoteOptions: u32 {
- const actionbar = 0b00000001;
- const note_previews = 0b00000010;
- const small_pfp = 0b00000100;
- const medium_pfp = 0b00001000;
- const wide = 0b00010000;
- const selectable_text = 0b00100000;
- const textmode = 0b01000000;
+ pub struct NoteOptions: u64 {
+ const actionbar = 0b0000000000000001;
+ const note_previews = 0b0000000000000010;
+ const small_pfp = 0b0000000000000100;
+ const medium_pfp = 0b0000000000001000;
+ const wide = 0b0000000000010000;
+ const selectable_text = 0b0000000000100000;
+ const textmode = 0b0000000001000000;
+ const options_button = 0b0000000010000000;
}
}
@@ -36,6 +37,8 @@ impl NoteOptions {
create_setter!(set_selectable_text, selectable_text);
create_setter!(set_textmode, textmode);
create_setter!(set_actionbar, actionbar);
+ create_setter!(set_wide, wide);
+ create_setter!(set_options_button, options_button);
#[inline]
pub fn has_actionbar(self) -> bool {
@@ -67,27 +70,23 @@ impl NoteOptions {
(self & NoteOptions::medium_pfp) == NoteOptions::medium_pfp
}
- pub fn pfp_size(&self) -> f32 {
- if self.has_small_pfp() {
- ProfilePic::small_size()
- } else if self.has_medium_pfp() {
- ProfilePic::medium_size()
- } else {
- ProfilePic::default_size()
- }
- }
-
#[inline]
pub fn has_wide(self) -> bool {
(self & NoteOptions::wide) == NoteOptions::wide
}
#[inline]
- pub fn set_wide(&mut self, enable: bool) {
- if enable {
- *self |= NoteOptions::wide;
+ pub fn has_options_button(self) -> bool {
+ (self & NoteOptions::options_button) == NoteOptions::options_button
+ }
+
+ pub fn pfp_size(&self) -> f32 {
+ if self.has_small_pfp() {
+ ProfilePic::small_size()
+ } else if self.has_medium_pfp() {
+ ProfilePic::medium_size()
} else {
- *self &= !NoteOptions::wide;
+ ProfilePic::default_size()
}
}
}
diff --git a/src/ui/note/reply.rs b/src/ui/note/reply.rs
@@ -67,6 +67,7 @@ impl<'a> PostReplyView<'a> {
ui::NoteView::new(self.ndb, self.note_cache, self.img_cache, self.note)
.actionbar(false)
.medium_pfp(true)
+ .options_button(true)
.show(ui);
});
diff --git a/src/ui/thread.rs b/src/ui/thread.rs
@@ -115,15 +115,19 @@ impl<'a> ThreadView<'a> {
};
ui::padding(8.0, ui, |ui| {
- if let Some(bar_action) =
+ let note_response =
ui::NoteView::new(self.ndb, self.note_cache, self.img_cache, ¬e)
.note_previews(!self.textmode)
.textmode(self.textmode)
- .show(ui)
- .action
- {
+ .options_button(!self.textmode)
+ .show(ui);
+ if let Some(bar_action) = note_response.action {
action = Some(bar_action);
}
+
+ if let Some(selection) = note_response.context_selection {
+ selection.process(ui, ¬e);
+ }
});
ui::hline(ui);
diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs
@@ -149,6 +149,7 @@ fn timeline_ui(
let resp = ui::NoteView::new(ndb, note_cache, img_cache, ¬e)
.note_previews(!textmode)
.selectable_text(false)
+ .options_button(true)
.show(ui);
if let Some(ba) = resp.action {
@@ -156,6 +157,10 @@ fn timeline_ui(
} else if resp.response.clicked() {
debug!("clicked note");
}
+
+ if let Some(context) = resp.context_selection {
+ context.process(ui, ¬e);
+ }
});
ui::hline(ui);