From 681315ccbed426e302e96fe896310416d01e4fde Mon Sep 17 00:00:00 2001 From: vaw Date: Sun, 21 Sep 2025 18:10:27 +0200 Subject: [PATCH 01/10] Use pr modalkit#188 --- Cargo.lock | 18 ++++++------------ Cargo.toml | 8 ++++---- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd4acb8a..439b1f46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1437,8 +1437,7 @@ dependencies = [ [[package]] name = "editor-types" version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e99679670f67825fcd24a23cb4eb655a0f92c82bd4d1c1a1357c0cd71e87" +source = "git+https://github.com/ulyssa/modalkit?rev=6d05f301c08204e74e1e5bffe185fe5d90cce2e6#6d05f301c08204e74e1e5bffe185fe5d90cce2e6" dependencies = [ "bitflags 2.9.4", "editor-types-macros", @@ -1449,8 +1448,7 @@ dependencies = [ [[package]] name = "editor-types-macros" version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42680de76cf91f231abd90cc623750d39077f7d2fadb7962325fb082871f4c66" +source = "git+https://github.com/ulyssa/modalkit?rev=6d05f301c08204e74e1e5bffe185fe5d90cce2e6#6d05f301c08204e74e1e5bffe185fe5d90cce2e6" dependencies = [ "editor-types-parser", "nom", @@ -1462,8 +1460,7 @@ dependencies = [ [[package]] name = "editor-types-parser" version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cac4b91fe830fbbe0a60c37ba0264b6e9ffc70e3664c028234dac59e79299ad4" +source = "git+https://github.com/ulyssa/modalkit?rev=6d05f301c08204e74e1e5bffe185fe5d90cce2e6#6d05f301c08204e74e1e5bffe185fe5d90cce2e6" dependencies = [ "nom", "thiserror 1.0.69", @@ -2785,8 +2782,7 @@ dependencies = [ [[package]] name = "keybindings" version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a726307ed87e05155c31329676130e6a237e62dda80211f7e1ed811e47630f" +source = "git+https://github.com/ulyssa/modalkit?rev=6d05f301c08204e74e1e5bffe185fe5d90cce2e6#6d05f301c08204e74e1e5bffe185fe5d90cce2e6" dependencies = [ "textwrap", "unicode-segmentation", @@ -3419,8 +3415,7 @@ dependencies = [ [[package]] name = "modalkit" version = "0.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cbb03c35f23ec7d13f7870049803cd8829f5e60b69d38fa98f5e7876de9f34e" +source = "git+https://github.com/ulyssa/modalkit?rev=6d05f301c08204e74e1e5bffe185fe5d90cce2e6#6d05f301c08204e74e1e5bffe185fe5d90cce2e6" dependencies = [ "anymap2", "arboard", @@ -3442,8 +3437,7 @@ dependencies = [ [[package]] name = "modalkit-ratatui" version = "0.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9b01555837e6d34c67ba887aee1d3d83e4622c42cbad0da1d90de00230d2aa" +source = "git+https://github.com/ulyssa/modalkit?rev=6d05f301c08204e74e1e5bffe185fe5d90cce2e6#6d05f301c08204e74e1e5bffe185fe5d90cce2e6" dependencies = [ "crossterm", "intervaltree", diff --git a/Cargo.toml b/Cargo.toml index c4343605..f4a89beb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,13 +81,13 @@ optional = true [dependencies.modalkit] version = "0.0.24" default-features = false -#git = "https://github.com/ulyssa/modalkit" -#rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75" +git = "https://github.com/ulyssa/modalkit" +rev = "6d05f301c08204e74e1e5bffe185fe5d90cce2e6" [dependencies.modalkit-ratatui] version = "0.0.24" -#git = "https://github.com/ulyssa/modalkit" -#rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75" +git = "https://github.com/ulyssa/modalkit" +rev = "6d05f301c08204e74e1e5bffe185fe5d90cce2e6" [dependencies.matrix-sdk] version = "0.14.0" From 708e21a25388b43049439c30be759b214974eda5 Mon Sep 17 00:00:00 2001 From: vaw Date: Sat, 20 Sep 2025 20:36:32 +0200 Subject: [PATCH 02/10] Create parser --- src/base.rs | 274 +-------------------------- src/completions.rs | 448 +++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 3 +- 3 files changed, 454 insertions(+), 271 deletions(-) create mode 100644 src/completions.rs diff --git a/src/base.rs b/src/base.rs index d191b809..58588641 100644 --- a/src/base.rs +++ b/src/base.rs @@ -7,7 +7,6 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use std::convert::TryFrom; use std::fmt::{self, Display}; use std::hash::Hash; -use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -74,14 +73,12 @@ use modalkit::{ ApplicationStore, ApplicationWindowId, }, - completion::{complete_path, Completer, CompletionMap}, + completion::CompletionMap, context::EditContext, - cursor::Cursor, - rope::EditRope, store::Store, }, env::vim::{ - command::{CommandContext, CommandDescription, VimCommand, VimCommandMachine}, + command::{CommandContext, VimCommand, VimCommandMachine}, keybindings::VimMachine, }, errors::{UIError, UIResult}, @@ -1980,198 +1977,10 @@ impl ApplicationInfo for IambInfo { } } -pub struct IambCompleter; - -impl Completer for IambCompleter { - fn complete( - &mut self, - text: &EditRope, - cursor: &mut Cursor, - content: &IambBufferId, - store: &mut ChatStore, - ) -> Vec { - match content { - IambBufferId::Command(CommandType::Command) => complete_cmdbar(text, cursor, store), - IambBufferId::Command(CommandType::Search) => vec![], - IambBufferId::Room(_, _, RoomFocus::MessageBar) => complete_msgbar(text, cursor, store), - IambBufferId::Room(_, _, RoomFocus::Scrollback) => vec![], - - IambBufferId::DirectList => vec![], - IambBufferId::MemberList(_) => vec![], - IambBufferId::RoomList => vec![], - IambBufferId::SpaceList => vec![], - IambBufferId::VerifyList => vec![], - IambBufferId::Welcome => vec![], - IambBufferId::ChatList => vec![], - IambBufferId::UnreadList => vec![], - } - } -} - -/// Tab completion for user IDs. -fn complete_users(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { - let id = text - .get_prefix_word_mut(cursor, &MATRIX_ID_WORD) - .unwrap_or_else(EditRope::empty); - let id = Cow::from(&id); - - store - .presences - .complete(id.as_ref()) - .into_iter() - .map(|i| i.to_string()) - .collect() -} - -/// Tab completion within the message bar. -fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { - let id = text - .get_prefix_word_mut(cursor, &MATRIX_ID_WORD) - .unwrap_or_else(EditRope::empty); - let id = Cow::from(&id); - - match id.chars().next() { - // Complete room aliases. - Some('#') => { - return store.names.complete(id.as_ref()); - }, - - // Complete room identifiers. - Some('!') => { - return store - .rooms - .complete(id.as_ref()) - .into_iter() - .map(|i| i.to_string()) - .collect(); - }, - - // Complete Emoji shortcodes. - Some(':') => { - let list = store.emojis.complete(&id[1..]); - let iter = list.into_iter().take(200).map(|s| format!(":{s}:")); - - return iter.collect(); - }, - - // Complete usernames for @ and empty strings. - Some('@') | None => { - return store - .presences - .complete(id.as_ref()) - .into_iter() - .map(|i| i.to_string()) - .collect(); - }, - - // Unknown sigil. - Some(_) => return vec![], - } -} - -/// Tab completion for Matrix identifiers (usernames, room aliases, etc.) -fn complete_matrix_names(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { - let id = text - .get_prefix_word_mut(cursor, &MATRIX_ID_WORD) - .unwrap_or_else(EditRope::empty); - let id = Cow::from(&id); - - let list = store.names.complete(id.as_ref()); - if !list.is_empty() { - return list; - } - - let list = store.presences.complete(id.as_ref()); - if !list.is_empty() { - return list.into_iter().map(|i| i.to_string()).collect(); - } - - store - .rooms - .complete(id.as_ref()) - .into_iter() - .map(|i| i.to_string()) - .collect() -} - -/// Tab completion for Emoji shortcode names. -fn complete_emoji(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { - let sc = text.get_prefix_word_mut(cursor, &WordStyle::Little); - let sc = sc.unwrap_or_else(EditRope::empty); - let sc = Cow::from(&sc); - - store.emojis.complete(sc.as_ref()) -} - -/// Tab completion for command names. -fn complete_cmdname( - desc: CommandDescription, - text: &EditRope, - cursor: &mut Cursor, - store: &ChatStore, -) -> Vec { - // Complete command name and set cursor position. - let _ = text.get_prefix_word_mut(cursor, &WordStyle::Little); - store.cmds.complete_name(desc.command.as_str()) -} - -/// Tab completion for command arguments. -fn complete_cmdarg( - desc: CommandDescription, - text: &EditRope, - cursor: &mut Cursor, - store: &ChatStore, -) -> Vec { - let cmd = match store.cmds.get(desc.command.as_str()) { - Ok(cmd) => cmd, - Err(_) => return vec![], - }; - - match cmd.name.as_str() { - "cancel" | "dms" | "edit" | "redact" | "reply" => vec![], - "members" | "rooms" | "spaces" | "welcome" => vec![], - "download" | "keys" | "open" | "upload" => complete_path(text, cursor), - "react" | "unreact" => complete_emoji(text, cursor, store), - - "invite" => complete_users(text, cursor, store), - "join" | "split" | "vsplit" | "tabedit" => complete_matrix_names(text, cursor, store), - "room" => vec![], - "verify" => vec![], - "vertical" | "horizontal" | "aboveleft" | "belowright" | "tab" => { - complete_cmd(desc.arg.text.as_str(), text, cursor, store) - }, - _ => vec![], - } -} - -/// Tab completion for commands. -fn complete_cmd(cmd: &str, text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { - match CommandDescription::from_str(cmd) { - Ok(desc) => { - if desc.arg.untrimmed.is_empty() { - complete_cmdname(desc, text, cursor, store) - } else { - // Complete command argument. - complete_cmdarg(desc, text, cursor, store) - } - }, - - // Can't parse command text, so return zero completions. - Err(_) => vec![], - } -} - -/// Tab completion for the command bar. -fn complete_cmdbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { - let eo = text.cursor_to_offset(cursor); - let slice = text.slice(..eo); - let cow = Cow::from(&slice); - - complete_cmd(cow.as_ref(), text, cursor, store) -} - #[cfg(test)] pub mod tests { + use std::str::FromStr; + use super::*; use crate::config::user_style_from_color; use crate::tests::*; @@ -2338,79 +2147,4 @@ pub mod tests { Need { members: true, messages: Some(Vec::new()) } )],); } - - #[tokio::test] - async fn test_complete_msgbar() { - let store = mock_store().await; - let store = store.application; - - let text = EditRope::from("going for a walk :walk "); - let mut cursor = Cursor::new(0, 22); - let res = complete_msgbar(&text, &mut cursor, &store); - assert_eq!(res, vec![":walking:", ":walking_man:", ":walking_woman:"]); - assert_eq!(cursor, Cursor::new(0, 17)); - - let text = EditRope::from("hello @user1 "); - let mut cursor = Cursor::new(0, 12); - let res = complete_msgbar(&text, &mut cursor, &store); - assert_eq!(res, vec!["@user1:example.com"]); - assert_eq!(cursor, Cursor::new(0, 6)); - - let text = EditRope::from("see #room "); - let mut cursor = Cursor::new(0, 9); - let res = complete_msgbar(&text, &mut cursor, &store); - assert_eq!(res, vec!["#room1:example.com"]); - assert_eq!(cursor, Cursor::new(0, 4)); - } - - #[tokio::test] - async fn test_complete_cmdbar() { - let store = mock_store().await; - let store = store.application; - let users = vec![ - "@user1:example.com", - "@user2:example.com", - "@user3:example.com", - "@user4:example.com", - "@user5:example.com", - ]; - - let text = EditRope::from("invite "); - let mut cursor = Cursor::new(0, 7); - let id = text - .get_prefix_word_mut(&mut cursor, &MATRIX_ID_WORD) - .unwrap_or_else(EditRope::empty); - assert_eq!(id.to_string(), ""); - assert_eq!(cursor, Cursor::new(0, 7)); - - let text = EditRope::from("invite "); - let mut cursor = Cursor::new(0, 7); - let res = complete_cmdbar(&text, &mut cursor, &store); - assert_eq!(res, users); - - let text = EditRope::from("invite ignored"); - let mut cursor = Cursor::new(0, 7); - let res = complete_cmdbar(&text, &mut cursor, &store); - assert_eq!(res, users); - - let text = EditRope::from("invite @user1ignored"); - let mut cursor = Cursor::new(0, 13); - let res = complete_cmdbar(&text, &mut cursor, &store); - assert_eq!(res, vec!["@user1:example.com"]); - - let text = EditRope::from("abo hor"); - let mut cursor = Cursor::new(0, 7); - let res = complete_cmdbar(&text, &mut cursor, &store); - assert_eq!(res, vec!["horizontal"]); - - let text = EditRope::from("abo hor inv"); - let mut cursor = Cursor::new(0, 11); - let res = complete_cmdbar(&text, &mut cursor, &store); - assert_eq!(res, vec!["invite"]); - - let text = EditRope::from("abo hor invite \n"); - let mut cursor = Cursor::new(0, 15); - let res = complete_cmdbar(&text, &mut cursor, &store); - assert_eq!(res, users); - } } diff --git a/src/completions.rs b/src/completions.rs new file mode 100644 index 00000000..c17006bd --- /dev/null +++ b/src/completions.rs @@ -0,0 +1,448 @@ +use std::{borrow::Cow, str::FromStr}; + +use modalkit::{ + editing::{ + completion::{complete_path, Completer}, + cursor::Cursor, + rope::EditRope, + }, + env::vim::command::CommandDescription, + prelude::{CommandType, WordStyle}, +}; + +use crate::base::{ChatStore, IambBufferId, IambInfo, RoomFocus, MATRIX_ID_WORD}; + +mod parse { + use nom::{ + branch::alt, + bytes::complete::{escaped_transform, is_not, tag}, + character::complete::{char, space1}, + combinator::{cut, eof, opt, value}, + error::{ErrorKind, ParseError}, + IResult, + InputLength, + Parser, + }; + + fn parse_text(input: &str) -> IResult<&str, String> { + if input.is_empty() { + let err = ParseError::from_error_kind(input, ErrorKind::Eof); + let err = nom::Err::Error(err); + return Err(err); + } + + let _ = is_not("\"")(input)?; + + escaped_transform( + is_not("\t\n\\ |\""), + '\\', + alt(( + value("\\", tag("\\")), + value(" ", tag(" ")), + value("#", tag("#")), + value("%", tag("%")), + value("|", tag("|")), + value("\"", tag("\"")), + )), + )(input) + } + + fn parse_unclosed_quote(input: &str) -> IResult<&str, String> { + if input.is_empty() { + let err = ParseError::from_error_kind(input, ErrorKind::Eof); + let err = nom::Err::Error(err); + return Err(err); + } + + let (input, _) = char('\"')(input)?; + let (input, text) = cut(escaped_transform( + is_not("\t\n\\\""), + '\\', + alt(( + value("\t", tag("t")), + value("\r", tag("r")), + value("\n", tag("n")), + value("\\", tag("\\")), + value("\"", tag("\"")), + )), + ))(input)?; + + Ok((input, text)) + } + + fn parse_quote(input: &str) -> IResult<&str, String> { + let (input, text) = parse_unclosed_quote(input)?; + let (input, _) = char('\"')(input)?; + + Ok((input, text)) + } + + fn parse_string(input: &str) -> IResult<&str, String> { + alt((parse_quote, parse_text))(input) + } + + /// Acts linke [`separated_list0`](nom::multi::separated_list0) but additionally returns a copy of the last element unparsed. + fn separated_list0_last_raw( + mut sep: G, + mut f: F, + ) -> impl FnMut(I) -> IResult, I), E> + where + I: Clone + InputLength, + F: Parser, + G: Parser, + E: ParseError, + { + move |mut i: I| { + let mut res = Vec::new(); + let mut old_i = i.clone(); + + match f.parse(i.clone()) { + Err(nom::Err::Error(_)) => return Ok((i, (res, old_i))), + Err(e) => return Err(e), + Ok((i1, o)) => { + res.push(o); + i = i1; + }, + } + + loop { + let len = i.input_len(); + match sep.parse(i.clone()) { + Err(nom::Err::Error(_)) => return Ok((i, (res, old_i))), + Err(e) => return Err(e), + Ok((i1, _)) => { + // infinite loop check: the parser must always consume + if i1.input_len() == len { + return Err(nom::Err::Error(E::from_error_kind( + i1, + ErrorKind::SeparatedList, + ))); + } + + match f.parse(i1.clone()) { + Err(nom::Err::Error(_)) => return Ok((i, (res, old_i))), + Err(e) => return Err(e), + Ok((i2, o)) => { + res.push(o); + i = i2; + old_i = i1.clone(); + }, + } + }, + } + } + } + } + + fn parse_last_arg(input: &str) -> IResult<&str, (String, &str)> { + let (input, _) = space1(input)?; + + let old_input = input; + let (input, arg) = opt(parse_unclosed_quote)(input)?; + + Ok((input, (arg.unwrap_or_default(), old_input))) + } + + /// Returns a list with the parsed strings and a raw version of the last string to be stripped + /// from the input before completing. + pub fn parse_started_strings(input: &str) -> IResult<&str, (Vec, &str)> { + let (input, (mut args, mut last_arg_raw)) = + separated_list0_last_raw(space1, parse_string)(input)?; + let (input, end_arg) = opt(parse_last_arg)(input)?; + let (input, _) = eof(input)?; + + if let Some((arg, end_arg_raw)) = end_arg { + args.push(arg); + last_arg_raw = end_arg_raw; + } + + if args.is_empty() { + args.push(String::new()); + } + + Ok((input, (args, last_arg_raw))) + } +} + +/// Tab completion for user IDs. +fn complete_users(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { + let id = text + .get_prefix_word_mut(cursor, &MATRIX_ID_WORD) + .unwrap_or_else(EditRope::empty); + let id = Cow::from(&id); + + store + .presences + .complete(id.as_ref()) + .into_iter() + .map(|i| i.to_string()) + .collect() +} + +/// Tab completion for Matrix room aliases +fn complete_matrix_aliases(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { + let id = text + .get_prefix_word_mut(cursor, &MATRIX_ID_WORD) + .unwrap_or_else(EditRope::empty); + let id = Cow::from(&id); + + let list = store.names.complete(id.as_ref()); + if !list.is_empty() { + return list.into_iter().map(|i| i.to_string()).collect(); + } + + let list = store.presences.complete(id.as_ref()); + if !list.is_empty() { + return list.into_iter().map(|i| i.to_string()).collect(); + } + + store + .rooms + .complete(id.as_ref()) + .into_iter() + .map(|i| i.to_string()) + .collect() +} + +/// Tab completion for Emoji shortcode names. +fn complete_emoji(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { + let sc = text.get_prefix_word_mut(cursor, &WordStyle::Little); + let sc = sc.unwrap_or_else(EditRope::empty); + let sc = Cow::from(&sc); + + store.emojis.complete(sc.as_ref()) +} + +fn complete_invite(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { + let text = text.slice(..text.cursor_to_offset(cursor)); + let text = Cow::from(&text); + + todo!() +} + +/// Tab completion for command arguments. +fn complete_cmdarg( + desc: CommandDescription, + text: &EditRope, + cursor: &mut Cursor, + store: &ChatStore, +) -> Vec { + let cmd = match store.cmds.get(desc.command.as_str()) { + Ok(cmd) => cmd, + Err(_) => return vec![], + }; + + let Ok((_, (args, to_strip))) = parse::parse_started_strings(&desc.arg.text) else { + return vec![]; + }; + + match cmd.name.as_str() { + "cancel" | "dms" | "edit" | "redact" | "reply" => vec![], + "members" | "rooms" | "spaces" | "welcome" | "forget" => vec![], + "download" | "keys" | "open" | "upload" => complete_path(text, cursor), + "react" | "unreact" => complete_emoji(text, cursor, store), + + "invite" => complete_users(text, cursor, store), + "join" | "split" | "vsplit" | "tabedit" => complete_matrix_aliases(text, cursor, store), + "room" => vec![], + "verify" => vec![], + "vertical" | "horizontal" | "aboveleft" | "belowright" | "tab" => { + complete_cmd(desc.arg.text.as_str(), text, cursor, store) + }, + _ => vec![], + } +} + +/// Tab completion for command names. +fn complete_cmdname( + desc: CommandDescription, + text: &EditRope, + cursor: &mut Cursor, + store: &ChatStore, +) -> Vec { + // Complete command name and set cursor position. + let _ = text.get_prefix_word_mut(cursor, &WordStyle::Little); + store.cmds.complete_name(desc.command.as_str()) +} + +/// Tab completion for commands. +fn complete_cmd(cmd: &str, text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { + match CommandDescription::from_str(cmd) { + Ok(desc) => { + if desc.arg.untrimmed.is_empty() { + complete_cmdname(desc, text, cursor, store) + } else { + // Complete command argument. + complete_cmdarg(desc, text, cursor, store) + } + }, + + // Can't parse command text, so return zero completions. + Err(_) => vec![], + } +} + +/// Tab completion for the command bar. +fn complete_cmdbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { + let eo = text.cursor_to_offset(cursor); + let slice = text.slice(..eo); + let cow = Cow::from(&slice); + + complete_cmd(cow.as_ref(), text, cursor, store) +} + +/// Tab completion within the message bar. +fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { + let id = text + .get_prefix_word_mut(cursor, &MATRIX_ID_WORD) + .unwrap_or_else(EditRope::empty); + let id = Cow::from(&id); + + match id.chars().next() { + // Complete room aliases. + Some('#') => { + return store.names.complete(id.as_ref()); + }, + + // Complete room identifiers. + Some('!') => { + return store + .rooms + .complete(id.as_ref()) + .into_iter() + .map(|i| i.to_string()) + .collect(); + }, + + // Complete Emoji shortcodes. + Some(':') => { + let list = store.emojis.complete(&id[1..]); + let iter = list.into_iter().take(200).map(|s| format!(":{s}:")); + + return iter.collect(); + }, + + // Complete usernames for @ and empty strings. + Some('@') | None => { + return store + .presences + .complete(id.as_ref()) + .into_iter() + .map(|i| i.to_string()) + .collect(); + }, + + // Unknown sigil. + Some(_) => return vec![], + } +} + +pub struct IambCompleter; + +impl Completer for IambCompleter { + fn complete( + &mut self, + text: &EditRope, + cursor: &mut Cursor, + content: &IambBufferId, + store: &mut ChatStore, + ) -> Vec { + match content { + IambBufferId::Command(CommandType::Command) => complete_cmdbar(text, cursor, store), + IambBufferId::Command(CommandType::Search) => vec![], + IambBufferId::Room(_, _, RoomFocus::MessageBar) => complete_msgbar(text, cursor, store), + IambBufferId::Room(_, _, RoomFocus::Scrollback) => vec![], + + IambBufferId::DirectList => vec![], + IambBufferId::MemberList(_) => vec![], + IambBufferId::RoomList => vec![], + IambBufferId::SpaceList => vec![], + IambBufferId::VerifyList => vec![], + IambBufferId::Welcome => vec![], + IambBufferId::ChatList => vec![], + IambBufferId::UnreadList => vec![], + } + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + use crate::tests::*; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn test_complete_msgbar() { + let store = mock_store().await; + let store = store.application; + + let text = EditRope::from("going for a walk :walk "); + let mut cursor = Cursor::new(0, 22); + let res = complete_msgbar(&text, &mut cursor, &store); + assert_eq!(res, vec![":walking:", ":walking_man:", ":walking_woman:"]); + assert_eq!(cursor, Cursor::new(0, 17)); + + let text = EditRope::from("hello @user1 "); + let mut cursor = Cursor::new(0, 12); + let res = complete_msgbar(&text, &mut cursor, &store); + assert_eq!(res, vec!["@user1:example.com"]); + assert_eq!(cursor, Cursor::new(0, 6)); + + let text = EditRope::from("see #room "); + let mut cursor = Cursor::new(0, 9); + let res = complete_msgbar(&text, &mut cursor, &store); + assert_eq!(res, vec!["#room1:example.com"]); + assert_eq!(cursor, Cursor::new(0, 4)); + } + + #[tokio::test] + async fn test_complete_cmdbar() { + let store = mock_store().await; + let store = store.application; + let users = vec![ + "@user1:example.com", + "@user2:example.com", + "@user3:example.com", + "@user4:example.com", + "@user5:example.com", + ]; + + let text = EditRope::from("invite "); + let mut cursor = Cursor::new(0, 7); + let id = text + .get_prefix_word_mut(&mut cursor, &MATRIX_ID_WORD) + .unwrap_or_else(EditRope::empty); + assert_eq!(id.to_string(), ""); + assert_eq!(cursor, Cursor::new(0, 7)); + + let text = EditRope::from("invite "); + let mut cursor = Cursor::new(0, 7); + let res = complete_cmdbar(&text, &mut cursor, &store); + assert_eq!(res, users); + + let text = EditRope::from("invite ignored"); + let mut cursor = Cursor::new(0, 7); + let res = complete_cmdbar(&text, &mut cursor, &store); + assert_eq!(res, users); + + let text = EditRope::from("invite @user1ignored"); + let mut cursor = Cursor::new(0, 13); + let res = complete_cmdbar(&text, &mut cursor, &store); + assert_eq!(res, vec!["@user1:example.com"]); + + let text = EditRope::from("abo hor"); + let mut cursor = Cursor::new(0, 7); + let res = complete_cmdbar(&text, &mut cursor, &store); + assert_eq!(res, vec!["horizontal"]); + + let text = EditRope::from("abo hor inv"); + let mut cursor = Cursor::new(0, 11); + let res = complete_cmdbar(&text, &mut cursor, &store); + assert_eq!(res, vec!["invite"]); + + let text = EditRope::from("abo hor invite \n"); + let mut cursor = Cursor::new(0, 15); + let res = complete_cmdbar(&text, &mut cursor, &store); + assert_eq!(res, users); + } +} diff --git a/src/main.rs b/src/main.rs index 0a316c76..16229fc9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,6 +80,7 @@ mod util; mod windows; mod worker; +mod completions; #[cfg(test)] mod tests; @@ -89,7 +90,6 @@ use crate::{ ChatStore, HomeserverAction, IambAction, - IambCompleter, IambError, IambId, IambInfo, @@ -99,6 +99,7 @@ use crate::{ ProgramContext, ProgramStore, }, + completions::IambCompleter, config::{ApplicationSettings, Iamb}, windows::IambWindow, worker::{create_room, ClientWorker, LoginStyle, Requester}, From e47d1f2cacadd598cd870c54e5746322e5e8de83 Mon Sep 17 00:00:00 2001 From: vaw Date: Sun, 21 Sep 2025 13:37:42 +0200 Subject: [PATCH 03/10] Complete `:invite` and `:keys` --- src/completions.rs | 126 ++++++++++++++++++++++++++++----------------- 1 file changed, 80 insertions(+), 46 deletions(-) diff --git a/src/completions.rs b/src/completions.rs index c17006bd..d2398050 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -1,3 +1,4 @@ +//! Tab completion for iamb use std::{borrow::Cow, str::FromStr}; use modalkit::{ @@ -7,7 +8,15 @@ use modalkit::{ rope::EditRope, }, env::vim::command::CommandDescription, - prelude::{CommandType, WordStyle}, + prelude::{ + CommandType, + Count, + CursorMovements, + CursorMovementsContext, + MoveDir1D, + MoveType, + WordStyle, + }, }; use crate::base::{ChatStore, IambBufferId, IambInfo, RoomFocus, MATRIX_ID_WORD}; @@ -165,65 +174,75 @@ mod parse { } /// Tab completion for user IDs. -fn complete_users(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { - let id = text - .get_prefix_word_mut(cursor, &MATRIX_ID_WORD) - .unwrap_or_else(EditRope::empty); - let id = Cow::from(&id); - +fn complete_users(input: &str, store: &ChatStore) -> Vec { store .presences - .complete(id.as_ref()) + .complete(input) .into_iter() .map(|i| i.to_string()) .collect() } /// Tab completion for Matrix room aliases -fn complete_matrix_aliases(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { - let id = text - .get_prefix_word_mut(cursor, &MATRIX_ID_WORD) - .unwrap_or_else(EditRope::empty); - let id = Cow::from(&id); - - let list = store.names.complete(id.as_ref()); +fn complete_matrix_aliases(input: &str, store: &ChatStore) -> Vec { + let list = store.names.complete(input); if !list.is_empty() { return list.into_iter().map(|i| i.to_string()).collect(); } - let list = store.presences.complete(id.as_ref()); + let list = store.presences.complete(input); if !list.is_empty() { return list.into_iter().map(|i| i.to_string()).collect(); } - store - .rooms - .complete(id.as_ref()) - .into_iter() - .map(|i| i.to_string()) - .collect() + store.rooms.complete(input).into_iter().map(|i| i.to_string()).collect() } /// Tab completion for Emoji shortcode names. -fn complete_emoji(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { - let sc = text.get_prefix_word_mut(cursor, &WordStyle::Little); - let sc = sc.unwrap_or_else(EditRope::empty); - let sc = Cow::from(&sc); +fn complete_emoji(input: &str, store: &ChatStore) -> Vec { + store.emojis.complete(input) +} - store.emojis.complete(sc.as_ref()) +/// Tab completion for compile-time known strings +fn complete_choices(input: &str, options: &[&'static str]) -> Vec { + options + .iter() + .filter(|opt| opt.starts_with(input)) + .map(|opt| opt.to_string()) + .collect() } -fn complete_invite(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { - let text = text.slice(..text.cursor_to_offset(cursor)); - let text = Cow::from(&text); +/// Tab completion for `:invite` +fn complete_iamb_invite(args: Vec, store: &ChatStore) -> Vec { + match args.len() { + 1 => complete_choices(&args[0], &["accept", "reject", "send"]), + 2 if args[0] == "send" => complete_users(&args[1], store), + _ => vec![], + } +} - todo!() +/// Tab completion for `:keys` +fn complete_iamb_keys( + args: Vec, + input: &EditRope, + orig_cursor: Cursor, + cursor: &mut Cursor, +) -> Vec { + let subcmds = ["export", "import"]; + match args.len() { + 1 => complete_choices(&args[0], &subcmds), + 2 if subcmds.contains(&args[0].as_str()) => { + *cursor = orig_cursor; + complete_path(input, cursor) + }, + _ => vec![], + } } /// Tab completion for command arguments. fn complete_cmdarg( desc: CommandDescription, - text: &EditRope, + input: &EditRope, cursor: &mut Cursor, store: &ChatStore, ) -> Vec { @@ -235,22 +254,33 @@ fn complete_cmdarg( let Ok((_, (args, to_strip))) = parse::parse_started_strings(&desc.arg.text) else { return vec![]; }; + debug_assert!(!args.is_empty()); // empty string is inserted if text is empty - match cmd.name.as_str() { - "cancel" | "dms" | "edit" | "redact" | "reply" => vec![], - "members" | "rooms" | "spaces" | "welcome" | "forget" => vec![], - "download" | "keys" | "open" | "upload" => complete_path(text, cursor), - "react" | "unreact" => complete_emoji(text, cursor, store), - - "invite" => complete_users(text, cursor, store), - "join" | "split" | "vsplit" | "tabedit" => complete_matrix_aliases(text, cursor, store), - "room" => vec![], - "verify" => vec![], - "vertical" | "horizontal" | "aboveleft" | "belowright" | "tab" => { - complete_cmd(desc.arg.text.as_str(), text, cursor, store) - }, + // move cursor to start of last arg + let ctx = CursorMovementsContext { + action: &Default::default(), + view: &Default::default(), + context: &Default::default(), + }; + let movement = MoveType::Column(MoveDir1D::Previous, true); + let count = Count::Exact(to_strip.len()); + let Some(new_cursor) = input.movement(cursor, &movement, &count, &ctx) else { + return vec![]; + }; + let orig_cursor = cursor.clone(); + *cursor = new_cursor; + + let completions = match cmd.name.as_str() { + "invite" => complete_iamb_invite(args, store), + "keys" => complete_iamb_keys(args, input, orig_cursor, cursor), + + // TODO: replace old options _ => vec![], - } + }; + + // TODO: escape stuff + + completions } /// Tab completion for command names. @@ -445,4 +475,8 @@ pub mod tests { let res = complete_cmdbar(&text, &mut cursor, &store); assert_eq!(res, users); } + + fn test_all_commands_complete() { + todo!() + } } From c58eb19d61416fe99a0984d161c8265fa975befa Mon Sep 17 00:00:00 2001 From: vaw Date: Sun, 21 Sep 2025 14:01:17 +0200 Subject: [PATCH 04/10] Complete `:verify` --- src/base.rs | 2 +- src/completions.rs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/base.rs b/src/base.rs index 58588641..28fc8b0d 100644 --- a/src/base.rs +++ b/src/base.rs @@ -1584,7 +1584,7 @@ pub struct ChatStore { pub presences: CompletionMap, /// In-progress and completed verifications. - pub verifications: HashMap, + pub verifications: CompletionMap, /// Settings for the current profile loaded from config file. pub settings: ApplicationSettings, diff --git a/src/completions.rs b/src/completions.rs index d2398050..fa93ce27 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -198,6 +198,11 @@ fn complete_matrix_aliases(input: &str, store: &ChatStore) -> Vec { store.rooms.complete(input).into_iter().map(|i| i.to_string()).collect() } +/// Tab completion for open verification requests +fn complete_verification(input: &str, store: &ChatStore) -> Vec { + store.verifications.complete(input) +} + /// Tab completion for Emoji shortcode names. fn complete_emoji(input: &str, store: &ChatStore) -> Vec { store.emojis.complete(input) @@ -239,6 +244,17 @@ fn complete_iamb_keys( } } +/// Tab completion for `:verify` +fn complete_iamb_verify(args: Vec, store: &ChatStore) -> Vec { + let subcmds = ["request", "accept", "confirm", "cancel", "missmatch"]; + match args.len() { + 1 => complete_choices(&args[0], &subcmds), + 2 if args[0] == "request" => complete_users(&args[1], store), + 2 if subcmds.contains(&args[0].as_str()) => complete_verification(&args[1], store), + _ => vec![], + } +} + /// Tab completion for command arguments. fn complete_cmdarg( desc: CommandDescription, @@ -273,6 +289,7 @@ fn complete_cmdarg( let completions = match cmd.name.as_str() { "invite" => complete_iamb_invite(args, store), "keys" => complete_iamb_keys(args, input, orig_cursor, cursor), + "verify" => complete_iamb_verify(args, store), // TODO: replace old options _ => vec![], From b4242cd13afcfb5563435a4b771e3c7b3e95332d Mon Sep 17 00:00:00 2001 From: vaw Date: Sun, 21 Sep 2025 15:05:42 +0200 Subject: [PATCH 05/10] Add more completions --- src/completions.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/completions.rs b/src/completions.rs index fa93ce27..75a80a96 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -255,6 +255,14 @@ fn complete_iamb_verify(args: Vec, store: &ChatStore) -> Vec { } } +/// Tab completion for `:unreads` +fn complete_iamb_unreads(args: Vec) -> Vec { + match args.len() { + 1 if "clear".starts_with(&args[0]) => vec!["clear".to_string()], + _ => vec![], + } +} + /// Tab completion for command arguments. fn complete_cmdarg( desc: CommandDescription, @@ -288,9 +296,35 @@ fn complete_cmdarg( let completions = match cmd.name.as_str() { "invite" => complete_iamb_invite(args, store), + "keys" => complete_iamb_keys(args, input, orig_cursor, cursor), + "verify" => complete_iamb_verify(args, store), + // These have no arguments + "dms" | "members" | "leave" | "cancel" | "edit" => vec![], + + "react" if args.len() == 1 => complete_emoji(&args[0], store), + "react" => vec![], + + // TODO: Check whether we can get the id of the focused message to improve completion + "unreact" if args.len() == 1 => complete_emoji(&args[0], store), + "unreact" => vec![], + + // The redaction reason is free text + "redact" => vec![], + + // These have no arguments + "reply" | "editor" | "rooms" | "chats" => vec![], + + "unreads" => complete_iamb_unreads(args), + + // These have no arguments + "spaces" | "welcome" => vec![], + + "join" if args.len() == 1 => complete_matrix_aliases(&args[0], store), + "join" => vec![], + // TODO: replace old options _ => vec![], }; From c093704f45c18419a7162b14f9720e8319ff4508 Mon Sep 17 00:00:00 2001 From: vaw Date: Sun, 21 Sep 2025 16:08:10 +0200 Subject: [PATCH 06/10] Add completion for `:room` and `:create` --- docs/iamb.1 | 16 +++++++++++ src/commands.rs | 6 ++-- src/completions.rs | 72 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/docs/iamb.1 b/docs/iamb.1 index e7036e37..cf923970 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -194,6 +194,22 @@ Set the room's canonical alias to the one provided, and make the previous one an Delete the room's canonical alias. .It Sy ":room canon show" Show the room's canonical alias, if any is set. +.It Sy ":room history show" +Show the history visibility setting for the current room. +.It Sy ":room history set [mode]" .\" TODO: get options +Set the history visibility for the current room. +Valid modes are +.Dq invited +(new events after invitation), +.Dq joined +(new events after join), +.Dq shared +(whole history after join), +.Dq world_readable +(whole history for unjoined users). +.It Sy ":room history unset" +Reset the room visibility setting to +.Dq joined . .It Sy ":room ban [user] [reason]" Ban a user from this room with an optional reason. .It Sy ":room unban [user] [reason]" diff --git a/src/commands.rs b/src/commands.rs index d355aca4..c116baff 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -522,15 +522,15 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { ("notify", "show", None) => RoomAction::Show(RoomField::NotificationMode).into(), ("notify", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument), - // :room aliases show + // :room alias show ("alias", "show", None) => RoomAction::Show(RoomField::Aliases).into(), ("alias", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument), - // :room aliases unset + // :room alias unset ("alias", "unset", Some(s)) => RoomAction::Unset(RoomField::Alias(s)).into(), ("alias", "unset", None) => return Result::Err(CommandError::InvalidArgument), - // :room aliases set + // :room alias set ("alias", "set", Some(s)) => RoomAction::Set(RoomField::Alias(s), "".into()).into(), ("alias", "set", None) => return Result::Err(CommandError::InvalidArgument), diff --git a/src/completions.rs b/src/completions.rs index 75a80a96..835dbdb4 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -263,6 +263,72 @@ fn complete_iamb_unreads(args: Vec) -> Vec { } } +/// Tab completion for `:create` +fn complete_iamb_create(args: Vec) -> Vec { + let opts = ["++alias=", "++public", "++space", "++encrypted"]; + let opts_left: Vec<_> = opts + .iter() + .filter(|o| { + !args + .iter() + .any(|arg| arg.as_str() == **o || (o.ends_with('=') && arg.starts_with(*o))) + }) + .copied() + .collect(); + complete_choices(args.last().unwrap(), opts_left.as_slice()) +} + +/// Tab completion for `:room` +// TODO: Check whether we can get the id of the focused room to improve +// "kick","ban","unban", ".. unset" and "dm/tag set/unset" +fn complete_iamb_room(args: Vec, store: &ChatStore) -> Vec { + let subcmds = [ + "dm", + "kick", + "ban", + "unban", + "history", + "name", + "topic", + "tag", + "notify", + "alias", + "canonicalalias", + "id", + ]; + if args.len() == 1 { + complete_choices(&args[0], &subcmds) + } else if args.len() == 2 { + let input = &args[1]; + match args[0].as_str() { + "kick" | "ban" | "unban" => complete_users(input, store), + "id" => complete_choices(input, &["show"]), + "dm" | "name" | "tag" => complete_choices(input, &["set", "unset"]), + + "history" | "topic" | "notify" | "alias" | "canonicalalias" | "canon" => { + complete_choices(input, &["show", "set", "unset"]) + }, + + _ => vec![], + } + } else if args.len() == 3 { + let input = &args[2]; + match (args[0].as_str(), args[1].as_str()) { + ("history", "set") => { + complete_choices(input, &["invited", "joined", "shared", "world_readable"]) + }, + ("tag", "set") | ("tag", "unset") => { + complete_choices(input, &["favourite", "lowpriority", "server_notice", "u."]) + }, + ("notify", "set") => complete_choices(input, &["mute", "mentions", "keywords", "all"]), + + _ => vec![], + } + } else { + vec![] + } +} + /// Tab completion for command arguments. fn complete_cmdarg( desc: CommandDescription, @@ -325,11 +391,15 @@ fn complete_cmdarg( "join" if args.len() == 1 => complete_matrix_aliases(&args[0], store), "join" => vec![], + "create" => complete_iamb_create(args), + + "room" => complete_iamb_room(args, store), + // TODO: replace old options _ => vec![], }; - // TODO: escape stuff + // TODO: escape stuff including with paths completions } From f3f07e0692b647bb8196493fee251744b434a8bb Mon Sep 17 00:00:00 2001 From: vaw Date: Sun, 21 Sep 2025 16:40:44 +0200 Subject: [PATCH 07/10] Make `:space child set` accept a room alias and add completion --- docs/iamb.1 | 2 +- src/base.rs | 2 +- src/commands.rs | 24 +++++----------- src/completions.rs | 60 ++++++++++++++++++++++++++++++++------- src/windows/room/space.rs | 4 ++- src/worker.rs | 2 +- 6 files changed, 62 insertions(+), 32 deletions(-) diff --git a/docs/iamb.1 b/docs/iamb.1 index cf923970..16a21fa2 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -220,7 +220,7 @@ Kick a user from this room with an optional reason. .Sh "SPACE COMMANDS" .Bl -tag -width Ds -.It Sy ":space child set [room_id] [arguments]" +.It Sy ":space child set [room] [arguments]" Add a room to the currently focused space. .Dq ++suggested marks the room as a suggested child. diff --git a/src/base.rs b/src/base.rs index 28fc8b0d..f63c3e1d 100644 --- a/src/base.rs +++ b/src/base.rs @@ -187,7 +187,7 @@ pub enum SpaceAction { /// /// The [`Option`] argument is the order parameter. /// The [`bool`] argument indicates whether the room is suggested. - SetChild(OwnedRoomId, Option, bool), + SetChild(String, Option, bool), /// Remove the selected room. RemoveChild, diff --git a/src/commands.rs b/src/commands.rs index c116baff..b87ba2f1 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -2,9 +2,9 @@ //! //! The command-bar commands are set up here, and iamb-specific commands are defined here. See //! [modalkit::env::vim::command] for additional Vim commands we pull in. -use std::{convert::TryFrom, str::FromStr as _}; +use std::convert::TryFrom; -use matrix_sdk::ruma::{events::tag::TagName, OwnedRoomId, OwnedUserId}; +use matrix_sdk::ruma::{events::tag::TagName, OwnedUserId}; use modalkit::{ commands::{CommandError, CommandResult, CommandStep}, @@ -633,10 +633,7 @@ fn iamb_space(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { } } - let child = if let Some(child) = raw_child { - OwnedRoomId::from_str(&child) - .map_err(|_| CommandError::Error("Invalid room id specified".into()))? - } else { + let Some(child) = raw_child else { let msg = "Must specify a room to add"; return Err(CommandError::Error(msg.into())); }; @@ -848,7 +845,7 @@ pub fn setup_commands() -> ProgramCommands { #[cfg(test)] mod tests { use super::*; - use matrix_sdk::ruma::{room_id, user_id}; + use matrix_sdk::ruma::user_id; use modalkit::actions::WindowAction; use modalkit::editing::context::EditContext; @@ -1228,16 +1225,13 @@ mod tests { let cmd = "space child set !roomid:example.org"; let res = cmds.input_cmd(cmd, ctx.clone()).unwrap(); - let act = SpaceAction::SetChild(room_id!("!roomid:example.org").to_owned(), None, false); + let act = SpaceAction::SetChild("!roomid:example.org".to_owned(), None, false); assert_eq!(res, vec![(act.into(), ctx.clone())]); let cmd = "space child set ++order=abcd ++suggested !roomid:example.org"; let res = cmds.input_cmd(cmd, ctx.clone()).unwrap(); - let act = SpaceAction::SetChild( - room_id!("!roomid:example.org").to_owned(), - Some("abcd".into()), - true, - ); + let act = + SpaceAction::SetChild("!roomid:example.org".to_owned(), Some("abcd".into()), true); assert_eq!(res, vec![(act.into(), ctx.clone())]); let cmd = "space child set ++order=abcd ++order=1234 !roomid:example.org"; @@ -1263,10 +1257,6 @@ mod tests { let res = cmds.input_cmd(cmd, ctx.clone()); assert_eq!(res, Err(CommandError::InvalidArgument)); - let cmd = "space child set foo"; - let res = cmds.input_cmd(cmd, ctx.clone()); - assert_eq!(res, Err(CommandError::Error("Invalid room id specified".into()))); - let cmd = "space child set"; let res = cmds.input_cmd(cmd, ctx.clone()); assert_eq!(res, Err(CommandError::Error("Must specify a room to add".into()))); diff --git a/src/completions.rs b/src/completions.rs index 835dbdb4..91e9f91f 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -217,6 +217,20 @@ fn complete_choices(input: &str, options: &[&'static str]) -> Vec { .collect() } +/// Tab completion for vim-like options +fn complete_options(args: &[String], options: &[&'static str]) -> Vec { + let opts: Vec<_> = options + .iter() + .filter(|o| { + !args + .iter() + .any(|arg| arg.as_str() == **o || (o.ends_with('=') && arg.starts_with(*o))) + }) + .copied() + .collect(); + complete_choices(args.last().unwrap(), opts.as_slice()) +} + /// Tab completion for `:invite` fn complete_iamb_invite(args: Vec, store: &ChatStore) -> Vec { match args.len() { @@ -265,17 +279,8 @@ fn complete_iamb_unreads(args: Vec) -> Vec { /// Tab completion for `:create` fn complete_iamb_create(args: Vec) -> Vec { - let opts = ["++alias=", "++public", "++space", "++encrypted"]; - let opts_left: Vec<_> = opts - .iter() - .filter(|o| { - !args - .iter() - .any(|arg| arg.as_str() == **o || (o.ends_with('=') && arg.starts_with(*o))) - }) - .copied() - .collect(); - complete_choices(args.last().unwrap(), opts_left.as_slice()) + let options = ["++alias=", "++public", "++space", "++encrypted"]; + complete_options(args.as_slice(), &options) } /// Tab completion for `:room` @@ -329,6 +334,37 @@ fn complete_iamb_room(args: Vec, store: &ChatStore) -> Vec { } } +/// Tab completion for `:space` +fn complete_iamb_space(args: Vec, store: &ChatStore) -> Vec { + if args.len() == 1 { + complete_choices(&args[0], &["child"]) + } else if args.len() == 2 && &args[0] == "child" { + complete_choices(&args[1], &["set", "remove"]) + } else if args.len() > 2 && &args[0] == "child" && args[1] == "set" { + let options = ["++suggested", "++order="]; + + let has_room = args.iter().skip(2).rev().skip(1).any(|arg| !arg.starts_with('+')); + + let arg = args.last().unwrap(); + + if arg.is_empty() { + let mut opts = complete_options(args.as_slice(), &options); + if !has_room { + opts.extend(complete_matrix_aliases(arg, store)); + } + opts + } else if arg.starts_with('+') { + complete_options(args.as_slice(), &options) + } else if !has_room { + complete_matrix_aliases(arg, store) + } else { + vec![] + } + } else { + vec![] + } +} + /// Tab completion for command arguments. fn complete_cmdarg( desc: CommandDescription, @@ -395,6 +431,8 @@ fn complete_cmdarg( "room" => complete_iamb_room(args, store), + "space" => complete_iamb_space(args, store), + // TODO: replace old options _ => vec![], }; diff --git a/src/windows/room/space.rs b/src/windows/room/space.rs index 27b9fb9c..c6cced58 100644 --- a/src/windows/room/space.rs +++ b/src/windows/room/space.rs @@ -90,7 +90,7 @@ impl SpaceState { store: &mut ProgramStore, ) -> IambResult { match act { - SpaceAction::SetChild(child_id, order, suggested) => { + SpaceAction::SetChild(name, order, suggested) => { if !self .room .power_levels() @@ -105,6 +105,8 @@ impl SpaceState { return Err(IambError::InsufficientPermission.into()); } + let child_id = store.application.worker.join_room(name)?; + let via = self.room.route().await.map_err(IambError::from)?; let mut ev = SpaceChildEventContent::new(via); ev.order = order diff --git a/src/worker.rs b/src/worker.rs index f3c9791a..ff7d2588 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1398,7 +1398,7 @@ impl ClientWorker { } } - async fn join_room(&mut self, name: String) -> IambResult { + pub async fn join_room(&mut self, name: String) -> IambResult { if let Ok(alias_id) = OwnedRoomOrAliasId::from_str(name.as_str()) { match self.client.join_room_by_id_or_alias(&alias_id, &[]).await { Ok(resp) => Ok(resp.room_id().to_owned()), From feaf1bedf57c0599028722571ed137cdc3325cc5 Mon Sep 17 00:00:00 2001 From: vaw Date: Sun, 21 Sep 2025 16:51:31 +0200 Subject: [PATCH 08/10] Complete remaining options --- src/completions.rs | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/completions.rs b/src/completions.rs index 91e9f91f..b9ed5aab 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -365,6 +365,15 @@ fn complete_iamb_space(args: Vec, store: &ChatStore) -> Vec { } } +/// Tab completion for `:logout` +fn complete_iamb_logout(args: Vec, store: &ChatStore) -> Vec { + let id = store.settings.profile.user_id.as_str(); + match args.len() { + 1 if id.starts_with(&args[0]) => vec![id.to_string()], + _ => vec![], + } +} + /// Tab completion for command arguments. fn complete_cmdarg( desc: CommandDescription, @@ -404,7 +413,7 @@ fn complete_cmdarg( "verify" => complete_iamb_verify(args, store), // These have no arguments - "dms" | "members" | "leave" | "cancel" | "edit" => vec![], + "dms" | "members" | "leave" | "forget" | "cancel" | "edit" => vec![], "react" if args.len() == 1 => complete_emoji(&args[0], store), "react" => vec![], @@ -417,7 +426,7 @@ fn complete_cmdarg( "redact" => vec![], // These have no arguments - "reply" | "editor" | "rooms" | "chats" => vec![], + "reply" | "replied" | "editor" | "rooms" | "chats" => vec![], "unreads" => complete_iamb_unreads(args), @@ -433,8 +442,24 @@ fn complete_cmdarg( "space" => complete_iamb_space(args, store), - // TODO: replace old options - _ => vec![], + "upload" | "download" | "open" => { + *cursor = orig_cursor; + complete_path(input, cursor) + }, + + "logout" => complete_iamb_logout(args, store), + + "vertical" | "vert" | "horizontal" | "hor" | "aboveleft" | "lefta" | "leftabove" | + "abo" | "belowright" | "rightb" | "rightbelow" | "bel" | "tab" => { + complete_cmd(desc.arg.text.as_str(), input, cursor, store) + }, + + _cmd => { + #[cfg(test)] + panic!("trying to complete unknown subcommand `{}`", _cmd); + + vec![] + }, }; // TODO: escape stuff including with paths From f9a7350cdbcd9c74fec480c8a2299cc1925e1963 Mon Sep 17 00:00:00 2001 From: vaw Date: Sun, 21 Sep 2025 17:47:45 +0200 Subject: [PATCH 09/10] Test that completions for all commands exist --- src/commands.rs | 2 +- src/completions.rs | 80 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index b87ba2f1..8d61a182 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -714,7 +714,7 @@ fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { return Ok(step); } -fn add_iamb_commands(cmds: &mut ProgramCommands) { +pub fn add_iamb_commands(cmds: &mut ProgramCommands) { cmds.add_command(ProgramCommand { name: "cancel".into(), aliases: vec![], diff --git a/src/completions.rs b/src/completions.rs index b9ed5aab..aa33767f 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -476,7 +476,11 @@ fn complete_cmdname( ) -> Vec { // Complete command name and set cursor position. let _ = text.get_prefix_word_mut(cursor, &WordStyle::Little); - store.cmds.complete_name(desc.command.as_str()) + let mut comps = store.cmds.complete_name(desc.command.as_str()); + + comps.extend(store.cmds.complete_aliases(desc.command.as_str())); + + comps } /// Tab completion for commands. @@ -582,7 +586,12 @@ impl Completer for IambCompleter { #[cfg(test)] pub mod tests { use super::*; - use crate::tests::*; + use crate::{ + base::{ProgramCommand, ProgramCommands}, + commands::add_iamb_commands, + tests::*, + }; + use modalkit::{commands::CommandResult, env::vim::command::CommandContext}; use pretty_assertions::assert_eq; #[tokio::test] @@ -613,6 +622,7 @@ pub mod tests { async fn test_complete_cmdbar() { let store = mock_store().await; let store = store.application; + let cmds = vec!["accept", "reject", "send"]; let users = vec![ "@user1:example.com", "@user2:example.com", @@ -632,20 +642,20 @@ pub mod tests { let text = EditRope::from("invite "); let mut cursor = Cursor::new(0, 7); let res = complete_cmdbar(&text, &mut cursor, &store); - assert_eq!(res, users); + assert_eq!(res, cmds); let text = EditRope::from("invite ignored"); let mut cursor = Cursor::new(0, 7); let res = complete_cmdbar(&text, &mut cursor, &store); - assert_eq!(res, users); + assert_eq!(res, cmds); - let text = EditRope::from("invite @user1ignored"); - let mut cursor = Cursor::new(0, 13); + let text = EditRope::from("invite send @user1ignored"); + let mut cursor = Cursor::new(0, 18); let res = complete_cmdbar(&text, &mut cursor, &store); assert_eq!(res, vec!["@user1:example.com"]); - let text = EditRope::from("abo hor"); - let mut cursor = Cursor::new(0, 7); + let text = EditRope::from("abo hori"); + let mut cursor = Cursor::new(0, 8); let res = complete_cmdbar(&text, &mut cursor, &store); assert_eq!(res, vec!["horizontal"]); @@ -654,13 +664,59 @@ pub mod tests { let res = complete_cmdbar(&text, &mut cursor, &store); assert_eq!(res, vec!["invite"]); - let text = EditRope::from("abo hor invite \n"); - let mut cursor = Cursor::new(0, 15); + let text = EditRope::from("abo hor invite send \n"); + let mut cursor = Cursor::new(0, 20); let res = complete_cmdbar(&text, &mut cursor, &store); assert_eq!(res, users); } - fn test_all_commands_complete() { - todo!() + #[tokio::test] + async fn test_all_commands_complete() { + let mut cmds = ProgramCommands::new(); + add_iamb_commands(&mut cmds); + + let store = mock_store().await; + let mut store = store.application; + store.cmds = cmds; + let cmds = &store.cmds; + + for command in cmds.complete_name("") { + let mut text = EditRope::from(command); + text += " ".into(); + let mut cursor = text.last(); + cursor.right(1); + complete_cmdbar(&text, &mut cursor, &store); + } + } + + fn mock_command( + _: CommandDescription, + _: &mut CommandContext, + ) -> CommandResult { + panic!("mock command called"); + } + + #[tokio::test] + #[should_panic(expected = "trying to complete unknown subcommand `testmockcommand`")] + async fn test_complete_unknown_panics() { + let mut cmds = ProgramCommands::new(); + cmds.add_command(ProgramCommand { + name: "testmockcommand".into(), + aliases: vec![], + f: mock_command, + }); + + let store = mock_store().await; + let mut store = store.application; + store.cmds = cmds; + let cmds = &store.cmds; + + for command in cmds.complete_name("") { + let mut text = EditRope::from(command); + text += " ".into(); + let mut cursor = text.last(); + cursor.right(1); + complete_cmdbar(&text, &mut cursor, &store); + } } } From 31bfaa36b262743ce00e40095f785e3cbd5311df Mon Sep 17 00:00:00 2001 From: vaw Date: Sat, 4 Oct 2025 16:28:01 +0200 Subject: [PATCH 10/10] Handle escaped chars in completions --- src/completions.rs | 82 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/src/completions.rs b/src/completions.rs index aa33767f..fbe673fb 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -25,7 +25,7 @@ mod parse { use nom::{ branch::alt, bytes::complete::{escaped_transform, is_not, tag}, - character::complete::{char, space1}, + character::complete::{char, space0, space1}, combinator::{cut, eof, opt, value}, error::{ErrorKind, ParseError}, IResult, @@ -144,7 +144,7 @@ mod parse { } fn parse_last_arg(input: &str) -> IResult<&str, (String, &str)> { - let (input, _) = space1(input)?; + let (input, _) = space0(input)?; let old_input = input; let (input, arg) = opt(parse_unclosed_quote)(input)?; @@ -152,12 +152,24 @@ mod parse { Ok((input, (arg.unwrap_or_default(), old_input))) } + fn parse_trailing_last_arg(input: &str) -> IResult<&str, (String, &str)> { + let (input, _) = space1(input)?; + + parse_last_arg(input) + } + /// Returns a list with the parsed strings and a raw version of the last string to be stripped /// from the input before completing. pub fn parse_started_strings(input: &str) -> IResult<&str, (Vec, &str)> { let (input, (mut args, mut last_arg_raw)) = separated_list0_last_raw(space1, parse_string)(input)?; - let (input, end_arg) = opt(parse_last_arg)(input)?; + + let (input, end_arg) = if args.is_empty() { + opt(parse_last_arg)(input)? + } else { + opt(parse_trailing_last_arg)(input)? + }; + let (input, _) = eof(input)?; if let Some((arg, end_arg_raw)) = end_arg { @@ -171,6 +183,45 @@ mod parse { Ok((input, (args, last_arg_raw))) } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn parse_strings() { + let text = "some normal args"; + let parsed = parse_started_strings(text).unwrap(); + assert_eq!(("", (vec!["some".into(), "normal".into(), "args".into()], "args")), parsed); + + let text = "started "; + let parsed = parse_started_strings(text).unwrap(); + assert_eq!(("", (vec!["started".into(), "".into()], "")), parsed); + + let text = "args \"with quotes\""; + let parsed = parse_started_strings(text).unwrap(); + assert_eq!( + ("", (vec!["args".into(), "with quotes".into()], "\"with quotes\"")), + parsed + ); + + let text = "and \"started "; + let parsed = parse_started_strings(text).unwrap(); + assert_eq!(("", (vec!["and".into(), "started ".into()], "\"started ")), parsed); + + let text = "\"only started "; + let parsed = parse_started_strings(text).unwrap(); + assert_eq!(("", (vec!["only started ".into()], "\"only started ")), parsed); + + let text = " "; + let parsed = parse_started_strings(text).unwrap(); + assert_eq!(("", (vec!["".into()], "")), parsed); + + let text = "escaped\\ spaces here"; + let parsed = parse_started_strings(text).unwrap(); + assert_eq!(("", (vec!["escaped spaces".into(), "here".into()], "here")), parsed); + } + } } /// Tab completion for user IDs. @@ -405,7 +456,7 @@ fn complete_cmdarg( let orig_cursor = cursor.clone(); *cursor = new_cursor; - let completions = match cmd.name.as_str() { + let mut completions = match cmd.name.as_str() { "invite" => complete_iamb_invite(args, store), "keys" => complete_iamb_keys(args, input, orig_cursor, cursor), @@ -443,8 +494,14 @@ fn complete_cmdarg( "space" => complete_iamb_space(args, store), "upload" | "download" | "open" => { - *cursor = orig_cursor; - complete_path(input, cursor) + if input.get_char_at_cursor(cursor) == Some('"') { + // Use the escaped instead of the qouted filename. + let mut args = args; + vec![args.pop().unwrap()] + } else { + *cursor = orig_cursor; + return complete_path(input, cursor); + } }, "logout" => complete_iamb_logout(args, store), @@ -458,11 +515,22 @@ fn complete_cmdarg( #[cfg(test)] panic!("trying to complete unknown subcommand `{}`", _cmd); + #[cfg(not(test))] vec![] }, }; - // TODO: escape stuff including with paths + completions.iter_mut().for_each(|completion| { + if completion.contains(['\\', ' ', '#', '%', '"', '|']) { + *completion = completion + .replace('\\', "\\\\") + .replace(' ', "\\ ") + .replace('#', "\\#") + .replace('%', "\\%") + .replace('"', "\\\"") + .replace('|', "\\|"); + } + }); completions }