diff --git a/Cargo.lock b/Cargo.lock index 30f4e053..c83dcc4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2532,6 +2532,8 @@ dependencies = [ "serde_json", "shellexpand", "sled", + "strum", + "strum_macros", "temp-dir", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 01ee80a7..5c8b8a7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,8 @@ serde = "1.0" serde_json = "1.0" shellexpand = "3.1" sled = "0.34.7" +strum = "0.28.0" +strum_macros = "0.28.0" temp-dir = "0.2" thiserror = "2.0" toml = "1.1" diff --git a/docs/iamb.1 b/docs/iamb.1 index 0fd07e60..ac8cbdac 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -71,7 +71,29 @@ View a list of rooms with mentions of the current user. View the startup Welcome window. .It Sy ":forget" Remove all left rooms from the internal database. +.Id Sy ":reload" +Reload the config. +.It Sy ":se[t] {options}" +Change config options at runtime. +Use +.Dq {option} +and +.Dq no{option} +for booleans and +.Dq {option}={value} +for other settings. + +.Ss Example 1: Disable notifications +.Bd -literal -offset indent +:set notifications.noenabled +.Ed .El +.Ss Example 2: Remove a username override +.Bd -literal -offset indent +:set users.@username:example.org.name= +.Ed +.El + .Sh "E2EE COMMANDS" .Bl -tag -width Ds .It Sy ":keys export [path] [passphrase]" diff --git a/src/base.rs b/src/base.rs index d3f70bdd..12544df8 100644 --- a/src/base.rs +++ b/src/base.rs @@ -4,6 +4,7 @@ use std::collections::hash_map::{Entry, IntoIter}; use std::collections::{BTreeSet, HashSet}; +use std::path::PathBuf; use emojis::Emoji; use matrix_sdk::Client; @@ -41,8 +42,10 @@ use modalkit::keybindings::SequenceStatus; use serde::de::Error as SerdeError; use serde::de::Visitor; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use strum::VariantNames; use tokio::sync::Mutex as AsyncMutex; +use crate::config::{ReloadError, TunablesUpdate}; use crate::notifications::NotificationHandle; use crate::prelude::*; @@ -190,7 +193,8 @@ bitflags::bitflags! { } /// Fields that rooms and spaces can be sorted by. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, VariantNames)] +#[strum(serialize_all = "lowercase")] pub enum SortFieldRoom { /// Sort rooms by whether they have the Favorite tag. Favorite, @@ -205,6 +209,7 @@ pub enum SortFieldRoom { Alias, /// Sort rooms by their Matrix room identifier. + #[strum(serialize = "id")] RoomId, /// Sort rooms by the server portion of their canonical room alias. @@ -225,9 +230,12 @@ pub enum SortFieldRoom { } /// Fields that users can be sorted by. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, VariantNames)] +#[strum(serialize_all = "lowercase")] pub enum SortFieldUser { + #[strum(serialize = "power")] PowerLevel, + #[strum(serialize = "id")] UserId, LocalPart, Server, @@ -256,7 +264,7 @@ impl<'de> Deserialize<'de> for SortColumn { } /// [serde] visitor for deserializing [SortColumn] for rooms and spaces. -struct SortRoomVisitor; +pub(crate) struct SortRoomVisitor; impl Visitor<'_> for SortRoomVisitor { type Value = SortColumn; @@ -310,7 +318,7 @@ impl<'de> Deserialize<'de> for SortColumn { } /// [serde] visitor for deserializing [SortColumn] for users. -struct SortUserVisitor; +pub(crate) struct SortUserVisitor; impl Visitor<'_> for SortUserVisitor { type Value = SortColumn; @@ -566,6 +574,16 @@ pub enum KeysAction { Import(String, String), } +/// An action performed on the application settings. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SettingsAction { + /// Change some settings. + Set(Vec), + + /// Reload the (specified) config file. + Reload(Option), +} + /// An action that the main program loop should. /// /// See [the commands module][super::commands] for where these are usually created. @@ -583,6 +601,9 @@ pub enum IambAction { /// Perform an action on the current space. Space(SpaceAction), + /// Perform an action on the application settings. + Settings(SettingsAction), + /// Open a URL. OpenLink(String), @@ -630,6 +651,12 @@ impl From for IambAction { } } +impl From for IambAction { + fn from(act: SettingsAction) -> Self { + IambAction::Settings(act) + } +} + impl From for IambAction { fn from(act: RoomAction) -> Self { IambAction::Room(act) @@ -653,6 +680,7 @@ impl ApplicationAction for IambAction { IambAction::Room(..) => SequenceStatus::Break, IambAction::OpenLink(..) => SequenceStatus::Break, IambAction::Send(..) => SequenceStatus::Break, + IambAction::Settings(..) => SequenceStatus::Break, IambAction::ToggleScrollbackFocus => SequenceStatus::Break, IambAction::Verify(..) => SequenceStatus::Break, IambAction::VerifyRequest(..) => SequenceStatus::Break, @@ -669,6 +697,7 @@ impl ApplicationAction for IambAction { IambAction::OpenLink(..) => SequenceStatus::Atom, IambAction::Room(..) => SequenceStatus::Atom, IambAction::Send(..) => SequenceStatus::Atom, + IambAction::Settings(..) => SequenceStatus::Atom, IambAction::ToggleScrollbackFocus => SequenceStatus::Atom, IambAction::Verify(..) => SequenceStatus::Atom, IambAction::VerifyRequest(..) => SequenceStatus::Atom, @@ -685,6 +714,7 @@ impl ApplicationAction for IambAction { IambAction::Room(..) => SequenceStatus::Ignore, IambAction::OpenLink(..) => SequenceStatus::Ignore, IambAction::Send(..) => SequenceStatus::Ignore, + IambAction::Settings(..) => SequenceStatus::Ignore, IambAction::ToggleScrollbackFocus => SequenceStatus::Ignore, IambAction::Verify(..) => SequenceStatus::Ignore, IambAction::VerifyRequest(..) => SequenceStatus::Ignore, @@ -700,6 +730,7 @@ impl ApplicationAction for IambAction { IambAction::Room(..) => false, IambAction::Keys(..) => false, IambAction::Send(..) => false, + IambAction::Settings(..) => false, IambAction::OpenLink(..) => false, IambAction::ToggleScrollbackFocus => false, IambAction::Verify(..) => false, @@ -883,6 +914,10 @@ pub enum IambError { /// A generic error that doesn't need a specific error type. #[error("{0}")] Custom(String), + + /// Config couldn't be reloaded + #[error("Reload error: {0}")] + ConfigReload(#[from] ReloadError), } impl From for UIError { diff --git a/src/commands.rs b/src/commands.rs index 05a39b07..12b50969 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -18,8 +18,10 @@ use crate::base::{ ProgramCommand, ProgramCommands, RoomField, + SettingsAction, VerifyAction, }; +use crate::config::TunablesUpdate; use crate::prelude::*; type ProgContext = CommandContext; @@ -995,6 +997,43 @@ fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { return Ok(step); } +fn iamb_set(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let args = desc.arg.strings()?; + + let mut updates = vec![]; + + for arg in args { + let (option, value) = if let Some((option, value)) = arg.split_once('=') { + (option.to_string(), Some(value)) + } else { + (arg.clone(), None) + }; + + match TunablesUpdate::new(option, value) { + Ok(update) => updates.push(update), + Err(err) => return Result::Err(CommandError::ParseFailed(format!("{err}: {arg}"))), + } + } + + let iact = IambAction::from(SettingsAction::Set(updates)); + let step = CommandStep::Continue(iact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn iamb_reload(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.strings()?; + + if args.len() > 1 { + return Result::Err(CommandError::InvalidArgument); + } + + let iact = IambAction::from(SettingsAction::Reload(args.pop().map(Into::into))); + let step = CommandStep::Continue(iact.into(), ctx.context.clone()); + + return Ok(step); +} + pub fn add_iamb_commands(cmds: &mut ProgramCommands) { cmds.add_command(ProgramCommand { name: "cancel".into(), @@ -1128,6 +1167,16 @@ pub fn add_iamb_commands(cmds: &mut ProgramCommands) { aliases: vec![], f: iamb_logout, }); + cmds.add_command(ProgramCommand { + name: "set".into(), + aliases: vec!["se".into()], + f: iamb_set, + }); + cmds.add_command(ProgramCommand { + name: "reload".into(), + aliases: vec![], + f: iamb_reload, + }); } /// Initialize the default command state. diff --git a/src/completions.rs b/src/completions.rs index 85ebb67d..f060d658 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -3,8 +3,23 @@ use modalkit::editing::completion::{Completer, complete_path}; use modalkit::editing::cursor::Cursor; use modalkit::env::vim::command::CommandDescription; - -use crate::base::MATRIX_ID_WORD; +use strum::{EnumProperty as _, VariantArray as _, VariantNames as _}; + +use crate::base::{MATRIX_ID_WORD, SortFieldRoom, SortFieldUser}; +use crate::config::{ + CursorShape, + EncryptionIndicator, + EncryptionUpdateDiscriminants, + IambProtocolType, + MarkupFormat, + NotificationsUpdateDiscriminants, + ReadReceiptTrigger, + SortUpdateDiscriminants, + SplitDirection, + TerminalUpdateDiscriminants, + TunablesUpdateDiscriminants, + UserDisplayStyle, +}; use crate::prelude::*; mod parse { @@ -318,6 +333,29 @@ fn complete_options(args: &[String], options: &[&'static str]) -> Vec { complete_choices(args.last().unwrap(), opts.as_slice()) } +/// Tab completion for [`Color`](ratatui::style::Color). +fn complete_colors(input: &str) -> Vec { + complete_choices(input, &[ + "black", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "gray", + "dark-gray", + "light-red", + "light-green", + "light-yellow", + "light-blue", + "light-magenta", + "light-cyan", + "white", + "reset", + ]) +} + /// Tab completion for `:invite` fn complete_iamb_invite(args: Vec, store: &ChatStore) -> Vec { match args.len() { @@ -391,6 +429,281 @@ fn complete_iamb_self(args: Vec) -> Vec { } } +/// Tab completion for `:set` +fn complete_iamb_set(arg: &str, store: &ChatStore) -> Vec { + if let Some((orig_option, value)) = arg.split_once('=') { + let mut option = orig_option.to_string(); + option.retain(|c| c != '_'); + + match option.as_str() { + "loglevel" => { + complete_choices(value, &["off", "error", "warn", "info", "debug", "trace"]) + .into_iter() + .map(|mut s| { + s.insert(0, '='); + s.insert_str(0, orig_option); + s + }) + .collect() + }, + "usernamedisplay" => { + complete_choices(value, UserDisplayStyle::VARIANTS) + .into_iter() + .map(|mut s| { + s.insert(0, '='); + s.insert_str(0, orig_option); + s + }) + .collect() + }, + "defaultmarkup" => { + complete_choices(value, MarkupFormat::VARIANTS) + .into_iter() + .map(|mut s| { + s.insert(0, '='); + s.insert_str(0, orig_option); + s + }) + .collect() + }, + "defaultsplit" => { + complete_choices(value, SplitDirection::VARIANTS) + .into_iter() + .map(|mut s| { + s.insert(0, '='); + s.insert_str(0, orig_option); + s + }) + .collect() + }, + "memberssplit" => { + complete_choices(value, SplitDirection::VARIANTS) + .into_iter() + .map(|mut s| { + s.insert(0, '='); + s.insert_str(0, orig_option); + s + }) + .collect() + }, + "readreceipttrigger" => { + complete_choices(value, ReadReceiptTrigger::VARIANTS) + .into_iter() + .map(|mut s| { + s.insert(0, '='); + s.insert_str(0, orig_option); + s + }) + .collect() + }, + "encryption.indicator" => { + complete_choices(value, EncryptionIndicator::VARIANTS) + .into_iter() + .map(|mut s| { + s.insert(0, '='); + s.insert_str(0, orig_option); + s + }) + .collect() + }, + "encryption.indicatorlocation" => { + let choices = ["title", "prompt", "title|prompt", "prompt|title"]; + complete_choices(value, &choices) + .into_iter() + .map(|mut s| { + s.insert(0, '='); + s.insert_str(0, orig_option); + s + }) + .collect() + }, + "imagepreview.protocol.type" => { + complete_choices(value, IambProtocolType::VARIANTS) + .into_iter() + .map(|mut s| { + s.insert(0, '='); + s.insert_str(0, orig_option); + s + }) + .collect() + }, + "imagepreview.protocol.filter" => { + let choices = ["Nearest", "Triangle", "CatmullRom", "Gaussian", "Lanczos3"]; + complete_choices(value, &choices) + .into_iter() + .map(|mut s| { + s.insert(0, '='); + s.insert_str(0, orig_option); + s + }) + .collect() + }, + "sort.chats" | "sort.dms" | "sort.rooms" | "sort.spaces" => { + let last = value.rsplit_once(',').map(|(_, v)| v).unwrap_or(value); + let prev = arg.strip_suffix(last).unwrap(); + + SortFieldRoom::VARIANTS + .iter() + .flat_map(|option| vec![format!("{prev}{option}"), format!("{prev}~{option}")]) + .filter(|option| option.starts_with(arg)) + .collect() + }, + "sort.members" => { + let last = value.rsplit_once(',').map(|(_, v)| v).unwrap_or(value); + let prev = arg.strip_suffix(last).unwrap(); + + SortFieldUser::VARIANTS + .iter() + .flat_map(|option| vec![format!("{prev}{option}"), format!("{prev}~{option}")]) + .filter(|option| option.starts_with(arg)) + .collect() + }, + "terminal.cursorshape" => { + complete_choices(value, CursorShape::VARIANTS) + .into_iter() + .map(|mut s| { + s.insert(0, '='); + s.insert_str(0, orig_option); + s + }) + .collect() + }, + "notifications.via" => { + #[cfg(feature = "desktop")] + let choices = ["bell", "desktop", "desktop|bell", "bell|desktop"]; + #[cfg(not(feature = "desktop"))] + let choices = ["bell"]; + + complete_choices(value, &choices) + .into_iter() + .map(|mut s| { + s.insert(0, '='); + s.insert_str(0, orig_option); + s + }) + .collect() + }, + opt if opt.starts_with("users.") && opt.ends_with(".color") => { + complete_colors(value) + .into_iter() + .map(|mut s| { + s.insert(0, '='); + s.insert_str(0, orig_option); + s + }) + .collect() + }, + _ => vec![], + } + } else { + let mut orig_option = arg.to_string(); + orig_option.retain(|c| c != '_'); + + match orig_option.split_once('.') { + Some(("sort", _)) => { + SortUpdateDiscriminants::VARIANTS + .iter() + .map(|variant| { + let name = <_ as Into<&'static str>>::into(variant); + format!("sort.{name}") + }) + .filter(|option| option.starts_with(&orig_option) | option.starts_with(arg)) + .collect() + }, + Some(("encryption", _)) => { + EncryptionUpdateDiscriminants::VARIANTS + .iter() + .map(|variant| { + let name = <_ as Into<&'static str>>::into(variant); + format!("encryption.{name}") + }) + .filter(|option| option.starts_with(&orig_option) | option.starts_with(arg)) + .collect() + }, + Some(("imagepreview", rest)) => { + let choices = [ + "enabled", + "noenabled", + "protocol.filter", + "protocol.type", + "size.height", + "size.width", + ]; + complete_choices(rest, &choices) + .into_iter() + .map(|mut option| { + option.insert_str(0, "image_preview."); + option + }) + .collect() + }, + Some(("notifications", _)) => { + NotificationsUpdateDiscriminants::VARIANTS + .iter() + .flat_map(|variant| { + let name = <_ as Into<&'static str>>::into(variant); + if variant.get_bool("is_bool") == Some(true) { + vec![ + format!("notifications.no{name}"), + format!("notifications.{name}"), + ] + } else { + vec![format!("notifications.{name}")] + } + }) + .filter(|option| option.starts_with(&orig_option) | option.starts_with(arg)) + .collect() + }, + Some(("terminal", _)) => { + TerminalUpdateDiscriminants::VARIANTS + .iter() + .map(|variant| { + let name = <_ as Into<&'static str>>::into(variant); + format!("terminal.{name}") + }) + .filter(|option| option.starts_with(&orig_option) | option.starts_with(arg)) + .collect() + }, + Some(("users", _)) => { + let suboption = arg.strip_prefix("users.").unwrap(); + let mut completions = complete_users(suboption, store); + + for completion in &mut completions { + completion.insert_str(0, "users."); + } + + if let Some((user, end)) = suboption.rsplit_once('.') && + UserId::parse(user).and_then(|user| user.validate_strict()).is_ok() + { + if "name".starts_with(end) { + completions.push(format!("users.{user}.name")); + } + if "color".starts_with(end) { + completions.push(format!("users.{user}.color")); + } + } + + completions + }, + None => { + TunablesUpdateDiscriminants::VARIANTS + .iter() + .flat_map(|variant| { + let name = <_ as Into<&'static str>>::into(variant); + if variant.get_bool("is_bool") == Some(true) { + vec![format!("no{name}"), name.to_string()] + } else { + vec![name.to_string()] + } + }) + .filter(|option| option.starts_with(arg)) + .collect() + }, + _ => vec![], + } + } +} + /// Tab completion for `:unreads` fn complete_iamb_unreads(args: Vec) -> Vec { match args.len() { @@ -588,6 +901,8 @@ fn complete_cmdarg( "self" => complete_iamb_self(args), + "set" => complete_iamb_set(args.last().map(Deref::deref).unwrap_or_default(), store), + "space" => complete_iamb_space(args, store), // TODO: Check whether we can get the id of the focused message to improve completion @@ -596,7 +911,8 @@ fn complete_cmdarg( "unreads" => complete_iamb_unreads(args), - "upload" | "download" | "open" => { + // complete file path + "upload" | "download" | "open" | "reload" => { if input.get_char_at_cursor(cursor) == Some('"') { // Use the escaped instead of the qouted filename. let mut args = args; diff --git a/src/config.rs b/src/config.rs index a03e7551..15762006 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,14 +15,28 @@ use matrix_sdk::reqwest::header::{HeaderMap, HeaderValue}; use matrix_sdk::ruma::OwnedDeviceId; use modalkit::env::vim::VimMode; use modalkit::keybindings::InputKey; +use ratatui::crossterm::cursor::SetCursorStyle; use ratatui_image::FilterType; use ratatui_image::picker::ProtocolType; use serde::de::Error as SerdeError; use serde::de::Visitor; use serde::{Deserialize, Deserializer, Serialize}; +use strum::{ + EnumDiscriminants, + EnumProperty, + EnumString, + IntoStaticStr, + VariantArray, + VariantNames, +}; +use tracing::Level; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::fmt::format::{DefaultFields, Format}; use crate::base::{SortColumn, SortFieldRoom, SortFieldUser, SortOrder}; +use crate::base::{SortRoomVisitor, SortUserVisitor}; use crate::prelude::*; +use crate::preview::PreviewManager; type Macros = HashMap>; @@ -74,6 +88,14 @@ const COLORS: [Color; 13] = [ Color::Yellow, ]; +pub fn parse_env_logger( + directives: &str, +) -> Result { + EnvFilter::builder() + .with_default_directive(Level::WARN.into()) + .parse(directives) +} + pub fn user_color(user: &str) -> Color { let mut hasher = DefaultHasher::new(); user.hash(&mut hasher); @@ -168,6 +190,21 @@ pub enum ConfigError { InvalidJSON(#[from] serde_json::Error), } +#[derive(thiserror::Error, Debug)] +pub enum ReloadError { + #[error(transparent)] + Config(#[from] ConfigError), + + #[error("invalid `log_level`: {0}")] + LogLevel(#[from] tracing_subscriber::filter::ParseError), + + #[error("The current profile is not in the new config file")] + ProfileNotFound, + + #[error("The user_id in the new config is different")] + UserIdChanged, +} + macro_rules! deserialize_str_with_visitor { ($t: ident, $v: ident) => { impl<'de> Deserialize<'de> for $t { @@ -345,8 +382,9 @@ where } } -#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, EnumString, VariantNames)] #[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "lowercase")] #[repr(u8)] pub enum ReadReceiptTrigger { /// Update read receipts for a room when a window for it is focused, and it is scrolled to the @@ -378,8 +416,9 @@ impl ReadReceiptTrigger { } } -#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, EnumString, VariantNames)] #[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] #[repr(u8)] pub enum EncryptionIndicator { /// Always indicate the room's encryption status. @@ -432,8 +471,9 @@ impl Visitor<'_> for EncryptionIndicatorLocationVisitor { } } -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, EnumString, VariantNames)] #[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] pub enum UserDisplayStyle { // The Matrix username for the sender (e.g., "@user:example.com"). #[default] @@ -450,8 +490,9 @@ pub enum UserDisplayStyle { DisplayName, } -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, EnumString, VariantNames)] #[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] pub enum SplitDirection { #[default] Horizontal, @@ -676,7 +717,7 @@ pub struct Notifications { pub sound_hint: Option, } -#[derive(Clone)] +#[derive(Clone, PartialEq)] pub struct ImagePreviewValues { pub enabled: bool, pub lazy_load: bool, @@ -703,7 +744,7 @@ impl ImagePreview { } } -#[derive(Clone, Debug, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Default, PartialEq)] pub struct ImagePreviewProtocolValues { pub r#type: Option, pub filter: Option, @@ -750,6 +791,556 @@ impl SortOverrides { } } +#[derive(Debug, Clone)] +pub struct LogLevelUpdate { + filter: EnvFilter, + directives: String, +} + +impl LogLevelUpdate { + fn parse(directives: String) -> Result, tracing_subscriber::filter::ParseError> { + let filter = parse_env_logger(&directives)?; + Ok(Box::new(Self { filter, directives })) + } +} + +impl PartialEq for LogLevelUpdate { + fn eq(&self, other: &Self) -> bool { + self.directives == other.directives + } +} +impl Eq for LogLevelUpdate {} + +/// Error returned by [`TunablesUpdate::new`]. +#[derive(thiserror::Error, Debug)] +pub enum TunablesUpdateError { + #[error("Unknown option")] + UnknownOption, + + #[error("Invalid argument")] + InvalidArgument, + + #[error(transparent)] + LogLevel(#[from] tracing_subscriber::filter::ParseError), + + #[error("This option requires an argument")] + NoArguments, + + #[error(transparent)] + ParseEnum(#[from] strum::ParseError), + + #[error(transparent)] + ParseInt(#[from] std::num::ParseIntError), + + #[error("Invalid user id: {0}")] + UserId(#[from] matrix_sdk::IdParseError), + + #[error("{0}")] + Custom(String), +} + +impl serde::de::Error for TunablesUpdateError { + fn custom(msg: T) -> Self + where + T: fmt::Display, + { + Self::Custom(format!("{msg}")) + } +} + +#[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] +#[strum_discriminants( + strum(serialize_all = "snake_case"), + derive(IntoStaticStr, VariantArray) +)] +pub enum SortUpdate { + Chats(Vec>), + Dms(Vec>), + Rooms(Vec>), + Spaces(Vec>), + Members(Vec>), +} + +impl SortUpdate { + fn new(option: &str, value: &str) -> Result { + if option == "members" { + let order: Result, TunablesUpdateError> = value + .split(',') + .filter(|v| !v.is_empty()) + .map(|v| SortUserVisitor.visit_str(v)) + .collect(); + return Ok(Self::Members(order?)); + } + + let order: Result, TunablesUpdateError> = value + .split(',') + .filter(|v| !v.is_empty()) + .map(|v| SortRoomVisitor.visit_str(v)) + .collect(); + + Ok(match option { + "chats" => Self::Chats(order?), + "dms" => Self::Dms(order?), + "rooms" => Self::Rooms(order?), + "spaces" => Self::Spaces(order?), + _ => return Err(TunablesUpdateError::UnknownOption), + }) + } +} + +#[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] +#[strum_discriminants( + strum(serialize_all = "snake_case"), + derive(IntoStaticStr, EnumProperty, VariantArray) +)] +pub enum NotificationsUpdate { + Via(NotifyVia), + SoundHint(Option), + + #[strum_discriminants(strum(props(is_bool = true)))] + Enabled(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + ShowMessage(bool), +} + +impl NotificationsUpdate { + fn new(option: &str, value: Option<&str>) -> Result { + let res = match option { + "via" => { + if let Some(value) = value { + let via = NotifyViaVisitor.visit_str::(value)?; + Self::Via(via) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "soundhint" => { + if let Some(value) = value { + if value.is_empty() { + Self::SoundHint(None) + } else { + Self::SoundHint(Some(value.to_owned())) + } + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + + "enabled" => Self::Enabled(true), + "noenabled" => Self::Enabled(false), + "showmessage" => Self::ShowMessage(true), + "noshowmessage" => Self::ShowMessage(false), + + _ => return Err(TunablesUpdateError::UnknownOption), + }; + + Ok(res) + } +} + +#[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] +#[strum_discriminants(derive(IntoStaticStr, VariantArray))] +pub enum UserDisplayUpdate { + Name(Option), + Color(Option), +} + +impl UserDisplayUpdate { + fn new(option: &str, value: &str) -> Result { + let res = match option { + "name" => { + if value.is_empty() { + Self::Name(None) + } else { + Self::Name(Some(value.to_owned())) + } + }, + "color" => { + if value.is_empty() { + Self::Color(None) + } else { + let color = UserColorVisitor.visit_str::(value)?; + Self::Color(Some(color)) + } + }, + + _ => return Err(TunablesUpdateError::UnknownOption), + }; + + Ok(res) + } +} + +/// This should always mirrir [`ProtocolType`] +#[derive(Clone, Copy, PartialEq, Eq, Debug, VariantNames)] +#[strum(serialize_all = "lowercase")] +pub enum IambProtocolType { + Halfblocks, + Sixel, + Kitty, + Iterm2, +} + +impl From for ProtocolType { + fn from(value: IambProtocolType) -> Self { + match value { + IambProtocolType::Halfblocks => Self::Halfblocks, + IambProtocolType::Sixel => Self::Sixel, + IambProtocolType::Kitty => Self::Kitty, + IambProtocolType::Iterm2 => Self::Iterm2, + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy, EnumDiscriminants)] +#[strum_discriminants(derive(IntoStaticStr, VariantArray))] +pub enum ImagePreviewUpdate { + Enabled(bool), + Width(u16), + Height(u16), + ProtocolType(IambProtocolType), + ProtocolFilter(FilterType), + + /// Reload the image previews without chaning a setting (used by `:reload`) + Reload, +} + +impl ImagePreviewUpdate { + fn new(option: &str, value: Option<&str>) -> Result { + let res = match option { + "size.width" => { + if let Some(value) = value { + let width = u16::from_str(value)?; + Self::Width(width) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "size.height" => { + if let Some(value) = value { + let height = u16::from_str(value)?; + Self::Height(height) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "protocol.type" => { + let Some(value) = value else { + return Err(TunablesUpdateError::NoArguments); + }; + let protocol_type = match value { + "sixel" => IambProtocolType::Sixel, + "kitty" => IambProtocolType::Kitty, + "iterm2" => IambProtocolType::Iterm2, + "halfblocks" => IambProtocolType::Halfblocks, + _ => return Err(TunablesUpdateError::InvalidArgument), + }; + + Self::ProtocolType(protocol_type) + }, + + "protocol.filter" => { + let Some(value) = value else { + return Err(TunablesUpdateError::NoArguments); + }; + let filter = match value { + "Nearest" => FilterType::Nearest, + "Triangle" => FilterType::Triangle, + "CatmullRom" => FilterType::CatmullRom, + "Gaussian" => FilterType::Gaussian, + "Lanczos3" => FilterType::Lanczos3, + _ => return Err(TunablesUpdateError::InvalidArgument), + }; + + Self::ProtocolFilter(filter) + }, + + "enabled" => Self::Enabled(true), + "noenabled" => Self::Enabled(false), + + _ => return Err(TunablesUpdateError::UnknownOption), + }; + + Ok(res) + } +} + +#[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] +#[strum_discriminants( + strum(serialize_all = "snake_case"), + derive(IntoStaticStr, VariantArray) +)] +pub enum EncryptionUpdate { + Indicator(EncryptionIndicator), + IndicatorLocation(EncryptionIndicatorLocation), +} + +impl EncryptionUpdate { + fn new(option: &str, value: &str) -> Result { + let res = match option { + "indicator" => Self::Indicator(EncryptionIndicator::from_str(value)?), + "indicatorlocation" => { + let via = + EncryptionIndicatorLocationVisitor.visit_str::(value)?; + Self::IndicatorLocation(via) + }, + + _ => return Err(TunablesUpdateError::UnknownOption), + }; + + Ok(res) + } +} + +#[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] +#[strum_discriminants( + strum(serialize_all = "snake_case"), + derive(IntoStaticStr, VariantArray) +)] +pub enum TerminalUpdate { + CursorShape(CursorShape), +} + +impl TerminalUpdate { + fn new(option: &str, value: Option<&str>) -> Result { + let res = match option { + "cursorshape" => { + if let Some(value) = value { + Self::CursorShape(CursorShape::from_str(value)?) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + + _ => return Err(TunablesUpdateError::UnknownOption), + }; + + Ok(res) + } +} + +/// A update for the [`TunableValues`] after invoking the `:set` command. +#[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] +#[strum_discriminants( + strum(serialize_all = "snake_case"), + derive(IntoStaticStr, EnumProperty, VariantArray) +)] +pub enum TunablesUpdate { + // multilevel options + Sort(SortUpdate), + Notifications(NotificationsUpdate), + Users(OwnedUserId, UserDisplayUpdate), + ImagePreview(ImagePreviewUpdate), + Encryption(EncryptionUpdate), + Terminal(TerminalUpdate), + + // value options + DefaultMarkup(MarkupFormat), + DefaultSplit(SplitDirection), + InputPrompt(Option), + LogLevel(Box), + MembersSplit(Option), + ReadReceiptTrigger(ReadReceiptTrigger), + UsernameDisplay(UserDisplayStyle), + OpenCommand(Vec), + ExternalEditFileSuffix(String), + UserGutterWidth(usize), + Tabstop(usize), + + // bool options + #[strum_discriminants(strum(props(is_bool = true)))] + MessageShortcodeDisplay(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + NormalAfterSend(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + ReactionDisplay(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + ReactionShortcodeDisplay(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + ReadReceiptSend(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + ReadReceiptDisplay(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + TypingNoticeSend(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + TypingNoticeDisplay(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + MessageUserColor(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + Ignorecase(bool), +} + +impl TunablesUpdate { + pub fn new(mut option: String, value: Option<&str>) -> Result { + option.retain(|c| c != '_'); + + // sort options + if let Some(sort_option) = option.strip_prefix("sort.") { + let Some(value) = value else { + return Err(TunablesUpdateError::NoArguments); + }; + + return Ok(Self::Sort(SortUpdate::new(sort_option, value)?)); + } + // notifications + if let Some(notification_option) = option.strip_prefix("notifications.") { + return Ok(Self::Notifications(NotificationsUpdate::new(notification_option, value)?)); + } + // user overrides + if let Some(users_option) = option.strip_prefix("users.") { + let Some((user_id, user_option)) = users_option.rsplit_once('.') else { + return Err(TunablesUpdateError::UnknownOption); + }; + + let user_id = OwnedUserId::from_str(user_id)?; + + let Some(value) = value else { + return Err(TunablesUpdateError::NoArguments); + }; + + let update = UserDisplayUpdate::new(user_option, value)?; + + return Ok(Self::Users(user_id, update)); + } + // image previews + if let Some(image_preview_option) = option.strip_prefix("imagepreview.") { + return Ok(Self::ImagePreview(ImagePreviewUpdate::new(image_preview_option, value)?)); + } + // encryption indicator + if let Some(encryption_option) = option.strip_prefix("encryption.") { + let Some(value) = value else { + return Err(TunablesUpdateError::NoArguments); + }; + + return Ok(Self::Encryption(EncryptionUpdate::new(encryption_option, value)?)); + } + // terminal + if let Some(terminal_option) = option.strip_prefix("terminal.") { + return Ok(Self::Terminal(TerminalUpdate::new(terminal_option, value)?)); + } + + let res = match option.as_str() { + // value options + "loglevel" => { + if let Some(value) = value { + Self::LogLevel(LogLevelUpdate::parse(value.to_owned())?) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "defaultmarkup" => { + if let Some(value) = value { + Self::DefaultMarkup(MarkupFormat::from_str(value)?) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "defaultsplit" => { + if let Some(value) = value { + Self::DefaultSplit(SplitDirection::from_str(value)?) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "inputprompt" => { + match value { + Some("") => Self::InputPrompt(None), + Some(value) => Self::InputPrompt(Some(value.to_string())), + None => return Err(TunablesUpdateError::NoArguments), + } + }, + "memberssplit" => { + if let Some(value) = value { + if value.is_empty() { + Self::MembersSplit(None) + } else { + Self::MembersSplit(Some(SplitDirection::from_str(value)?)) + } + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "readreceipttrigger" => { + if let Some(value) = value { + Self::ReadReceiptTrigger(ReadReceiptTrigger::from_str(value)?) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "usernamedisplay" => { + if let Some(value) = value { + let display = UserDisplayStyle::from_str(value)?; + Self::UsernameDisplay(display) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "opencommand" => { + if let Some(value) = value { + // TODO: use command parsing + let args = value + .split(' ') + .filter(|arg| !arg.is_empty()) + .map(str::to_string) + .collect(); + Self::OpenCommand(args) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "externaleditfilesuffix" => { + if let Some(value) = value { + Self::ExternalEditFileSuffix(value.to_string()) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "usergutterwidth" => { + if let Some(value) = value { + let width = usize::from_str(value)?; + Self::UserGutterWidth(width) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "tabstop" => { + if let Some(value) = value { + let tabstop = usize::from_str(value)?; + Self::Tabstop(tabstop) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + + // bool options + "messageshortcodedisplay" => Self::MessageShortcodeDisplay(true), + "nomessageshortcodedisplay" => Self::MessageShortcodeDisplay(false), + "normalaftersend" => Self::NormalAfterSend(true), + "nonormalaftersend" => Self::NormalAfterSend(false), + "reactiondisplay" => Self::ReactionDisplay(true), + "noreactiondisplay" => Self::ReactionDisplay(false), + "reactionshortcodedisplay" => Self::ReactionShortcodeDisplay(true), + "noreactionshortcodedisplay" => Self::ReactionShortcodeDisplay(false), + "readreceiptsend" => Self::ReadReceiptSend(true), + "noreadreceiptsend" => Self::ReadReceiptSend(false), + "readreceiptdisplay" => Self::ReadReceiptDisplay(true), + "noreadreceiptdisplay" => Self::ReadReceiptDisplay(false), + "typingnoticesend" => Self::TypingNoticeSend(true), + "notypingnoticesend" => Self::TypingNoticeSend(false), + "typingnoticedisplay" => Self::TypingNoticeDisplay(true), + "notypingnoticedisplay" => Self::TypingNoticeDisplay(false), + "messageusercolor" => Self::MessageUserColor(true), + "nomessageusercolor" => Self::MessageUserColor(false), + "ignorecase" => Self::Ignorecase(true), + "noignorecase" => Self::Ignorecase(false), + + _ => return Err(TunablesUpdateError::UnknownOption), + }; + + Ok(res) + } +} + #[derive(Clone, Debug, Default, Deserialize)] pub struct Terminal { pub cursor_shape: Option, @@ -971,8 +1562,9 @@ impl Tunables { } } -#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, EnumString, VariantNames)] #[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] #[repr(u8)] pub enum CursorShape { #[default] @@ -993,8 +1585,9 @@ impl From for modalkit::crossterm::cursor::SetCursorStyle { } } -#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, EnumString, VariantNames)] #[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] #[repr(u8)] pub enum MarkupFormat { Html, @@ -1169,6 +1762,33 @@ impl IambConfig { } } +#[derive(Clone)] +pub enum SettingsFile { + Toml(PathBuf), + Json(PathBuf), +} + +impl SettingsFile { + fn display(&self) -> std::path::Display<'_> { + match self { + Self::Toml(path) | Self::Json(path) => path.display(), + } + } +} + +type ReloadHandle = tracing_subscriber::reload::Handle< + EnvFilter, + tracing_subscriber::layer::Layered< + tracing_subscriber::fmt::Layer< + tracing_subscriber::Registry, + DefaultFields, + Format, + tracing_appender::non_blocking::NonBlocking, + >, + tracing_subscriber::Registry, + >, +>; + #[derive(Clone)] pub struct ApplicationSettings { pub layout_json: PathBuf, @@ -1182,6 +1802,9 @@ pub struct ApplicationSettings { pub dirs: DirectoryValues, pub layout: Layout, pub macros: Macros, + pub log_level_handle: Option, + /// The file the settings were loaded from. + pub load_file: SettingsFile, } impl ApplicationSettings { @@ -1189,7 +1812,7 @@ impl ApplicationSettings { env::var("XDG_CONFIG_HOME").ok().map(PathBuf::from) } - pub fn load(cli: Iamb) -> Result> { + pub fn load(cli: Iamb) -> Result { let mut config_dir = cli .config_directory .or_else(Self::get_xdg_config_home) @@ -1206,10 +1829,10 @@ impl ApplicationSettings { let config_json = config_dir.join("config.json"); let config_toml = config_dir.join("config.toml"); - let config = if config_toml.is_file() { - IambConfig::load_toml(config_toml.as_path())? + let (config, load_file) = if config_toml.is_file() { + (IambConfig::load_toml(config_toml.as_path())?, SettingsFile::Toml(config_toml)) } else if config_json.is_file() { - IambConfig::load_json(config_json.as_path())? + (IambConfig::load_json(config_json.as_path())?, SettingsFile::Json(config_json)) } else { usage!( "Please create a configuration file at {}\n\n\ @@ -1234,7 +1857,7 @@ impl ApplicationSettings { usage!( "No configured profile with the name {:?} in {}", profile, - config_json.display() + load_file.display() ); }) } else if profiles.len() == 1 { @@ -1321,11 +1944,213 @@ impl ApplicationSettings { dirs, layout, macros, + log_level_handle: None, + load_file, }; Ok(settings) } + pub fn reload( + &mut self, + path: Option, + previews: &mut PreviewManager, + ) -> Result<(), ReloadError> { + let load_file = path.unwrap_or_else(|| self.load_file.clone()); + + let config = match &load_file { + SettingsFile::Toml(path) => IambConfig::load_toml(path.as_path())?, + SettingsFile::Json(path) => IambConfig::load_json(path.as_path())?, + }; + + let IambConfig { mut profiles, dirs, settings: global, .. } = config; + + // TODO: validate profiles? + + let mut profile = + profiles.remove(&self.profile_name).ok_or(ReloadError::ProfileNotFound)?; + + if profile.user_id != self.profile.user_id { + return Err(ReloadError::UserIdChanged); + } + + // TODO: update macros + + let tunables = global.unwrap_or_default(); + let tunables = profile.settings.take().unwrap_or_default().merge(tunables); + let tunables = tunables.values(); + + let dirs = dirs.unwrap_or_default(); + let dirs = profile.dirs.take().unwrap_or_default().merge(dirs); + let dirs = dirs.values(); + + let image_preview_changed = tunables.image_preview != self.tunables.image_preview; + + // update values + self.tunables = tunables; + self.profile = profile; + self.load_file = load_file; + self.dirs.downloads = dirs.downloads; + + // apply changes that need more setup + + self.update( + TunablesUpdate::LogLevel(LogLevelUpdate::parse(self.tunables.log_level.to_owned())?), + previews, + ); + + if image_preview_changed { + self.update(TunablesUpdate::ImagePreview(ImagePreviewUpdate::Reload), previews); + } + + Ok(()) + } + + /// Update [`self.tunables`](`Self::tunables`) with `new`. + /// This will make sure that the updated value is applied. + pub fn update(&mut self, update: TunablesUpdate, previews: &mut PreviewManager) { + match update { + TunablesUpdate::LogLevel(update) => { + if let Some(handle) = &mut self.log_level_handle { + handle + .reload(update.filter) + .expect("cannot update appending tracing logger"); + self.tunables.log_level = update.directives; + } + }, + TunablesUpdate::ImagePreview(image_preview_update) => { + let image_preview = &mut self.tunables.image_preview; + match image_preview_update { + ImagePreviewUpdate::Width(width) => image_preview.size.width = width, + ImagePreviewUpdate::Height(height) => image_preview.size.height = height, + ImagePreviewUpdate::ProtocolType(protocol_type) => { + image_preview.protocol.r#type = Some(protocol_type.into()) + }, + ImagePreviewUpdate::Enabled(enabled) => image_preview.enabled = enabled, + ImagePreviewUpdate::ProtocolFilter(filter) => { + image_preview.protocol.filter = Some(filter) + }, + ImagePreviewUpdate::Reload => (), + } + + if matches!( + image_preview_update, + ImagePreviewUpdate::Reload | ImagePreviewUpdate::ProtocolType(_) + ) && let Some(protocol_type) = image_preview.protocol.r#type + { + previews.update_protocol_type(protocol_type); + } + + previews.mark_all_queued(image_preview.size); + }, + TunablesUpdate::Sort(sort_update) => { + match sort_update { + SortUpdate::Chats(order) => self.tunables.sort.chats = order, + SortUpdate::Dms(order) => self.tunables.sort.dms = order, + SortUpdate::Rooms(order) => self.tunables.sort.rooms = order, + SortUpdate::Spaces(order) => self.tunables.sort.spaces = order, + SortUpdate::Members(order) => self.tunables.sort.members = order, + } + }, + TunablesUpdate::Notifications(notify_update) => { + match notify_update { + NotificationsUpdate::Enabled(value) => { + self.tunables.notifications.enabled = value + }, + NotificationsUpdate::Via(value) => self.tunables.notifications.via = value, + NotificationsUpdate::ShowMessage(value) => { + self.tunables.notifications.show_message = value + }, + NotificationsUpdate::SoundHint(value) => { + self.tunables.notifications.sound_hint = value + }, + } + }, + TunablesUpdate::Users(user_id, user_update) => { + let user = self.tunables.users.entry(user_id).or_default(); + + match user_update { + UserDisplayUpdate::Name(name) => user.name = name, + UserDisplayUpdate::Color(color) => user.color = color, + } + }, + TunablesUpdate::OpenCommand(open_command) => { + if open_command.is_empty() { + self.tunables.open_command = None; + } else { + self.tunables.open_command = Some(open_command); + } + }, + TunablesUpdate::DefaultMarkup(format) => { + self.tunables.default_markup = format; + }, + TunablesUpdate::DefaultSplit(direction) => { + self.tunables.default_split = direction; + }, + TunablesUpdate::MembersSplit(direction) => { + self.tunables.members_split = direction; + }, + TunablesUpdate::ReadReceiptTrigger(trigger) => { + self.tunables.read_receipt_trigger = trigger; + }, + TunablesUpdate::Encryption(EncryptionUpdate::Indicator(indicator)) => { + self.tunables.encryption.indicator = indicator; + }, + TunablesUpdate::Encryption(EncryptionUpdate::IndicatorLocation(indicator_location)) => { + self.tunables.encryption.indicator_location = indicator_location; + }, + TunablesUpdate::Terminal(TerminalUpdate::CursorShape(shape)) => { + self.tunables.terminal.cursor_shape = shape; + + let cursor_shape = SetCursorStyle::from(shape); + let _ = modalkit::crossterm::execute!(std::io::stdout(), cursor_shape); + }, + TunablesUpdate::UsernameDisplay(username_display) => { + self.tunables.username_display = username_display + }, + TunablesUpdate::ExternalEditFileSuffix(external_edit_file_suffix) => { + self.tunables.external_edit_file_suffix = external_edit_file_suffix + }, + TunablesUpdate::UserGutterWidth(user_gutter_width) => { + self.tunables.user_gutter_width = user_gutter_width + }, + TunablesUpdate::Tabstop(tabstop) => self.tunables.tabstop = tabstop, + TunablesUpdate::MessageShortcodeDisplay(message_shortcode_display) => { + self.tunables.message_shortcode_display = message_shortcode_display + }, + TunablesUpdate::NormalAfterSend(normal_after_send) => { + self.tunables.normal_after_send = normal_after_send + }, + TunablesUpdate::ReactionDisplay(reaction_display) => { + self.tunables.reaction_display = reaction_display + }, + TunablesUpdate::ReactionShortcodeDisplay(reaction_shortcode_display) => { + self.tunables.reaction_shortcode_display = reaction_shortcode_display + }, + TunablesUpdate::ReadReceiptSend(read_receipt_send) => { + self.tunables.read_receipt_send = read_receipt_send + }, + TunablesUpdate::ReadReceiptDisplay(read_receipt_display) => { + self.tunables.read_receipt_display = read_receipt_display + }, + TunablesUpdate::TypingNoticeSend(typing_notice_send) => { + self.tunables.typing_notice_send = typing_notice_send + }, + TunablesUpdate::TypingNoticeDisplay(typing_notice_display) => { + self.tunables.typing_notice_display = typing_notice_display + }, + TunablesUpdate::MessageUserColor(message_user_color) => { + self.tunables.message_user_color = message_user_color + }, + TunablesUpdate::Ignorecase(ic) => { + self.tunables.ignorecase = ic; + }, + TunablesUpdate::InputPrompt(prompt) => { + self.tunables.input_prompt = prompt; + }, + } + } + pub fn read_session(&self, path: impl AsRef) -> Result { let file = File::open(path)?; let reader = BufReader::new(file); diff --git a/src/main.rs b/src/main.rs index 2bbaff8a..623d04a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,12 +61,11 @@ use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use temp_dir::TempDir; use tokio::sync::Mutex as AsyncMutex; -use tracing::Level; use tracing_subscriber::{EnvFilter, FmtSubscriber}; -use crate::base::{HomeserverAction, KeysAction}; +use crate::base::{HomeserverAction, KeysAction, SettingsAction}; use crate::completions::IambCompleter; -use crate::config::Iamb; +use crate::config::{Iamb, SettingsFile, parse_env_logger}; use crate::prelude::*; use crate::windows::IambWindow; use crate::worker::{ClientWorker, LoginStyle, create_room}; @@ -575,6 +574,11 @@ impl Application { return verifications::iamb_verify_request(user_id, store).await; }, + + IambAction::Settings(act) => { + self.settings_command(act, store)?; + None + }, }; Ok(info) @@ -680,6 +684,34 @@ impl Application { } } + fn settings_command( + &mut self, + action: SettingsAction, + store: &mut ProgramStore, + ) -> IambResult<()> { + match action { + SettingsAction::Set(tunables_updates) => { + for update in tunables_updates { + store.application.settings.update(update, &mut store.application.previews); + } + Ok(()) + }, + SettingsAction::Reload(path) => { + let path = match path { + None => None, + Some(path) if path.ends_with(".json") => Some(SettingsFile::Json(path)), + Some(path) => Some(SettingsFile::Toml(path)), + }; + + Ok(store + .application + .settings + .reload(path, &mut store.application.previews) + .map_err(IambError::from)?) + }, + } + } + async fn keys_command( &mut self, action: KeysAction, @@ -1105,7 +1137,9 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> { Ok(()) } -fn setup_logging(settings: &ApplicationSettings) -> tracing_appender::non_blocking::WorkerGuard { +fn setup_logging( + settings: &mut ApplicationSettings, +) -> tracing_appender::non_blocking::WorkerGuard { let log_prefix = format!("iamb-log-{}", settings.profile_name); let log_dir = settings.dirs.logs.as_path(); let max_log_files = settings.tunables.max_log_files; @@ -1116,27 +1150,27 @@ fn setup_logging(settings: &ApplicationSettings) -> tracing_appender::non_blocki .filename_prefix(log_prefix) .max_log_files(max_log_files) .build(log_dir) - .expect("can build appending tracing logger"); + .expect("cannot build appending tracing logger"); let (appender, guard) = tracing_appender::non_blocking(appender); - let filter = if let Ok(dirs) = std::env::var(EnvFilter::DEFAULT_ENV) { - EnvFilter::builder() - .with_default_directive(Level::WARN.into()) - .parse(dirs) + let filter = if let Ok(directives) = std::env::var(EnvFilter::DEFAULT_ENV) { + parse_env_logger(&directives) .map_err(|err| format!("Unable to parse {}: {err}", EnvFilter::DEFAULT_ENV)) .unwrap_or_else(print_exit) } else { - EnvFilter::builder() - .with_default_directive(Level::WARN.into()) - .parse(log_level) + parse_env_logger(log_level) .map_err(|err| format!("Unable to parse `log_level`: {err}")) .unwrap_or_else(print_exit) }; - let subscriber = FmtSubscriber::builder() + let builder = FmtSubscriber::builder() .with_writer(appender) .with_env_filter(filter) - .finish(); + .with_filter_reloading(); + + settings.log_level_handle = Some(builder.reload_handle()); + + let subscriber = builder.finish(); tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); @@ -1153,7 +1187,7 @@ fn main() { } // Load configuration and set up the Matrix SDK. - let settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit); + let mut settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit); // Set umask on Unix platforms so that tokens, keys, etc. are only readable by the user. #[cfg(unix)] @@ -1161,7 +1195,7 @@ fn main() { libc::umask(0o077); }; - let guard = setup_logging(&settings); + let guard = setup_logging(&mut settings); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() diff --git a/src/notifications.rs b/src/notifications.rs index 9edede3f..21b58bb7 100644 --- a/src/notifications.rs +++ b/src/notifications.rs @@ -11,7 +11,6 @@ use matrix_sdk::notification_settings::{ use matrix_sdk::ruma::events::{AnyMessageLikeEventContent, AnySyncTimelineEvent}; use matrix_sdk::ruma::serde::Raw; -use crate::config::NotifyVia; use crate::prelude::*; const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") { @@ -34,17 +33,7 @@ impl Drop for NotificationHandle { } } -pub async fn register_notifications( - client: &Client, - settings: &ApplicationSettings, - store: &AsyncProgramStore, -) { - if !settings.tunables.notifications.enabled { - return; - } - let notify_via = settings.tunables.notifications.via; - let show_message = settings.tunables.notifications.show_message; - let sound_hint = settings.tunables.notifications.sound_hint.clone(); +pub async fn register_notifications(client: &Client, store: &AsyncProgramStore) { let server_settings = client.notification_settings().await; let Some(startup_ts) = MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now()) else { return; @@ -55,7 +44,6 @@ pub async fn register_notifications( .register_notification_handler(move |notification, room: MatrixRoom, client: Client| { let store = store.clone(); let server_settings = server_settings.clone(); - let sound_hint = sound_hint.clone(); async move { let mode = global_or_room_mode(&server_settings, &room).await; if mode == RoomNotificationMode::Mute { @@ -69,7 +57,7 @@ pub async fn register_notifications( let room_id = room.room_id().to_owned(); match notification.event { RawAnySyncOrStrippedTimelineEvent::Sync(e) => { - match parse_full_notification(e, room, show_message).await { + match parse_full_notification(e, room).await { Ok((summary, body, server_ts)) => { if server_ts < startup_ts { return; @@ -79,15 +67,14 @@ pub async fn register_notifications( return; } - send_notification( - ¬ify_via, - &summary, - body.as_deref(), - room_id, - &store, - sound_hint.as_deref(), - ) - .await; + let mut locked = store.lock().await; + + if !locked.application.settings.tunables.notifications.enabled { + return; + } + + send_notification(&summary, body.as_deref(), room_id, &mut locked) + .await; }, Err(err) => { tracing::error!("Failed to extract notification data: {err}") @@ -105,30 +92,27 @@ pub async fn register_notifications( } async fn send_notification( - via: &NotifyVia, summary: &str, body: Option<&str>, room_id: OwnedRoomId, - store: &AsyncProgramStore, - sound_hint: Option<&str>, + store: &mut ProgramStore, ) { #[cfg(feature = "desktop")] - if via.desktop { - send_notification_desktop(summary, body, room_id, store, sound_hint).await; + if store.application.settings.tunables.notifications.via.desktop { + send_notification_desktop(summary, body, room_id, store).await; } #[cfg(not(feature = "desktop"))] { let _ = (summary, body, IAMB_XDG_NAME); } - if via.bell { + if store.application.settings.tunables.notifications.via.bell { send_notification_bell(store).await; } } -async fn send_notification_bell(store: &AsyncProgramStore) { - let mut locked = store.lock().await; - locked.application.ring_bell = true; +async fn send_notification_bell(store: &mut ProgramStore) { + store.application.ring_bell = true; } #[cfg(feature = "desktop")] @@ -137,8 +121,7 @@ async fn send_notification_desktop( summary: &str, body: Option<&str>, room_id: OwnedRoomId, - _store: &AsyncProgramStore, - sound_hint: Option<&str>, + store: &mut ProgramStore, ) { let mut desktop_notification = notify_rust::Notification::new(); desktop_notification @@ -147,14 +130,16 @@ async fn send_notification_desktop( .icon(IAMB_XDG_NAME) .action("default", "default"); - if let Some(sound_hint) = sound_hint { + if let Some(sound_hint) = &store.application.settings.tunables.notifications.sound_hint { desktop_notification.sound_name(sound_hint); } #[cfg(all(unix, not(target_os = "macos")))] desktop_notification.urgency(notify_rust::Urgency::Normal); - if let Some(body) = body { + if store.application.settings.tunables.notifications.show_message && + let Some(body) = body + { desktop_notification.body(body); } @@ -167,9 +152,7 @@ async fn send_notification_desktop( Err(err) => tracing::error!("Failed to send notification: {err}"), Ok(handle) => { #[cfg(all(unix, not(target_os = "macos")))] - _store - .lock() - .await + store .application .open_notifications .entry(room_id) @@ -236,7 +219,6 @@ async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool { pub async fn parse_full_notification( event: Raw, room: MatrixRoom, - show_body: bool, ) -> IambResult<(String, Option, MilliSecondsSinceUnixEpoch)> { let event = event.deserialize().map_err(IambError::from)?; @@ -261,11 +243,7 @@ pub async fn parse_full_notification( sender_name.to_string() }; - let body = if show_body { - event_notification_body(&event, sender_name).map(truncate) - } else { - None - }; + let body = event_notification_body(&event, sender_name).map(truncate); return Ok((summary, body, server_ts)); } diff --git a/src/preview.rs b/src/preview.rs index 75381dbd..c2d4108f 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -1,6 +1,6 @@ use matrix_sdk::Media; use matrix_sdk::media::{MediaFormat, MediaRequestParameters, UniqueKey}; -use ratatui_image::picker::Picker; +use ratatui_image::picker::{Picker, ProtocolType}; use ratatui_image::sliced::SlicedProtocol; use ratatui_image::{FilterType, Resize}; use tokio::sync::Semaphore; @@ -56,6 +56,24 @@ impl PreviewManager { self.previews.get(&(source.unique_key(), kind)) } + /// Mark all registered previews as queued. + /// + /// Useful when changing preview settings. + pub fn mark_all_queued(&mut self, size: Size) { + for status in self.previews.values_mut() { + *status = ImageStatus::Queued(size); + } + } + + /// Change the [ProtocolType`] used to render the previews. + /// + /// This change only applies to newly rendered previews. + pub fn update_protocol_type(&mut self, protocol_type: ProtocolType) { + let mut picker = self.picker.deref().clone(); + picker.set_protocol_type(protocol_type); + self.picker = picker.into(); + } + fn insert(&mut self, key: String, kind: PreviewKind, status: ImageStatus) { self.previews.insert((key, kind), status); } diff --git a/src/tests.rs b/src/tests.rs index a89544f6..7cc210f3 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -218,6 +218,8 @@ pub fn mock_settings() -> ApplicationSettings { dirs: mock_dirs(), layout: Default::default(), macros: HashMap::default(), + log_level_handle: None, + load_file: SettingsFile::Toml("/dev/null".into()), } } diff --git a/src/worker.rs b/src/worker.rs index a3d3be52..d21a3abd 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1355,7 +1355,6 @@ impl ClientWorker { self.load_handle = tokio::spawn({ let client = self.client.clone(); - let settings = self.settings.clone(); async move { while !client.is_active() { @@ -1365,7 +1364,7 @@ impl ClientWorker { let load = load_older_forever(&client, &store); let rcpt = send_receipts_forever(&client, &store); let room = refresh_rooms_forever(&client, &store); - let notifications = register_notifications(&client, &settings, &store); + let notifications = register_notifications(&client, &store); let sendqueue = subscribe_sendqueue_forever(&client, &store); let ((), (), (), (), ()) = tokio::join!(load, rcpt, room, notifications, sendqueue); }