diff --git a/config.example.toml b/config.example.toml index 2cb09c80..e9571765 100644 --- a/config.example.toml +++ b/config.example.toml @@ -32,6 +32,12 @@ username_display = "username" indicator = "only-encrypted" indicator_location = "title|prompt" +[settings.colors] +message_state = "cyan" +message_redacted = "245" # light gray +# commented out because some terminals don't support truecolor +#room_list_unread = "#ff55bb" # pink + [settings.image_preview] protocol = { type = "sixel", filter = "Nearest" } size = { "width" = 66, "height" = 10 } diff --git a/docs/iamb.5 b/docs/iamb.5 index 7e22199c..4904f69f 100644 --- a/docs/iamb.5 +++ b/docs/iamb.5 @@ -132,6 +132,12 @@ Specifying this object will replace the upstream defaults and possibly lead to u Possible keys and their effect are documented at: .br .Lk https://docs.rs/matrix-sdk/latest/matrix_sdk/media/struct.MediaRetentionPolicy.html#fields +.It Sy colors +Change the colors of different elements in +.Sy iamb . +See +.Sx "COLORSCHEME" +for details on the format. .It Sy default_markup Controls how text in the message bar is interpreted by default. Possible values are @@ -365,6 +371,111 @@ and Both can be used via .Dq Sy title|prompt . .El +.Sh COLORSCHEME +The +.Sy settings.colors +subsection allows configuring the appearance of many elements. +All settings take a color value that is either a named color, an 8-bit color value or a hex value. +The named colors are: +.Dq Sy black , +.Dq Sy red , +.Dq Sy green , +.Dq Sy yellow , +.Dq Sy blue , +.Dq Sy magenta , +.Dq Sy cyan , +.Dq Sy gray , +.Dq Sy dark-gray , +.Dq Sy light-red , +.Dq Sy light-green , +.Dq Sy light-yellow , +.Dq Sy light-blue , +.Dq Sy light-magenta , +.Dq Sy light-cyan , +.Dq Sy white , +and +.Dq Sy reset , +The 8-bit ANSI color must in the range of 0 to 255 where the first 16 colors correspond to the named colors. +The hex value is given as +.Dq Sy #RRGGBB . +If the terminal does not support truecolor, all hex colors will fall back to the default text color. +All values must be given as a string. +.Bl -tag -width Ds +.It Sy border +The border of the focused buffer. +Defaults to the terminal foreground color. +.It Sy border_unfocused +The border of all unfocused buffers. +Defaults to +.Sy border . +Note that this value is dimmed before use. +.It Sy window_title +The text at the top-left of the border. +Defaults to the terminal foreground color. +Note that this value is dimmed for unfocused buffers. +.It Sy tab_title +The active entry in the tab list. +Defaults to the terminal foreground color. +.It Sy tab_title_unfocused +The inactive entries in the tab list. +Defaults to +.Sy tab_title . +Note that this value is dimmed before use. +.It Sy room_list +Entry in a room list like the +.Dq Sy :chats +window. +Defaults to the terminal foreground color. +.It Sy room_list_unread +Unread entry in a room list. +Defaults to +.Sy room_list . +.It Sy message_time +The timestamp to the right of messages. +Defaults to the terminal foreground color. +.It Sy message_date +The date divider between different days in the message scrollback. +Defaults to the terminal foreground color. +Note that this value is used in bold with might make it brighter on some terminals. +.It Sy message_normal +The text color for text messages. +Defaults to the terminal foreground color. +.It Sy message_state +The text color for state events like users joining rooms. +Defaults to +.Sy message_normal . +.It Sy message_sticker +The text color for stickers. +Defaults to +.Sy message_normal . +.It Sy message_redacted +The text color for redacted (deleted) messages. +Defaults to +.Sy message_normal . +.It Sy message_poll +The text color for polls. +Defaults to +.Sy message_normal . +.It Sy message_notice +The text color for automated messages. +Defaults to +.Sy message_state . +.It Sy message_other +The text color for other (mostly unsupported) message types. +Defaults to +.Sy message_normal . +.It Sy codeblock_background +Background color for code blocks in messages. +Defaults to +.Dq Sy 236 +(a dark shade of gray). +.El +.Ss Example: Make the code background a lighter gray and change the color of state events. +.Bd -literal -offset indent +[settings.colors] +codeblock_background = "245" +message_state = "cyan" +.Ed .Sh IMAGE PREVIEWS The .Sy settings.image_preview @@ -642,25 +753,9 @@ and are typically written as inline tables containing the following keys: Change the display name of the user. .It Sy color Change the color the user is shown as. -Possible values are: -.Dq Sy black , -.Dq Sy blue , -.Dq Sy cyan , -.Dq Sy dark-gray , -.Dq Sy gray , -.Dq Sy green , -.Dq Sy light-blue , -.Dq Sy light-cyan , -.Dq Sy light-green , -.Dq Sy light-magenta , -.Dq Sy light-red , -.Dq Sy light-yellow , -.Dq Sy magenta , -.Dq Sy none , -.Dq Sy red , -.Dq Sy white , -and -.Dq Sy yellow . +See the introduction of +.Sx "COLORSCHEME" +for the supported color format. .El .Ss Example 1: Override how @ada:example.com appears in chat .Bd -literal -offset indent diff --git a/src/config.rs b/src/config.rs index 19db80ed..ab589102 100644 --- a/src/config.rs +++ b/src/config.rs @@ -228,7 +228,6 @@ macro_rules! deserialize_str_with_visitor { deserialize_str_with_visitor!(Keys, KeysVisitor); deserialize_str_with_visitor!(VimModes, VimModesVisitor); -deserialize_str_with_visitor!(UserColor, UserColorVisitor); deserialize_str_with_visitor!(EncryptionIndicatorLocation, EncryptionIndicatorLocationVisitor); deserialize_str_with_visitor!(NotifyVia, NotifyViaVisitor); deserialize_str_with_visitor!(ProxyUrl, ProxyUrlVisitor); @@ -290,44 +289,6 @@ impl Visitor<'_> for VimModesVisitor { } } -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UserColor(pub Color); -pub struct UserColorVisitor; - -impl Visitor<'_> 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")), - } - } -} - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Session { access_token: String, @@ -364,7 +325,7 @@ impl From for Session { #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] pub struct UserDisplayTunables { - pub color: Option, + pub color: Option, pub name: Option, } @@ -758,6 +719,115 @@ pub struct ImagePreviewProtocolValues { pub font_size: Option<(u16, u16)>, } +#[derive(Clone, Deserialize, Default, Debug)] +pub struct Colorscheme { + pub border: Option, + pub border_unfocused: Option, + pub window_title: Option, + pub tab_title: Option, + pub tab_title_unfocused: Option, + pub room_list: Option, + pub room_list_unread: Option, + pub message_time: Option, + pub message_date: Option, + pub message_normal: Option, + pub message_state: Option, + pub message_sticker: Option, + pub message_redacted: Option, + pub message_poll: Option, + pub message_notice: Option, + pub message_other: Option, + pub codeblock_background: Option, +} + +impl Colorscheme { + fn merge(self, other: Self) -> Self { + Self { + border: self.border.or(other.border), + border_unfocused: self.border_unfocused.or(other.border_unfocused), + window_title: self.window_title.or(other.window_title), + tab_title: self.tab_title.or(other.tab_title), + tab_title_unfocused: self.tab_title_unfocused.or(other.tab_title_unfocused), + room_list: self.room_list.or(other.room_list), + room_list_unread: self.room_list_unread.or(other.room_list_unread), + message_time: self.message_time.or(other.message_time), + message_date: self.message_date.or(other.message_date), + message_normal: self.message_normal.or(other.message_normal), + message_state: self.message_state.or(other.message_state), + message_sticker: self.message_state.or(other.message_sticker), + message_redacted: self.message_redacted.or(other.message_redacted), + message_poll: self.message_poll.or(other.message_poll), + message_notice: self.message_notice.or(other.message_notice), + message_other: self.message_other.or(other.message_other), + codeblock_background: self.codeblock_background.or(other.codeblock_background), + } + } +} + +#[derive(Clone, Deserialize)] +pub struct ColorschemeValues { + pub border: Style, + pub border_unfocused: Style, + pub window_title: Style, + pub tab_title: Style, + pub tab_title_unfocused: Style, + pub room_list: Style, + pub room_list_unread: Style, + pub message_time: Style, + pub message_date: Style, + pub message_normal: Style, + pub message_state: Style, + pub message_sticker: Style, + pub message_redacted: Style, + pub message_poll: Style, + pub message_notice: Style, + pub message_other: Style, + pub codeblock_background: Style, +} + +impl Colorscheme { + pub fn values(self) -> ColorschemeValues { + let border = self.border.map(Into::into).unwrap_or_default(); + let border_unfocused = self.border_unfocused.map(Into::into).unwrap_or(border); + let window_title = self.window_title.map(Into::into).unwrap_or_default(); + let tab_title = self.tab_title.map(Into::into).unwrap_or_default(); + let tab_title_unfocused = self.tab_title_unfocused.map(Into::into).unwrap_or(tab_title); + let room_list = self.room_list.map(Into::into).unwrap_or_default(); + let room_list_unread = self.room_list_unread.map(Into::into).unwrap_or(room_list); + let message_time = self.message_time.map(Into::into).unwrap_or_default(); + let message_date = self.message_date.map(Into::into).unwrap_or_default(); + let message_normal = self.message_normal.map(Into::into).unwrap_or_default(); + let message_state = self.message_state.map(Into::into).unwrap_or(message_normal); + let message_sticker = self.message_sticker.map(Into::into).unwrap_or(message_normal); + let message_redacted = self.message_redacted.map(Into::into).unwrap_or(message_normal); + let message_poll = self.message_poll.map(Into::into).unwrap_or(message_normal); + let message_notice = self.message_notice.map(Into::into).unwrap_or(message_state); + let message_other = self.message_other.map(Into::into).unwrap_or(message_normal); + let codeblock_background = + Style::new().bg(self.codeblock_background.unwrap_or(Color::Indexed(236))); + + ColorschemeValues { + border, + border_unfocused, + window_title, + tab_title, + tab_title_unfocused, + room_list, + room_list_unread, + message_time, + message_date, + message_normal, + message_state, + message_sticker, + message_redacted, + message_poll, + message_notice, + message_other, + codeblock_background, + } + } +} + #[derive(Clone)] pub struct SortValues { pub chats: Vec>, @@ -872,6 +942,7 @@ pub struct TunableValues { pub default_split: SplitDirection, pub ssl_verify: bool, pub cache_policy: MediaRetentionPolicy, + pub colors: ColorschemeValues, } #[derive(Clone, Debug, Default, Deserialize)] @@ -927,6 +998,8 @@ pub struct Tunables { pub members_split: Option, pub default_split: Option, pub ssl_verify: Option, + #[serde(default)] + pub colors: Colorscheme, pub cache_policy: Option, } @@ -982,6 +1055,7 @@ impl Tunables { default_split: self.default_split.or(other.default_split), ssl_verify: self.ssl_verify.or(other.ssl_verify), cache_policy: self.cache_policy.or(other.cache_policy), + colors: self.colors.merge(other.colors), } } @@ -1028,6 +1102,7 @@ impl Tunables { default_split: self.default_split.unwrap_or_default(), ssl_verify: self.ssl_verify.unwrap_or(true), cache_policy: self.cache_policy.unwrap_or_default(), + colors: self.colors.values(), } } } @@ -1447,12 +1522,7 @@ impl ApplicationSettings { .tunables .users .get(user_id) - .map(|user| { - ( - user.color.as_ref().map(|c| c.0), - user.name.as_ref().and_then(|s| s.chars().next()), - ) - }) + .map(|user| (user.color, user.name.as_ref().and_then(|s| s.chars().next()))) .unwrap_or_default(); let color = color.unwrap_or_else(|| user_color(user_id.as_str())); @@ -1470,7 +1540,7 @@ impl ApplicationSettings { self.tunables .users .get(user_id) - .map(|user| (user.color.as_ref().map(|c| c.0), user.name.clone().map(Cow::Owned))) + .map(|user| (user.color, user.name.clone().map(Cow::Owned))) .unwrap_or_default() } @@ -1478,7 +1548,7 @@ impl ApplicationSettings { self.tunables .users .get(user_id) - .and_then(|user| user.color.as_ref().map(|c| c.0)) + .and_then(|user| user.color) .unwrap_or_else(|| user_color(user_id.as_str())) } @@ -1555,13 +1625,13 @@ mod tests { fn test_merge_users() { let a = None; let b = vec![(user_id!("@a:b.c").to_owned(), UserDisplayTunables { - color: Some(UserColor(Color::Red)), + color: Some(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)), + color: Some(Color::Green), name: Some("World".into()), })] .into_iter() @@ -1615,7 +1685,7 @@ mod tests { assert_eq!(res.typing_notice_send, None); assert_eq!(res.typing_notice_display, None); let users = vec![(user_id!("@a:b.c").to_owned(), UserDisplayTunables { - color: Some(UserColor(Color::Black)), + color: Some(Color::Black), name: Some("Tim".into()), })]; assert_eq!(res.users, Some(users.into_iter().collect())); diff --git a/src/main.rs b/src/main.rs index 952b5fce..bf49fcf9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -319,6 +319,7 @@ impl Application { let focused = self.focused; let sstate = &mut self.screen; let term = &mut self.terminal; + let colors = store.application.settings.tunables.colors.clone(); if store.application.ring_bell { store.application.ring_bell = term.backend_mut().write_all(&[7]).is_err(); @@ -347,9 +348,10 @@ impl Application { .show_dialog(dialogstr) .show_mode(modestr) .borders(true) - .border_style(Style::default().add_modifier(StyleModifier::DIM)) - .tab_style(Style::default().add_modifier(StyleModifier::DIM)) - .tab_style_focused(Style::default().remove_modifier(StyleModifier::DIM)) + .border_style(colors.border_unfocused.add_modifier(StyleModifier::DIM)) + .border_style_focused(colors.border.remove_modifier(StyleModifier::DIM)) + .tab_style(colors.tab_title_unfocused.add_modifier(StyleModifier::DIM)) + .tab_style_focused(colors.tab_title.remove_modifier(StyleModifier::DIM)) .focus(focused); f.render_stateful_widget(screen, area, sstate); diff --git a/src/message/html.rs b/src/message/html.rs index 1bce6a39..32deda59 100644 --- a/src/message/html.rs +++ b/src/message/html.rs @@ -368,7 +368,13 @@ impl StyleTreeNode { } }, StyleTreeNode::Code(child, _) => { + let style = style.patch(printer.settings().tunables.colors.codeblock_background); + + let old_style = printer.replace_base_style(style); + child.print(printer, style); + + printer.replace_base_style(old_style); }, StyleTreeNode::Header(child, level) => { let style = style.add_modifier(StyleModifier::BOLD); @@ -1446,6 +1452,7 @@ pub mod tests { fn test_pre_tag() { let info = mock_room(); let settings = mock_settings(); + let code_style = settings.tunables.colors.codeblock_background; let s = concat!( "
",
             "fn hello() -> usize {\n",
@@ -1469,19 +1476,19 @@ pub mod tests {
             text.lines[1],
             Line::from(vec![
                 Span::raw(line::VERTICAL),
-                Span::raw("fn"),
-                Span::raw(" "),
-                Span::raw("hello"),
-                Span::raw("("),
-                Span::raw(")"),
-                Span::raw(" "),
-                Span::raw("-"),
-                Span::raw(">"),
-                Span::raw(" "),
-                Span::raw("usize"),
-                Span::raw(" "),
-                Span::raw("{"),
-                Span::raw("  "),
+                Span::styled("fn", code_style),
+                Span::styled(" ", code_style),
+                Span::styled("hello", code_style),
+                Span::styled("(", code_style),
+                Span::styled(")", code_style),
+                Span::styled(" ", code_style),
+                Span::styled("-", code_style),
+                Span::styled(">", code_style),
+                Span::styled(" ", code_style),
+                Span::styled("usize", code_style),
+                Span::styled(" ", code_style),
+                Span::styled("{", code_style),
+                Span::styled("  ", code_style),
                 Span::raw(line::VERTICAL)
             ])
         );
@@ -1489,13 +1496,13 @@ pub mod tests {
             text.lines[2],
             Line::from(vec![
                 Span::raw(line::VERTICAL),
-                Span::raw(" "),
-                Span::raw("   "),
-                Span::raw("/"),
-                Span::raw("/"),
-                Span::raw(" "),
-                Span::raw("weired"),
-                Span::raw("          "),
+                Span::styled(" ", code_style),
+                Span::styled("   ", code_style),
+                Span::styled("/", code_style),
+                Span::styled("/", code_style),
+                Span::styled(" ", code_style),
+                Span::styled("weired", code_style),
+                Span::styled("          ", code_style),
                 Span::raw(line::VERTICAL)
             ])
         );
@@ -1503,12 +1510,12 @@ pub mod tests {
             text.lines[3],
             Line::from(vec![
                 Span::raw(line::VERTICAL),
-                Span::raw("    "),
-                Span::raw("return"),
-                Span::raw(" "),
-                Span::raw("5"),
-                Span::raw(";"),
-                Span::raw("          "),
+                Span::styled("    ", code_style),
+                Span::styled("return", code_style),
+                Span::styled(" ", code_style),
+                Span::styled("5", code_style),
+                Span::styled(";", code_style),
+                Span::styled("          ", code_style),
                 Span::raw(line::VERTICAL)
             ])
         );
@@ -1516,8 +1523,8 @@ pub mod tests {
             text.lines[4],
             Line::from(vec![
                 Span::raw(line::VERTICAL),
-                Span::raw("}"),
-                Span::raw(" ".repeat(22)),
+                Span::styled("}", code_style),
+                Span::styled(" ".repeat(22), code_style),
                 Span::raw(line::VERTICAL)
             ])
         );
diff --git a/src/message/mod.rs b/src/message/mod.rs
index a3497e12..2c131b60 100644
--- a/src/message/mod.rs
+++ b/src/message/mod.rs
@@ -117,14 +117,6 @@ const fn span_static(s: &'static str) -> Span<'static> {
     }
 }
 
-const BOLD_STYLE: Style = Style {
-    fg: None,
-    bg: None,
-    add_modifier: StyleModifier::BOLD,
-    sub_modifier: StyleModifier::empty(),
-    underline_color: None,
-};
-
 const TIME_GUTTER: usize = 12;
 const READ_GUTTER: usize = 5;
 const MIN_MSG_LEN: usize = 30;
@@ -228,10 +220,10 @@ impl MessageTimeStamp {
         dt1.date_naive() == dt2.date_naive()
     }
 
-    fn show_date(self) -> Span<'static> {
+    fn show_date(self, settings: &ApplicationSettings) -> Span<'static> {
         let time = self.as_datetime().format("%A, %B %d %Y").to_string();
 
-        Span::styled(time, BOLD_STYLE)
+        Span::styled(time, settings.tunables.colors.message_date.add_modifier(StyleModifier::BOLD))
     }
 
     /// A compact date and time, for places without a date separator line.
@@ -239,11 +231,11 @@ impl MessageTimeStamp {
         self.as_datetime().format("%Y-%m-%d %H:%M").to_string()
     }
 
-    fn show_time(self) -> Span<'static> {
+    fn show_time(self, settings: &ApplicationSettings) -> Span<'static> {
         let time = self.as_datetime().format("%T");
         let time = format!("  [{time}]");
 
-        Span::raw(time)
+        Span::styled(time, settings.tunables.colors.message_time)
     }
 }
 
@@ -492,6 +484,35 @@ impl MessageEvent {
         self.msgtype().and_then(content_filename)
     }
 
+    fn message_style(&self, settings: &ApplicationSettings) -> Style {
+        let content = match self {
+            MessageEvent::EncryptedOriginal(_) | MessageEvent::EncryptedRedacted(_) => {
+                return settings.tunables.colors.message_other;
+            },
+            MessageEvent::Redacted(..) => return settings.tunables.colors.message_redacted,
+            MessageEvent::State(_) => return settings.tunables.colors.message_state,
+            MessageEvent::Original(ev, _) => &ev.content,
+            MessageEvent::Local(_, _, content) => content,
+            MessageEvent::Sticker(..) => return settings.tunables.colors.message_sticker,
+            MessageEvent::Poll(..) | MessageEvent::UnstablePoll(..) => {
+                return settings.tunables.colors.message_poll;
+            },
+        };
+
+        match &content.msgtype {
+            MessageType::Text(_) |
+            MessageType::Audio(_) |
+            MessageType::Emote(_) |
+            MessageType::File(_) |
+            MessageType::Image(_) |
+            MessageType::Video(_) => settings.tunables.colors.message_normal,
+            MessageType::Notice(_) | MessageType::ServerNotice(_) => {
+                settings.tunables.colors.message_notice
+            },
+            _ => settings.tunables.colors.message_other,
+        }
+    }
+
     fn redact(&mut self, redaction: SyncRoomRedactionEvent) {
         match self {
             MessageEvent::EncryptedOriginal(_) => return,
@@ -819,7 +840,7 @@ impl<'a> MessageFormatter<'a> {
         let reply_style = if settings.tunables.message_user_color {
             style.patch(settings.get_user_color(&msg.sender))
         } else {
-            style
+            style.patch(msg.event.message_style(settings))
         };
 
         let width = self.width();
@@ -932,14 +953,13 @@ impl<'a> MessageFormatter<'a> {
         protos
     }
 
-    fn push_thread_reply_count(&mut self, len: usize, text: &mut Text<'a>) {
+    fn push_thread_reply_count(&mut self, len: usize, text: &mut Text<'a>, style: Style) {
         if len == 0 {
             return;
         }
 
         // If we have threaded replies to this message, show how many.
         let plural = len != 1;
-        let style = Style::default();
         let mut threaded =
             printer::TextPrinter::new(self.width(), style, self.settings, self.info).literal(true);
         let len = Span::styled(len.to_string(), style.add_modifier(StyleModifier::BOLD));
@@ -1055,7 +1075,7 @@ impl Message {
     }
 
     fn get_render_style(&self, selected: bool, settings: &ApplicationSettings) -> Style {
-        let mut style = Style::default();
+        let mut style = self.event.message_style(settings);
 
         if selected {
             style = style.add_modifier(StyleModifier::REVERSED)
@@ -1133,7 +1153,7 @@ impl Message {
         settings: &'a ApplicationSettings,
     ) -> MessageFormatter<'a> {
         let orig = width;
-        let date = self.show_date(prev).then(|| self.timestamp.show_date());
+        let date = self.show_date(prev).then(|| self.timestamp.show_date(settings));
         let trackbar = self.show_trackbar(prev, info, settings);
         let user_gutter = settings.tunables.user_gutter_width;
 
@@ -1143,7 +1163,7 @@ impl Message {
             let cols = MessageColumns::Four;
             let fill = width - user_gutter - TIME_GUTTER - READ_GUTTER;
             let user = self.show_sender(prev, true, info, settings, width);
-            let time = Some(self.timestamp.show_time());
+            let time = Some(self.timestamp.show_time(settings));
 
             let read = self
                 .event
@@ -1176,7 +1196,7 @@ impl Message {
             let cols = MessageColumns::Three;
             let fill = width - user_gutter - TIME_GUTTER;
             let user = self.show_sender(prev, true, info, settings, width);
-            let time = Some(self.timestamp.show_time());
+            let time = Some(self.timestamp.show_time(settings));
             let read = Vec::new();
 
             MessageFormatter {
@@ -1323,7 +1343,7 @@ impl Message {
         }
 
         if let Some(thread) = self.event.event_id().and_then(|id| info.get_thread(Some(id))) {
-            fmt.push_thread_reply_count(thread.len(), &mut text);
+            fmt.push_thread_reply_count(thread.len(), &mut text, style);
         }
 
         (text, protos)
diff --git a/src/message/printer.rs b/src/message/printer.rs
index 83b6dca3..26ddb24d 100644
--- a/src/message/printer.rs
+++ b/src/message/printer.rs
@@ -69,6 +69,11 @@ impl<'a> TextPrinter<'a> {
         self
     }
 
+    /// Set the base style and return the old style.
+    pub fn replace_base_style(&mut self, style: Style) -> Style {
+        std::mem::replace(&mut self.base_style, style)
+    }
+
     /// Indicates whether emojis should be replaced by shortcodes
     pub fn emoji_shortcodes(&self) -> bool {
         self.tunables().message_shortcode_display
diff --git a/src/tests.rs b/src/tests.rs
index 4a62bebd..98349df9 100644
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -178,7 +178,7 @@ pub fn mock_tunables() -> TunableValues {
         typing_notice_send: true,
         typing_notice_display: true,
         users: vec![(TEST_USER5.clone(), UserDisplayTunables {
-            color: Some(UserColor(Color::Black)),
+            color: Some(Color::Black),
             name: Some("USER 5".into()),
         })]
         .into_iter()
@@ -201,6 +201,7 @@ pub fn mock_tunables() -> TunableValues {
         default_split: Default::default(),
         ssl_verify: true,
         cache_policy: Default::default(),
+        colors: Colorscheme::default().values(),
     }
 }
 
diff --git a/src/windows/mod.rs b/src/windows/mod.rs
index 286d19c1..3fa55d68 100644
--- a/src/windows/mod.rs
+++ b/src/windows/mod.rs
@@ -31,26 +31,21 @@ pub mod welcome;
 const MEMBER_FETCH_DEBOUNCE: Duration = Duration::from_secs(5);
 
 #[inline]
-fn bold_style() -> Style {
-    Style::default().add_modifier(StyleModifier::BOLD)
+fn bold_span(s: &str, style: Style) -> Span<'_> {
+    Span::styled(s, style.bold())
 }
 
 #[inline]
-fn bold_span(s: &str) -> Span<'_> {
-    Span::styled(s, bold_style())
+fn bold_spans(s: &str, style: Style) -> Line<'_> {
+    bold_span(s, style).into()
 }
 
 #[inline]
-fn bold_spans(s: &str) -> Line<'_> {
-    bold_span(s).into()
-}
-
-#[inline]
-pub fn selected_style(selected: bool) -> Style {
+pub fn selected_style(selected: bool, style: Style) -> Style {
     if selected {
-        Style::default().add_modifier(StyleModifier::REVERSED)
+        style.add_modifier(StyleModifier::REVERSED)
     } else {
-        Style::default()
+        style
     }
 }
 
@@ -409,8 +404,11 @@ impl IambWindow {
 }
 
 pub type MemberListState = ListState;
+
 pub type PinnedListState = ListState;
+
 pub type RoomListState = ListState;
+
 pub type VerifyListState = ListState;
 
 impl From for IambWindow {
@@ -783,24 +781,25 @@ impl Window for IambWindow {
     }
 
     fn get_tab_title(&self, store: &mut ProgramStore) -> Line<'_> {
+        let style = Default::default();
         match self {
-            IambWindow::DirectList(_) => bold_spans("Direct Messages"),
-            IambWindow::RoomList(_) => bold_spans("Rooms"),
-            IambWindow::SpaceList(_) => bold_spans("Spaces"),
-            IambWindow::VerifyList(_) => bold_spans("Verifications"),
-            IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
-            IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
-            IambWindow::UnreadList(_) => bold_spans("Unread Messages"),
-            IambWindow::MentionsList(_) => bold_spans("Unread Mentions"),
-            IambWindow::InvitesList(_) => bold_spans("Open Invites"),
+            IambWindow::DirectList(_) => bold_spans("Direct Messages", style),
+            IambWindow::RoomList(_) => bold_spans("Rooms", style),
+            IambWindow::SpaceList(_) => bold_spans("Spaces", style),
+            IambWindow::VerifyList(_) => bold_spans("Verifications", style),
+            IambWindow::Welcome(_) => bold_spans("Welcome to iamb", style),
+            IambWindow::ChatList(_) => bold_spans("DMs & Rooms", style),
+            IambWindow::UnreadList(_) => bold_spans("Unread Messages", style),
+            IambWindow::MentionsList(_) => bold_spans("Unread Mentions", style),
+            IambWindow::InvitesList(_) => bold_spans("Open Invites", style),
 
             IambWindow::Room(w) => w.get_tab_title(store),
             IambWindow::MemberList(state, room_id, _) => {
                 let title = store.application.get_room_title(room_id.as_ref());
                 let n = state.len();
                 let v = vec![
-                    bold_span("Room Members "),
-                    Span::styled(format!("({n}): "), bold_style()),
+                    bold_span("Room Members ", style),
+                    Span::styled(format!("({n}): "), style.bold()),
                     title.into(),
                 ];
                 Line::from(v)
@@ -809,8 +808,8 @@ impl Window for IambWindow {
                 let title = store.application.get_room_title(room_id.as_ref());
                 let n = state.len();
                 let v = vec![
-                    bold_span("Pinned Messages "),
-                    Span::styled(format!("({n}): "), bold_style()),
+                    bold_span("Pinned Messages ", style),
+                    Span::styled(format!("({n}): "), style.bold()),
                     title.into(),
                 ];
                 Line::from(v)
@@ -819,24 +818,25 @@ impl Window for IambWindow {
     }
 
     fn get_win_title(&self, store: &mut ProgramStore) -> Line<'_> {
+        let style = store.application.settings.tunables.colors.window_title;
         match self {
-            IambWindow::DirectList(_) => bold_spans("Direct Messages"),
-            IambWindow::RoomList(_) => bold_spans("Rooms"),
-            IambWindow::SpaceList(_) => bold_spans("Spaces"),
-            IambWindow::VerifyList(_) => bold_spans("Verifications"),
-            IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
-            IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
-            IambWindow::UnreadList(_) => bold_spans("Unread Messages"),
-            IambWindow::MentionsList(_) => bold_spans("Unread Mentions"),
-            IambWindow::InvitesList(_) => bold_spans("Open Invites"),
-
-            IambWindow::Room(w) => w.get_title(store),
+            IambWindow::DirectList(_) => bold_spans("Direct Messages", style),
+            IambWindow::RoomList(_) => bold_spans("Rooms", style),
+            IambWindow::SpaceList(_) => bold_spans("Spaces", style),
+            IambWindow::VerifyList(_) => bold_spans("Verifications", style),
+            IambWindow::Welcome(_) => bold_spans("Welcome to iamb", style),
+            IambWindow::ChatList(_) => bold_spans("DMs & Rooms", style),
+            IambWindow::UnreadList(_) => bold_spans("Unread Messages", style),
+            IambWindow::MentionsList(_) => bold_spans("Unread Mentions", style),
+            IambWindow::InvitesList(_) => bold_spans("Open Invites", style),
+
+            IambWindow::Room(w) => w.get_title(store, style),
             IambWindow::MemberList(state, room_id, _) => {
                 let title = store.application.get_room_title(room_id.as_ref());
                 let n = state.len();
                 let v = vec![
-                    bold_span("Room Members "),
-                    Span::styled(format!("({n}): "), bold_style()),
+                    bold_span("Room Members ", style),
+                    Span::styled(format!("({n}): "), style.bold()),
                     title.into(),
                 ];
                 Line::from(v)
@@ -845,8 +845,8 @@ impl Window for IambWindow {
                 let title = store.application.get_room_title(room_id.as_ref());
                 let n = state.len();
                 let v = vec![
-                    bold_span("Pinned Messages "),
-                    Span::styled(format!("({n}): "), bold_style()),
+                    bold_span("Pinned Messages ", style),
+                    Span::styled(format!("({n}): "), style.bold()),
                     title.into(),
                 ];
                 Line::from(v)
@@ -1084,9 +1084,15 @@ impl ListItem for GenericRoomItem {
         &self,
         selected: bool,
         _: &ViewportContext,
-        _: &mut ProgramStore,
+        store: &mut ProgramStore,
     ) -> Text<'_> {
-        let style = selected_style(selected);
+        let style = if self.unread.is_unread() {
+            store.application.settings.tunables.colors.room_list_unread
+        } else {
+            store.application.settings.tunables.colors.room_list
+        };
+
+        let style = selected_style(selected, style);
         let (name, mut labels) = name_and_labels(&self.name, &self.unread, self.membership, style);
         let mut spans = vec![name];
 
diff --git a/src/windows/room/mod.rs b/src/windows/room/mod.rs
index e45f019f..3233adf2 100644
--- a/src/windows/room/mod.rs
+++ b/src/windows/room/mod.rs
@@ -891,14 +891,14 @@ impl RoomState {
         }
     }
 
-    pub fn get_title(&self, store: &mut ProgramStore) -> Line<'_> {
+    pub fn get_title(&self, store: &mut ProgramStore, style: Style) -> Line<'_> {
         let Some(room) = self.room() else {
             return Line::from("Unjoined Room");
         };
 
         let room_id = room.room_id();
         let title = store.application.get_room_title(room_id);
-        let style = Style::default().add_modifier(StyleModifier::BOLD);
+        let bold_style = style.add_modifier(StyleModifier::BOLD);
         let mut spans = vec![];
 
         let encryption_settings = &store.application.settings.tunables.encryption;
@@ -910,16 +910,16 @@ impl RoomState {
         if let RoomState::Chat(chat) = self &&
             chat.thread().is_some()
         {
-            spans.push("Thread in ".into());
+            spans.push(Span::styled("Thread in ", style));
         }
 
-        spans.push(Span::styled(title, style));
+        spans.push(Span::styled(title, bold_style));
 
         match room.topic() {
             Some(desc) if !desc.is_empty() => {
-                spans.push(" (".into());
-                spans.push(desc.into());
-                spans.push(")".into());
+                spans.push(Span::styled(" (", style));
+                spans.push(Span::styled(desc, style));
+                spans.push(Span::styled(")", style));
             },
             _ => {
                 spans.push(" ".into());
diff --git a/src/windows/verify.rs b/src/windows/verify.rs
index cfebe07e..0b5531b8 100644
--- a/src/windows/verify.rs
+++ b/src/windows/verify.rs
@@ -140,9 +140,8 @@ impl ListItem for VerifyItem {
         store: &mut ProgramStore,
     ) -> Text<'_> {
         let mut lines = vec![];
-        let bold = Style::default().add_modifier(StyleModifier::BOLD);
-        let selected_bold = super::selected_style(selected).add_modifier(StyleModifier::BOLD);
-        let selected = super::selected_style(selected);
+        let bold = Style::default().bold();
+        let selected = super::selected_style(selected, Default::default());
 
         let mut other_device = None;
         let state = match self.request.state() {
@@ -277,13 +276,13 @@ impl ListItem for VerifyItem {
                 if let Some(display_name) = device.display_name() {
                     vec![
                         Span::styled("Device verification with ", selected),
-                        Span::styled(display_name.to_owned(), selected_bold),
+                        Span::styled(display_name.to_owned(), selected.bold()),
                         Span::styled(format!(" ({state})"), selected),
                     ]
                 } else {
                     vec![
                         Span::styled("Device verification with ", selected),
-                        Span::styled(device.device_id().to_string(), selected_bold),
+                        Span::styled(device.device_id().to_string(), selected.bold()),
                         Span::styled(format!(" ({state})"), selected),
                     ]
                 }
@@ -297,7 +296,7 @@ impl ListItem for VerifyItem {
             let color = store.application.settings.get_user_color(self.request.other_user_id());
             vec![
                 Span::styled("User verification with ", selected),
-                Span::styled(self.request.other_user_id().as_str(), selected_bold.patch(color)),
+                Span::styled(self.request.other_user_id().as_str(), selected.bold().patch(color)),
                 Span::styled(format!(" ({state})"), selected),
             ]
         };