From 9ed9400b67ffee3191c342b817781cbb679e0d8d Mon Sep 17 00:00:00 2001 From: Ulyssa Date: Sat, 31 May 2025 09:29:49 -0700 Subject: [PATCH] Support automatically toggling room focus (#337) --- src/base.rs | 7 ++ src/commands.rs | 12 +-- src/windows/room/chat.rs | 175 ++++++++++++++++++++++++++++++++- src/windows/room/scrollback.rs | 26 +++-- 4 files changed, 203 insertions(+), 17 deletions(-) diff --git a/src/base.rs b/src/base.rs index b8b0978..2660948 100644 --- a/src/base.rs +++ b/src/base.rs @@ -1766,6 +1766,13 @@ impl RoomFocus { pub fn is_msgbar(&self) -> bool { matches!(self, RoomFocus::MessageBar) } + + pub fn toggle(&mut self) { + *self = match self { + RoomFocus::MessageBar => RoomFocus::Scrollback, + RoomFocus::Scrollback => RoomFocus::MessageBar, + }; + } } /// Identifiers used to track where a mark was placed. diff --git a/src/commands.rs b/src/commands.rs index fc111c8..2577bf4 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1141,18 +1141,18 @@ mod tests { let mut cmds = setup_commands(); let ctx = EditContext::default(); - let cmd = format!("room notify set mute"); - let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap(); + let cmd = "room notify set mute"; + let res = cmds.input_cmd(cmd, ctx.clone()).unwrap(); let act = RoomAction::Set(RoomField::NotificationMode, "mute".into()); assert_eq!(res, vec![(act.into(), ctx.clone())]); - let cmd = format!("room notify unset"); - let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap(); + let cmd = "room notify unset"; + let res = cmds.input_cmd(cmd, ctx.clone()).unwrap(); let act = RoomAction::Unset(RoomField::NotificationMode); assert_eq!(res, vec![(act.into(), ctx.clone())]); - let cmd = format!("room notify show"); - let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap(); + let cmd = "room notify show"; + let res = cmds.input_cmd(cmd, ctx.clone()).unwrap(); let act = RoomAction::Show(RoomField::NotificationMode); assert_eq!(res, vec![(act.into(), ctx.clone())]); } diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index 0b83df6..cb584f4 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -638,10 +638,7 @@ impl ChatState { } pub fn focus_toggle(&mut self) { - self.focus = match self.focus { - RoomFocus::Scrollback => RoomFocus::MessageBar, - RoomFocus::MessageBar => RoomFocus::Scrollback, - }; + self.focus.toggle(); } pub fn room(&self) -> &MatrixRoom { @@ -652,6 +649,14 @@ impl ChatState { &self.room_id } + pub fn auto_toggle_focus( + &mut self, + act: &EditorAction, + ctx: &ProgramContext, + ) -> Option { + auto_toggle_focus(&mut self.focus, act, ctx, &self.scrollback, &mut self.tbox) + } + pub fn typing_notice( &self, act: &EditorAction, @@ -754,8 +759,15 @@ impl Editable for ChatState { ctx: &ProgramContext, store: &mut ProgramStore, ) -> EditResult { + // Check whether we should automatically switch between the message bar + // or message scrollback, and use an adjusted action if we do so. + let adjusted = self.auto_toggle_focus(act, ctx); + let act = adjusted.as_ref().unwrap_or(act); + + // Send typing notice if needed. self.typing_notice(act, ctx, store); + // And now we can finally run the editor command. match delegate!(self, w => w.editor_command(act, ctx, store)) { res @ Ok(_) => res, Err(EditError::WrongBuffer(IambBufferId::Room(room_id, thread, focus))) @@ -993,3 +1005,158 @@ fn cmd(open_command: &Vec) -> Option { } None } + +pub fn auto_toggle_focus( + focus: &mut RoomFocus, + act: &EditorAction, + ctx: &ProgramContext, + scrollback: &ScrollbackState, + tbox: &mut TextBoxState, +) -> Option { + let is_insert = ctx.get_insert_style().is_some(); + + match (focus, act) { + (f @ RoomFocus::Scrollback, _) if is_insert => { + // Insert mode commands should switch focus. + f.toggle(); + None + }, + (f @ RoomFocus::Scrollback, EditorAction::InsertText(_)) => { + // Pasting or otherwise inserting text should switch. + f.toggle(); + None + }, + ( + f @ RoomFocus::Scrollback, + EditorAction::Edit( + op, + EditTarget::Motion(mov @ MoveType::Line(MoveDir1D::Next), count), + ), + ) if ctx.resolve(op).is_motion() => { + let count = ctx.resolve(count); + + if count > 0 && scrollback.is_latest() { + // Trying to move down a line when already at the end of room history should + // switch. + f.toggle(); + + // And decrement the count for the action. + let count = count.saturating_sub(1).into(); + let target = EditTarget::Motion(mov.clone(), count); + let dec = EditorAction::Edit(op.clone(), target); + + Some(dec) + } else { + None + } + }, + ( + f @ RoomFocus::MessageBar, + EditorAction::Edit( + op, + EditTarget::Motion(mov @ MoveType::Line(MoveDir1D::Previous), count), + ), + ) if !is_insert && ctx.resolve(op).is_motion() => { + let count = ctx.resolve(count); + + if count > 0 && tbox.get_cursor().y == 0 { + // Trying to move up a line when already at the top of the msgbar should + // switch as long as we're not in Insert mode. + f.toggle(); + + // And decrement the count for the action. + let count = count.saturating_sub(1).into(); + let target = EditTarget::Motion(mov.clone(), count); + let dec = EditorAction::Edit(op.clone(), target); + + Some(dec) + } else { + None + } + }, + (RoomFocus::Scrollback, _) | (RoomFocus::MessageBar, _) => { + // Do not switch. + None + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use modalkit::actions::{EditAction, InsertTextAction}; + + use crate::tests::{mock_store, TEST_ROOM1_ID}; + + macro_rules! move_line { + ($dir: expr, $count: expr) => { + EditorAction::Edit( + EditAction::Motion.into(), + EditTarget::Motion(MoveType::Line($dir), $count.into()), + ) + }; + } + + #[tokio::test] + async fn test_auto_focus() { + let mut store = mock_store().await; + let ctx = ProgramContext::default(); + + let room_id = TEST_ROOM1_ID.clone(); + let scrollback = ScrollbackState::new(room_id.clone(), None); + + let id = IambBufferId::Room(room_id, None, RoomFocus::MessageBar); + let ebuf = store.load_buffer(id); + let mut tbox = TextBoxState::new(ebuf); + + // Start out focused on the scrollback. + let mut focused = RoomFocus::Scrollback; + + // Inserting text toggles: + let act = EditorAction::InsertText(InsertTextAction::Type( + Char::from('a').into(), + MoveDir1D::Next, + 1.into(), + )); + let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox); + assert_eq!(focused, RoomFocus::MessageBar); + assert!(res.is_none()); + + // Going down in message bar doesn't toggle: + let act = move_line!(MoveDir1D::Next, 1); + let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox); + assert_eq!(focused, RoomFocus::MessageBar); + assert!(res.is_none()); + + // But going up will: + let act = move_line!(MoveDir1D::Previous, 1); + let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox); + assert_eq!(focused, RoomFocus::Scrollback); + assert_eq!(res, Some(move_line!(MoveDir1D::Previous, 0))); + + // Going up in scrollback doesn't toggle: + let act = move_line!(MoveDir1D::Previous, 1); + let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox); + assert_eq!(focused, RoomFocus::Scrollback); + assert_eq!(res, None); + + // And then go back down: + let act = move_line!(MoveDir1D::Next, 1); + let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox); + assert_eq!(focused, RoomFocus::MessageBar); + assert_eq!(res, Some(move_line!(MoveDir1D::Next, 0))); + + // Go up 2 will go up 1 in scrollback: + let act = move_line!(MoveDir1D::Previous, 2); + let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox); + assert_eq!(focused, RoomFocus::Scrollback); + assert_eq!(res, Some(move_line!(MoveDir1D::Previous, 1))); + + // Go down 3 will go down 2 in messagebar: + let act = move_line!(MoveDir1D::Next, 3); + let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox); + assert_eq!(focused, RoomFocus::MessageBar); + assert_eq!(res, Some(move_line!(MoveDir1D::Next, 2))); + } +} diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs index 08ad84d..1566931 100644 --- a/src/windows/room/scrollback.rs +++ b/src/windows/room/scrollback.rs @@ -79,14 +79,20 @@ fn nth_key_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey { } fn nth_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor { - nth_key_before(pos, n, thread).into() + let key = nth_key_before(pos, n, thread); + + if matches!(thread.last_key_value(), Some((last, _)) if &key == last) { + MessageCursor::latest() + } else { + MessageCursor::from(key) + } } -fn nth_key_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey { +fn nth_key_after(pos: MessageKey, n: usize, thread: &Messages) -> Option { let mut end = &pos; - let iter = thread.range(&pos..).enumerate(); + let mut iter = thread.range(&pos..).enumerate(); - for (i, (key, _)) in iter { + for (i, (key, _)) in iter.by_ref() { end = key; if i >= n { @@ -94,11 +100,12 @@ fn nth_key_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey { } } - end.clone() + // Avoid returning the key if it's at the end. + iter.next().map(|_| end.clone()) } fn nth_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor { - nth_key_after(pos, n, thread).into() + nth_key_after(pos, n, thread).map(MessageCursor::from).unwrap_or_default() } fn prevmsg<'a>(key: &MessageKey, thread: &'a Messages) -> Option<&'a Message> { @@ -150,6 +157,10 @@ impl ScrollbackState { } } + pub fn is_latest(&self) -> bool { + self.cursor.timestamp.is_none() + } + pub fn goto_latest(&mut self) { self.cursor = MessageCursor::latest(); } @@ -1524,8 +1535,9 @@ mod tests { scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap(); assert_eq!(scrollback.cursor, MSG5_KEY.clone().into()); + // And one more becomes "latest" cursor: scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap(); - assert_eq!(scrollback.cursor, MSG1_KEY.clone().into()); + assert_eq!(scrollback.cursor, MessageCursor::latest()); } #[tokio::test]