mirror of
https://github.com/mikedilger/gossip.git
synced 2024-09-29 08:21:47 +00:00
Tagging: Cache hover positions for tagging tooltips, clean up and re-structure tagging code
This commit is contained in:
parent
debc559696
commit
621577da51
@ -1,20 +1,20 @@
|
||||
use super::FeedNoteParams;
|
||||
use crate::ui::widgets::InformationPopup;
|
||||
use crate::ui::{widgets, you, FeedKind, GossipUi, HighlightType, Page, Theme};
|
||||
use eframe::egui;
|
||||
use eframe::epaint::text::LayoutJob;
|
||||
use egui::containers::CollapsingHeader;
|
||||
use egui::{Align, Context, Key, Layout, Modifiers, RichText, Ui};
|
||||
use egui_winit::egui::TextureHandle;
|
||||
use egui_winit::egui::text::CCursor;
|
||||
use egui_winit::egui::text_edit::CCursorRange;
|
||||
use egui_winit::egui::text_edit::{CCursorRange, TextEditOutput};
|
||||
use egui_winit::egui::Id;
|
||||
use gossip_lib::comms::ToOverlordMessage;
|
||||
use gossip_lib::{DmChannel, Person};
|
||||
use gossip_lib::DmChannel;
|
||||
use gossip_lib::Relay;
|
||||
use gossip_lib::GLOBALS;
|
||||
use memoize::memoize;
|
||||
use nostr_types::{ContentSegment, NostrBech32, NostrUrl, ShatteredContent, Tag};
|
||||
|
||||
const TAGG_WIDTH: f32 = 200.0;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[memoize]
|
||||
pub fn textarea_highlighter(theme: Theme, text: String, interests: Vec<String>) -> LayoutJob {
|
||||
@ -64,7 +64,7 @@ pub fn textarea_highlighter(theme: Theme, text: String, interests: Vec<String>)
|
||||
// following code only works if interests are sorted the way
|
||||
// they occur in the text
|
||||
let mut interests = interests.to_owned();
|
||||
interests.sort_by(|a, b| { chunk.find(a).cmp(&chunk.find(b)) });
|
||||
interests.sort_by(|a, b| chunk.find(a).cmp(&chunk.find(b)));
|
||||
|
||||
// any entry in interests gets it's own layout section
|
||||
for interest in &interests {
|
||||
@ -73,15 +73,15 @@ pub fn textarea_highlighter(theme: Theme, text: String, interests: Vec<String>)
|
||||
job.append(
|
||||
&chunk[pos..ipos],
|
||||
0.0,
|
||||
theme.highlight_text_format(HighlightType::Nothing)
|
||||
theme.highlight_text_format(HighlightType::Nothing),
|
||||
);
|
||||
|
||||
pos = ipos+interest.len();
|
||||
pos = ipos + interest.len();
|
||||
// add the interest
|
||||
job.append(
|
||||
&chunk[ipos..pos],
|
||||
&chunk[ipos..pos],
|
||||
0.0,
|
||||
theme.highlight_text_format(HighlightType::Hyperlink)
|
||||
theme.highlight_text_format(HighlightType::Hyperlink),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -354,12 +354,12 @@ fn real_posting_area(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Fram
|
||||
// Text area
|
||||
let theme = app.theme;
|
||||
let mut layouter = |ui: &Ui, text: &str, wrap_width: f32| {
|
||||
|
||||
let interests = app.draft_data.replacements
|
||||
let interests = app
|
||||
.draft_data
|
||||
.replacements
|
||||
.iter()
|
||||
.map(|(k, _v)| {
|
||||
k.clone()
|
||||
}).collect::<Vec<String>>();
|
||||
.map(|(k, _v)| k.clone())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let mut layout_job = textarea_highlighter(theme, text.to_owned(), interests);
|
||||
layout_job.wrap.max_width = wrap_width;
|
||||
@ -403,7 +403,11 @@ fn real_posting_area(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Fram
|
||||
if let Some(captures) = GLOBALS.tagging_regex.captures(precursor) {
|
||||
if let Some(mat) = captures.get(1) {
|
||||
// only search if this is not already a replacement
|
||||
if !app.draft_data.replacements.contains_key(&precursor[mat.start()-1..mat.end()]) {
|
||||
if !app
|
||||
.draft_data
|
||||
.replacements
|
||||
.contains_key(&precursor[mat.start() - 1..mat.end()])
|
||||
{
|
||||
app.draft_data.tagging_search_substring =
|
||||
Some(mat.as_str().to_owned());
|
||||
}
|
||||
@ -452,15 +456,6 @@ fn real_posting_area(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Fram
|
||||
(None, false)
|
||||
};
|
||||
|
||||
// Temporary for debugging:
|
||||
if let Some(tagging) = &app.draft_data.tagging_search_substring {
|
||||
ui.label(format!(
|
||||
"TAGGING SEARCH SUBSTRING = {}, results: {}",
|
||||
tagging,
|
||||
app.draft_data.tagging_search_results.len()
|
||||
));
|
||||
}
|
||||
|
||||
let text_edit_area = text_edit_multiline!(app, app.draft_data.draft)
|
||||
.id(compose_area_id)
|
||||
.hint_text("Type your message here")
|
||||
@ -468,7 +463,7 @@ fn real_posting_area(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Fram
|
||||
.lock_focus(true)
|
||||
.interactive(app.draft_data.repost.is_none())
|
||||
.layouter(&mut layouter);
|
||||
let output = text_edit_area.show(ui);
|
||||
let mut output = text_edit_area.show(ui);
|
||||
|
||||
if app.draft_needs_focus {
|
||||
output.response.request_focus();
|
||||
@ -493,257 +488,21 @@ fn real_posting_area(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Fram
|
||||
}
|
||||
}
|
||||
|
||||
let pos = if let Some(cursor) = output.cursor_range {
|
||||
let rect = output.galley.pos_from_cursor(&cursor.primary); // position within textedit
|
||||
output.text_draw_pos + rect.center_bottom().to_vec2()
|
||||
} else {
|
||||
let rect = output.galley.pos_from_cursor(&output.galley.end()); // position within textedit
|
||||
output.text_draw_pos + rect.center_bottom().to_vec2()
|
||||
};
|
||||
|
||||
// always compute the tooltip, but it is only shown when
|
||||
// is_open is true. This is so we get the animation.
|
||||
let frame = egui::Frame::popup(ui.style())
|
||||
.rounding(egui::Rounding::ZERO)
|
||||
.inner_margin(egui::Margin::same(0.0));
|
||||
let area = egui::Area::new(ui.auto_id_with("compose-tagging-tooltip"))
|
||||
.fixed_pos(pos)
|
||||
.movable(false)
|
||||
.constrain(true)
|
||||
.interactable(true)
|
||||
.order(egui::Order::Middle);
|
||||
|
||||
// show tagging slector tooltip
|
||||
if let Some(search) = &app.draft_data.tagging_search_substring {
|
||||
// only do the search when search string changes
|
||||
if app.draft_data.tagging_search_substring
|
||||
!= app.draft_data.tagging_search_searched
|
||||
{
|
||||
let pairs = GLOBALS
|
||||
.people
|
||||
.search_people_to_tag(search)
|
||||
.unwrap_or(vec![]);
|
||||
app.draft_data.tagging_search_searched = Some(search.clone());
|
||||
app.draft_data.tagging_search_results = pairs.to_owned();
|
||||
}
|
||||
} else {
|
||||
// no more search substring, clear results
|
||||
app.draft_data.tagging_search_searched = None;
|
||||
app.draft_data.tagging_search_results.clear();
|
||||
// show tag hovers first, as they depend on information in the output.galley
|
||||
// do not run them after replacements are added, rather wait for the next frame
|
||||
if output.response.changed()
|
||||
|| app.draft_data.replacements_changed
|
||||
|| app.draft_data.last_textedit_rect != output.response.rect
|
||||
{
|
||||
calc_tag_hovers(ui, app, &output);
|
||||
app.draft_data.replacements_changed = false;
|
||||
}
|
||||
show_tag_hovers(ui, app, &mut output);
|
||||
|
||||
// show search results
|
||||
if !app.draft_data.tagging_search_results.is_empty() {
|
||||
area.show(ui.ctx(), |ui| {
|
||||
frame.show(ui, |ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_width(TAGG_WIDTH)
|
||||
.max_height(250.0)
|
||||
.show(ui, |ui| {
|
||||
// need to clone results to avoid immutable borrow error on app.
|
||||
let pairs = app.draft_data.tagging_search_results.clone();
|
||||
for (i, pair) in pairs.iter().enumerate() {
|
||||
let avatar = if let Some(avatar) =
|
||||
app.try_get_avatar(ctx, &pair.1)
|
||||
{
|
||||
avatar
|
||||
} else {
|
||||
app.placeholder_avatar.clone()
|
||||
};
|
||||
calc_tagging_search(app);
|
||||
show_tagging_result(ui, app, &mut output, enter_key);
|
||||
|
||||
let frame = egui::Frame::none()
|
||||
.rounding(egui::Rounding::ZERO)
|
||||
.inner_margin(egui::Margin::symmetric(10.0, 5.0));
|
||||
let mut prepared = frame.begin(ui);
|
||||
|
||||
prepared.content_ui.set_min_width(TAGG_WIDTH);
|
||||
prepared.content_ui.set_max_width(TAGG_WIDTH);
|
||||
prepared.content_ui.set_min_height(27.0);
|
||||
|
||||
let frame_rect = (prepared.frame.inner_margin
|
||||
+ prepared.frame.outer_margin)
|
||||
.expand_rect(prepared.content_ui.min_rect());
|
||||
|
||||
let response = ui
|
||||
.interact(
|
||||
frame_rect,
|
||||
ui.auto_id_with(pair.1.as_hex_string()),
|
||||
egui::Sense::click(),
|
||||
)
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
|
||||
// mouse hover moves selected index
|
||||
app.draft_data.tagging_search_selected =
|
||||
if response.hovered() {
|
||||
Some(i)
|
||||
} else {
|
||||
app.draft_data.tagging_search_selected
|
||||
};
|
||||
let is_selected =
|
||||
Some(i) == app.draft_data.tagging_search_selected;
|
||||
|
||||
{
|
||||
// render inside of frame using prepared.content_ui
|
||||
let ui = &mut prepared.content_ui;
|
||||
if is_selected {
|
||||
app.theme.on_accent_style(ui.style_mut())
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::Image::new(&avatar)
|
||||
.max_size(egui::Vec2 { x: 27.0, y: 27.0 })
|
||||
.maintain_aspect_ratio(true),
|
||||
);
|
||||
ui.vertical(|ui| {
|
||||
widgets::truncated_label(
|
||||
ui,
|
||||
RichText::new(&pair.0).small(),
|
||||
TAGG_WIDTH - 33.0,
|
||||
);
|
||||
if let Ok(Some(person)) =
|
||||
GLOBALS.storage.read_person(&pair.1)
|
||||
{
|
||||
let mut nip05 = RichText::new(
|
||||
person.nip05().unwrap_or_default(),
|
||||
)
|
||||
.weak()
|
||||
.small();
|
||||
if !person.nip05_valid {
|
||||
nip05 = nip05.strikethrough()
|
||||
}
|
||||
widgets::truncated_label(
|
||||
ui,
|
||||
nip05,
|
||||
TAGG_WIDTH - 33.0,
|
||||
);
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
prepared.frame.fill = if is_selected {
|
||||
app.theme.accent_color()
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
};
|
||||
|
||||
prepared.end(ui);
|
||||
|
||||
if is_selected {
|
||||
response.scroll_to_me(None)
|
||||
}
|
||||
let clicked = response.clicked();
|
||||
if clicked || (enter_key && is_selected) {
|
||||
// remove @ and search text
|
||||
let search = if let Some(search) =
|
||||
app.draft_data.tagging_search_searched.as_ref()
|
||||
{
|
||||
search.clone()
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
// complete name and add replacement
|
||||
let name = pair.0.clone();
|
||||
let nostr_url: NostrUrl = pair.1.into();
|
||||
app.draft_data.draft = app
|
||||
.draft_data
|
||||
.draft
|
||||
.as_str()
|
||||
.replace(&format!("@{}", search), name.as_str())
|
||||
.to_string();
|
||||
|
||||
// move cursor to end of replacement
|
||||
if let Some(pos) = app.draft_data.draft.find(name.as_str()) {
|
||||
let cpos = pos + name.len();
|
||||
let mut state = output.state.clone();
|
||||
let mut ccrange = CCursorRange::default();
|
||||
ccrange.primary.index = cpos;
|
||||
ccrange.secondary.index = cpos;
|
||||
state.set_ccursor_range(Some(ccrange));
|
||||
state.store(ctx, compose_area_id);
|
||||
|
||||
// add it to our replacement list
|
||||
app.draft_data.replacements.insert(name, ContentSegment::NostrUrl(nostr_url));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let is_open = app.draft_data.tagging_search_substring.is_some();
|
||||
area.show_open_close_animation(ui.ctx(), &frame, is_open);
|
||||
|
||||
// find replacements in the galley and interact with them
|
||||
let mut deletelist: Vec<String> = Vec::new();
|
||||
for (pat, content) in app.draft_data.replacements.clone() {
|
||||
if let Some(pos) = output.galley.job.text.find(&pat) {
|
||||
// find the rect that covers the replacement
|
||||
let ccstart = CCursor::new(pos);
|
||||
let ccend = CCursor::new(pos+pat.len());
|
||||
let start_rect = output.galley.pos_from_cursor(&output.galley.from_ccursor(ccstart));
|
||||
let end_rect = output.galley.pos_from_cursor(&output.galley.from_ccursor(ccend));
|
||||
let rect = egui::Rect::from_two_pos(
|
||||
output.text_draw_pos + start_rect.left_top().to_vec2(),
|
||||
output.text_draw_pos + end_rect.right_bottom().to_vec2());
|
||||
|
||||
match content {
|
||||
ContentSegment::NostrUrl(nostr_url) => {
|
||||
let maybe_pubkey = match &nostr_url.0 {
|
||||
NostrBech32::Profile(p) => {
|
||||
Some(p.pubkey)
|
||||
},
|
||||
NostrBech32::Pubkey(pk) => {
|
||||
Some(*pk)
|
||||
},
|
||||
NostrBech32::EventAddr(_) |
|
||||
NostrBech32::EventPointer(_) |
|
||||
NostrBech32::Id(_) |
|
||||
NostrBech32::Relay(_) => { None },
|
||||
};
|
||||
|
||||
if let Some(pubkey) = maybe_pubkey {
|
||||
let avatar = if let Some(avatar) =
|
||||
app.try_get_avatar(ui.ctx(), &pubkey)
|
||||
{
|
||||
avatar
|
||||
} else {
|
||||
app.placeholder_avatar.clone()
|
||||
};
|
||||
|
||||
// interact with rect
|
||||
let resp = ui.interact(rect, ui.auto_id_with(&pat), egui::Sense::click());
|
||||
resp.context_menu(|ui| {
|
||||
// only look up person when hovered
|
||||
if let Ok(Some(person)) =
|
||||
GLOBALS.storage.read_person(&pubkey)
|
||||
{
|
||||
show_mini_person(ui, &avatar, &person);
|
||||
if ui.link("remove").clicked() {
|
||||
deletelist.push(pat);
|
||||
}
|
||||
}
|
||||
}).on_hover_ui(|ui| {
|
||||
// only look up person when hovered
|
||||
if let Ok(Some(person)) =
|
||||
GLOBALS.storage.read_person(&pubkey)
|
||||
{
|
||||
show_mini_person(ui, &avatar, &person);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
for key in deletelist {
|
||||
app.draft_data.replacements.remove(&key);
|
||||
}
|
||||
app.draft_data.last_textedit_rect = output.response.rect;
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
@ -866,52 +625,300 @@ fn real_posting_area(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Fram
|
||||
|
||||
ui.label(format!("{}: {}", i, rendered));
|
||||
}
|
||||
// let mut deletelist: Vec<String> = Vec::new();
|
||||
// for (text, segment) in app.draft_data.replacements.iter() {
|
||||
// match segment {
|
||||
// ContentSegment::NostrUrl(nostr_url) => {
|
||||
// if ui.link(format!("{} -> {}", text, nostr_url.0.to_string())).clicked() {
|
||||
// deletelist.push(text.clone());
|
||||
// };
|
||||
// }
|
||||
// _ => {} // not supported
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
fn calc_tagging_search(app: &mut GossipUi) {
|
||||
// show tagging slector tooltip
|
||||
if let Some(search) = &app.draft_data.tagging_search_substring {
|
||||
// only do the search when search string changes
|
||||
if app.draft_data.tagging_search_substring != app.draft_data.tagging_search_searched {
|
||||
let pairs = GLOBALS
|
||||
.people
|
||||
.search_people_to_tag(search)
|
||||
.unwrap_or(vec![]);
|
||||
app.draft_data.tagging_search_searched = Some(search.clone());
|
||||
app.draft_data.tagging_search_results = pairs.to_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_mini_person(ui: &mut Ui, avatar: &TextureHandle, person: &Person) {
|
||||
egui::Frame::none()
|
||||
fn show_tagging_result(
|
||||
ui: &mut Ui,
|
||||
app: &mut GossipUi,
|
||||
output: &mut TextEditOutput,
|
||||
enter_key: bool,
|
||||
) {
|
||||
let pos = if let Some(cursor) = output.cursor_range {
|
||||
let rect = output.galley.pos_from_cursor(&cursor.primary); // position within textedit
|
||||
output.text_draw_pos + rect.center_bottom().to_vec2()
|
||||
} else {
|
||||
let rect = output.galley.pos_from_cursor(&output.galley.end()); // position within textedit
|
||||
output.text_draw_pos + rect.center_bottom().to_vec2()
|
||||
};
|
||||
|
||||
// always compute the tooltip, but it is only shown when
|
||||
// is_open is true. This is so we get the animation.
|
||||
let frame = egui::Frame::popup(ui.style())
|
||||
.rounding(egui::Rounding::ZERO)
|
||||
.inner_margin(egui::Margin::symmetric(10.0, 5.0))
|
||||
.show(ui, |ui|{
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::Image::new(avatar)
|
||||
.max_size(egui::Vec2 { x: 27.0, y: 27.0 })
|
||||
.maintain_aspect_ratio(true),
|
||||
);
|
||||
ui.vertical(|ui| {
|
||||
widgets::truncated_label(
|
||||
ui,
|
||||
RichText::new(&person.best_name()).small(),
|
||||
TAGG_WIDTH - 33.0,
|
||||
);
|
||||
.inner_margin(egui::Margin::same(0.0));
|
||||
let area = egui::Area::new(ui.auto_id_with("compose-tagging-tooltip"))
|
||||
.fixed_pos(pos)
|
||||
.movable(false)
|
||||
.constrain(true)
|
||||
.interactable(true)
|
||||
.order(egui::Order::Middle);
|
||||
|
||||
let mut nip05 = RichText::new(
|
||||
person.nip05().unwrap_or_default(),
|
||||
)
|
||||
.weak()
|
||||
.small();
|
||||
if !person.nip05_valid {
|
||||
nip05 = nip05.strikethrough()
|
||||
}
|
||||
widgets::truncated_label(
|
||||
ui,
|
||||
nip05,
|
||||
TAGG_WIDTH - 33.0,
|
||||
);
|
||||
});
|
||||
// show search results
|
||||
if !app.draft_data.tagging_search_results.is_empty() {
|
||||
area.show(ui.ctx(), |ui| {
|
||||
frame.show(ui, |ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_width(widgets::TAGG_WIDTH)
|
||||
.max_height(250.0)
|
||||
.show(ui, |ui| {
|
||||
// need to clone results to avoid immutable borrow error on app.
|
||||
let pairs = app.draft_data.tagging_search_results.clone();
|
||||
for (i, pair) in pairs.iter().enumerate() {
|
||||
let avatar = if let Some(avatar) = app.try_get_avatar(ui.ctx(), &pair.1)
|
||||
{
|
||||
avatar
|
||||
} else {
|
||||
app.placeholder_avatar.clone()
|
||||
};
|
||||
|
||||
let frame = egui::Frame::none()
|
||||
.rounding(egui::Rounding::ZERO)
|
||||
.inner_margin(egui::Margin::symmetric(10.0, 5.0));
|
||||
let mut prepared = frame.begin(ui);
|
||||
|
||||
prepared.content_ui.set_min_width(widgets::TAGG_WIDTH);
|
||||
prepared.content_ui.set_max_width(widgets::TAGG_WIDTH);
|
||||
prepared.content_ui.set_min_height(27.0);
|
||||
|
||||
let frame_rect = (prepared.frame.inner_margin
|
||||
+ prepared.frame.outer_margin)
|
||||
.expand_rect(prepared.content_ui.min_rect());
|
||||
|
||||
let response = ui
|
||||
.interact(
|
||||
frame_rect,
|
||||
ui.auto_id_with(pair.1.as_hex_string()),
|
||||
egui::Sense::click(),
|
||||
)
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
|
||||
// mouse hover moves selected index
|
||||
app.draft_data.tagging_search_selected = if response.hovered() {
|
||||
Some(i)
|
||||
} else {
|
||||
app.draft_data.tagging_search_selected
|
||||
};
|
||||
let is_selected = Some(i) == app.draft_data.tagging_search_selected;
|
||||
|
||||
{
|
||||
// render inside of frame using prepared.content_ui
|
||||
let ui = &mut prepared.content_ui;
|
||||
if is_selected {
|
||||
app.theme.on_accent_style(ui.style_mut())
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::Image::new(&avatar)
|
||||
.max_size(egui::Vec2 { x: 27.0, y: 27.0 })
|
||||
.maintain_aspect_ratio(true),
|
||||
);
|
||||
ui.vertical(|ui| {
|
||||
widgets::truncated_label(
|
||||
ui,
|
||||
RichText::new(&pair.0).small(),
|
||||
widgets::TAGG_WIDTH - 33.0,
|
||||
);
|
||||
if let Ok(Some(person)) =
|
||||
GLOBALS.storage.read_person(&pair.1)
|
||||
{
|
||||
let mut nip05 =
|
||||
RichText::new(person.nip05().unwrap_or_default())
|
||||
.weak()
|
||||
.small();
|
||||
if !person.nip05_valid {
|
||||
nip05 = nip05.strikethrough()
|
||||
}
|
||||
widgets::truncated_label(
|
||||
ui,
|
||||
nip05,
|
||||
widgets::TAGG_WIDTH - 33.0,
|
||||
);
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
prepared.frame.fill = if is_selected {
|
||||
app.theme.accent_color()
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
};
|
||||
|
||||
prepared.end(ui);
|
||||
|
||||
if is_selected {
|
||||
response.scroll_to_me(None)
|
||||
}
|
||||
let clicked = response.clicked();
|
||||
if clicked || (enter_key && is_selected) {
|
||||
// remove @ and search text
|
||||
let search = if let Some(search) =
|
||||
app.draft_data.tagging_search_searched.as_ref()
|
||||
{
|
||||
search.clone()
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
// complete name and add replacement
|
||||
let name = pair.0.clone();
|
||||
let nostr_url: NostrUrl = pair.1.into();
|
||||
app.draft_data.draft = app
|
||||
.draft_data
|
||||
.draft
|
||||
.as_str()
|
||||
.replace(&format!("@{}", search), name.as_str())
|
||||
.to_string();
|
||||
|
||||
// move cursor to end of replacement
|
||||
if let Some(pos) = app.draft_data.draft.find(name.as_str()) {
|
||||
let cpos = pos + name.len();
|
||||
let mut state = output.state.clone();
|
||||
let mut ccrange = CCursorRange::default();
|
||||
ccrange.primary.index = cpos;
|
||||
ccrange.secondary.index = cpos;
|
||||
state.set_ccursor_range(Some(ccrange));
|
||||
state.store(ui.ctx(), output.response.id);
|
||||
|
||||
// add it to our replacement list
|
||||
app.draft_data
|
||||
.replacements
|
||||
.insert(name, ContentSegment::NostrUrl(nostr_url));
|
||||
app.draft_data.replacements_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let is_open = app.draft_data.tagging_search_substring.is_some();
|
||||
area.show_open_close_animation(ui.ctx(), &frame, is_open);
|
||||
|
||||
if !is_open {
|
||||
// no more search substring, clear results
|
||||
app.draft_data.tagging_search_searched = None;
|
||||
app.draft_data.tagging_search_results.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn calc_tag_hovers(ui: &mut Ui, app: &mut GossipUi, output: &TextEditOutput) {
|
||||
let mut hovers: HashMap<Id, Box<dyn InformationPopup>> = HashMap::new();
|
||||
|
||||
// find replacements in the galley and interact with them
|
||||
for (pat, content) in app.draft_data.replacements.clone() {
|
||||
let popup_id = ui.auto_id_with(&pat);
|
||||
if let Some(pos) = output.galley.job.text.find(&pat) {
|
||||
// find the rect that covers the replacement
|
||||
let ccstart = CCursor::new(pos);
|
||||
let ccend = CCursor::new(pos + pat.len());
|
||||
let mut cstart = output.galley.from_ccursor(ccstart);
|
||||
cstart.pcursor.prefer_next_row = true;
|
||||
let cend = output.galley.from_ccursor(ccend);
|
||||
let start_rect = output.galley.pos_from_cursor(&cstart);
|
||||
let end_rect = output.galley.pos_from_cursor(&cend);
|
||||
let interact_rect = egui::Rect::from_two_pos(
|
||||
output.text_draw_pos + start_rect.left_top().to_vec2(),
|
||||
output.text_draw_pos + end_rect.right_bottom().to_vec2(),
|
||||
);
|
||||
|
||||
match content {
|
||||
ContentSegment::NostrUrl(nostr_url) => {
|
||||
let maybe_pubkey = match &nostr_url.0 {
|
||||
NostrBech32::Profile(p) => Some(p.pubkey),
|
||||
NostrBech32::Pubkey(pk) => Some(*pk),
|
||||
NostrBech32::EventAddr(_)
|
||||
| NostrBech32::EventPointer(_)
|
||||
| NostrBech32::Id(_)
|
||||
| NostrBech32::Relay(_) => None,
|
||||
};
|
||||
|
||||
if let Some(pubkey) = maybe_pubkey {
|
||||
let avatar = if let Some(avatar) = app.try_get_avatar(ui.ctx(), &pubkey) {
|
||||
avatar
|
||||
} else {
|
||||
app.placeholder_avatar.clone()
|
||||
};
|
||||
|
||||
// create popup and store it
|
||||
if let Ok(Some(person)) = GLOBALS.storage.read_person(&pubkey) {
|
||||
let popup = Box::new(
|
||||
widgets::ProfilePopup::new(popup_id, interact_rect, avatar, person)
|
||||
.show_duration(1.0)
|
||||
.tag(pat),
|
||||
);
|
||||
|
||||
hovers.insert(popup_id, popup);
|
||||
}
|
||||
|
||||
// egui::containers::popup::popup_below_widget(ui,
|
||||
// popup_id,
|
||||
// &resp,
|
||||
// |ui|{
|
||||
|
||||
// });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(entry) = app.popups.get_mut(&output.response.id) {
|
||||
*entry = hovers;
|
||||
} else {
|
||||
app.popups.insert(output.response.id, hovers);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_tag_hovers(ui: &mut Ui, app: &mut GossipUi, output: &mut TextEditOutput) {
|
||||
if let Some(hovers) = app.popups.get_mut(&output.response.id) {
|
||||
let uitime = ui.input(|i| i.time);
|
||||
let mut deletelist: Vec<(egui::Id, String)> = Vec::new();
|
||||
for (id, popup) in &mut *hovers {
|
||||
let resp = ui.interact(popup.interact_rect(), id.with("_h"), egui::Sense::hover());
|
||||
if resp.hovered() {
|
||||
popup.set_last_seen(uitime);
|
||||
}
|
||||
if resp.hovered() || popup.get_until() > Some(uitime) {
|
||||
let response = popup.show(ui, Box::new(|ui| ui.link("remove")));
|
||||
|
||||
// pointer over the popup extends its life
|
||||
if ui.rect_contains_pointer(response.response.rect) {
|
||||
popup.set_last_seen(uitime);
|
||||
}
|
||||
|
||||
if response.inner.clicked() {
|
||||
if let Some(tag) = popup.tag() {
|
||||
deletelist.push((*id, tag.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, tag) in deletelist {
|
||||
app.draft_data.replacements.remove(&tag);
|
||||
|
||||
// remove popup
|
||||
app.popups.remove(&id);
|
||||
|
||||
// mark textedit changed
|
||||
output.response.mark_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -33,6 +33,7 @@ use egui::{
|
||||
};
|
||||
#[cfg(feature = "video-ffmpeg")]
|
||||
use egui_video::{AudioDevice, Player};
|
||||
use egui_winit::egui::Rect;
|
||||
use egui_winit::egui::Response;
|
||||
use gossip_lib::comms::ToOverlordMessage;
|
||||
use gossip_lib::About;
|
||||
@ -45,6 +46,7 @@ use gossip_lib::{ZapState, GLOBALS};
|
||||
use nostr_types::ContentSegment;
|
||||
use nostr_types::{Id, Metadata, MilliSatoshi, Profile, PublicKey, UncheckedUrl, Url};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::Hash;
|
||||
#[cfg(feature = "video-ffmpeg")]
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::Ordering;
|
||||
@ -275,8 +277,12 @@ pub struct DraftData {
|
||||
// The draft text displayed in the edit textbox
|
||||
pub draft: String,
|
||||
|
||||
// The last position of the TextEdit
|
||||
pub last_textedit_rect: Rect,
|
||||
|
||||
// text replacements like nurls, hyperlinks or hashtags
|
||||
pub replacements: HashMap<String,ContentSegment>,
|
||||
pub replacements: HashMap<String, ContentSegment>,
|
||||
pub replacements_changed: bool,
|
||||
|
||||
pub include_subject: bool,
|
||||
pub subject: String,
|
||||
@ -299,7 +305,9 @@ impl Default for DraftData {
|
||||
fn default() -> DraftData {
|
||||
DraftData {
|
||||
draft: "".to_owned(),
|
||||
last_textedit_rect: Rect::ZERO,
|
||||
replacements: HashMap::new(),
|
||||
replacements_changed: false,
|
||||
include_subject: false,
|
||||
subject: "".to_owned(),
|
||||
include_content_warning: false,
|
||||
@ -320,7 +328,9 @@ impl Default for DraftData {
|
||||
impl DraftData {
|
||||
pub fn clear(&mut self) {
|
||||
self.draft = "".to_owned();
|
||||
self.last_textedit_rect = Rect::ZERO;
|
||||
self.replacements.clear();
|
||||
self.replacements_changed = true;
|
||||
self.include_subject = false;
|
||||
self.subject = "".to_owned();
|
||||
self.include_content_warning = false;
|
||||
@ -348,6 +358,9 @@ struct GossipUi {
|
||||
current_scroll_offset: f32,
|
||||
future_scroll_offset: f32,
|
||||
|
||||
// Ui timers
|
||||
popups: HashMap<egui::Id, HashMap<egui::Id, Box<dyn widgets::InformationPopup>>>,
|
||||
|
||||
// QR codes being rendered (in feed or elsewhere)
|
||||
// the f32's are the recommended image size
|
||||
qr_codes: HashMap<String, Result<(TextureHandle, f32, f32), Error>>,
|
||||
@ -607,6 +620,7 @@ impl GossipUi {
|
||||
original_dpi_value: override_dpi_value,
|
||||
current_scroll_offset: 0.0,
|
||||
future_scroll_offset: 0.0,
|
||||
popups: HashMap::new(),
|
||||
qr_codes: HashMap::new(),
|
||||
notes: Notes::new(),
|
||||
relays: relays::RelayUi::new(),
|
||||
|
138
gossip-bin/src/ui/widgets/information_popup.rs
Normal file
138
gossip-bin/src/ui/widgets/information_popup.rs
Normal file
@ -0,0 +1,138 @@
|
||||
use egui_winit::egui::{self, Id, InnerResponse, Rect, Response, RichText, TextureHandle, Ui};
|
||||
use gossip_lib::Person;
|
||||
pub trait InformationPopup {
|
||||
fn id(&self) -> Id;
|
||||
fn interact_rect(&self) -> Rect;
|
||||
fn set_last_seen(&mut self, time: f64);
|
||||
fn get_until(&self) -> Option<f64>;
|
||||
|
||||
fn tag(&self) -> &Option<String>;
|
||||
|
||||
fn show(
|
||||
&self,
|
||||
ui: &mut Ui,
|
||||
actions: Box<dyn FnOnce(&mut Ui) -> Response>,
|
||||
) -> InnerResponse<Response>;
|
||||
}
|
||||
|
||||
pub struct ProfilePopup {
|
||||
id: Id,
|
||||
tag: Option<String>,
|
||||
interact_rect: Rect,
|
||||
show_until: Option<f64>,
|
||||
show_duration: Option<f64>,
|
||||
|
||||
avatar: TextureHandle,
|
||||
person: Person,
|
||||
}
|
||||
|
||||
impl ProfilePopup {
|
||||
/// Creates a new [`ProfilePopup`].
|
||||
pub fn new(id: Id, interact_rect: Rect, avatar: TextureHandle, person: Person) -> Self {
|
||||
Self {
|
||||
id,
|
||||
tag: None,
|
||||
interact_rect,
|
||||
show_until: None,
|
||||
show_duration: None,
|
||||
avatar,
|
||||
person,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_duration(mut self, time: f64) -> Self {
|
||||
self.show_duration = Some(time);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tag(mut self, tag: String) -> Self {
|
||||
self.tag = Some(tag);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl InformationPopup for ProfilePopup {
|
||||
fn id(&self) -> Id {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn interact_rect(&self) -> egui_winit::egui::Rect {
|
||||
self.interact_rect
|
||||
}
|
||||
|
||||
fn set_last_seen(&mut self, time: f64) {
|
||||
if let Some(duration) = self.show_duration {
|
||||
self.show_until = Some(time + duration);
|
||||
} else {
|
||||
self.show_until = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn get_until(&self) -> Option<f64> {
|
||||
self.show_until
|
||||
}
|
||||
|
||||
fn tag(&self) -> &Option<String> {
|
||||
&self.tag
|
||||
}
|
||||
|
||||
fn show(
|
||||
&self,
|
||||
ui: &mut Ui,
|
||||
actions: Box<dyn FnOnce(&mut Ui) -> Response>,
|
||||
) -> InnerResponse<Response> {
|
||||
let frame = prepare_mini_person(ui);
|
||||
let area = egui::Area::new(self.id)
|
||||
.fixed_pos(self.interact_rect.left_bottom())
|
||||
.movable(false)
|
||||
.constrain(true)
|
||||
.interactable(true)
|
||||
.order(egui::Order::Foreground);
|
||||
|
||||
area.show(ui.ctx(), |ui| {
|
||||
show_mini_person(frame, ui, &self.avatar, &self.person, actions)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_mini_person(ui: &mut Ui) -> egui::Frame {
|
||||
egui::Frame::popup(ui.style())
|
||||
.rounding(egui::Rounding::ZERO)
|
||||
.inner_margin(egui::Margin::symmetric(10.0, 5.0))
|
||||
}
|
||||
|
||||
fn show_mini_person(
|
||||
frame: egui::Frame,
|
||||
ui: &mut Ui,
|
||||
avatar: &TextureHandle,
|
||||
person: &Person,
|
||||
actions: Box<dyn FnOnce(&mut Ui) -> Response>,
|
||||
) -> Response {
|
||||
frame
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::Image::new(avatar)
|
||||
.max_size(egui::Vec2 { x: 27.0, y: 27.0 })
|
||||
.maintain_aspect_ratio(true),
|
||||
);
|
||||
ui.vertical(|ui| {
|
||||
super::truncated_label(
|
||||
ui,
|
||||
RichText::new(&person.best_name()).small(),
|
||||
super::TAGG_WIDTH - 33.0,
|
||||
);
|
||||
|
||||
let mut nip05 = RichText::new(person.nip05().unwrap_or_default())
|
||||
.weak()
|
||||
.small();
|
||||
if !person.nip05_valid {
|
||||
nip05 = nip05.strikethrough()
|
||||
}
|
||||
super::truncated_label(ui, nip05, super::TAGG_WIDTH - 33.0);
|
||||
});
|
||||
});
|
||||
actions(ui)
|
||||
})
|
||||
.inner
|
||||
}
|
@ -14,8 +14,14 @@ pub use relay_entry::{RelayEntry, RelayEntryView};
|
||||
mod modal_popup;
|
||||
pub use modal_popup::modal_popup;
|
||||
|
||||
mod information_popup;
|
||||
pub use information_popup::InformationPopup;
|
||||
pub use information_popup::ProfilePopup;
|
||||
|
||||
use super::GossipUi;
|
||||
|
||||
pub const DROPDOWN_DISTANCE: f32 = 10.0;
|
||||
pub const TAGG_WIDTH: f32 = 200.0;
|
||||
|
||||
// pub fn break_anywhere_label(ui: &mut Ui, text: impl Into<WidgetText>) {
|
||||
// let mut job = text.into().into_text_job(
|
||||
|
Loading…
Reference in New Issue
Block a user