diff --git a/data/src/history.rs b/data/src/history.rs index 033bac92..720240de 100644 --- a/data/src/history.rs +++ b/data/src/history.rs @@ -395,6 +395,14 @@ impl History { *stored = (*stored).max(Some(read_marker)); } + + pub fn read_marker(&self) -> Option { + match self { + History::Partial { read_marker, .. } | History::Full { read_marker, .. } => { + *read_marker + } + } + } } #[derive(Debug)] diff --git a/data/src/history/manager.rs b/data/src/history/manager.rs index e8045014..e5f44b78 100644 --- a/data/src/history/manager.rs +++ b/data/src/history/manager.rs @@ -12,9 +12,6 @@ use crate::user::Nick; use crate::{config, input}; use crate::{server, Buffer, Config, Input, Server, User}; -// Hack since log messages are app wide and not scoped to any server -const LOG_SERVER_NAME: &str = ""; - #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Resource { pub server: server::Server, @@ -24,7 +21,7 @@ pub struct Resource { impl Resource { pub fn logs() -> Self { Self { - server: Server::from(LOG_SERVER_NAME), + server: server::LOGS.clone(), kind: history::Kind::Logs, } } @@ -64,6 +61,7 @@ pub enum Message { } pub enum Event { + Loaded(server::Server, history::Kind), Closed(server::Server, history::Kind, Option), Exited(Vec<(Server, history::Kind, Option)>), } @@ -111,7 +109,8 @@ impl Manager { "loaded history for {kind} on {server}: {} messages", loaded.messages.len() ); - self.data.load_full(server, kind, loaded); + self.data.load_full(server.clone(), kind.clone(), loaded); + return Some(Event::Loaded(server, kind)); } Message::LoadFull(server, kind, Err(error)) => { log::warn!("failed to load history for {kind} on {server}: {error}"); @@ -245,7 +244,7 @@ impl Manager { record: crate::log::Record, ) -> Option> { self.data.add_message( - Server::from(LOG_SERVER_NAME), + server::LOGS.clone(), history::Kind::Logs, crate::Message::log(record), ) @@ -268,57 +267,14 @@ impl Manager { self.data.channel_joined(server, channel) } - pub fn get_channel_messages( - &self, - server: &Server, - channel: &str, - limit: Option, - buffer_config: &config::Buffer, - ) -> Option> { - self.data.history_view( - server, - &history::Kind::Channel(channel.to_string()), - limit, - buffer_config, - ) - } - - pub fn get_server_messages( - &self, - server: &Server, - limit: Option, - buffer_config: &config::Buffer, - ) -> Option> { - self.data - .history_view(server, &history::Kind::Server, limit, buffer_config) - } - - pub fn get_query_messages( + pub fn get_messages( &self, server: &Server, - nick: &Nick, - limit: Option, - buffer_config: &config::Buffer, - ) -> Option> { - self.data.history_view( - server, - &history::Kind::Query(nick.clone()), - limit, - buffer_config, - ) - } - - pub fn get_log_messages( - &self, + kind: &history::Kind, limit: Option, buffer_config: &config::Buffer, ) -> Option> { - self.data.history_view( - &Server::from(LOG_SERVER_NAME), - &history::Kind::Logs, - limit, - buffer_config, - ) + self.data.history_view(server, kind, limit, buffer_config) } pub fn get_unique_queries(&self, server: &Server) -> Vec<&Nick> { diff --git a/data/src/history/metadata.rs b/data/src/history/metadata.rs index 22b471a9..76f0d68e 100644 --- a/data/src/history/metadata.rs +++ b/data/src/history/metadata.rs @@ -7,7 +7,8 @@ use serde::{Deserialize, Serialize}; use tokio::fs; use crate::history::{dir_path, Error, Kind}; -use crate::{message, server, Message}; +use crate::message::source; +use crate::{server, Message}; #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)] pub struct Metadata { @@ -23,7 +24,14 @@ impl ReadMarker { messages .iter() .rev() - .find(|message| !matches!(message.target.source(), message::Source::Internal(_))) + .find(|message| match message.target.source() { + source::Source::Internal(source) => match source { + source::Internal::Status(_) => false, + // Logs are in their own buffer and this gives us backlog support there + source::Internal::Logs => true, + }, + _ => true, + }) .map(|message| message.server_time) .map(Self) } diff --git a/data/src/input.rs b/data/src/input.rs index e718271d..560aa998 100644 --- a/data/src/input.rs +++ b/data/src/input.rs @@ -1,12 +1,10 @@ use std::collections::HashMap; -use chrono::Utc; use irc::proto; use irc::proto::format; use crate::buffer::AutoFormat; use crate::message::formatting; -use crate::time::Posix; use crate::{command, message, Buffer, Command, Message, Server, User}; const INPUT_HISTORY_LENGTH: usize = 100; @@ -90,24 +88,18 @@ impl Input { targets .split(',') .filter_map(|target| to_target(target, message::Source::User(user.clone()))) - .map(|target| Message { - received_at: Posix::now(), - server_time: Utc::now(), - direction: message::Direction::Sent, - target, - content: message::parse_fragments(text.clone(), channel_users), - id: None, + .map(|target| { + Message::sent( + target, + message::parse_fragments(text.clone(), channel_users), + ) }) .collect(), ), - Command::Me(target, action) => Some(vec![Message { - received_at: Posix::now(), - server_time: Utc::now(), - direction: message::Direction::Sent, - target: to_target(&target, message::Source::Action)?, - content: message::action_text(user.nickname(), Some(&action)), - id: None, - }]), + Command::Me(target, action) => Some(vec![Message::sent( + to_target(&target, message::Source::Action)?, + message::action_text(user.nickname(), Some(&action)), + )]), _ => None, } } diff --git a/data/src/log.rs b/data/src/log.rs index 25975e77..bc79e3af 100644 --- a/data/src/log.rs +++ b/data/src/log.rs @@ -28,7 +28,7 @@ fn path() -> Result { Ok(parent.join("halloy.log")) } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Record { pub timestamp: DateTime, pub level: Level, diff --git a/data/src/message.rs b/data/src/message.rs index 6b661f81..95fb3f5d 100644 --- a/data/src/message.rs +++ b/data/src/message.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::hash::{DefaultHasher, Hash as _, Hasher}; use std::iter; use chrono::{DateTime, Utc}; @@ -153,6 +154,7 @@ pub struct Message { pub target: Target, pub content: Content, pub id: Option, + pub hash: Hash, } impl Message { @@ -168,6 +170,7 @@ impl Message { | source::server::Kind::MonitoredOffline ) } + Source::Internal(source::Internal::Logs) => true, _ => false, } } @@ -189,42 +192,70 @@ impl Message { &channel_users, )?; let target = target(encoded, &our_nick, &resolve_attributes)?; + let received_at = Posix::now(); + let hash = Hash::new(&received_at, &content); Some(Message { - received_at: Posix::now(), + received_at, server_time, direction: Direction::Received, target, content, id, + hash, }) } + pub fn sent(target: Target, content: Content) -> Self { + let received_at = Posix::now(); + let hash = Hash::new(&received_at, &content); + + Message { + received_at, + server_time: Utc::now(), + direction: Direction::Sent, + target, + content, + id: None, + hash, + } + } + pub fn file_transfer_request_received(from: &Nick, filename: &str) -> Message { + let received_at = Posix::now(); + let content = plain(format!("{from} wants to send you \"{filename}\"")); + let hash = Hash::new(&received_at, &content); + Message { - received_at: Posix::now(), + received_at, server_time: Utc::now(), direction: Direction::Received, target: Target::Query { nick: from.clone(), source: Source::Action, }, - content: plain(format!("{from} wants to send you \"{filename}\"")), + content, id: None, + hash, } } pub fn file_transfer_request_sent(to: &Nick, filename: &str) -> Message { + let received_at = Posix::now(); + let content = plain(format!("offering to send {to} \"{filename}\"")); + let hash = Hash::new(&received_at, &content); + Message { - received_at: Posix::now(), + received_at, server_time: Utc::now(), direction: Direction::Sent, target: Target::Query { nick: to.clone(), source: Source::Action, }, - content: plain(format!("offering to send {to} \"{filename}\"")), + content, id: None, + hash, } } @@ -241,13 +272,19 @@ impl Message { } pub fn log(record: crate::log::Record) -> Self { + let received_at = Posix::now(); + let server_time = record.timestamp; + let content = Content::Log(record); + let hash = Hash::new(&received_at, &content); + Self { - received_at: Posix::now(), - server_time: record.timestamp, + received_at, + server_time, direction: Direction::Received, target: Target::Logs, - content: Content::Log(record), + content, id: None, + hash, } } } @@ -320,6 +357,8 @@ impl<'de> Deserialize<'de> for Message { Content::Plain("".to_string()) }; + let hash = Hash::new(&received_at, &content); + Ok(Message { received_at, server_time, @@ -327,10 +366,23 @@ impl<'de> Deserialize<'de> for Message { target, content, id, + hash, }) } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Hash(u64); + +impl Hash { + pub fn new(received_at: &time::Posix, content: &Content) -> Self { + let mut hasher = DefaultHasher::new(); + received_at.hash(&mut hasher); + content.hash(&mut hasher); + Self(hasher.finish()) + } +} + pub fn plain(text: String) -> Content { Content::Plain(text) } @@ -478,7 +530,7 @@ fn parse_user_and_channel_fragments(text: &str, channel_users: &[User]) -> Vec), @@ -495,7 +547,7 @@ impl Content { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Fragment { Text(String), Channel(String), @@ -1036,7 +1088,7 @@ pub enum Limit { impl Limit { pub const DEFAULT_STEP: usize = 50; - const DEFAULT_COUNT: usize = 500; + pub const DEFAULT_COUNT: usize = 500; pub fn top() -> Self { Self::Top(Self::DEFAULT_COUNT) diff --git a/data/src/message/broadcast.rs b/data/src/message/broadcast.rs index 3269ca99..b0269c8d 100644 --- a/data/src/message/broadcast.rs +++ b/data/src/message/broadcast.rs @@ -5,7 +5,7 @@ use super::{parse_fragments, plain, source, Content, Direction, Message, Source, use crate::config::buffer::UsernameFormat; use crate::time::Posix; use crate::user::Nick; -use crate::{Config, User}; +use crate::{message, Config, User}; enum Cause { Server(Option), @@ -21,13 +21,17 @@ fn expand( sent_time: DateTime, ) -> Vec { let message = |target, content| -> Message { + let received_at = Posix::now(); + let hash = message::Hash::new(&received_at, &content); + Message { - received_at: Posix::now(), + received_at, server_time: sent_time, direction: Direction::Received, target, content, id: None, + hash, } }; diff --git a/data/src/message/formatting.rs b/data/src/message/formatting.rs index 50372c47..f864c7ff 100644 --- a/data/src/message/formatting.rs +++ b/data/src/message/formatting.rs @@ -152,7 +152,7 @@ pub fn parse(text: &str) -> Option> { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Formatting { pub bold: bool, pub italics: bool, @@ -233,7 +233,7 @@ impl TryFrom for Modifier { } /// https://modern.ircdocs.horse/formatting.html#colors -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[repr(u8)] pub enum Color { White = 0, diff --git a/data/src/server.rs b/data/src/server.rs index 6aef893f..3796ee3a 100644 --- a/data/src/server.rs +++ b/data/src/server.rs @@ -1,3 +1,4 @@ +use once_cell::sync::Lazy; use std::collections::BTreeMap; use std::{fmt, str}; use tokio::fs; @@ -11,6 +12,9 @@ use crate::config; use crate::config::server::Sasl; use crate::config::Error; +// Hack since log messages are app wide and not scoped to any server +pub static LOGS: Lazy = Lazy::new(|| Server("".to_string())); + pub type Handle = Sender; #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -71,7 +75,9 @@ async fn read_from_command(pass_command: &str) -> Result { // trailing newline Ok(str::from_utf8(&output.stdout)?.trim_end().to_string()) } else { - Err(Error::ExecutePasswordCommand(String::from_utf8(output.stderr)?)) + Err(Error::ExecutePasswordCommand(String::from_utf8( + output.stderr, + )?)) } } @@ -131,7 +137,7 @@ impl Map { password_file: None, password_command: None, .. - } => {}, + } => {} Sasl::Plain { password: password @ None, password_file: Some(pass_file), diff --git a/src/buffer.rs b/src/buffer.rs index 7b735572..943c5c3c 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -38,7 +38,7 @@ pub enum Message { Server(server::Message), Query(query::Message), FileTransfers(file_transfers::Message), - Log(logs::Message), + Logs(logs::Message), } pub enum Event { @@ -52,6 +52,16 @@ impl Buffer { Self::Empty } + pub fn server(&self) -> Option<&data::Server> { + match self { + Buffer::Empty | Buffer::FileTransfers(_) => None, + Buffer::Channel(state) => Some(&state.server), + Buffer::Server(state) => Some(&state.server), + Buffer::Query(state) => Some(&state.server), + Buffer::Logs(_) => Some(&data::server::LOGS), + } + } + pub fn data(&self) -> Option<&data::Buffer> { match self { Buffer::Empty => None, @@ -110,6 +120,17 @@ impl Buffer { (command.map(Message::FileTransfers), None) } + (Buffer::Logs(state), Message::Logs(message)) => { + let (command, event) = state.update(message); + + let event = event.map(|event| match event { + logs::Event::UserContext(event) => Event::UserContext(event), + logs::Event::OpenChannel(channel) => Event::OpenChannel(channel), + logs::Event::History(task) => Event::History(task), + }); + + (command.map(Message::Logs), event) + } _ => (Task::none(), None), } } @@ -147,7 +168,7 @@ impl Buffer { Buffer::FileTransfers(state) => { file_transfers::view(state, file_transfers).map(Message::FileTransfers) } - Buffer::Logs(state) => logs::view(state, history, config, theme).map(Message::Log), + Buffer::Logs(state) => logs::view(state, history, config, theme).map(Message::Logs), } } @@ -231,7 +252,7 @@ impl Buffer { Buffer::Logs(log) => log .scroll_view .scroll_to_start() - .map(|message| Message::Log(logs::Message::ScrollView(message))), + .map(|message| Message::Logs(logs::Message::ScrollView(message))), } } @@ -253,7 +274,41 @@ impl Buffer { Buffer::Logs(log) => log .scroll_view .scroll_to_end() - .map(|message| Message::Log(logs::Message::ScrollView(message))), + .map(|message| Message::Logs(logs::Message::ScrollView(message))), + } + } + + pub fn scroll_to_backlog( + &mut self, + history: &history::Manager, + config: &Config, + ) -> Task { + match self { + Buffer::Empty | Buffer::FileTransfers(_) => Task::none(), + Buffer::Channel(state) => state + .scroll_view + .scroll_to_backlog( + scroll_view::Kind::Channel(&state.server, &state.channel), + history, + config, + ) + .map(|message| Message::Channel(channel::Message::ScrollView(message))), + Buffer::Server(state) => state + .scroll_view + .scroll_to_backlog(scroll_view::Kind::Server(&state.server), history, config) + .map(|message| Message::Server(server::Message::ScrollView(message))), + Buffer::Query(state) => state + .scroll_view + .scroll_to_backlog( + scroll_view::Kind::Query(&state.server, &state.nick), + history, + config, + ) + .map(|message| Message::Query(query::Message::ScrollView(message))), + Buffer::Logs(state) => state + .scroll_view + .scroll_to_backlog(scroll_view::Kind::Logs, history, config) + .map(|message| Message::Logs(logs::Message::ScrollView(message))), } } } diff --git a/src/buffer/logs.rs b/src/buffer/logs.rs index 615f43cf..8718d919 100644 --- a/src/buffer/logs.rs +++ b/src/buffer/logs.rs @@ -26,7 +26,7 @@ pub fn view<'a>( let messages = container( scroll_view::view( &state.scroll_view, - scroll_view::Kind::Log, + scroll_view::Kind::Logs, history, config, move |message, _, _| match message.target.source() { diff --git a/src/buffer/scroll_view.rs b/src/buffer/scroll_view.rs index 0dfe9a43..dd110c1f 100644 --- a/src/buffer/scroll_view.rs +++ b/src/buffer/scroll_view.rs @@ -1,5 +1,5 @@ use data::message::{self, Limit}; -use data::server::Server; +use data::server::{self, Server}; use data::user::Nick; use data::{history, time, Config}; use iced::widget::{column, container, horizontal_rule, row, scrollable, text, Scrollable}; @@ -20,6 +20,7 @@ pub enum Message { }, UserContext(user_context::Message), Link(message::Link), + ScrollTo(scroll_to::Result), } #[derive(Debug, Clone)] @@ -33,7 +34,29 @@ pub enum Kind<'a> { Server(&'a Server), Channel(&'a Server, &'a str), Query(&'a Server, &'a Nick), - Log, + Logs, +} + +impl Kind<'_> { + fn server(&self) -> &Server { + match self { + Kind::Server(server) => server, + Kind::Channel(server, _) => server, + Kind::Query(server, _) => server, + Kind::Logs => &server::LOGS, + } + } +} + +impl From> for history::Kind { + fn from(value: Kind<'_>) -> Self { + match value { + Kind::Server(_) => history::Kind::Server, + Kind::Channel(_, channel) => history::Kind::Channel(channel.to_string()), + Kind::Query(_, nick) => history::Kind::Query(nick.clone()), + Kind::Logs => history::Kind::Logs, + } + } } pub fn view<'a>( @@ -49,18 +72,12 @@ pub fn view<'a>( new_messages, max_nick_chars, max_prefix_chars, - }) = (match kind { - Kind::Server(server) => { - history.get_server_messages(server, Some(state.limit), &config.buffer) - } - Kind::Channel(server, channel) => { - history.get_channel_messages(server, channel, Some(state.limit), &config.buffer) - } - Kind::Query(server, user) => { - history.get_query_messages(server, user, Some(state.limit), &config.buffer) - } - Kind::Log => history.get_log_messages(Some(state.limit), &config.buffer), - }) + }) = history.get_messages( + kind.server(), + &kind.into(), + Some(state.limit), + &config.buffer, + ) else { return column![].into(); }; @@ -86,19 +103,25 @@ pub fn view<'a>( let old = old_messages .into_iter() - .filter_map(|message| format(message, max_nick_width, max_prefix_width)) + .filter_map(|message| { + format(message, max_nick_width, max_prefix_width) + .map(|element| scroll_to::keyed(scroll_to::Key::message(message), element)) + }) .collect::>(); let new = new_messages .into_iter() - .filter_map(|message| format(message, max_nick_width, max_prefix_width)) + .filter_map(|message| { + format(message, max_nick_width, max_prefix_width) + .map(|element| scroll_to::keyed(scroll_to::Key::message(message), element)) + }) .collect::>(); let show_divider = !new.is_empty() || matches!(status, Status::Idle(Anchor::Bottom)); - let content = if show_divider { + let divider = if show_divider { let font_size = config.font.size.map(f32::from).unwrap_or(theme::TEXT_SIZE) - 1.0; - let divider = row![ + row![ container(horizontal_rule(1)) .width(Length::Fill) .padding(padding::right(6)), @@ -110,13 +133,17 @@ pub fn view<'a>( .padding(padding::left(6)) ] .padding(2) - .align_y(iced::Alignment::Center); - - column![column(old), divider, column(new)] + .align_y(iced::Alignment::Center) } else { - column![column(old), column(new)] + row![] }; + let content = column![ + column(old), + scroll_to::keyed(scroll_to::Key::Divider, divider), + column(new) + ]; + Scrollable::new(container(content).width(Length::Fill).padding([0, 8])) .direction(scrollable::Direction::Vertical( scrollable::Scrollbar::default() @@ -169,6 +196,9 @@ impl State { let relative_offset = viewport.relative_offset().y; match old_status { + Status::ScrollTo => { + return (Task::none(), None); + } Status::Loading(anchor) => { self.status = Status::Unlocked(anchor); @@ -244,6 +274,36 @@ impl State { ))), ) } + Message::ScrollTo(scroll_to::Result { prev, hit }) => { + let scroll_to::Offsets { absolute, relative } = hit; + + self.status = Status::Idle(Anchor::Bottom); + + // Offsets are given relative to top, + // and we must scroll to offsets relative to + // the bottom + let offset = if relative == 1.0 { + 0.0 + } else { + let total = absolute / relative; + + // If a prev element exists, put scrollable halfway over prev + // element so it's obvious user can scroll up + if let Some(prev) = prev { + total * (1.0 - (relative + prev.relative) / 2.0) + } else { + total * (1.0 - relative) + } + }; + + return ( + scrollable::scroll_to( + self.scrollable.clone(), + scrollable::AbsoluteOffset { x: 0.0, y: offset }, + ), + None, + ); + } } (Task::none(), None) @@ -266,6 +326,66 @@ impl State { scrollable::AbsoluteOffset { x: 0.0, y: 0.0 }, ) } + + pub fn scroll_to_message( + &mut self, + message: &data::Message, + kind: Kind, + history: &history::Manager, + config: &Config, + ) -> Task { + let Some(history::View { + total, + old_messages, + new_messages, + .. + }) = history.get_messages(kind.server(), &kind.into(), None, &config.buffer) + else { + return Task::none(); + }; + + let Some(pos) = old_messages + .iter() + .chain(&new_messages) + .position(|m| m.hash == message.hash) + else { + return Task::none(); + }; + + // Get all messages from bottom until 1 before found message + let offset = total - pos + 1; + + self.limit = Limit::Bottom(offset.max(Limit::DEFAULT_COUNT)); + self.status = Status::ScrollTo; + + scroll_to::find_offsets(self.scrollable.clone(), scroll_to::Key::message(message)) + .map(Message::ScrollTo) + } + + pub fn scroll_to_backlog( + &mut self, + kind: Kind, + history: &history::Manager, + config: &Config, + ) -> Task { + let Some(history::View { + total, + old_messages, + .. + }) = history.get_messages(kind.server(), &kind.into(), None, &config.buffer) + else { + return Task::none(); + }; + + // Get all messages from bottom until 1 before backlog + let offset = total - old_messages.len() + 1; + + self.limit = Limit::Bottom(offset.max(Limit::DEFAULT_COUNT)); + self.status = Status::ScrollTo; + + scroll_to::find_offsets(self.scrollable.clone(), scroll_to::Key::Divider) + .map(Message::ScrollTo) + } } #[derive(Debug, Clone, Copy)] @@ -273,6 +393,7 @@ pub enum Status { Idle(Anchor), Unlocked(Anchor), Loading(Anchor), + ScrollTo, } #[derive(Debug, Clone, Copy)] @@ -287,6 +408,7 @@ impl Status { Status::Idle(anchor) => anchor, Status::Unlocked(anchor) => anchor, Status::Loading(anchor) => anchor, + Status::ScrollTo => Anchor::Bottom, } } @@ -301,6 +423,7 @@ impl Status { Anchor::Top => scrollable::Anchor::Start, Anchor::Bottom => scrollable::Anchor::End, }, + Status::ScrollTo => scrollable::Anchor::Start, } } @@ -359,3 +482,139 @@ impl Default for Status { Self::Idle(Anchor::Bottom) } } + +mod scroll_to { + use data::message; + use iced::advanced::widget::{self, Operation}; + use iced::widget::scrollable; + use iced::{advanced, Rectangle, Task, Vector}; + + use crate::widget::Element; + use crate::widget::{decorate, Renderer}; + + #[derive(Debug, Clone, Copy)] + pub enum Key { + Divider, + Message(message::Hash), + } + + impl Key { + pub fn message(message: &data::Message) -> Self { + Self::Message(message.hash) + } + } + + impl PartialEq for Key { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Key::Divider, Key::Divider) => true, + (Key::Message(a), Key::Message(b)) => a == b, + _ => false, + } + } + } + + pub fn keyed<'a, Message: 'a>( + key: Key, + inner: impl Into>, + ) -> Element<'a, Message> { + #[derive(Default)] + struct State; + + decorate(inner) + .operate( + move |_state: &mut State, + inner: &Element<'a, Message>, + tree: &mut advanced::widget::Tree, + layout: advanced::Layout<'_>, + renderer: &Renderer, + operation: &mut dyn advanced::widget::Operation<()>| { + operation.custom(&mut (key, layout.bounds()), None); + inner.as_widget().operate(tree, layout, renderer, operation); + }, + ) + .into() + } + + #[derive(Debug, Clone, Copy)] + pub struct Offsets { + pub absolute: f32, + pub relative: f32, + } + + #[derive(Debug, Clone, Copy)] + pub struct Result { + pub prev: Option, + pub hit: Offsets, + } + + pub fn find_offsets(scrollable: scrollable::Id, key: Key) -> Task { + #[derive(Debug, Clone)] + struct State { + scrollable: scrollable::Id, + key: Key, + viewport: Option<(Rectangle, Rectangle)>, + prev: Option, + hit: Option, + } + + impl Operation for State { + fn scrollable( + &mut self, + _state: &mut dyn widget::operation::Scrollable, + id: Option<&widget::Id>, + bounds: Rectangle, + content_bounds: Rectangle, + _translation: Vector, + ) { + if id == Some(&self.scrollable.clone().into()) { + self.viewport = Some((bounds, content_bounds)); + } + } + + fn container( + &mut self, + _id: Option<&widget::Id>, + _bounds: Rectangle, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), + ) { + operate_on_children(self) + } + + fn custom(&mut self, state: &mut dyn std::any::Any, _id: Option<&widget::Id>) { + if let Some((viewport, content)) = &self.viewport { + if let Some((key, bounds)) = state.downcast_ref::<(Key, Rectangle)>() { + if self.key == *key { + let absolute = bounds.y - content.y; + let relative = (absolute / (content.height - viewport.height)).min(1.0); + self.hit = Some(Offsets { absolute, relative }); + } else if self.hit.is_none() { + let absolute = bounds.y - content.y; + let relative = (absolute / (content.height - viewport.height)).min(1.0); + self.prev = Some(Offsets { absolute, relative }); + } + } + } + } + + fn finish(&self) -> widget::operation::Outcome { + widget::operation::Outcome::Some(self.clone()) + } + } + + widget::operate(State { + scrollable, + key, + viewport: None, + hit: None, + prev: None, + }) + .map(|state| { + state.hit.map(|hit| Result { + prev: state.prev, + hit, + }) + }) + .and_then(Task::done) + } +} diff --git a/src/screen/dashboard.rs b/src/screen/dashboard.rs index 4052f4a4..38919056 100644 --- a/src/screen/dashboard.rs +++ b/src/screen/dashboard.rs @@ -509,6 +509,28 @@ impl Dashboard { Message::History(message) => { if let Some(event) = self.history.update(message) { match event { + history::manager::Event::Loaded(server, kind) => { + if let Some((window, pane, state)) = + self.panes.iter_mut(main_window.id).find(|(_, _, state)| { + state.buffer.server() == Some(&server) + && state.buffer.data().map_or(true, |data| { + data.target().as_deref() == kind.target() + }) + }) + { + return ( + state.buffer.scroll_to_backlog(&self.history, config).map( + move |message| { + Message::Pane( + window, + pane::Message::Buffer(pane, message), + ) + }, + ), + None, + ); + } + } history::manager::Event::Closed(server, kind, read_marker) => { if let Some((target, read_marker)) = kind.target().zip(read_marker) { clients.send_markread(&server, target, read_marker); @@ -1504,6 +1526,9 @@ impl Dashboard { None if matches!(pane.buffer, Buffer::FileTransfers(_)) => { self.toggle_file_transfers(config, main_window) } + None if matches!(pane.buffer, Buffer::Logs(_)) => { + self.toggle_logs(config, main_window) + } None => self.new_pane(pane_grid::Axis::Horizontal, config, main_window), }; @@ -1956,6 +1981,20 @@ impl Panes { })) } + fn iter_mut( + &mut self, + main_window: window::Id, + ) -> impl Iterator { + self.main + .iter_mut() + .map(move |(pane, state)| (main_window, *pane, state)) + .chain(self.popout.iter_mut().flat_map(|(window_id, panes)| { + panes + .iter_mut() + .map(|(pane, state)| (*window_id, *pane, state)) + })) + } + fn resources(&self) -> impl Iterator + '_ { self.main.panes.values().filter_map(Pane::resource).chain( self.popout