From a7b21ce222153b6e8800ece144290e5b42fa43fd Mon Sep 17 00:00:00 2001 From: vaw Date: Wed, 25 Jun 2025 20:41:32 +0200 Subject: [PATCH 1/6] Add colorscheme config --- src/config.rs | 63 +++++++++++++++++++++++++++++ src/main.rs | 8 ++-- src/tests.rs | 1 + src/windows/mod.rs | 90 ++++++++++++++++++++++------------------- src/windows/room/mod.rs | 14 +++---- src/windows/verify.rs | 11 +++-- 6 files changed, 129 insertions(+), 58 deletions(-) diff --git a/src/config.rs b/src/config.rs index 19db80ed..b10214ef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -758,6 +758,64 @@ 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, +} + +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), + } + } +} + +#[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, +} + +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); + + ColorschemeValues { + border, + border_unfocused, + window_title, + tab_title, + tab_title_unfocused, + room_list, + room_list_unread, + } + } +} + #[derive(Clone)] pub struct SortValues { pub chats: Vec>, @@ -872,6 +930,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 +986,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 +1043,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 +1090,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(), } } } 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/tests.rs b/src/tests.rs index 4a62bebd..5a9589c3 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -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), ] }; From ae16e9a585259d99279b06e45d2dd57af34e6fcf Mon Sep 17 00:00:00 2001 From: vaw Date: Mon, 23 Mar 2026 23:43:32 +0100 Subject: [PATCH 2/6] Use ratatui `Color` for user overrides --- docs/iamb.5 | 6 ++++-- src/config.rs | 58 +++++++-------------------------------------------- src/tests.rs | 2 +- 3 files changed, 12 insertions(+), 54 deletions(-) diff --git a/docs/iamb.5 b/docs/iamb.5 index 7e22199c..2b6e7752 100644 --- a/docs/iamb.5 +++ b/docs/iamb.5 @@ -659,8 +659,10 @@ Possible values are: .Dq Sy none , .Dq Sy red , .Dq Sy white , -and -.Dq Sy yellow . +.Dq Sy yellow , +or an 8-bit color (integer in the range of 16 - 255). +For terminals with truecolor support, this can also be given as a hex value of +.Dq Sy #RRGGBB . .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 b10214ef..e1835e76 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, } @@ -1510,12 +1471,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())); @@ -1533,7 +1489,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() } @@ -1541,7 +1497,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())) } @@ -1618,13 +1574,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() @@ -1678,7 +1634,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/tests.rs b/src/tests.rs index 5a9589c3..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() From b37fadb1cb741dc6398da0fe1a884214a815866a Mon Sep 17 00:00:00 2001 From: vaw Date: Tue, 24 Mar 2026 00:09:29 +0100 Subject: [PATCH 3/6] Add color option for message time and date --- src/config.rs | 10 ++++++++++ src/message/mod.rs | 22 +++++++--------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/config.rs b/src/config.rs index e1835e76..5f4f42ce 100644 --- a/src/config.rs +++ b/src/config.rs @@ -728,6 +728,8 @@ pub struct Colorscheme { pub tab_title_unfocused: Option, pub room_list: Option, pub room_list_unread: Option, + pub message_time: Option, + pub message_date: Option, } impl Colorscheme { @@ -740,6 +742,8 @@ impl Colorscheme { 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), } } } @@ -753,6 +757,8 @@ pub struct ColorschemeValues { pub tab_title_unfocused: Style, pub room_list: Style, pub room_list_unread: Style, + pub message_time: Style, + pub message_date: Style, } impl Colorscheme { @@ -764,6 +770,8 @@ impl Colorscheme { 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(); ColorschemeValues { border, @@ -773,6 +781,8 @@ impl Colorscheme { tab_title_unfocused, room_list, room_list_unread, + message_time, + message_date, } } } diff --git a/src/message/mod.rs b/src/message/mod.rs index a3497e12..532ba002 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) } } @@ -1133,7 +1125,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 +1135,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 +1168,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 { From 27e0343619cba8df401b6ef4c3b48b2c0fe0576a Mon Sep 17 00:00:00 2001 From: vaw Date: Tue, 24 Mar 2026 16:45:05 +0100 Subject: [PATCH 4/6] Add different colors for message types --- docs/iamb.5 | 135 ++++++++++++++++++++++++++++++++++++++------- src/config.rs | 35 ++++++++++++ src/message/mod.rs | 38 +++++++++++-- 3 files changed, 182 insertions(+), 26 deletions(-) diff --git a/docs/iamb.5 b/docs/iamb.5 index 2b6e7752..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,27 +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 , -.Dq Sy yellow , -or an 8-bit color (integer in the range of 16 - 255). -For terminals with truecolor support, this can also be given as a hex value of -.Dq Sy #RRGGBB . +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 5f4f42ce..1dacbdaa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -730,6 +730,13 @@ pub struct Colorscheme { 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, } impl Colorscheme { @@ -744,6 +751,13 @@ impl Colorscheme { 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), } } } @@ -759,6 +773,13 @@ pub struct ColorschemeValues { 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, } impl Colorscheme { @@ -772,6 +793,13 @@ impl Colorscheme { 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); ColorschemeValues { border, @@ -783,6 +811,13 @@ impl Colorscheme { room_list_unread, message_time, message_date, + message_normal, + message_state, + message_sticker, + message_redacted, + message_poll, + message_notice, + message_other, } } } diff --git a/src/message/mod.rs b/src/message/mod.rs index 532ba002..2c131b60 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -484,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, @@ -811,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(); @@ -924,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)); @@ -1047,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) @@ -1315,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) From 26c3119516b7edf88741860837b72ce2c5e451fa Mon Sep 17 00:00:00 2001 From: vaw Date: Tue, 24 Mar 2026 17:03:41 +0100 Subject: [PATCH 5/6] Add background to code blocks --- config.example.toml | 6 ++++ src/config.rs | 6 ++++ src/message/html.rs | 63 +++++++++++++++++++++++------------------- src/message/printer.rs | 5 ++++ 4 files changed, 52 insertions(+), 28 deletions(-) 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/src/config.rs b/src/config.rs index 1dacbdaa..ab589102 100644 --- a/src/config.rs +++ b/src/config.rs @@ -737,6 +737,7 @@ pub struct Colorscheme { pub message_poll: Option, pub message_notice: Option, pub message_other: Option, + pub codeblock_background: Option, } impl Colorscheme { @@ -758,6 +759,7 @@ impl Colorscheme { 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), } } } @@ -780,6 +782,7 @@ pub struct ColorschemeValues { pub message_poll: Style, pub message_notice: Style, pub message_other: Style, + pub codeblock_background: Style, } impl Colorscheme { @@ -800,6 +803,8 @@ impl Colorscheme { 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, @@ -818,6 +823,7 @@ impl Colorscheme { message_poll, message_notice, message_other, + codeblock_background, } } } 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/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

From d73a7456ae6774f33cad073e7d2e981db445b4bb Mon Sep 17 00:00:00 2001
From: Ulyssa 
Date: Wed, 23 Sep 2026 20:21:24 -0400
Subject: [PATCH 6/6] Move options into their own `theme` configuration section

---
 config.example.toml              |  12 +-
 docs/iamb.5                      | 197 ++++++++++---------
 src/{config.rs => config/mod.rs} | 171 +++++-----------
 src/config/theme.rs              | 322 +++++++++++++++++++++++++++++++
 src/main.rs                      |  13 +-
 src/message/html.rs              |   8 +-
 src/message/mod.rs               |  22 +--
 src/tests.rs                     |   3 +-
 src/windows/mod.rs               |   6 +-
 9 files changed, 517 insertions(+), 237 deletions(-)
 rename src/{config.rs => config/mod.rs} (93%)
 create mode 100644 src/config/theme.rs

diff --git a/config.example.toml b/config.example.toml
index e9571765..40fb800e 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -32,12 +32,6 @@ 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 }
@@ -94,3 +88,9 @@ split = [
 cache = "/home/user/.cache/iamb/"
 logs = "/home/user/.local/share/iamb/logs/"
 downloads = "/home/user/Downloads/"
+
+[theme]
+timeline.state.color = "cyan"
+timeline.redacted.color = "245" # light gray
+# How to use truecolor for terminals that support it:
+# rooms.unread.color = "#ff55bb" # pink
diff --git a/docs/iamb.5 b/docs/iamb.5
index 4904f69f..1fa6b7b9 100644
--- a/docs/iamb.5
+++ b/docs/iamb.5
@@ -132,12 +132,6 @@ 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
@@ -371,11 +365,14 @@ and
 Both can be used via
 .Dq Sy title|prompt .
 .El
-.Sh COLORSCHEME
+.Sh COLORS
 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.
+.Sy settings.users
+and
+.Sy theme
+subsections allows configuring the appearance of many elements.
+For settings that take a color value, you can use either a named color, an 8-bit color value or a hex value.
+.Pp
 The named colors are:
 .Dq Sy black ,
 .Dq Sy red ,
@@ -400,82 +397,6 @@ 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
@@ -741,6 +662,104 @@ Whether to set the title of the terminal window to
 .Dq Sy iamb () .
 Defaults to true.
 .El
+.Sh "THEME OVERRIDES"
+The
+.Sy theme
+subsection allows you to override the colors used for different parts of the user interface.
+Individual, stylable UI elements have the following properties:
+.Bl -tag -width Ds
+.It Sy color
+Configure the foreground color for text.
+See the
+.Sx "COLORS"
+section for the supported color format.
+.It Sy background
+Configure the background color for text.
+See the
+.Sx "COLORS"
+section for the supported color format.
+.El
+Within
+.Sy theme ,
+there are several subsections for specific UI areas:
+.Bl -tag -width Ds
+.It Sy messages
+For controlling how the bodies of messages are styled.
+UI elements you can set within this subsection are:
+.Bl -tag -width Ds
+.It Sy default
+Configure the default styling for messages.
+.It Sy code
+Configure the styling for
+.Sy ""
+elements in formatted message bodies.
+.It Sy "code_block"
+Configure the styling for
+.Sy "
"
+elements in formatted message bodies.
+.El
+.It Sy timeline
+For controlling how other elements within the timeline are styled.
+UI elements you can set within this subsection are:
+.Bl -tag -width Ds
+.It Sy default
+Configure the default styling for text in the timeline.
+.It Sy date
+Configure the how the dates for messages are styled.
+.It Sy time
+Configure the how the time a message was sent is styled.
+.It Sy state
+Configure how the text shown for state events is styled.
+.It Sy sticker
+Configure how any text shown for stickers is styled.
+.It Sy poll
+Configure how the text in polls is styled.
+.It Sy notice
+Configure how server notices is styled.
+.It Sy redacted
+Configure how redacted events in the timeline is styled.
+.El
+.It Sy windows
+For controlling how the windows are styled.
+.Bl -tag -width Ds
+.It Sy border
+Configure how the border characters are styled.
+.It Sy border_focused
+Configure how the border characters for focused window are styled.
+.It Sy title
+Configure how the window titles are styled.
+.El
+.It Sy tabs
+For controlling how tabs are styled.
+UI elements you can set within this subsection are:
+.Bl -tag -width Ds
+.It Sy title
+Configure how tab titles are styled.
+.It Sy title_focused
+Configure how the title of the focused tab is styled.
+.El
+.It Sy rooms
+For controlling how lists of rooms like
+.Sy :unreads
+or
+.Sy :chats
+are styled.
+UI elements you can set within this subsection are:
+.Bl -tag -width Ds
+.It Sy default
+Configure the default style for text in room lists.
+.It Sy unread
+Configure the text for unread rooms in lists is styled.
+.El
+.El
+.Ss Example: Make code blocks a lighter gray background and state events red
+.Bd -literal -offset indent
+[theme.messages]
+code_block.background = "245"
+
+[theme.timeline]
+state.color = "cyan"
+.Ed
 .Sh "USER OVERRIDES"
 The
 .Sy settings.users
@@ -753,9 +772,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.
-See the introduction of
-.Sx "COLORSCHEME"
-for the supported color format.
+See the
+.Sx "COLORS"
+section 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/mod.rs
similarity index 93%
rename from src/config.rs
rename to src/config/mod.rs
index ab589102..042455bb 100644
--- a/src/config.rs
+++ b/src/config/mod.rs
@@ -6,6 +6,7 @@ use std::fs::File;
 use std::hash::{Hash, Hasher};
 use std::io::{BufReader, BufWriter, Write as _};
 use std::process;
+use std::sync::Arc;
 
 use clap::Parser;
 use indexmap::IndexMap;
@@ -27,6 +28,8 @@ use serde::{Deserialize, Deserializer, Serialize};
 use crate::base::{SortColumn, SortFieldRoom, SortFieldUser, SortOrder};
 use crate::prelude::*;
 
+pub mod theme;
+
 pub type Aliases = IndexMap;
 type Macros = HashMap>;
 
@@ -719,115 +722,6 @@ 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>,
@@ -942,7 +836,6 @@ pub struct TunableValues {
     pub default_split: SplitDirection,
     pub ssl_verify: bool,
     pub cache_policy: MediaRetentionPolicy,
-    pub colors: ColorschemeValues,
 }
 
 #[derive(Clone, Debug, Default, Deserialize)]
@@ -998,8 +891,6 @@ pub struct Tunables {
     pub members_split: Option,
     pub default_split: Option,
     pub ssl_verify: Option,
-    #[serde(default)]
-    pub colors: Colorscheme,
     pub cache_policy: Option,
 }
 
@@ -1055,7 +946,6 @@ 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),
         }
     }
 
@@ -1102,7 +992,6 @@ 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(),
         }
     }
 }
@@ -1278,6 +1167,7 @@ pub struct ProfileConfig {
     pub password_file: Option,
     pub url: Option,
     pub settings: Option,
+    pub theme: Option,
     pub dirs: Option,
     pub layout: Option,
     pub macros: Option,
@@ -1293,6 +1183,7 @@ pub struct IambConfig {
     pub layout: Option,
     pub macros: Option,
     pub aliases: Option,
+    pub theme: Option,
 }
 
 impl IambConfig {
@@ -1321,6 +1212,7 @@ pub struct ApplicationSettings {
     pub sqlite_cache_dir: PathBuf,
     pub profile_name: String,
     pub profile: ProfileConfig,
+    pub theme: Arc,
     pub tunables: TunableValues,
     pub dirs: DirectoryValues,
     pub layout: Layout,
@@ -1375,6 +1267,7 @@ impl ApplicationSettings {
             layout,
             macros,
             aliases,
+            theme,
         } = config;
 
         validate_profile_names(&profiles);
@@ -1428,6 +1321,10 @@ impl ApplicationSettings {
         let dirs = profile.dirs.take().unwrap_or_default().merge(dirs);
         let dirs = dirs.values();
 
+        let theme = theme.unwrap_or_default().merge(theme::default_theme());
+        let theme = profile.theme.take().unwrap_or_default().merge(theme);
+        let theme = Arc::new(theme.values());
+
         // Create directories
         dirs.create_dir_all()?;
 
@@ -1472,6 +1369,7 @@ impl ApplicationSettings {
             sqlite_cache_dir,
             profile_name,
             profile,
+            theme,
             tunables,
             dirs,
             layout,
@@ -1677,17 +1575,52 @@ mod tests {
         assert_eq!(res.typing_notice_send, None);
         assert_eq!(res.typing_notice_display, None);
         assert_eq!(res.users, Some(HashMap::new()));
+    }
 
+    #[test]
+    fn test_parse_user_colors() {
+        let expect = |color| UserDisplayTunables { color: Some(color), name: Some("Tim".into()) };
+
+        // Unprefixed color:
         let res: Tunables = serde_json::from_str(
             "{\"users\": {\"@a:b.c\": {\"color\": \"black\", \"name\": \"Tim\"}}}",
         )
         .unwrap();
         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(Color::Black),
-            name: Some("Tim".into()),
-        })];
+        let users = vec![(user_id!("@a:b.c").to_owned(), expect(Color::Black))];
+        assert_eq!(res.users, Some(users.into_iter().collect()));
+
+        // Color with `light-` prefix:
+        let res: Tunables = serde_json::from_str(
+            "{\"users\": {\"@a:b.c\": {\"color\": \"light-red\", \"name\": \"Tim\"}}}",
+        )
+        .unwrap();
+        let users = vec![(user_id!("@a:b.c").to_owned(), expect(Color::LightRed))];
+        assert_eq!(res.users, Some(users.into_iter().collect()));
+
+        // Color name with `light-` prefix:
+        let res: Tunables = serde_json::from_str(
+            "{\"users\": {\"@a:b.c\": {\"color\": \"light-red\", \"name\": \"Tim\"}}}",
+        )
+        .unwrap();
+        let users = vec![(user_id!("@a:b.c").to_owned(), expect(Color::LightRed))];
+        assert_eq!(res.users, Some(users.into_iter().collect()));
+
+        // Color name with `light` prefix, no hyphen:
+        let res: Tunables = serde_json::from_str(
+            "{\"users\": {\"@a:b.c\": {\"color\": \"lightblue\", \"name\": \"Tim\"}}}",
+        )
+        .unwrap();
+        let users = vec![(user_id!("@a:b.c").to_owned(), expect(Color::LightBlue))];
+        assert_eq!(res.users, Some(users.into_iter().collect()));
+
+        // Hex color name:
+        let res: Tunables = serde_json::from_str(
+            "{\"users\": {\"@a:b.c\": {\"color\": \"#ff55bb\", \"name\": \"Tim\"}}}",
+        )
+        .unwrap();
+        let users = vec![(user_id!("@a:b.c").to_owned(), expect(Color::Rgb(0xff, 0x55, 0xbb)))];
         assert_eq!(res.users, Some(users.into_iter().collect()));
     }
 
@@ -1990,6 +1923,7 @@ mod tests {
             layout,
             macros,
             aliases,
+            theme,
         } = &config;
 
         // There should be an example object for each top-level field.
@@ -2000,6 +1934,7 @@ mod tests {
         assert!(layout.is_some());
         assert!(macros.is_some());
         assert!(aliases.is_some());
+        assert!(theme.is_some());
     }
 
     #[test]
diff --git a/src/config/theme.rs b/src/config/theme.rs
new file mode 100644
index 00000000..953f1967
--- /dev/null
+++ b/src/config/theme.rs
@@ -0,0 +1,322 @@
+//! # Logic for parsing styling configuration into [Style].
+use serde::Deserialize;
+
+use crate::prelude::*;
+
+pub fn default_theme() -> Theme {
+    Theme {
+        messages: ThemeMessages {
+            code: Stylable { color: None, background: Some(Color::Indexed(236)) },
+            ..Default::default()
+        },
+        ..Default::default()
+    }
+}
+
+#[derive(Clone, Copy, Debug, Default, Deserialize)]
+pub struct Stylable {
+    pub background: Option,
+    pub color: Option,
+}
+
+impl Stylable {
+    fn merge(self, other: Self) -> Self {
+        Self {
+            background: self.background.or(other.background),
+            color: self.color.or(other.color),
+        }
+    }
+}
+
+impl From for Style {
+    fn from(styled: Stylable) -> Self {
+        let mut style = Style::default();
+
+        if let Some(bg) = styled.background {
+            style = style.bg(bg);
+        }
+
+        if let Some(fg) = styled.color {
+            style = style.fg(fg);
+        }
+
+        style
+    }
+}
+
+#[derive(Clone, Debug, Default, Deserialize)]
+pub struct Theme {
+    /// Configuration specificaly for messages shown within a room's timeline.
+    #[serde(default)]
+    messages: ThemeMessages,
+
+    /// Configuration for styling a room's timeline.
+    #[serde(default)]
+    timeline: ThemeTimeline,
+
+    /// Configuration for styling items within room lists.
+    #[serde(default)]
+    rooms: ThemeRooms,
+
+    /// Configuration for styling tabs.
+    #[serde(default)]
+    tabs: ThemeTabs,
+
+    /// Configuration for styling windows.
+    #[serde(default)]
+    windows: ThemeWindows,
+}
+
+impl Theme {
+    pub fn merge(self, other: Self) -> Self {
+        Self {
+            messages: self.messages.merge(other.messages),
+            tabs: self.tabs.merge(other.tabs),
+            timeline: self.timeline.merge(other.timeline),
+            rooms: self.rooms.merge(other.rooms),
+            windows: self.windows.merge(other.windows),
+        }
+    }
+
+    pub fn values(self) -> ThemeValues {
+        let base = Style::default();
+
+        ThemeValues {
+            messages: self.messages.values(base),
+            timeline: self.timeline.values(base),
+            rooms: self.rooms.values(base),
+            tabs: self.tabs.values(base),
+            windows: self.windows.values(base),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct ThemeValues {
+    /// Styling specificaly for messages shown within a room's timeline.
+    pub messages: ThemeMessagesValues,
+
+    /// Styling for a room's timeline.
+    pub timeline: ThemeTimelineValues,
+
+    /// Styling for items within room lists.
+    pub rooms: ThemeRoomsValues,
+
+    /// Styling for rendering tabs.
+    pub tabs: ThemeTabsValues,
+
+    /// Styling for rendering windows.
+    pub windows: ThemeWindowsValues,
+}
+
+#[derive(Clone, Debug, Default, Deserialize)]
+struct ThemeMessages {
+    #[serde(default)]
+    default: Stylable,
+
+    #[serde(default)]
+    code: Stylable,
+
+    #[serde(default)]
+    code_block: Stylable,
+}
+
+impl ThemeMessages {
+    fn merge(self, other: Self) -> Self {
+        Self {
+            default: self.default.merge(other.default),
+            code: self.code.merge(other.code),
+            code_block: self.code_block.merge(other.code_block),
+        }
+    }
+
+    fn values(self, base: Style) -> ThemeMessagesValues {
+        let code = self.code.merge(self.default);
+        let code_block = self.code_block.merge(code);
+
+        ThemeMessagesValues {
+            default: base.patch(self.default),
+            code: base.patch(code),
+            code_block: base.patch(code_block),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct ThemeMessagesValues {
+    pub default: Style,
+    pub code: Style,
+    pub code_block: Style,
+}
+
+#[derive(Clone, Debug, Default, Deserialize)]
+struct ThemeRooms {
+    #[serde(default)]
+    default: Stylable,
+
+    #[serde(default)]
+    unread: Stylable,
+}
+
+impl ThemeRooms {
+    fn merge(self, other: Self) -> Self {
+        Self {
+            default: self.default.merge(other.default),
+            unread: self.unread.merge(other.unread),
+        }
+    }
+
+    fn values(self, base: Style) -> ThemeRoomsValues {
+        let unread = self.unread.merge(self.default);
+
+        ThemeRoomsValues {
+            default: base.patch(self.default),
+            unread: base.patch(unread),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct ThemeRoomsValues {
+    pub default: Style,
+    pub unread: Style,
+}
+
+#[derive(Clone, Debug, Default, Deserialize)]
+struct ThemeTabs {
+    #[serde(default)]
+    title: Stylable,
+
+    #[serde(default)]
+    title_focused: Stylable,
+}
+
+impl ThemeTabs {
+    fn merge(self, other: Self) -> Self {
+        Self {
+            title: self.title.merge(other.title),
+            title_focused: self.title_focused.merge(other.title_focused),
+        }
+    }
+
+    fn values(self, base: Style) -> ThemeTabsValues {
+        let title_focused = self.title_focused.merge(self.title);
+
+        ThemeTabsValues {
+            title: base.patch(self.title),
+            title_focused: base.patch(title_focused),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct ThemeTabsValues {
+    pub title: Style,
+    pub title_focused: Style,
+}
+
+#[derive(Clone, Debug, Default, Deserialize)]
+struct ThemeTimeline {
+    #[serde(default)]
+    default: Stylable,
+
+    #[serde(default)]
+    date: Stylable,
+
+    #[serde(default)]
+    time: Stylable,
+
+    #[serde(default)]
+    state: Stylable,
+
+    #[serde(default)]
+    sticker: Stylable,
+
+    #[serde(default)]
+    poll: Stylable,
+
+    #[serde(default)]
+    notice: Stylable,
+
+    #[serde(default)]
+    redacted: Stylable,
+}
+
+impl ThemeTimeline {
+    fn merge(self, other: Self) -> Self {
+        Self {
+            default: self.default.merge(other.default),
+            date: self.date.merge(other.date),
+            time: self.time.merge(other.time),
+            state: self.state.merge(other.state),
+            sticker: self.sticker.merge(other.sticker),
+            poll: self.poll.merge(other.poll),
+            notice: self.notice.merge(other.notice),
+            redacted: self.redacted.merge(other.redacted),
+        }
+    }
+
+    fn values(self, base: Style) -> ThemeTimelineValues {
+        let base = base.patch(self.default);
+
+        ThemeTimelineValues {
+            default: base,
+            date: base.patch(self.date),
+            time: base.patch(self.time),
+            state: base.patch(self.state),
+            sticker: base.patch(self.sticker),
+            poll: base.patch(self.poll),
+            notice: base.patch(self.notice),
+            redacted: base.patch(self.redacted),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct ThemeTimelineValues {
+    pub default: Style,
+    pub date: Style,
+    pub time: Style,
+    pub state: Style,
+    pub sticker: Style,
+    pub poll: Style,
+    pub notice: Style,
+    pub redacted: Style,
+}
+
+#[derive(Clone, Debug, Default, Deserialize)]
+struct ThemeWindows {
+    #[serde(default)]
+    border: Stylable,
+
+    #[serde(default)]
+    border_focused: Stylable,
+
+    #[serde(default)]
+    title: Stylable,
+}
+
+impl ThemeWindows {
+    fn merge(self, other: Self) -> Self {
+        Self {
+            border: self.border.merge(other.border),
+            border_focused: self.border_focused.merge(other.border_focused),
+            title: self.title.merge(other.title),
+        }
+    }
+
+    fn values(self, base: Style) -> ThemeWindowsValues {
+        ThemeWindowsValues {
+            border: base.patch(self.border),
+            border_focused: base.patch(self.border_focused.merge(self.border)),
+            title: base.patch(self.title),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct ThemeWindowsValues {
+    pub border: Style,
+    pub border_focused: Style,
+    pub title: Style,
+}
diff --git a/src/main.rs b/src/main.rs
index bf49fcf9..9855e8b4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -319,7 +319,6 @@ 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();
@@ -333,6 +332,8 @@ impl Application {
             term.clear()?;
         }
 
+        let theme = store.application.settings.theme.clone();
+
         term.draw(|f| {
             let area = f.area();
 
@@ -348,10 +349,12 @@ impl Application {
                 .show_dialog(dialogstr)
                 .show_mode(modestr)
                 .borders(true)
-                .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))
+                .border_style(theme.windows.border.add_modifier(StyleModifier::DIM))
+                .border_style_focused(
+                    theme.windows.border_focused.remove_modifier(StyleModifier::DIM),
+                )
+                .tab_style(theme.tabs.title.add_modifier(StyleModifier::DIM))
+                .tab_style_focused(theme.tabs.title_focused.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 32deda59..c55926a3 100644
--- a/src/message/html.rs
+++ b/src/message/html.rs
@@ -368,7 +368,7 @@ impl StyleTreeNode {
                 }
             },
             StyleTreeNode::Code(child, _) => {
-                let style = style.patch(printer.settings().tunables.colors.codeblock_background);
+                let style = style.patch(printer.settings().theme.messages.code);
 
                 let old_style = printer.replace_base_style(style);
 
@@ -421,9 +421,11 @@ impl StyleTreeNode {
             },
             StyleTreeNode::Pre(child) => {
                 let mut subp = printer.sub(2).literal(true);
+                let code_style = style.patch(subp.settings().theme.messages.code_block);
+                let _ = subp.replace_base_style(code_style);
                 let subw = subp.width();
 
-                child.print(&mut subp, style);
+                child.print(&mut subp, code_style);
 
                 printer.commit();
                 printer.push_line(
@@ -1452,7 +1454,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 code_style = settings.theme.messages.code_block;
         let s = concat!(
             "
",
             "fn hello() -> usize {\n",
diff --git a/src/message/mod.rs b/src/message/mod.rs
index 2c131b60..137a7b5d 100644
--- a/src/message/mod.rs
+++ b/src/message/mod.rs
@@ -223,7 +223,7 @@ impl MessageTimeStamp {
     fn show_date(self, settings: &ApplicationSettings) -> Span<'static> {
         let time = self.as_datetime().format("%A, %B %d %Y").to_string();
 
-        Span::styled(time, settings.tunables.colors.message_date.add_modifier(StyleModifier::BOLD))
+        Span::styled(time, settings.theme.timeline.date.add_modifier(StyleModifier::BOLD))
     }
 
     /// A compact date and time, for places without a date separator line.
@@ -235,7 +235,7 @@ impl MessageTimeStamp {
         let time = self.as_datetime().format("%T");
         let time = format!("  [{time}]");
 
-        Span::styled(time, settings.tunables.colors.message_time)
+        Span::styled(time, settings.theme.timeline.time)
     }
 }
 
@@ -487,15 +487,15 @@ impl MessageEvent {
     fn message_style(&self, settings: &ApplicationSettings) -> Style {
         let content = match self {
             MessageEvent::EncryptedOriginal(_) | MessageEvent::EncryptedRedacted(_) => {
-                return settings.tunables.colors.message_other;
+                return settings.theme.timeline.default;
             },
-            MessageEvent::Redacted(..) => return settings.tunables.colors.message_redacted,
-            MessageEvent::State(_) => return settings.tunables.colors.message_state,
+            MessageEvent::Redacted(..) => return settings.theme.timeline.redacted,
+            MessageEvent::State(_) => return settings.theme.timeline.state,
             MessageEvent::Original(ev, _) => &ev.content,
             MessageEvent::Local(_, _, content) => content,
-            MessageEvent::Sticker(..) => return settings.tunables.colors.message_sticker,
+            MessageEvent::Sticker(..) => return settings.theme.timeline.sticker,
             MessageEvent::Poll(..) | MessageEvent::UnstablePoll(..) => {
-                return settings.tunables.colors.message_poll;
+                return settings.theme.timeline.poll;
             },
         };
 
@@ -505,11 +505,9 @@ impl MessageEvent {
             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,
+            MessageType::Video(_) => settings.theme.messages.default,
+            MessageType::Notice(_) | MessageType::ServerNotice(_) => settings.theme.timeline.notice,
+            _ => settings.theme.messages.default,
         }
     }
 
diff --git a/src/tests.rs b/src/tests.rs
index 98349df9..d5851e6b 100644
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -201,7 +201,6 @@ pub fn mock_tunables() -> TunableValues {
         default_split: Default::default(),
         ssl_verify: true,
         cache_policy: Default::default(),
-        colors: Colorscheme::default().values(),
     }
 }
 
@@ -224,12 +223,14 @@ pub fn mock_settings() -> ApplicationSettings {
             layout: None,
             macros: None,
             aliases: None,
+            theme: None,
         },
         tunables: mock_tunables(),
         dirs: mock_dirs(),
         layout: Default::default(),
         macros: HashMap::default(),
         aliases: Aliases::default(),
+        theme: crate::config::theme::default_theme().values().into(),
         enable_enhanced_keys: false,
     }
 }
diff --git a/src/windows/mod.rs b/src/windows/mod.rs
index 3fa55d68..8cdb2f90 100644
--- a/src/windows/mod.rs
+++ b/src/windows/mod.rs
@@ -818,7 +818,7 @@ impl Window for IambWindow {
     }
 
     fn get_win_title(&self, store: &mut ProgramStore) -> Line<'_> {
-        let style = store.application.settings.tunables.colors.window_title;
+        let style = store.application.settings.theme.windows.title;
         match self {
             IambWindow::DirectList(_) => bold_spans("Direct Messages", style),
             IambWindow::RoomList(_) => bold_spans("Rooms", style),
@@ -1087,9 +1087,9 @@ impl ListItem for GenericRoomItem {
         store: &mut ProgramStore,
     ) -> Text<'_> {
         let style = if self.unread.is_unread() {
-            store.application.settings.tunables.colors.room_list_unread
+            store.application.settings.theme.rooms.unread
         } else {
-            store.application.settings.tunables.colors.room_list
+            store.application.settings.theme.rooms.default
         };
 
         let style = selected_style(selected, style);