From 504b520fe1eb362b194f444955295a07959986e5 Mon Sep 17 00:00:00 2001 From: Ulyssa Date: Fri, 6 Jan 2023 16:56:28 -0800 Subject: [PATCH] Support configuring a user's color and name (#19) --- Cargo.lock | 2 +- README.md | 4 + src/base.rs | 42 ++++--- src/config.rs | 209 ++++++++++++++++++++++++++++++++- src/message.rs | 71 +++++------ src/tests.rs | 40 +++++-- src/windows/mod.rs | 36 +++--- src/windows/room/scrollback.rs | 37 ++++-- 8 files changed, 343 insertions(+), 98 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f0a333..1a64bda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1128,7 +1128,7 @@ dependencies = [ [[package]] name = "iamb" -version = "0.0.1" +version = "0.0.2" dependencies = [ "chrono", "clap", diff --git a/README.md b/README.md index ecfec60..4f18f6f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # iamb +[![Build Status](https://github.com/ulyssa/iamb/workflows/CI/badge.svg)](https://github.com/ulyssa/iamb/actions?query=workflow%3ACI+) +[![License: Apache 2.0](https://img.shields.io/crates/l/iamb.svg?logo=apache)](https://crates.io/crates/iamb) +[![Latest Version](https://img.shields.io/crates/v/iamb.svg?logo=rust)](https://crates.io/crates/iamb) + ## About `iamb` is a Matrix client for the terminal that uses Vim keybindings. diff --git a/src/base.rs b/src/base.rs index 52b8aec..42ecbe9 100644 --- a/src/base.rs +++ b/src/base.rs @@ -41,7 +41,7 @@ use modalkit::{ }; use crate::{ - message::{user_style, Message, Messages}, + message::{Message, Messages}, worker::Requester, ApplicationSettings, }; @@ -222,24 +222,20 @@ impl RoomInfo { } } - fn get_typing_spans(&self) -> Spans { + fn get_typing_spans(&self, settings: &ApplicationSettings) -> Spans { let typers = self.get_typers(); let n = typers.len(); match n { 0 => Spans(vec![]), 1 => { - let user = typers[0].as_str(); - let user = Span::styled(user, user_style(user)); + let user = settings.get_user_span(typers[0].as_ref()); Spans(vec![user, Span::from(" is typing...")]) }, 2 => { - let user1 = typers[0].as_str(); - let user1 = Span::styled(user1, user_style(user1)); - - let user2 = typers[1].as_str(); - let user2 = Span::styled(user2, user_style(user2)); + let user1 = settings.get_user_span(typers[0].as_ref()); + let user2 = settings.get_user_span(typers[1].as_ref()); Spans(vec![ user1, @@ -274,7 +270,7 @@ impl RoomInfo { let top = Rect::new(area.x, area.y, area.width, area.height - 1); let bar = Rect::new(area.x, area.y + top.height, area.width, 1); - Paragraph::new(self.get_typing_spans()) + Paragraph::new(self.get_typing_spans(settings)) .alignment(Alignment::Center) .render(bar, buf); @@ -448,11 +444,14 @@ impl ApplicationInfo for IambInfo { #[cfg(test)] pub mod tests { use super::*; + use crate::config::{user_style, user_style_from_color}; use crate::tests::*; + use modalkit::tui::style::Color; #[test] fn test_typing_spans() { let mut info = RoomInfo::default(); + let settings = mock_settings(); let users0 = vec![]; let users1 = vec![TEST_USER1.clone()]; @@ -473,18 +472,18 @@ pub mod tests { // Nothing set. assert_eq!(info.users_typing, None); - assert_eq!(info.get_typing_spans(), Spans(vec![])); + assert_eq!(info.get_typing_spans(&settings), Spans(vec![])); // Empty typing list. info.set_typing(users0); assert!(info.users_typing.is_some()); - assert_eq!(info.get_typing_spans(), Spans(vec![])); + assert_eq!(info.get_typing_spans(&settings), Spans(vec![])); // Single user typing. info.set_typing(users1); assert!(info.users_typing.is_some()); assert_eq!( - info.get_typing_spans(), + info.get_typing_spans(&settings), Spans(vec![ Span::styled("@user1:example.com", user_style("@user1:example.com")), Span::from(" is typing...") @@ -495,7 +494,7 @@ pub mod tests { info.set_typing(users2); assert!(info.users_typing.is_some()); assert_eq!( - info.get_typing_spans(), + info.get_typing_spans(&settings), Spans(vec![ Span::styled("@user1:example.com", user_style("@user1:example.com")), Span::raw(" and "), @@ -507,11 +506,22 @@ pub mod tests { // Four users typing. info.set_typing(users4); assert!(info.users_typing.is_some()); - assert_eq!(info.get_typing_spans(), Spans::from("Several people are typing...")); + assert_eq!(info.get_typing_spans(&settings), Spans::from("Several people are typing...")); // Five users typing. info.set_typing(users5); assert!(info.users_typing.is_some()); - assert_eq!(info.get_typing_spans(), Spans::from("Many people are typing...")); + assert_eq!(info.get_typing_spans(&settings), Spans::from("Many people are typing...")); + + // Test that USER5 gets rendered using the configured color and name. + info.set_typing(vec![TEST_USER5.clone()]); + assert!(info.users_typing.is_some()); + assert_eq!( + info.get_typing_spans(&settings), + Spans(vec![ + Span::styled("USER 5", user_style_from_color(Color::Black)), + Span::from(" is typing...") + ]) + ); } } diff --git a/src/config.rs b/src/config.rs index 6a6aa89..13ae6f8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,14 +1,22 @@ +use std::collections::hash_map::DefaultHasher; use std::collections::HashMap; +use std::fmt; use std::fs::File; +use std::hash::{Hash, Hasher}; use std::io::BufReader; use std::path::{Path, PathBuf}; use std::process; use clap::Parser; -use matrix_sdk::ruma::OwnedUserId; -use serde::Deserialize; +use matrix_sdk::ruma::{OwnedUserId, UserId}; +use serde::{de::Error as SerdeError, de::Visitor, Deserialize, Deserializer}; use url::Url; +use modalkit::tui::{ + style::{Color, Modifier as StyleModifier, Style}, + text::Span, +}; + macro_rules! usage { ( $($args: tt)* ) => { println!($($args)*); @@ -16,6 +24,38 @@ macro_rules! usage { } } +const COLORS: [Color; 13] = [ + Color::Blue, + Color::Cyan, + Color::Green, + Color::LightBlue, + Color::LightGreen, + Color::LightCyan, + Color::LightMagenta, + Color::LightRed, + Color::LightYellow, + Color::Magenta, + Color::Red, + Color::Reset, + Color::Yellow, +]; + +pub fn user_color(user: &str) -> Color { + let mut hasher = DefaultHasher::new(); + user.hash(&mut hasher); + let color = hasher.finish() as usize % COLORS.len(); + + COLORS[color] +} + +pub fn user_style_from_color(color: Color) -> Style { + Style::default().fg(color).add_modifier(StyleModifier::BOLD) +} + +pub fn user_style(user: &str) -> Style { + user_style_from_color(user_color(user)) +} + fn is_profile_char(c: char) -> bool { c.is_ascii_alphanumeric() || c == '.' || c == '-' } @@ -69,16 +109,88 @@ pub enum ConfigError { Invalid(#[from] serde_json::Error), } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserColor(pub Color); +pub struct UserColorVisitor; + +impl<'de> Visitor<'de> for UserColorVisitor { + type Value = UserColor; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid color") + } + + fn visit_str(self, value: &str) -> Result + where + E: SerdeError, + { + match value { + "none" => Ok(UserColor(Color::Reset)), + "red" => Ok(UserColor(Color::Red)), + "black" => Ok(UserColor(Color::Black)), + "green" => Ok(UserColor(Color::Green)), + "yellow" => Ok(UserColor(Color::Yellow)), + "blue" => Ok(UserColor(Color::Blue)), + "magenta" => Ok(UserColor(Color::Magenta)), + "cyan" => Ok(UserColor(Color::Cyan)), + "gray" => Ok(UserColor(Color::Gray)), + "dark-gray" => Ok(UserColor(Color::DarkGray)), + "light-red" => Ok(UserColor(Color::LightRed)), + "light-green" => Ok(UserColor(Color::LightGreen)), + "light-yellow" => Ok(UserColor(Color::LightYellow)), + "light-blue" => Ok(UserColor(Color::LightBlue)), + "light-magenta" => Ok(UserColor(Color::LightMagenta)), + "light-cyan" => Ok(UserColor(Color::LightCyan)), + "white" => Ok(UserColor(Color::White)), + _ => Err(E::custom("Could not parse color")), + } + } +} + +impl<'de> Deserialize<'de> for UserColor { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(UserColorVisitor) + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +pub struct UserDisplayTunables { + pub color: Option, + pub name: Option, +} + +pub type UserOverrides = HashMap; + +fn merge_users(a: Option, b: Option) -> Option { + match (a, b) { + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (Some(mut a), Some(b)) => { + for (k, v) in b { + a.insert(k, v); + } + + Some(a) + }, + (None, None) => None, + } +} + #[derive(Clone)] pub struct TunableValues { pub typing_notice: bool, pub typing_notice_display: bool, + pub users: UserOverrides, } #[derive(Clone, Default, Deserialize)] pub struct Tunables { pub typing_notice: Option, pub typing_notice_display: Option, + pub users: Option, } impl Tunables { @@ -86,6 +198,7 @@ impl Tunables { Tunables { typing_notice: self.typing_notice.or(other.typing_notice), typing_notice_display: self.typing_notice_display.or(other.typing_notice_display), + users: merge_users(self.users, other.users), } } @@ -93,6 +206,7 @@ impl Tunables { TunableValues { typing_notice: self.typing_notice.unwrap_or(true), typing_notice_display: self.typing_notice.unwrap_or(true), + users: self.users.unwrap_or_default(), } } } @@ -255,11 +369,32 @@ impl ApplicationSettings { Ok(settings) } + + pub fn get_user_span<'a>(&self, user_id: &'a UserId) -> Span<'a> { + if let Some(user) = self.tunables.users.get(user_id) { + let color = if let Some(UserColor(c)) = user.color { + c + } else { + user_color(user_id.as_str()) + }; + + let style = user_style_from_color(color); + + if let Some(name) = &user.name { + Span::styled(name.clone(), style) + } else { + Span::styled(user_id.as_str(), style) + } + } else { + Span::styled(user_id.as_str(), user_style(user_id.as_str())) + } + } } #[cfg(test)] mod tests { use super::*; + use matrix_sdk::ruma::user_id; #[test] fn test_profile_name_invalid() { @@ -283,4 +418,74 @@ mod tests { assert_eq!(validate_profile_name("a.b-c"), true); assert_eq!(validate_profile_name("a.B-c"), true); } + + #[test] + fn test_merge_users() { + let a = None; + let b = vec![(user_id!("@a:b.c").to_owned(), UserDisplayTunables { + color: Some(UserColor(Color::Red)), + name: Some("Hello".into()), + })] + .into_iter() + .collect::>(); + let c = vec![(user_id!("@a:b.c").to_owned(), UserDisplayTunables { + color: Some(UserColor(Color::Green)), + name: Some("World".into()), + })] + .into_iter() + .collect::>(); + + let res = merge_users(a.clone(), a.clone()); + assert_eq!(res, None); + + let res = merge_users(a.clone(), Some(b.clone())); + assert_eq!(res, Some(b.clone())); + + let res = merge_users(Some(b.clone()), a.clone()); + assert_eq!(res, Some(b.clone())); + + let res = merge_users(Some(b.clone()), Some(b.clone())); + assert_eq!(res, Some(b.clone())); + + let res = merge_users(Some(b.clone()), Some(c.clone())); + assert_eq!(res, Some(c.clone())); + + let res = merge_users(Some(c.clone()), Some(b.clone())); + assert_eq!(res, Some(b.clone())); + } + + #[test] + fn test_parse_tunables() { + let res: Tunables = serde_json::from_str("{}").unwrap(); + assert_eq!(res.typing_notice, None); + assert_eq!(res.typing_notice_display, None); + assert_eq!(res.users, None); + + let res: Tunables = serde_json::from_str("{\"typing_notice\": true}").unwrap(); + assert_eq!(res.typing_notice, Some(true)); + assert_eq!(res.typing_notice_display, None); + assert_eq!(res.users, None); + + let res: Tunables = serde_json::from_str("{\"typing_notice\": false}").unwrap(); + assert_eq!(res.typing_notice, Some(false)); + assert_eq!(res.typing_notice_display, None); + assert_eq!(res.users, None); + + let res: Tunables = serde_json::from_str("{\"users\": {}}").unwrap(); + assert_eq!(res.typing_notice, None); + assert_eq!(res.typing_notice_display, None); + assert_eq!(res.users, Some(HashMap::new())); + + let res: Tunables = serde_json::from_str( + "{\"users\": {\"@a:b.c\": {\"color\": \"black\", \"name\": \"Tim\"}}}", + ) + .unwrap(); + assert_eq!(res.typing_notice, None); + assert_eq!(res.typing_notice_display, None); + let users = vec![(user_id!("@a:b.c").to_owned(), UserDisplayTunables { + color: Some(UserColor(Color::Black)), + name: Some("Tim".into()), + })]; + assert_eq!(res.users, Some(users.into_iter().collect())); + } } diff --git a/src/message.rs b/src/message.rs index a649b4e..3050292 100644 --- a/src/message.rs +++ b/src/message.rs @@ -22,35 +22,22 @@ use matrix_sdk::ruma::{ }; use modalkit::tui::{ - style::{Color, Modifier as StyleModifier, Style}, + style::{Modifier as StyleModifier, Style}, text::{Span, Spans, Text}, }; use modalkit::editing::{base::ViewportContext, cursor::Cursor}; -use crate::base::{IambResult, RoomInfo}; +use crate::{ + base::{IambResult, RoomInfo}, + config::ApplicationSettings, +}; pub type MessageEvent = MessageLikeEvent; pub type MessageFetchResult = IambResult<(Option, Vec)>; pub type MessageKey = (MessageTimeStamp, OwnedEventId); pub type Messages = BTreeMap; -const COLORS: [Color; 13] = [ - Color::Blue, - Color::Cyan, - Color::Green, - Color::LightBlue, - Color::LightGreen, - Color::LightCyan, - Color::LightMagenta, - Color::LightRed, - Color::LightYellow, - Color::Magenta, - Color::Red, - Color::Reset, - Color::Yellow, -]; - const USER_GUTTER: usize = 30; const TIME_GUTTER: usize = 12; const MIN_MSG_LEN: usize = 30; @@ -66,18 +53,6 @@ const USER_GUTTER_EMPTY_SPAN: Span<'static> = Span { }, }; -pub(crate) fn user_color(user: &str) -> Color { - let mut hasher = DefaultHasher::new(); - user.hash(&mut hasher); - let color = hasher.finish() as usize % COLORS.len(); - - COLORS[color] -} - -pub(crate) fn user_style(user: &str) -> Style { - Style::default().fg(user_color(user)).add_modifier(StyleModifier::BOLD) -} - struct WrappedLinesIterator<'a> { iter: Lines<'a>, curr: Option<&'a str>, @@ -390,7 +365,13 @@ impl Message { Message { content, sender, timestamp } } - pub fn show(&self, selected: bool, vwctx: &ViewportContext) -> Text { + pub fn show( + &self, + prev: Option<&Message>, + selected: bool, + vwctx: &ViewportContext, + settings: &ApplicationSettings, + ) -> Text { let width = vwctx.get_width(); let msg = self.as_ref(); @@ -414,7 +395,7 @@ impl Message { let trailing = Span::styled(space(lw.saturating_sub(w)), style); if i == 0 { - let user = self.show_sender(true); + let user = self.show_sender(prev, true, settings); if let Some(time) = self.timestamp.show() { lines.push(Spans(vec![user, line, trailing, time])) @@ -435,7 +416,7 @@ impl Message { let trailing = Span::styled(space(lw.saturating_sub(w)), style); let prefix = if i == 0 { - self.show_sender(true) + self.show_sender(prev, true, settings) } else { USER_GUTTER_EMPTY_SPAN }; @@ -443,7 +424,7 @@ impl Message { lines.push(Spans(vec![prefix, line, trailing])) } } else { - lines.push(Spans::from(self.show_sender(false))); + lines.push(Spans::from(self.show_sender(prev, false, settings))); for (line, _) in wrap(msg, width.saturating_sub(2)) { let line = format!(" {}", line); @@ -456,14 +437,26 @@ impl Message { return Text { lines }; } - fn show_sender(&self, align_right: bool) -> Span { - let sender = self.sender.to_string(); - let style = user_style(sender.as_str()); + fn show_sender( + &self, + prev: Option<&Message>, + align_right: bool, + settings: &ApplicationSettings, + ) -> Span { + let user = if matches!(prev, Some(prev) if self.sender == prev.sender) { + USER_GUTTER_EMPTY_SPAN + } else { + settings.get_user_span(self.sender.as_ref()) + }; + + let Span { content, style } = user; + let stop = content.len().min(28); + let s = &content[..stop]; let sender = if align_right { - format!("{: >width$} ", sender, width = 28) + format!("{: >width$} ", s, width = 28) } else { - format!("{: DirectoryValues { } } +pub fn mock_tunables() -> TunableValues { + TunableValues { + typing_notice: true, + typing_notice_display: true, + users: vec![(TEST_USER5.clone(), UserDisplayTunables { + color: Some(UserColor(Color::Black)), + name: Some("USER 5".into()), + })] + .into_iter() + .collect::>(), + } +} + pub fn mock_settings() -> ApplicationSettings { ApplicationSettings { matrix_dir: PathBuf::new(), @@ -129,7 +149,7 @@ pub fn mock_settings() -> ApplicationSettings { settings: None, dirs: None, }, - tunables: TunableValues { typing_notice: true, typing_notice_display: true }, + tunables: mock_tunables(), dirs: mock_dirs(), } } diff --git a/src/windows/mod.rs b/src/windows/mod.rs index 133a515..8b98557 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -51,19 +51,16 @@ use modalkit::{ }, }; -use crate::{ - base::{ - ChatStore, - IambBufferId, - IambId, - IambInfo, - IambResult, - ProgramAction, - ProgramContext, - ProgramStore, - RoomAction, - }, - message::user_style, +use crate::base::{ + ChatStore, + IambBufferId, + IambId, + IambInfo, + IambResult, + ProgramAction, + ProgramContext, + ProgramStore, + RoomAction, }; use self::{room::RoomState, welcome::WelcomeState}; @@ -845,15 +842,18 @@ impl ToString for MemberItem { } impl ListItem for MemberItem { - fn show(&self, selected: bool, _: &ViewportContext, _: &mut ProgramStore) -> Text { - let mut style = user_style(self.member.user_id().as_str()); + fn show( + &self, + selected: bool, + _: &ViewportContext, + store: &mut ProgramStore, + ) -> Text { + let mut user = store.application.settings.get_user_span(self.member.user_id()); if selected { - style = style.add_modifier(StyleModifier::REVERSED); + user.style = user.style.add_modifier(StyleModifier::REVERSED); } - let user = Span::styled(self.to_string(), style); - let state = match self.member.membership() { MembershipState::Ban => Span::raw(" (banned)").into(), MembershipState::Invite => Span::raw(" (invited)").into(), diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs index 8e4967f..6f4af26 100644 --- a/src/windows/room/scrollback.rs +++ b/src/windows/room/scrollback.rs @@ -61,6 +61,7 @@ use modalkit::editing::{ use crate::{ base::{IambBufferId, IambInfo, ProgramContext, ProgramStore, RoomFocus, RoomInfo}, + config::ApplicationSettings, message::{Message, MessageCursor, MessageKey}, }; @@ -148,7 +149,13 @@ impl ScrollbackState { } } - fn scrollview(&mut self, idx: MessageKey, pos: MovePosition, info: &RoomInfo) { + fn scrollview( + &mut self, + idx: MessageKey, + pos: MovePosition, + info: &RoomInfo, + settings: &ApplicationSettings, + ) { let selidx = if let Some(key) = self.cursor.to_key(info) { key } else { @@ -165,7 +172,7 @@ impl ScrollbackState { for (key, item) in info.messages.range(..=&idx).rev() { let sel = selidx == key; - let len = item.show(sel, &self.viewctx).lines.len(); + let len = item.show(None, sel, &self.viewctx, settings).lines.len(); if key == &idx { lines += len / 2; @@ -187,7 +194,7 @@ impl ScrollbackState { for (key, item) in info.messages.range(..=&idx).rev() { let sel = key == selidx; - let len = item.show(sel, &self.viewctx).lines.len(); + let len = item.show(None, sel, &self.viewctx, settings).lines.len(); lines += len; @@ -202,7 +209,7 @@ impl ScrollbackState { } } - fn shift_cursor(&mut self, info: &RoomInfo) { + fn shift_cursor(&mut self, info: &RoomInfo, settings: &ApplicationSettings) { let last_key = if let Some(k) = info.messages.last_key_value() { k.0 } else { @@ -227,7 +234,7 @@ impl ScrollbackState { break; } - lines += item.show(false, &self.viewctx).height().max(1); + lines += item.show(None, false, &self.viewctx, settings).height().max(1); if lines >= self.viewctx.get_height() { // We've reached the end of the viewport; move cursor into it. @@ -930,7 +937,8 @@ impl ScrollActions for ScrollbackState { ctx: &ProgramContext, store: &mut ProgramStore, ) -> EditResult { - let info = store.application.get_room_info(self.room_id.clone()); + let info = store.application.rooms.entry(self.room_id.clone()).or_default(); + let settings = &store.application.settings; let mut corner = self.viewctx.corner.clone(); let last_key = if let Some(k) = info.messages.last_key_value() { @@ -956,7 +964,7 @@ impl ScrollActions for ScrollbackState { for (key, item) in info.messages.range(..=&corner_key).rev() { let sel = key == cursor_key; - let txt = item.show(sel, &self.viewctx); + let txt = item.show(None, sel, &self.viewctx, settings); let len = txt.height().max(1); let max = len.saturating_sub(1); @@ -982,7 +990,7 @@ impl ScrollActions for ScrollbackState { MoveDir2D::Down => { for (key, item) in info.messages.range(&corner_key..) { let sel = key == cursor_key; - let txt = item.show(sel, &self.viewctx); + let txt = item.show(None, sel, &self.viewctx, settings); let len = txt.height().max(1); let max = len.saturating_sub(1); @@ -1018,7 +1026,7 @@ impl ScrollActions for ScrollbackState { } self.viewctx.corner = corner; - self.shift_cursor(info); + self.shift_cursor(info, settings); Ok(None) } @@ -1038,10 +1046,11 @@ impl ScrollActions for ScrollbackState { Err(err) }, Axis::Vertical => { - let info = store.application.get_room_info(self.room_id.clone()); + let info = store.application.rooms.entry(self.room_id.clone()).or_default(); + let settings = &store.application.settings; if let Some(key) = self.cursor.to_key(info).cloned() { - self.scrollview(key, pos, info); + self.scrollview(key, pos, info, settings); } Ok(None) @@ -1126,6 +1135,7 @@ impl<'a> StatefulWidget for Scrollback<'a> { fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let info = self.store.application.rooms.entry(state.room_id.clone()).or_default(); + let settings = &self.store.application.settings; let area = info.render_typing(area, buf, &self.store.application.settings); state.set_term_info(area); @@ -1157,10 +1167,13 @@ impl<'a> StatefulWidget for Scrollback<'a> { let mut lines = vec![]; let mut sawit = false; + let mut prev = None; for (key, item) in info.messages.range(&corner_key..) { let sel = key == cursor_key; - let txt = item.show(self.focused && sel, &state.viewctx); + let txt = item.show(prev, self.focused && sel, &state.viewctx, settings); + + prev = Some(item); for (row, line) in txt.lines.into_iter().enumerate() { if sawit && lines.len() >= height {