From e62a9dced6f73610a020fc7e58de0134adcb21e8 Mon Sep 17 00:00:00 2001 From: Cory Forsstrom Date: Mon, 16 Sep 2024 09:41:46 -0700 Subject: [PATCH] Add context menu to user fragments --- src/buffer.rs | 10 +- src/buffer/channel.rs | 52 +++---- src/buffer/channel/topic.rs | 4 +- src/buffer/input_view.rs | 21 +-- src/buffer/query.rs | 32 +++-- src/buffer/server.rs | 18 +-- src/buffer/user_context.rs | 104 +++++++------- src/screen/dashboard.rs | 19 ++- src/screen/dashboard/sidebar.rs | 5 +- src/theme.rs | 1 + src/theme/context_menu.rs | 15 ++ src/widget.rs | 92 +----------- src/widget/context_menu.rs | 216 ++++++++++++++++++----------- src/widget/double_pass.rs | 29 ++-- src/widget/message_content.rs | 139 +++++++++++++++++++ src/widget/selectable_rich_text.rs | 165 ++++++++++++++++++---- 16 files changed, 596 insertions(+), 326 deletions(-) create mode 100644 src/theme/context_menu.rs create mode 100644 src/widget/message_content.rs diff --git a/src/buffer.rs b/src/buffer.rs index c0ae412ba..96570a7f9 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -47,12 +47,12 @@ impl Buffer { Self::Empty } - pub fn data(&self) -> Option { + pub fn data(&self) -> Option<&data::Buffer> { match self { Buffer::Empty => None, - Buffer::Channel(state) => Some(state.buffer()), - Buffer::Server(state) => Some(state.buffer()), - Buffer::Query(state) => Some(state.buffer()), + Buffer::Channel(state) => Some(&state.buffer), + Buffer::Server(state) => Some(&state.buffer), + Buffer::Query(state) => Some(&state.buffer), Buffer::FileTransfers(_) => None, } } @@ -177,7 +177,7 @@ impl Buffer { nick: Nick, history: &mut history::Manager, ) -> Task { - if let Some(buffer) = self.data() { + if let Some(buffer) = self.data().cloned() { match self { Buffer::Empty | Buffer::Server(_) | Buffer::FileTransfers(_) => Task::none(), Buffer::Channel(channel) => channel diff --git a/src/buffer/channel.rs b/src/buffer/channel.rs index 54e8e9be7..9262e667f 100644 --- a/src/buffer/channel.rs +++ b/src/buffer/channel.rs @@ -32,8 +32,8 @@ pub fn view<'a>( theme: &'a Theme, is_focused: bool, ) -> Element<'a, Message> { - let buffer = state.buffer(); - let input = history.input(&buffer); + let buffer = &state.buffer; + let input = history.input(buffer); let our_nick = clients.nickname(&state.server); let our_user = our_nick @@ -88,6 +88,8 @@ pub fn view<'a>( match message.target.source() { message::Source::User(user) => { + let current_user = users.iter().find(|current_user| *current_user == user); + let mut text = selectable_text( config .buffer @@ -109,20 +111,26 @@ pub fn view<'a>( .horizontal_alignment(alignment::Horizontal::Right); } - let nick = user_context::view( - text, - user, - users.iter().find(|current_user| *current_user == user), - state.buffer(), - our_user, - ) - .map(scroll_view::Message::UserContext); + let nick = user_context::view(text, user, current_user, buffer, our_user) + .map(scroll_view::Message::UserContext); - let text = message_content( + let text = message_content::with_context( &message.content, theme, scroll_view::Message::Link, theme::selectable_text::default, + move |link| match link { + message::Link::Url(_) => vec![], + message::Link::User(_) => { + user_context::Entry::list(buffer, our_user) + } + }, + move |link, entry, length| match link { + message::Link::Url(_) => row![].into(), + message::Link::User(user) => entry + .view(user, current_user, length) + .map(scroll_view::Message::UserContext), + }, config, ); @@ -235,7 +243,7 @@ pub fn view<'a>( .width(Length::FillPortion(2)) .height(Length::Fill); - let nick_list = nick_list::view(users, &buffer, our_user, config).map(Message::UserContext); + let nick_list = nick_list::view(users, buffer, our_user, config).map(Message::UserContext); // If topic toggles from None to Some then it messes with messages' scroll state, // so produce a zero-height placeholder when topic is None. @@ -287,6 +295,7 @@ pub fn view<'a>( #[derive(Debug, Clone)] pub struct Channel { + pub buffer: data::Buffer, pub server: Server, pub channel: String, @@ -297,6 +306,7 @@ pub struct Channel { impl Channel { pub fn new(server: Server, channel: String) -> Self { Self { + buffer: data::Buffer::Channel(server.clone(), channel.clone()), server, channel, scroll_view: scroll_view::State::new(), @@ -304,10 +314,6 @@ impl Channel { } } - pub fn buffer(&self) -> data::Buffer { - data::Buffer::Channel(self.server.clone(), self.channel.clone()) - } - pub fn update( &mut self, message: Message, @@ -326,11 +332,9 @@ impl Channel { (command.map(Message::ScrollView), event) } Message::InputView(message) => { - let buffer = self.buffer(); - - let (command, event) = self - .input_view - .update(message, buffer, clients, history, config); + let (command, event) = + self.input_view + .update(message, &self.buffer, clients, history, config); let command = command.map(Message::InputView); match event { @@ -383,7 +387,7 @@ fn topic<'a>( topic.time.as_ref(), config.buffer.channel.topic.max_lines, users, - &state.buffer(), + &state.buffer, our_user, config, theme, @@ -404,7 +408,7 @@ mod nick_list { pub fn view<'a>( users: &'a [User], - buffer: &Buffer, + buffer: &'a Buffer, our_user: Option<&'a User>, config: &'a Config, ) -> Element<'a, Message> { @@ -442,7 +446,7 @@ mod nick_list { }) .width(Length::Fixed(width)); - user_context::view(content, user, Some(user), buffer.clone(), our_user) + user_context::view(content, user, Some(user), buffer, our_user) })); Scrollable::new(content) diff --git a/src/buffer/channel/topic.rs b/src/buffer/channel/topic.rs index 0f23d56d9..32e1a1035 100644 --- a/src/buffer/channel/topic.rs +++ b/src/buffer/channel/topic.rs @@ -14,7 +14,7 @@ pub fn view<'a>( time: Option<&'a DateTime>, max_lines: u16, users: &'a [User], - buffer: &Buffer, + buffer: &'a Buffer, our_user: Option<&'a User>, config: &'a Config, theme: &'a Theme, @@ -33,7 +33,7 @@ pub fn view<'a>( }), user, Some(user), - buffer.clone(), + buffer, our_user, ) } else { diff --git a/src/buffer/input_view.rs b/src/buffer/input_view.rs index cb200d930..6292bdd45 100644 --- a/src/buffer/input_view.rs +++ b/src/buffer/input_view.rs @@ -117,7 +117,7 @@ impl State { pub fn update( &mut self, message: Message, - buffer: Buffer, + buffer: &Buffer, clients: &mut client::Map, history: &mut history::Manager, config: &Config, @@ -139,14 +139,14 @@ impl State { self.completion.process(&input, users, channels, &isupport); history.record_draft(Draft { - buffer, + buffer: buffer.clone(), text: input, }); (Task::none(), None) } Message::Send => { - let input = history.input(&buffer).draft; + let input = history.input(buffer).draft; // Reset error self.error = None; @@ -174,7 +174,7 @@ impl State { }; if let Some(encoded) = input.encoded() { - clients.send(&buffer, encoded); + clients.send(buffer, encoded); } if let Some(nick) = clients.nickname(buffer.server()) { @@ -201,7 +201,7 @@ impl State { } } Message::Tab(reverse) => { - let input = history.input(&buffer).draft; + let input = history.input(buffer).draft; if let Some(entry) = self.completion.tab(reverse) { let new_input = entry.complete_input(input); @@ -212,7 +212,7 @@ impl State { } } Message::Up => { - let cache = history.input(&buffer); + let cache = history.input(buffer); self.completion.reset(); @@ -245,7 +245,7 @@ impl State { (Task::none(), None) } Message::Down => { - let cache = history.input(&buffer); + let cache = history.input(buffer); self.completion.reset(); @@ -279,11 +279,14 @@ impl State { fn on_completion( &self, - buffer: Buffer, + buffer: &Buffer, history: &mut history::Manager, text: String, ) -> (Task, Option) { - history.record_draft(Draft { buffer, text }); + history.record_draft(Draft { + buffer: buffer.clone(), + text, + }); (text_input::move_cursor_to_end(self.input_id.clone()), None) } diff --git a/src/buffer/query.rs b/src/buffer/query.rs index b386b262a..515ed12b8 100644 --- a/src/buffer/query.rs +++ b/src/buffer/query.rs @@ -27,8 +27,8 @@ pub fn view<'a>( is_focused: bool, ) -> Element<'a, Message> { let status = clients.status(&state.server); - let buffer = state.buffer(); - let input = history.input(&buffer); + let buffer = &state.buffer; + let input = history.input(buffer); let messages = container( scroll_view::view( @@ -71,14 +71,24 @@ pub fn view<'a>( .horizontal_alignment(alignment::Horizontal::Right); } - let nick = user_context::view(text, user, None, state.buffer(), None) + let nick = user_context::view(text, user, None, buffer, None) .map(scroll_view::Message::UserContext); - let message = message_content( + let message = message_content::with_context( &message.content, theme, scroll_view::Message::Link, theme::selectable_text::default, + move |link| match link { + message::Link::Url(_) => vec![], + message::Link::User(_) => user_context::Entry::list(buffer, None), + }, + move |link, entry, length| match link { + message::Link::Url(_) => row![].into(), + message::Link::User(user) => entry + .view(user, None, length) + .map(scroll_view::Message::UserContext), + }, config, ); @@ -201,6 +211,7 @@ pub fn view<'a>( #[derive(Debug, Clone)] pub struct Query { + pub buffer: data::Buffer, pub server: Server, pub nick: Nick, pub scroll_view: scroll_view::State, @@ -210,6 +221,7 @@ pub struct Query { impl Query { pub fn new(server: Server, nick: Nick) -> Self { Self { + buffer: data::Buffer::Query(server.clone(), nick.clone()), server, nick, scroll_view: scroll_view::State::new(), @@ -217,10 +229,6 @@ impl Query { } } - pub fn buffer(&self) -> data::Buffer { - data::Buffer::Query(self.server.clone(), self.nick.clone()) - } - pub fn update( &mut self, message: Message, @@ -239,11 +247,9 @@ impl Query { (command.map(Message::ScrollView), event) } Message::InputView(message) => { - let buffer = self.buffer(); - - let (command, event) = self - .input_view - .update(message, buffer, clients, history, config); + let (command, event) = + self.input_view + .update(message, &self.buffer, clients, history, config); let command = command.map(Message::InputView); match event { diff --git a/src/buffer/server.rs b/src/buffer/server.rs index 493f2b66b..fd1d35256 100644 --- a/src/buffer/server.rs +++ b/src/buffer/server.rs @@ -21,8 +21,8 @@ pub fn view<'a>( is_focused: bool, ) -> Element<'a, Message> { let status = clients.status(&state.server); - let buffer = state.buffer(); - let input = history.input(&buffer); + let buffer = &state.buffer; + let input = history.input(buffer); let messages = container( scroll_view::view( @@ -97,6 +97,7 @@ pub fn view<'a>( #[derive(Debug, Clone)] pub struct Server { + pub buffer: data::Buffer, pub server: data::server::Server, pub scroll_view: scroll_view::State, pub input_view: input_view::State, @@ -105,16 +106,13 @@ pub struct Server { impl Server { pub fn new(server: data::server::Server) -> Self { Self { + buffer: data::Buffer::Server(server.clone()), server, scroll_view: scroll_view::State::new(), input_view: input_view::State::new(), } } - pub fn buffer(&self) -> data::Buffer { - data::Buffer::Server(self.server.clone()) - } - pub fn update( &mut self, message: Message, @@ -128,11 +126,9 @@ impl Server { command.map(Message::ScrollView) } Message::InputView(message) => { - let buffer = self.buffer(); - - let (command, event) = self - .input_view - .update(message, buffer, clients, history, config); + let (command, event) = + self.input_view + .update(message, &self.buffer, clients, history, config); let command = command.map(Message::InputView); match event { diff --git a/src/buffer/user_context.rs b/src/buffer/user_context.rs index 6f2981810..017a17b4d 100644 --- a/src/buffer/user_context.rs +++ b/src/buffer/user_context.rs @@ -7,7 +7,7 @@ use crate::widget::{context_menu, double_pass, Element}; use crate::{icon, theme}; #[derive(Debug, Clone, Copy)] -enum Entry { +pub enum Entry { Whois, Query, ToggleAccessLevelOp, @@ -18,7 +18,7 @@ enum Entry { } impl Entry { - fn list(buffer: &Buffer, our_user: Option<&User>) -> Vec { + pub fn list(buffer: &Buffer, our_user: Option<&User>) -> Vec { match buffer { Buffer::Channel(_, _) => { if our_user.is_some_and(|u| u.has_access_level(data::user::AccessLevel::Oper)) { @@ -44,6 +44,56 @@ impl Entry { Buffer::Server(_) | Buffer::Query(_, _) => vec![Entry::Whois, Entry::SendFile], } } + + pub fn view<'a>( + self, + user: &User, + current_user: Option<&User>, + length: Length, + ) -> Element<'a, Message> { + let nickname = user.nickname().to_owned(); + + match self { + Entry::Whois => menu_button("Whois", Message::Whois(nickname), length), + Entry::Query => menu_button("Message", Message::Query(nickname), length), + Entry::ToggleAccessLevelOp => { + if user.has_access_level(data::user::AccessLevel::Oper) { + menu_button( + "Take Op (-o)", + Message::ToggleAccessLevel(nickname, "-o".to_owned()), + length, + ) + } else { + menu_button( + "Give Op (+o)", + Message::ToggleAccessLevel(nickname, "+o".to_owned()), + length, + ) + } + } + Entry::ToggleAccessLevelVoice => { + if user.has_access_level(data::user::AccessLevel::Voice) { + menu_button( + "Take Voice (-v)", + Message::ToggleAccessLevel(nickname, "-v".to_owned()), + length, + ) + } else { + menu_button( + "Give Voice (+v)", + Message::ToggleAccessLevel(nickname, "+v".to_owned()), + length, + ) + } + } + Entry::SendFile => menu_button("Send File", Message::SendFile(nickname), length), + Entry::UserInfo => user_info(current_user, length), + Entry::HorizontalRule => match length { + Length::Fill => container(horizontal_rule(1)).padding([0, 6]).into(), + _ => Space::new(length, 1).into(), + }, + } + } } #[derive(Clone, Debug)] @@ -86,10 +136,10 @@ pub fn view<'a>( content: impl Into>, user: &'a User, current_user: Option<&'a User>, - buffer: Buffer, + buffer: &'a Buffer, our_user: Option<&'a User>, ) -> Element<'a, Message> { - let entries = Entry::list(&buffer, our_user); + let entries = Entry::list(buffer, our_user); let content = button(content) .padding(0) @@ -97,49 +147,9 @@ pub fn view<'a>( .on_press(Message::SingleClick(user.nickname().to_owned())); context_menu(content, entries, move |entry, length| { - let nickname = user.nickname().to_owned(); - - match entry { - Entry::Whois => menu_button("Whois", Message::Whois(nickname), length), - Entry::Query => menu_button("Message", Message::Query(nickname), length), - Entry::ToggleAccessLevelOp => { - if user.has_access_level(data::user::AccessLevel::Oper) { - menu_button( - "Take Op (-o)", - Message::ToggleAccessLevel(nickname, "-o".to_owned()), - length, - ) - } else { - menu_button( - "Give Op (+o)", - Message::ToggleAccessLevel(nickname, "+o".to_owned()), - length, - ) - } - } - Entry::ToggleAccessLevelVoice => { - if user.has_access_level(data::user::AccessLevel::Voice) { - menu_button( - "Take Voice (-v)", - Message::ToggleAccessLevel(nickname, "-v".to_owned()), - length, - ) - } else { - menu_button( - "Give Voice (+v)", - Message::ToggleAccessLevel(nickname, "+v".to_owned()), - length, - ) - } - } - Entry::SendFile => menu_button("Send File", Message::SendFile(nickname), length), - Entry::UserInfo => user_info(current_user, length), - Entry::HorizontalRule => match length { - Length::Fill => container(horizontal_rule(1)).padding([0, 6]).into(), - _ => Space::new(length, 1).into(), - }, - } + entry.view(user, current_user, length) }) + .into() } fn menu_button(content: &str, message: Message, length: Length) -> Element<'_, Message> { @@ -154,7 +164,7 @@ fn right_justified_padding() -> Padding { padding::all(5).right(5.0 + double_pass::horizontal_expansion()) } -fn user_info(current_user: Option<&User>, length: Length) -> Element<'_, Message> { +fn user_info<'a>(current_user: Option<&User>, length: Length) -> Element<'a, Message> { if let Some(current_user) = current_user { if current_user.is_away() { row![] diff --git a/src/screen/dashboard.rs b/src/screen/dashboard.rs index d6f369c04..0681fe11b 100644 --- a/src/screen/dashboard.rs +++ b/src/screen/dashboard.rs @@ -624,11 +624,9 @@ impl Dashboard { let open_buffers = open_buffers(self, main_window.id); if let Some((window, pane, state)) = self.get_focused_mut(main_window) { - if let Some(buffer) = cycle_next_buffer( - state.buffer.data().as_ref(), - all_buffers, - &open_buffers, - ) { + if let Some(buffer) = + cycle_next_buffer(state.buffer.data(), all_buffers, &open_buffers) + { state.buffer = Buffer::from(buffer); self.focus = None; return (self.focus_pane(main_window, window, pane), None); @@ -641,7 +639,7 @@ impl Dashboard { if let Some((window, pane, state)) = self.get_focused_mut(main_window) { if let Some(buffer) = cycle_previous_buffer( - state.buffer.data().as_ref(), + state.buffer.data(), all_buffers, &open_buffers, ) { @@ -653,7 +651,7 @@ impl Dashboard { } LeaveBuffer => { if let Some((_, _, state)) = self.get_focused_mut(main_window) { - if let Some(buffer) = state.buffer.data() { + if let Some(buffer) = state.buffer.data().cloned() { return self.leave_buffer(main_window, clients, buffer); } } @@ -1009,7 +1007,7 @@ impl Dashboard { // If channel already is open, we focus it. for (window, id, pane) in panes.iter(main_window.id) { - if pane.buffer.data().as_ref() == Some(&kind) { + if pane.buffer.data() == Some(&kind) { self.focus = Some((window, id)); return self.focus_pane(main_window, window, id); @@ -1069,7 +1067,7 @@ impl Dashboard { .panes .iter(main_window.id) .find_map(|(window, pane, state)| { - (state.buffer.data().as_ref() == Some(&buffer)).then_some((window, pane)) + (state.buffer.data() == Some(&buffer)).then_some((window, pane)) }); let mut tasks = vec![]; @@ -1436,7 +1434,7 @@ impl Dashboard { .remove(&window) .and_then(|panes| panes.get(pane).cloned()) { - let task = match pane.buffer.data() { + let task = match pane.buffer.data().cloned() { Some(buffer) => self.open_buffer(buffer, pane.settings, main_window), None if matches!(pane.buffer, Buffer::FileTransfers(_)) => { self.toggle_file_transfers(config, main_window) @@ -1885,6 +1883,7 @@ fn open_buffers(dashboard: &Dashboard, main_window: window::Id) -> Vec = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(super::container::tooltip) + } + + fn style(&self, class: &Self::Class<'_>) -> Style { + class(self) + } +} diff --git a/src/widget.rs b/src/widget.rs index 360f74427..62c28b133 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -1,11 +1,7 @@ #![allow(dead_code)] -use data::theme::randomize_color; -use data::user::NickColor; -use data::{message, Config}; -use iced::widget::span; -use iced::{alignment, border}; +use iced::alignment; -use crate::{font, Theme}; +use crate::Theme; pub use self::anchored_overlay::anchored_overlay; pub use self::color_picker::color_picker; @@ -14,6 +10,7 @@ pub use self::context_menu::context_menu; pub use self::decorate::decorate; pub use self::double_pass::double_pass; pub use self::key_press::key_press; +pub use self::message_content::message_content; pub use self::modal::modal; pub use self::selectable_rich_text::selectable_rich_text; pub use self::selectable_text::selectable_text; @@ -30,6 +27,7 @@ pub mod double_click; pub mod double_pass; pub mod hover; pub mod key_press; +pub mod message_content; pub mod modal; pub mod selectable_rich_text; pub mod selectable_text; @@ -46,88 +44,6 @@ pub type Text<'a> = iced::widget::Text<'a, Theme, Renderer>; pub type Container<'a, Message> = iced::widget::Container<'a, Message, Theme, Renderer>; pub type Button<'a, Message> = iced::widget::Button<'a, Message, Theme>; -pub fn message_content<'a, M: 'a>( - content: &'a message::Content, - theme: &'a Theme, - on_link: impl Fn(message::Link) -> M + 'a, - style: impl Fn(&Theme) -> selectable_text::Style + 'a, - config: &Config, -) -> Element<'a, M> { - match content { - data::message::Content::Plain(text) => selectable_text(text).style(style).into(), - data::message::Content::Fragments(fragments) => selectable_rich_text( - fragments - .iter() - .map(|fragment| match fragment { - data::message::Fragment::Text(s) => span(s), - data::message::Fragment::User(user) => { - let color_kind = &config.buffer.channel.message.nickname_color; - - let NickColor { seed, color } = - user.nick_color(theme.colors(), *color_kind); - - let color = match seed { - Some(seed) => randomize_color(color, &seed), - None => theme.colors().text.primary, - }; - - span(user.nickname().to_string()) - .color(color) - .link(message::Link::User(user.clone())) - } - data::message::Fragment::Url(s) => span(s.as_str()) - .color(theme.colors().buffer.url) - .link(message::Link::Url(s.as_str().to_string())), - data::message::Fragment::Formatted { text, formatting } => { - let mut span = span(text) - .color_maybe( - formatting - .fg - .and_then(|color| color.into_iced(theme.colors())), - ) - .background_maybe( - formatting - .bg - .and_then(|color| color.into_iced(theme.colors())), - ) - .underline(formatting.underline) - .strikethrough(formatting.strikethrough); - - if formatting.monospace { - span = span - .padding([0, 4]) - .color(theme.colors().buffer.code) - .border( - border::rounded(3) - .color(theme.colors().general.border) - .width(1), - ); - } - - match (formatting.bold, formatting.italics) { - (true, true) => { - span = span.font(font::MONO_BOLD_ITALICS.clone()); - } - (true, false) => { - span = span.font(font::MONO_BOLD.clone()); - } - (false, true) => { - span = span.font(font::MONO_ITALICS.clone()); - } - (false, false) => {} - } - - span - } - }) - .collect::>(), - ) - .on_link(on_link) - .style(style) - .into(), - } -} - pub fn message_marker<'a, M: 'a>( width: Option, style: impl Fn(&Theme) -> selectable_text::Style + 'a, diff --git a/src/widget/context_menu.rs b/src/widget/context_menu.rs index 5aa3d50ed..e45865ebf 100644 --- a/src/widget/context_menu.rs +++ b/src/widget/context_menu.rs @@ -1,22 +1,19 @@ use std::slice; use iced::advanced::widget::{operation, tree, Operation}; -use iced::advanced::{layout, overlay, renderer, widget, Clipboard, Layout, Shell, Widget}; +use iced::advanced::{self, layout, overlay, renderer, widget, Clipboard, Layout, Shell, Widget}; use iced::widget::{column, container}; -use iced::{event, mouse, Event, Length, Point, Rectangle, Size, Task, Vector}; +use iced::{event, mouse, Element, Event, Length, Point, Rectangle, Size, Task, Vector}; -use super::{double_pass, Element, Renderer}; -use crate::{theme, Theme}; +pub use iced::widget::container::{Style, StyleFn}; -pub fn context_menu<'a, T, Message>( - base: impl Into>, +use super::double_pass; + +pub fn context_menu<'a, T, Message, Theme, Renderer>( + base: impl Into>, entries: Vec, - entry: impl Fn(T, Length) -> Element<'a, Message> + 'a, -) -> Element<'a, Message> -where - Message: 'a, - T: 'a + Copy, -{ + entry: impl Fn(T, Length) -> Element<'a, Message, Theme, Renderer> + 'a, +) -> ContextMenu<'a, T, Message, Theme, Renderer> { ContextMenu { base: base.into(), entries, @@ -24,54 +21,40 @@ where menu: None, } - .into() } -fn menu<'a, T, Message>( - entries: &[T], - entry: &(dyn Fn(T, Length) -> Element<'a, Message> + 'a), -) -> Element<'a, Message> -where - Message: 'a, - T: Copy + 'a, -{ - let build_menu = |length, view: &(dyn Fn(T, Length) -> Element<'a, Message> + 'a)| { - container(column( - entries.iter().copied().map(|entry| view(entry, length)), - )) - .padding(4) - .style(theme::container::tooltip) - }; - - double_pass( - build_menu(Length::Shrink, entry), - build_menu(Length::Fill, entry), - ) -} - -struct ContextMenu<'a, T, Message> { - base: Element<'a, Message>, +pub struct ContextMenu<'a, T, Message, Theme, Renderer> { + base: Element<'a, Message, Theme, Renderer>, entries: Vec, - entry: Box Element<'a, Message> + 'a>, + entry: Box Element<'a, Message, Theme, Renderer> + 'a>, // Cached, recreated during `overlay` if menu is open - menu: Option>, + menu: Option>, } #[derive(Debug)] -struct State { - status: Status, +pub struct State { + pub status: Status, menu_tree: widget::Tree, } +impl State { + pub fn new() -> Self { + State { + status: Status::Closed, + menu_tree: widget::Tree::empty(), + } + } +} + #[derive(Debug, Clone, Copy)] -enum Status { +pub enum Status { Closed, Open(Point), } impl Status { - fn open(self) -> Option { + pub fn open(self) -> Option { match self { Status::Closed => None, Status::Open(position) => Some(position), @@ -79,10 +62,14 @@ impl Status { } } -impl<'a, T, Message> Widget for ContextMenu<'a, T, Message> +impl<'a, T, Message, Theme, Renderer> Widget + for ContextMenu<'a, T, Message, Theme, Renderer> where - Message: 'a, T: Copy + 'a, + Message: 'a, + Theme: 'a + container::Catalog + Catalog, + ::Class<'a>: From>, + Renderer: advanced::Renderer + 'a, { fn size(&self) -> Size { self.base.as_widget().size() @@ -211,40 +198,21 @@ where renderer: &Renderer, translation: Vector, ) -> Option> { - let state = tree.state.downcast_mut::(); - let base_state = tree.children.first_mut().unwrap(); let base = self .base .as_widget_mut() .overlay(base_state, layout, renderer, translation); - // Ensure overlay is created / diff'd - match state.status { - Status::Open(_) => match &self.menu { - Some(menu) => state.menu_tree.diff(menu), - None => { - let menu = menu(&self.entries, &self.entry); - state.menu_tree = widget::Tree::new(&menu); - self.menu = Some(menu); - } - }, - Status::Closed => { - self.menu = None; - } - } + let state = tree.state.downcast_mut::(); - let overlay = state - .status - .open() - .zip(self.menu.as_mut()) - .map(|(position, menu)| { - overlay::Element::new(Box::new(Overlay { - menu, - state, - position: position + translation, - })) - }); + let overlay = overlay( + state, + &mut self.menu, + &self.entries, + &self.entry, + translation, + ); if base.is_none() && overlay.is_none() { None @@ -254,6 +222,78 @@ where } } +fn build_menu<'a, T, Message, Theme, Renderer>( + entries: &[T], + entry: &(dyn Fn(T, Length) -> Element<'a, Message, Theme, Renderer> + 'a), +) -> Element<'a, Message, Theme, Renderer> +where + T: Copy + 'a, + Message: 'a, + Theme: 'a + container::Catalog + Catalog, + ::Class<'a>: From>, + Renderer: advanced::Renderer + 'a, +{ + let build_menu = + |length, view: &(dyn Fn(T, Length) -> Element<'a, Message, Theme, Renderer> + 'a)| { + container(column( + entries.iter().copied().map(|entry| view(entry, length)), + )) + .padding(4) + .style(|theme| ::style(theme, &::default())) + }; + + double_pass( + build_menu(Length::Shrink, entry), + build_menu(Length::Fill, entry), + ) +} + +pub fn overlay<'a, 'b, T, Message, Theme, Renderer>( + state: &'b mut State, + menu: &'b mut Option>, + entries: &[T], + entry: &(dyn Fn(T, Length) -> Element<'a, Message, Theme, Renderer> + 'a), + translation: Vector, +) -> Option> +where + T: Copy + 'a, + Message: 'a, + Theme: 'a + container::Catalog + Catalog, + ::Class<'a>: From>, + Renderer: advanced::Renderer + 'a, +{ + if entries.is_empty() { + return None; + } + + // Ensure overlay is created / diff'd + match state.status { + Status::Open(_) => match menu { + Some(menu) => state.menu_tree.diff(&*menu), + None => { + let _menu = build_menu(entries, entry); + state.menu_tree = widget::Tree::new(&_menu); + *menu = Some(_menu); + } + }, + Status::Closed => { + *menu = None; + } + } + + state + .status + .open() + .zip(menu.as_mut()) + .map(|(position, menu)| { + overlay::Element::new(Box::new(Overlay { + menu, + state, + position: position + translation, + })) + }) +} + pub fn close(f: fn(bool) -> Message) -> Task { struct Close { any_closed: bool, @@ -290,23 +330,31 @@ pub fn close(f: fn(bool) -> Message) -> Task { }) } -impl<'a, T, Message> From> for Element<'a, Message> +impl<'a, T, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> where - Message: 'a, T: Copy + 'a, + Message: 'a, + Theme: 'a + container::Catalog + Catalog, + ::Class<'a>: From>, + Renderer: advanced::Renderer + 'a, { - fn from(context_menu: ContextMenu<'a, T, Message>) -> Self { + fn from(context_menu: ContextMenu<'a, T, Message, Theme, Renderer>) -> Self { Element::new(context_menu) } } -struct Overlay<'a, 'b, Message> { - menu: &'b mut Element<'a, Message>, +struct Overlay<'a, 'b, Message, Theme, Renderer> { + menu: &'b mut Element<'a, Message, Theme, Renderer>, state: &'b mut State, position: Point, } -impl<'a, 'b, Message> overlay::Overlay for Overlay<'a, 'b, Message> { +impl<'a, 'b, Message, Theme, Renderer> overlay::Overlay + for Overlay<'a, 'b, Message, Theme, Renderer> +where + Renderer: advanced::Renderer, +{ fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { let limits = layout::Limits::new(Size::ZERO, bounds) .width(Length::Fill) @@ -418,3 +466,15 @@ impl<'a, 'b, Message> overlay::Overlay for Overlay<'a, layout.bounds().contains(cursor_position) } } + +/// The theme catalog of a [`Catalog`]. +pub trait Catalog { + /// The item class of the [`Catalog`]. + type Class<'a>; + + /// The default class produced by the [`Catalog`]. + fn default<'a>() -> Self::Class<'a>; + + /// The [`Style`] of a class with the given status. + fn style(&self, class: &Self::Class<'_>) -> container::Style; +} diff --git a/src/widget/double_pass.rs b/src/widget/double_pass.rs index d3e3090c7..430859905 100644 --- a/src/widget/double_pass.rs +++ b/src/widget/double_pass.rs @@ -2,19 +2,20 @@ //! //! Layout from first pass is used as the limits for the second pass -use iced::advanced::{layout, widget}; -use iced::Size; +use iced::advanced::{self, layout, widget}; +use iced::{Element, Size}; -use super::{decorate, Element, Renderer}; -use crate::Theme; +use super::decorate; /// Layout from first pass is used as the limits for the second pass -pub fn double_pass<'a, Message>( - first_pass: impl Into>, - second_pass: impl Into>, -) -> Element<'a, Message> +pub fn double_pass<'a, Message, Theme, Renderer>( + first_pass: impl Into>, + second_pass: impl Into>, +) -> Element<'a, Message, Theme, Renderer> where Message: 'a, + Theme: 'a, + Renderer: advanced::Renderer + 'a, { decorate(second_pass) .layout(Layout { @@ -23,11 +24,17 @@ where .into() } -struct Layout<'a, Message> { - first_pass: Element<'a, Message>, +struct Layout<'a, Message, Theme, Renderer> { + first_pass: Element<'a, Message, Theme, Renderer>, } -impl<'a, Message> decorate::Layout<'a, Message, Theme, Renderer, ()> for Layout<'a, Message> { +impl<'a, Message, Theme, Renderer> decorate::Layout<'a, Message, Theme, Renderer, ()> + for Layout<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: advanced::Renderer + 'a, +{ fn layout( &self, _state: &mut (), diff --git a/src/widget/message_content.rs b/src/widget/message_content.rs new file mode 100644 index 000000000..9e92615f1 --- /dev/null +++ b/src/widget/message_content.rs @@ -0,0 +1,139 @@ +use data::theme::randomize_color; +use data::user::NickColor; +use data::{message, Config}; +use iced::widget::span; +use iced::{border, Length}; + +use crate::{font, Theme}; + +use super::{selectable_rich_text, selectable_text, Element, Renderer}; + +pub fn message_content<'a, M: 'a>( + content: &'a message::Content, + theme: &'a Theme, + on_link: impl Fn(message::Link) -> M + 'a, + style: impl Fn(&Theme) -> selectable_text::Style + 'a, + config: &Config, +) -> Element<'a, M> { + message_content_impl::<(), M>( + content, + theme, + on_link, + style, + Option::<(fn(&message::Link) -> _, fn(&message::Link, _, _) -> _)>::None, + config, + ) +} + +pub fn with_context<'a, T: Copy + 'a, M: 'a>( + content: &'a message::Content, + theme: &'a Theme, + on_link: impl Fn(message::Link) -> M + 'a, + style: impl Fn(&Theme) -> selectable_text::Style + 'a, + link_entries: impl Fn(&message::Link) -> Vec + 'a, + entry: impl Fn(&message::Link, T, Length) -> Element<'a, M> + 'a, + config: &Config, +) -> Element<'a, M> { + message_content_impl( + content, + theme, + on_link, + style, + Some((link_entries, entry)), + config, + ) +} + +#[allow(clippy::type_complexity)] +fn message_content_impl<'a, T: Copy + 'a, M: 'a>( + content: &'a message::Content, + theme: &'a Theme, + on_link: impl Fn(message::Link) -> M + 'a, + style: impl Fn(&Theme) -> selectable_text::Style + 'a, + context_menu: Option<( + impl Fn(&message::Link) -> Vec + 'a, + impl Fn(&message::Link, T, Length) -> Element<'a, M> + 'a, + )>, + config: &Config, +) -> Element<'a, M> { + match content { + data::message::Content::Plain(text) => selectable_text(text).style(style).into(), + data::message::Content::Fragments(fragments) => { + let mut text = selectable_rich_text::( + fragments + .iter() + .map(|fragment| match fragment { + data::message::Fragment::Text(s) => span(s), + data::message::Fragment::User(user) => { + let color_kind = &config.buffer.channel.message.nickname_color; + + let NickColor { seed, color } = + user.nick_color(theme.colors(), *color_kind); + + let color = match seed { + Some(seed) => randomize_color(color, &seed), + None => theme.colors().text.primary, + }; + + span(user.nickname().to_string()) + .color(color) + .link(message::Link::User(user.clone())) + } + data::message::Fragment::Url(s) => span(s.as_str()) + .color(theme.colors().buffer.url) + .link(message::Link::Url(s.as_str().to_string())), + data::message::Fragment::Formatted { text, formatting } => { + let mut span = span(text) + .color_maybe( + formatting + .fg + .and_then(|color| color.into_iced(theme.colors())), + ) + .background_maybe( + formatting + .bg + .and_then(|color| color.into_iced(theme.colors())), + ) + .underline(formatting.underline) + .strikethrough(formatting.strikethrough); + + if formatting.monospace { + span = span + .padding([0, 4]) + .color(theme.colors().buffer.code) + .border( + border::rounded(3) + .color(theme.colors().general.border) + .width(1), + ); + } + + match (formatting.bold, formatting.italics) { + (true, true) => { + span = span.font(font::MONO_BOLD_ITALICS.clone()); + } + (true, false) => { + span = span.font(font::MONO_BOLD.clone()); + } + (false, true) => { + span = span.font(font::MONO_ITALICS.clone()); + } + (false, false) => {} + } + + span + } + }) + .collect::>(), + ) + .on_link(on_link) + .style(style); + + if let Some((link_entries, view)) = context_menu { + text = text.context_menu(link_entries, view); + } + + text.into() + } + } +} diff --git a/src/widget/selectable_rich_text.rs b/src/widget/selectable_rich_text.rs index 48c11bcd3..d633a783b 100644 --- a/src/widget/selectable_rich_text.rs +++ b/src/widget/selectable_rich_text.rs @@ -11,6 +11,7 @@ use iced::alignment; use iced::event; use iced::mouse; use iced::widget; +use iced::widget::container; use iced::widget::text::{LineHeight, Shaping}; use iced::widget::text_input::Value; use iced::Border; @@ -21,14 +22,16 @@ use iced::Vector; use iced::{self, Background, Color, Element, Event, Pixels, Rectangle, Size}; use itertools::Itertools; +use super::context_menu; use super::selectable_text::{selection, Catalog, Interaction, Style, StyleFn}; use std::borrow::Cow; +use std::sync::Arc; /// Creates a new [`Rich`] text widget with the provided spans. -pub fn selectable_rich_text<'a, Message, Link, Theme, Renderer>( +pub fn selectable_rich_text<'a, Message, Link, Entry, Theme, Renderer>( spans: impl Into]>>, -) -> Rich<'a, Message, Link, Theme, Renderer> +) -> Rich<'a, Message, Link, Entry, Theme, Renderer> where Link: self::Link + 'static, Theme: Catalog, @@ -39,7 +42,7 @@ where /// A bunch of [`Rich`] text. #[allow(missing_debug_implementations)] -pub struct Rich<'a, Message, Link = (), Theme = iced::Theme, Renderer = iced::Renderer> +pub struct Rich<'a, Message, Link = (), Entry = (), Theme = iced::Theme, Renderer = iced::Renderer> where Link: self::Link + 'static, Theme: Catalog, @@ -55,9 +58,17 @@ where align_y: alignment::Vertical, class: Theme::Class<'a>, on_link: Option Message + 'a>>, + + #[allow(clippy::type_complexity)] + context_menu: Option<( + Box Vec + 'a>, + Arc Element<'a, Message, Theme, Renderer> + 'a>, + )>, + cached_entries: Vec, + cached_menu: Option>, } -impl<'a, Message, Link, Theme, Renderer> Rich<'a, Message, Link, Theme, Renderer> +impl<'a, Message, Link, Entry, Theme, Renderer> Rich<'a, Message, Link, Entry, Theme, Renderer> where Link: self::Link + 'static, Theme: Catalog, @@ -76,6 +87,10 @@ where align_y: alignment::Vertical::Top, class: Theme::default(), on_link: None, + + context_menu: None, + cached_entries: vec![], + cached_menu: None, } } @@ -172,14 +187,20 @@ where self } - /// Adds a new text [`Span`] to the [`Rich`] text. - pub fn push(mut self, span: impl Into>) -> Self { - self.spans.to_mut().push(span.into()); - self + pub fn context_menu( + self, + link_entries: impl Fn(&Link) -> Vec + 'a, + view: impl Fn(&Link, Entry, Length) -> Element<'a, Message, Theme, Renderer> + 'a, + ) -> Self { + Self { + context_menu: Some((Box::new(link_entries), Arc::new(view))), + ..self + } } } -impl<'a, Message, Link, Theme, Renderer> Default for Rich<'a, Message, Link, Theme, Renderer> +impl<'a, Message, Link, Entry, Theme, Renderer> Default + for Rich<'a, Message, Link, Entry, Theme, Renderer> where Link: self::Link + 'static, Theme: Catalog, @@ -204,14 +225,20 @@ struct State { paragraph: P, interaction: Interaction, shown_spoiler: Option<(usize, Color, Highlight)>, + + context_menu_link: Option, + context_menu: context_menu::State, } -impl<'a, Message, Link, Theme, Renderer> Widget - for Rich<'a, Message, Link, Theme, Renderer> +impl<'a, Message, Link, Entry, Theme, Renderer> Widget + for Rich<'a, Message, Link, Entry, Theme, Renderer> where + Message: 'a, Link: self::Link + 'static, - Theme: Catalog, - Renderer: text::Renderer, + Entry: Copy + 'a, + Theme: 'a + container::Catalog + context_menu::Catalog + Catalog, + ::Class<'a>: From>, + Renderer: text::Renderer + 'a, { fn tag(&self) -> tree::Tag { tree::Tag::of::>() @@ -224,6 +251,8 @@ where paragraph: Renderer::Paragraph::default(), interaction: Interaction::default(), shown_spoiler: None, + context_menu_link: None, + context_menu: context_menu::State::new(), }) } @@ -265,18 +294,26 @@ where renderer: &Renderer, _clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, - _viewport: &Rectangle, + viewport: &Rectangle, ) -> event::Status { let state = tree .state .downcast_mut::>(); + let bounds = layout.bounds(); + + if viewport.intersection(&bounds).is_none() + && matches!(state.interaction, Interaction::Idle) + { + return event::Status::Ignored; + } + let mut status = event::Status::Ignored; match event { iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | iced::Event::Touch(touch::Event::FingerPressed { .. }) => { - if let Some(position) = cursor.position_in(layout.bounds()) { + if let Some(position) = cursor.position_in(bounds) { if let Some(span) = state.paragraph.hit_span(position) { state.span_pressed = Some(span); @@ -300,7 +337,7 @@ where if let Some(span_pressed) = state.span_pressed { state.span_pressed = None; - if let Some(position) = cursor.position_in(layout.bounds()) { + if let Some(position) = cursor.position_in(bounds) { match state.paragraph.hit_span(position) { Some(span) if span == span_pressed => { if let Some(link) = @@ -329,8 +366,6 @@ where } } - let bounds = layout.bounds(); - let size = self.size.unwrap_or_else(|| renderer.default_size()); let font = self.font.unwrap_or_else(|| renderer.default_font()); @@ -388,6 +423,39 @@ where Renderer::Paragraph::with_spans(text_with_spans(state.spans.as_ref())); } } + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => { + if let Some(position) = cursor.position_in(bounds) { + if let Some((link_entries, _)) = &self.context_menu { + if let Some((link, entries)) = + state.spans.iter().enumerate().find_map(|(i, span)| { + if span.link.is_some() + && state + .paragraph + .span_bounds(i) + .into_iter() + .any(|bounds| bounds.contains(position)) + { + let link = span.link.clone().unwrap(); + let entries = (link_entries)(&link); + + if !entries.is_empty() { + return Some((link, entries)); + } + } + + None + }) + { + state.context_menu.status = context_menu::Status::Open( + // Need absolute position. Infallible since we're within position_in + cursor.position_over(bounds).unwrap(), + ); + state.context_menu_link = Some(link); + self.cached_entries = entries; + } + } + } + } _ => {} } @@ -414,7 +482,7 @@ where .state .downcast_ref::>(); - let style = theme.style(&self.class); + let style = ::style(theme, &self.class); let hovered_span = cursor .position_in(layout.bounds()) @@ -597,7 +665,7 @@ where ) { let state = tree .state - .downcast_ref::>(); + .downcast_mut::>(); let bounds = layout.bounds(); let value = Value::new(&self.spans.iter().map(|s| s.text.as_ref()).join("")); @@ -609,6 +677,49 @@ where let content = value.select(selection.start, selection.end).to_string(); operation.custom(&mut (bounds.y, content), None); } + + // Context menu + operation.custom(&mut state.context_menu, None); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + _layout: Layout<'_>, + _renderer: &Renderer, + translation: Vector, + ) -> Option> { + let state = tree + .state + .downcast_mut::>(); + + // Sync local state w/ context menu change + if state.context_menu.status.open().is_none() { + state.context_menu_link = None; + } + + if let Some((link, (link_entries, view))) = state + .context_menu_link + .clone() + .zip(self.context_menu.as_ref()) + { + let view = view.clone(); + + // Rebuild if not cached (view recreated) + if self.cached_entries.is_empty() { + self.cached_entries = link_entries(&link); + } + + context_menu::overlay( + &mut state.context_menu, + &mut self.cached_menu, + &self.cached_entries, + &move |entry, length| view(&link, entry, length), + translation, + ) + } else { + None + } } } @@ -695,8 +806,8 @@ where }) } -impl<'a, Message, Link, Theme, Renderer> FromIterator> - for Rich<'a, Message, Link, Theme, Renderer> +impl<'a, Message, Link, Entry, Theme, Renderer> FromIterator> + for Rich<'a, Message, Link, Entry, Theme, Renderer> where Link: self::Link + 'static, Theme: Catalog, @@ -710,16 +821,18 @@ where } } -impl<'a, Message, Link, Theme, Renderer> From> - for Element<'a, Message, Theme, Renderer> +impl<'a, Message, Link, Entry, Theme, Renderer> + From> for Element<'a, Message, Theme, Renderer> where Message: 'a, Link: self::Link + 'static, - Theme: Catalog + 'a, + Entry: Copy + 'a, + Theme: 'a + container::Catalog + context_menu::Catalog + Catalog, + ::Class<'a>: From>, Renderer: text::Renderer + 'a, { fn from( - text: Rich<'a, Message, Link, Theme, Renderer>, + text: Rich<'a, Message, Link, Entry, Theme, Renderer>, ) -> Element<'a, Message, Theme, Renderer> { Element::new(text) }