From be68cf8f41a8d500fbd3337a7e550a4bcb289ba3 Mon Sep 17 00:00:00 2001 From: vaw Date: Wed, 25 Feb 2026 14:56:44 +0100 Subject: [PATCH 1/7] Implement `:set` command --- Cargo.lock | 24 ++++- Cargo.toml | 2 + src/base.rs | 22 ++++- src/commands.rs | 31 ++++++ src/config.rs | 256 +++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 46 ++++++--- src/tests.rs | 1 + 7 files changed, 364 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5832b0c5..b72c3163 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2437,6 +2437,8 @@ dependencies = [ "serde_json", "shellexpand", "sled", + "strum 0.28.0", + "strum_macros 0.28.0", "temp-dir", "thiserror 2.0.18", "tokio", @@ -4566,7 +4568,7 @@ dependencies = [ "itertools 0.13.0", "lru", "paste", - "strum", + "strum 0.26.3", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", @@ -5543,9 +5545,15 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", ] +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + [[package]] name = "strum_macros" version = "0.26.4" @@ -5559,6 +5567,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index d967d8c7..2f6c74b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,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/src/base.rs b/src/base.rs index 55ca8584..b8bc492a 100644 --- a/src/base.rs +++ b/src/base.rs @@ -89,7 +89,7 @@ use modalkit::{ prelude::{CommandType, WordStyle}, }; -use crate::config::ImagePreviewProtocolValues; +use crate::config::{ImagePreviewProtocolValues, TunablesUpdate}; use crate::message::ImageStatus; use crate::notifications::NotificationHandle; use crate::preview::{source_from_event, spawn_insert_preview}; @@ -506,6 +506,13 @@ 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), +} + /// An action that the main program loop should. /// /// See [the commands module][super::commands] for where these are usually created. @@ -523,6 +530,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), @@ -570,6 +580,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) @@ -593,6 +609,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, @@ -609,6 +626,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, @@ -625,6 +643,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, @@ -640,6 +659,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, diff --git a/src/commands.rs b/src/commands.rs index d355aca4..539eaceb 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -27,9 +27,11 @@ use crate::base::{ RoomAction, RoomField, SendAction, + SettingsAction, SpaceAction, VerifyAction, }; +use crate::config::TunablesUpdate; type ProgContext = CommandContext; type ProgResult = CommandResult; @@ -717,6 +719,30 @@ 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 add_iamb_commands(cmds: &mut ProgramCommands) { cmds.add_command(ProgramCommand { name: "cancel".into(), @@ -834,6 +860,11 @@ 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, + }); } /// Initialize the default command state. diff --git a/src/config.rs b/src/config.rs index 39853ecb..de765147 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,7 @@ use std::hash::{Hash, Hasher}; use std::io::{BufReader, BufWriter, Write}; use std::path::{Path, PathBuf}; use std::process; +use std::str::FromStr; use clap::Parser; use matrix_sdk::authentication::matrix::MatrixSession; @@ -17,6 +18,10 @@ use ratatui::style::{Color, Modifier as StyleModifier, Style}; use ratatui::text::Span; use ratatui_image::picker::ProtocolType; use serde::{de::Error as SerdeError, de::Visitor, Deserialize, Deserializer, Serialize}; +use strum_macros::EnumString; +use tracing::Level; +use tracing_subscriber::fmt::format::{DefaultFields, Format}; +use tracing_subscriber::EnvFilter; use url::Url; use modalkit::{env::vim::VimMode, key::TerminalKey, keybindings::InputKey}; @@ -71,6 +76,15 @@ 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); @@ -342,8 +356,9 @@ where } } -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, EnumString)] #[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] pub enum UserDisplayStyle { // The Matrix username for the sender (e.g., "@user:example.com"). #[default] @@ -514,6 +529,172 @@ 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(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), +} + +/// A update for the [`TunableValues`] after invoking the `:set` command. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum TunablesUpdate { + // value options + LogLevel(Box), + UsernameDisplay(UserDisplayStyle), + OpenCommand(Vec), + ExternalEditFileSuffix(String), + UserGutterWidth(usize), + Tabstop(usize), + + // bool options + MessageShortcodeDisplay(bool), + NormalAfterSend(bool), + ReactionDisplay(bool), + ReactionShortcodeDisplay(bool), + ReadReceiptSend(bool), + ReadReceiptDisplay(bool), + TypingNoticeSend(bool), + TypingNoticeDisplay(bool), + MessageUserColor(bool), + // These may need to be adapted + + // TODO: still save state events if display is `false` + // StateEventDisplay(bool), + + // TODO: this might be complicated with the panic hook + // Mouse(Mouse), + + // TODO: this will be complicated + // Notifications(Notifications), + + // TODO: This will be possible/easier after #464 lands + // ImagePreview(Option), + + // TODO: how do we do this? + // Users(UserOverrides), + + // TODO: how do we parse these? + // Sort(SortValues), +} + +impl TunablesUpdate { + pub fn new(mut option: String, value: Option<&str>) -> Result { + option.retain(|c| c != '_'); + + 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); + } + }, + "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), + + _ => return Err(TunablesUpdateError::UnknownOption), + }; + + Ok(res) + } +} + #[derive(Clone)] pub struct TunableValues { pub log_level: String, @@ -821,6 +1002,19 @@ impl IambConfig { } } +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, @@ -834,6 +1028,7 @@ pub struct ApplicationSettings { pub dirs: DirectoryValues, pub layout: Layout, pub macros: Macros, + pub log_level_handle: Option, } impl ApplicationSettings { @@ -973,11 +1168,70 @@ impl ApplicationSettings { dirs, layout, macros, + log_level_handle: None, }; Ok(settings) } + /// Update [`self.tunables`](`Self::tunables`) with `new`. + /// This will make sure that the updated value is applied. + pub fn update(&mut self, update: TunablesUpdate) { + 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::OpenCommand(open_command) => { + if open_command.is_empty() { + self.tunables.open_command = None; + } else { + self.tunables.open_command = Some(open_command); + } + }, + + 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 + }, + } + } + 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 db2b4ab8..2f941c07 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,7 +36,6 @@ use rand::distr::Alphanumeric; use rand::RngExt as _; use temp_dir::TempDir; use tokio::sync::Mutex as AsyncMutex; -use tracing::Level; use tracing_subscriber::{EnvFilter, FmtSubscriber}; use modalkit::crossterm::{ @@ -86,6 +85,8 @@ mod worker; #[cfg(test)] mod tests; +use crate::base::SettingsAction; +use crate::config::parse_env_logger; use crate::{ base::{ AsyncProgramStore, @@ -630,6 +631,11 @@ impl Application { return Err(IambError::InvalidUserId(user_id).into()); } }, + + IambAction::Settings(act) => { + self.settings_command(act, store); + None + }, }; Ok(info) @@ -676,6 +682,16 @@ impl Application { } } + fn settings_command(&mut self, action: SettingsAction, store: &mut ProgramStore) { + match action { + SettingsAction::Set(tunables_updates) => { + for update in tunables_updates { + store.application.settings.update(update); + } + }, + } + } + async fn keys_command( &mut self, action: KeysAction, @@ -1080,7 +1096,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; @@ -1091,27 +1109,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"); @@ -1123,7 +1141,7 @@ fn main() -> IambResult<()> { let iamb = Iamb::parse(); // 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)] @@ -1131,7 +1149,7 @@ fn main() -> IambResult<()> { 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/tests.rs b/src/tests.rs index 9213d247..aa35964b 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -228,6 +228,7 @@ pub fn mock_settings() -> ApplicationSettings { dirs: mock_dirs(), layout: Default::default(), macros: HashMap::default(), + log_level_handle: None, } } From 419fe1a30aa5c76f10737ffee4ce900cb607823d Mon Sep 17 00:00:00 2001 From: vaw Date: Wed, 25 Feb 2026 20:36:08 +0100 Subject: [PATCH 2/7] Document `:set` --- docs/iamb.1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/iamb.1 b/docs/iamb.1 index d04ec9b0..125a6e5b 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -69,6 +69,15 @@ Mark all rooms as read. View the startup Welcome window. .It Sy ":forget" Remove all left rooms from the internal database. +.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. .El .Sh "E2EE COMMANDS" From 262feb35cde5669ec7ad735203bcc63e488cbf53 Mon Sep 17 00:00:00 2001 From: vaw Date: Wed, 25 Feb 2026 21:30:45 +0100 Subject: [PATCH 3/7] Add completions for `:set` --- src/base.rs | 25 ++++++++++++++++++++++++- src/config.rs | 14 ++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/base.rs b/src/base.rs index b8bc492a..ef403f34 100644 --- a/src/base.rs +++ b/src/base.rs @@ -7,6 +7,7 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use std::convert::TryFrom; use std::fmt::{self, Display}; use std::hash::Hash; +use std::ops::Deref; use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -28,6 +29,7 @@ use serde::{ Serialize, Serializer, }; +use strum::{EnumProperty, VariantArray}; use tokio::sync::Mutex as AsyncMutex; use url::Url; @@ -89,7 +91,7 @@ use modalkit::{ prelude::{CommandType, WordStyle}, }; -use crate::config::{ImagePreviewProtocolValues, TunablesUpdate}; +use crate::config::{ImagePreviewProtocolValues, TunablesUpdate, TunablesUpdateDiscriminants}; use crate::message::ImageStatus; use crate::notifications::NotificationHandle; use crate::preview::{source_from_event, spawn_insert_preview}; @@ -2159,6 +2161,27 @@ fn complete_cmdarg( "vertical" | "horizontal" | "aboveleft" | "belowright" | "tab" => { complete_cmd(desc.arg.text.as_str(), text, cursor, store) }, + "set" => { + // TODO: improve once #520 is merged + let word = text + .get_prefix_word_mut(cursor, &MATRIX_ID_WORD) + .unwrap_or_else(EditRope::empty); + let word = Cow::from(&word); + + TunablesUpdateDiscriminants::VARIANTS + .iter() + .flat_map(|variant| { + let name = <_ as Into<&'static str>>::into(variant).to_lowercase(); + if variant.get_bool("is_bool") == Some(true) { + vec![format!("no{name}"), name] + } else { + vec![name] + } + }) + .filter(|option| option.starts_with(word.deref())) + .map(|option| option.to_string()) + .collect() + }, _ => vec![], } } diff --git a/src/config.rs b/src/config.rs index de765147..cc4a4b16 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,7 +18,7 @@ use ratatui::style::{Color, Modifier as StyleModifier, Style}; use ratatui::text::Span; use ratatui_image::picker::ProtocolType; use serde::{de::Error as SerdeError, de::Visitor, Deserialize, Deserializer, Serialize}; -use strum_macros::EnumString; +use strum_macros::{EnumDiscriminants, EnumProperty, EnumString, IntoStaticStr, VariantArray}; use tracing::Level; use tracing_subscriber::fmt::format::{DefaultFields, Format}; use tracing_subscriber::EnvFilter; @@ -569,7 +569,8 @@ pub enum TunablesUpdateError { } /// A update for the [`TunableValues`] after invoking the `:set` command. -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] +#[strum_discriminants(derive(IntoStaticStr, EnumProperty, VariantArray))] pub enum TunablesUpdate { // value options LogLevel(Box), @@ -580,14 +581,23 @@ pub enum TunablesUpdate { 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), // These may need to be adapted From 290273619bc91bd595227b43f58ba926899adc78 Mon Sep 17 00:00:00 2001 From: vaw Date: Wed, 25 Feb 2026 22:58:00 +0100 Subject: [PATCH 4/7] Add `:reload` --- docs/iamb.1 | 2 ++ src/base.rs | 17 +++++++-- src/commands.rs | 18 ++++++++++ src/config.rs | 91 +++++++++++++++++++++++++++++++++++++++++++++---- src/main.rs | 19 +++++++++-- src/tests.rs | 2 ++ 6 files changed, 138 insertions(+), 11 deletions(-) diff --git a/docs/iamb.1 b/docs/iamb.1 index 125a6e5b..488f8eb9 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -69,6 +69,8 @@ Mark all rooms as read. 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 diff --git a/src/base.rs b/src/base.rs index ef403f34..96eac1d6 100644 --- a/src/base.rs +++ b/src/base.rs @@ -8,6 +8,7 @@ use std::convert::TryFrom; use std::fmt::{self, Display}; use std::hash::Hash; use std::ops::Deref; +use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -91,7 +92,12 @@ use modalkit::{ prelude::{CommandType, WordStyle}, }; -use crate::config::{ImagePreviewProtocolValues, TunablesUpdate, TunablesUpdateDiscriminants}; +use crate::config::{ + ImagePreviewProtocolValues, + ReloadError, + TunablesUpdate, + TunablesUpdateDiscriminants, +}; use crate::message::ImageStatus; use crate::notifications::NotificationHandle; use crate::preview::{source_from_event, spawn_insert_preview}; @@ -513,6 +519,9 @@ pub enum KeysAction { pub enum SettingsAction { /// Change some settings. Set(Vec), + + /// Reload the (specified) config file. + Reload(Option), } /// An action that the main program loop should. @@ -835,6 +844,10 @@ pub enum IambError { /// A failure while trying to show an image preview. #[error("Preview error: {0}")] Preview(String), + + /// Config couldn't be reloaded + #[error("Reload error: {0}")] + ConfigReload(#[from] ReloadError), } impl From for UIError { @@ -2151,7 +2164,7 @@ fn complete_cmdarg( match cmd.name.as_str() { "cancel" | "dms" | "edit" | "redact" | "reply" => vec![], "members" | "rooms" | "spaces" | "welcome" => vec![], - "download" | "keys" | "open" | "upload" => complete_path(text, cursor), + "download" | "keys" | "open" | "upload" | "reload" => complete_path(text, cursor), "react" | "unreact" => complete_emoji(text, cursor, store), "invite" => complete_users(text, cursor, store), diff --git a/src/commands.rs b/src/commands.rs index 539eaceb..b69383a5 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -743,6 +743,19 @@ fn iamb_set(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { 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); +} + fn add_iamb_commands(cmds: &mut ProgramCommands) { cmds.add_command(ProgramCommand { name: "cancel".into(), @@ -865,6 +878,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) { 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/config.rs b/src/config.rs index cc4a4b16..4204ccc2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -84,7 +84,6 @@ pub fn parse_env_logger( .parse(directives) } - pub fn user_color(user: &str) -> Color { let mut hasher = DefaultHasher::new(); user.hash(&mut hasher); @@ -162,6 +161,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, +} + #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct Keys(pub Vec, pub String); pub struct KeysVisitor; @@ -1012,6 +1026,20 @@ 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< @@ -1039,6 +1067,8 @@ pub struct ApplicationSettings { pub layout: Layout, pub macros: Macros, pub log_level_handle: Option, + /// The file the settings were loaded from. + pub load_file: SettingsFile, } impl ApplicationSettings { @@ -1046,7 +1076,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) @@ -1063,10 +1093,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\ @@ -1091,7 +1121,7 @@ impl ApplicationSettings { usage!( "No configured profile with the name {:?} in {}", profile, - config_json.display() + load_file.display() ); }) } else if profiles.len() == 1 { @@ -1179,18 +1209,65 @@ impl ApplicationSettings { layout, macros, log_level_handle: None, + load_file, }; Ok(settings) } + pub fn reload(&mut self, path: Option) -> 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(); + + // 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(), + )?)); + + 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) { match update { TunablesUpdate::LogLevel(update) => { if let Some(handle) = &mut self.log_level_handle { - handle.reload(update.filter).expect("cannot update appending tracing logger"); + handle + .reload(update.filter) + .expect("cannot update appending tracing logger"); self.tunables.log_level = update.directives; } }, diff --git a/src/main.rs b/src/main.rs index 2f941c07..804dc306 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,6 +87,7 @@ mod tests; use crate::base::SettingsAction; use crate::config::parse_env_logger; +use crate::config::SettingsFile; use crate::{ base::{ AsyncProgramStore, @@ -633,7 +634,7 @@ impl Application { }, IambAction::Settings(act) => { - self.settings_command(act, store); + self.settings_command(act, store)?; None }, }; @@ -682,12 +683,26 @@ impl Application { } } - fn settings_command(&mut self, action: SettingsAction, store: &mut ProgramStore) { + 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); } + 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).map_err(IambError::from)?) }, } } diff --git a/src/tests.rs b/src/tests.rs index aa35964b..6efa1673 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -30,6 +30,7 @@ use crate::{ Notifications, NotifyVia, ProfileConfig, + SettingsFile, SortOverrides, TunableValues, UserColor, @@ -229,6 +230,7 @@ pub fn mock_settings() -> ApplicationSettings { layout: Default::default(), macros: HashMap::default(), log_level_handle: None, + load_file: SettingsFile::Toml("/dev/null".into()), } } From 3385e144d05d4a4442085fa632c02a3a874f13ed Mon Sep 17 00:00:00 2001 From: vaw Date: Sun, 1 Mar 2026 19:02:26 +0100 Subject: [PATCH 5/7] Add `:set sort.*` --- src/base.rs | 4 +-- src/config.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/base.rs b/src/base.rs index 96eac1d6..ce0791ad 100644 --- a/src/base.rs +++ b/src/base.rs @@ -304,7 +304,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; @@ -357,7 +357,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; diff --git a/src/config.rs b/src/config.rs index 4204ccc2..d825e626 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,8 @@ use url::Url; use modalkit::{env::vim::VimMode, key::TerminalKey, keybindings::InputKey}; +use crate::base::{SortRoomVisitor, SortUserVisitor}; + use super::base::{ IambError, IambId, @@ -580,12 +582,64 @@ pub enum TunablesUpdateError { #[error(transparent)] ParseInt(#[from] std::num::ParseIntError), + + #[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(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), + }) + } } /// A update for the [`TunableValues`] after invoking the `:set` command. #[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] #[strum_discriminants(derive(IntoStaticStr, EnumProperty, VariantArray))] pub enum TunablesUpdate { + // multilevel options + Sort(SortUpdate), + // value options LogLevel(Box), UsernameDisplay(UserDisplayStyle), @@ -629,15 +683,21 @@ pub enum TunablesUpdate { // TODO: how do we do this? // Users(UserOverrides), - - // TODO: how do we parse these? - // Sort(SortValues), } impl TunablesUpdate { pub fn new(mut option: String, value: Option<&str>) -> Result { option.retain(|c| c != '_'); + // multilevel 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)?)); + } + let res = match option.as_str() { // value options "loglevel" => { @@ -1271,6 +1331,15 @@ impl ApplicationSettings { self.tunables.log_level = update.directives; } }, + 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::OpenCommand(open_command) => { if open_command.is_empty() { self.tunables.open_command = None; @@ -1278,7 +1347,6 @@ impl ApplicationSettings { self.tunables.open_command = Some(open_command); } }, - TunablesUpdate::UsernameDisplay(username_display) => { self.tunables.username_display = username_display }, From 4b10bb219926fc511c36ddc91df9efd91a18fe97 Mon Sep 17 00:00:00 2001 From: vaw Date: Sun, 1 Mar 2026 21:10:59 +0100 Subject: [PATCH 6/7] Add `:set notifications.*` --- docs/iamb.1 | 5 +++ src/config.rs | 65 ++++++++++++++++++++++++++++++++++++-- src/notifications.rs | 74 +++++++++++++++----------------------------- src/worker.rs | 3 +- 4 files changed, 93 insertions(+), 54 deletions(-) diff --git a/docs/iamb.1 b/docs/iamb.1 index 488f8eb9..c5c70405 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -80,6 +80,11 @@ and for booleans and .Dq {option}={value} for other settings. + +.Ss Example 1: Disable notifications +.Bd -literal -offset indent +:set notifications.noenabled +.Ed .El .Sh "E2EE COMMANDS" diff --git a/src/config.rs b/src/config.rs index d825e626..b92f0cea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -633,12 +633,57 @@ impl SortUpdate { } } +#[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] +#[strum_discriminants(derive(IntoStaticStr, VariantArray))] +pub enum NotificationsUpdate { + Enabled(bool), + Via(NotifyVia), + ShowMessage(bool), + SoundHint(Option), +} + +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) + } +} + /// A update for the [`TunableValues`] after invoking the `:set` command. #[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] #[strum_discriminants(derive(IntoStaticStr, EnumProperty, VariantArray))] pub enum TunablesUpdate { // multilevel options Sort(SortUpdate), + Notifications(NotificationsUpdate), // value options LogLevel(Box), @@ -675,9 +720,6 @@ pub enum TunablesUpdate { // TODO: this might be complicated with the panic hook // Mouse(Mouse), - // TODO: this will be complicated - // Notifications(Notifications), - // TODO: This will be possible/easier after #464 lands // ImagePreview(Option), @@ -697,6 +739,9 @@ impl TunablesUpdate { return Ok(Self::Sort(SortUpdate::new(sort_option, value)?)); } + if let Some(notification_option) = option.strip_prefix("notifications.") { + return Ok(Self::Notifications(NotificationsUpdate::new(notification_option, value)?)); + } let res = match option.as_str() { // value options @@ -1340,6 +1385,20 @@ impl ApplicationSettings { 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::OpenCommand(open_command) => { if open_command.is_empty() { self.tunables.open_command = None; diff --git a/src/notifications.rs b/src/notifications.rs index d175e0c1..f7cac874 100644 --- a/src/notifications.rs +++ b/src/notifications.rs @@ -16,10 +16,7 @@ use matrix_sdk::{ }; use unicode_segmentation::UnicodeSegmentation; -use crate::{ - base::{AsyncProgramStore, IambError, IambResult, ProgramStore}, - config::{ApplicationSettings, NotifyVia}, -}; +use crate::base::{AsyncProgramStore, IambError, IambResult, ProgramStore}; const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") { None => "iamb", @@ -41,17 +38,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; @@ -62,7 +49,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 { @@ -76,7 +62,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; @@ -86,15 +72,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}") @@ -112,30 +97,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")] @@ -144,8 +126,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 @@ -154,15 +135,17 @@ 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 { - desktop_notification.body(body); + if store.application.settings.tunables.notifications.show_message { + if let Some(body) = body { + desktop_notification.body(body); + } } #[cfg(all(unix, not(target_os = "macos")))] @@ -174,9 +157,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) @@ -243,7 +224,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)?; @@ -268,11 +248,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/worker.rs b/src/worker.rs index 01fd4055..56e22265 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1249,7 +1249,6 @@ impl ClientWorker { self.load_handle = tokio::spawn({ let client = self.client.clone(); - let settings = self.settings.clone(); async move { while !client.is_active() { @@ -1259,7 +1258,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 ((), (), (), ()) = tokio::join!(load, rcpt, room, notifications); } }) From 36337f2119de4b58a2148d6d2d47969f616c9de2 Mon Sep 17 00:00:00 2001 From: vaw Date: Sun, 1 Mar 2026 21:33:42 +0100 Subject: [PATCH 7/7] Add `:set users..*` --- docs/iamb.1 | 5 ++++ src/config.rs | 63 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/docs/iamb.1 b/docs/iamb.1 index c5c70405..6f1acb35 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -86,6 +86,11 @@ for other settings. :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 diff --git a/src/config.rs b/src/config.rs index b92f0cea..44e2915d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -583,6 +583,9 @@ pub enum TunablesUpdateError { #[error(transparent)] ParseInt(#[from] std::num::ParseIntError), + #[error("Invalid user id: {0}")] + UserId(#[from] matrix_sdk::IdParseError), + #[error("{0}")] Custom(String), } @@ -677,6 +680,39 @@ impl NotificationsUpdate { } } +#[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) + } +} + /// A update for the [`TunableValues`] after invoking the `:set` command. #[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] #[strum_discriminants(derive(IntoStaticStr, EnumProperty, VariantArray))] @@ -684,6 +720,7 @@ pub enum TunablesUpdate { // multilevel options Sort(SortUpdate), Notifications(NotificationsUpdate), + Users(OwnedUserId, UserDisplayUpdate), // value options LogLevel(Box), @@ -722,9 +759,6 @@ pub enum TunablesUpdate { // TODO: This will be possible/easier after #464 lands // ImagePreview(Option), - - // TODO: how do we do this? - // Users(UserOverrides), } impl TunablesUpdate { @@ -742,6 +776,21 @@ impl TunablesUpdate { if let Some(notification_option) = option.strip_prefix("notifications.") { return Ok(Self::Notifications(NotificationsUpdate::new(notification_option, value)?)); } + 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)); + } let res = match option.as_str() { // value options @@ -1399,6 +1448,14 @@ impl ApplicationSettings { }, } }, + 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;