From f4c9a73b30f6c7dd065c228059efbc15871ffa96 Mon Sep 17 00:00:00 2001 From: jtroo Date: Wed, 13 Nov 2024 21:08:14 -0800 Subject: [PATCH] feat: macro-cancel-on-press (#1345) --- cfg_samples/kanata.kbd | 6 +- docs/config.adoc | 58 ++++++++-- parser/src/cfg/list_actions.rs | 10 ++ parser/src/cfg/mod.rs | 62 +++++++++++ parser/src/custom_action.rs | 1 + src/kanata/mod.rs | 32 +++++- src/tests/sim_tests/macro_sim_tests.rs | 145 +++++++++++++++++++++++++ src/tests/sim_tests/mod.rs | 1 + 8 files changed, 302 insertions(+), 13 deletions(-) create mode 100644 src/tests/sim_tests/macro_sim_tests.rs diff --git a/cfg_samples/kanata.kbd b/cfg_samples/kanata.kbd index b13c54b90..b75279e05 100644 --- a/cfg_samples/kanata.kbd +++ b/cfg_samples/kanata.kbd @@ -603,12 +603,16 @@ If you need help, please feel welcome to ask in the GitHub discussions. tbm (macro A-(tab 200 tab 200 tab) 200 S-A-(tab 200 tab 200 tab)) hpy (macro S-i spc a m spc S-(h a p p y) spc m y S-f r S-i e S-n d @🙃) - rls (macro-release-cancel 1 500 bspc S-1 500 bspc S-2) + rls (macro-release-cancel Digit1 500 bspc S-1 500 bspc S-2) + cop (macro-cancel-on-press Digit1 500 bspc S-1 500 bspc S-2) + rlpr (macro-release-cancel-and-cancel-on-press Digit1 500 bspc S-1 500 bspc S-2) ;; repeat variants will repeat while held, once ALL macros have ended, ;; including the held macro. mr1 (macro-repeat mltp) mr2 (macro-repeat-release-cancel mltp) + mr3 (macro-repeat-cancel-on-press mltp) + mr4 (macro-repeat-release-cancel-and-cancel-on-press mltp) ;; Kanata also supports dynamic macros. Dynamic macros can be nested, but ;; cannot recurse. diff --git a/docs/config.adoc b/docs/config.adoc index 17f96dc95..916bf9b88 100644 --- a/docs/config.adoc +++ b/docs/config.adoc @@ -1421,11 +1421,17 @@ needs the delay of `5` to work correctly. ) ---- -There is a variant of the `+macro+` action that will cancel all active macros -upon releasing the key: `+macro-release-cancel+` or `+macro↑⤫+`. It is parsed identically to -the non-cancelling version. An example use case for this action is holding down -a key to get different outputs, similar to tap-dance but one can see which keys -are being outputted. +[[macro-release-cancel]] +==== macro-release-cancel + +The `macro-release-cancel` variant of the `+macro+` action +will cancel all active macros +upon releasing the key. +Shorter unicode variant: `+macro↑⤫+`. +This variant is parsed identically to the non-cancelling version. +An example use case for this action is holding down +a key to get different outputs, +similar to tap-dance but one can see which keys are being outputted. E.g. in the example below, when holding the key, first `1` is typed, then replaced by `!` after 500ms, and finally that is replaced by `@` after another @@ -1443,7 +1449,43 @@ and the rest of the macro does not run. ) ---- -There are further variants of the two `macro` actions which repeat while held. +[[macro-cancel-on-press]] +==== macro-cancel-on-press + +The `macro-cancel-on-press` variant of the `macro action` +enables a cancellation trigger for all active macros including itself, +which is activated when a physical press of any other key happens. +The trigger is enabled while the macro is in progress. + +[source] +---- +(defalias + 1 1 + 1!@ (macro-cancel-on-press @1 500 bspc S-1 500 bspc S-2) +) +---- + +[[macro-release-cancel-and-cancel-on-press]] +==== macro-release-cancel-and-cancel-on-press + +The `macro-release-cancel-and-cancel-on-press` variant +combines the cancel behaviours +of both the release-cancel and cancel-on-press. + +[source] +---- +(defalias + 1 1 + 1!@ (macro-release-cancel-and-cancel-on-press @1 500 bspc S-1 500 bspc S-2) +) +---- + + +[[macro-repeat]] +==== macro-repeat + +There are further `macro-repeat` variants of the three `macro` actions described previously. +These variants repeat while held. The repeat will only occur once all macros have completed, including the held macro key. If multiple repeating macros are being held simulaneously, @@ -1453,9 +1495,9 @@ only the most recently pressed macro will be repeated. ---- (defalias mr1 (macro-repeat mltp) - mr2 (macro⟳ mltp) mr2 (macro-repeat-release-cancel mltp) - mr2 (macro⟳↑⤫ mltp) + mr3 (macro-repeat-cancel-on-press mltp) + mr4 (macro-repeat-release-cancel-and-cancel-on-press mltp) ) ---- diff --git a/parser/src/cfg/list_actions.rs b/parser/src/cfg/list_actions.rs index a60b9ad1b..e8103af45 100644 --- a/parser/src/cfg/list_actions.rs +++ b/parser/src/cfg/list_actions.rs @@ -26,6 +26,12 @@ pub const MACRO_RELEASE_CANCEL: &str = "macro-release-cancel"; pub const MACRO_RELEASE_CANCEL_A: &str = "macro↑⤫"; pub const MACRO_REPEAT_RELEASE_CANCEL: &str = "macro-repeat-release-cancel"; pub const MACRO_REPEAT_RELEASE_CANCEL_A: &str = "macro⟳↑⤫"; +pub const MACRO_CANCEL_ON_NEXT_PRESS: &str = "macro-cancel-on-press"; +pub const MACRO_REPEAT_CANCEL_ON_NEXT_PRESS: &str = "macro-repeat-cancel-on-press"; +pub const MACRO_CANCEL_ON_NEXT_PRESS_CANCEL_ON_RELEASE: &str = + "macro-release-cancel-and-cancel-on-press"; +pub const MACRO_REPEAT_CANCEL_ON_NEXT_PRESS_CANCEL_ON_RELEASE: &str = + "macro-repeat-release-cancel-and-cancel-on-press"; pub const UNICODE: &str = "unicode"; pub const SYM: &str = "🔣"; pub const ONE_SHOT: &str = "one-shot"; @@ -221,6 +227,10 @@ pub fn is_list_action(ac: &str) -> bool { ON_RELEASE, ON_RELEASE_A, ON_IDLE, + MACRO_CANCEL_ON_NEXT_PRESS, + MACRO_REPEAT_CANCEL_ON_NEXT_PRESS, + MACRO_CANCEL_ON_NEXT_PRESS_CANCEL_ON_RELEASE, + MACRO_REPEAT_CANCEL_ON_NEXT_PRESS_CANCEL_ON_RELEASE, ]; LIST_ACTIONS.contains(&ac) } diff --git a/parser/src/cfg/mod.rs b/parser/src/cfg/mod.rs index 62ef9753d..d53c7db32 100755 --- a/parser/src/cfg/mod.rs +++ b/parser/src/cfg/mod.rs @@ -1744,6 +1744,18 @@ fn parse_action_list(ac: &[SExpr], s: &ParserState) -> Result<&'static KanataAct MACRO_REPEAT_RELEASE_CANCEL | MACRO_REPEAT_RELEASE_CANCEL_A => { parse_macro_release_cancel(&ac[1..], s, RepeatMacro::Yes) } + MACRO_CANCEL_ON_NEXT_PRESS => { + parse_macro_cancel_on_next_press(&ac[1..], s, RepeatMacro::No) + } + MACRO_REPEAT_CANCEL_ON_NEXT_PRESS => { + parse_macro_cancel_on_next_press(&ac[1..], s, RepeatMacro::Yes) + } + MACRO_CANCEL_ON_NEXT_PRESS_CANCEL_ON_RELEASE => { + parse_macro_cancel_on_next_press_cancel_on_release(&ac[1..], s, RepeatMacro::No) + } + MACRO_REPEAT_CANCEL_ON_NEXT_PRESS_CANCEL_ON_RELEASE => { + parse_macro_cancel_on_next_press_cancel_on_release(&ac[1..], s, RepeatMacro::Yes) + } UNICODE | SYM => parse_unicode(&ac[1..], s), ONE_SHOT | ONE_SHOT_PRESS | ONE_SHOT_PRESS_A => { parse_one_shot(&ac[1..], s, OneShotEndConfig::EndOnFirstPress) @@ -2130,6 +2142,56 @@ fn parse_macro_release_cancel( ]))))) } +fn parse_macro_cancel_on_next_press( + ac_params: &[SExpr], + s: &ParserState, + repeat: RepeatMacro, +) -> Result<&'static KanataAction> { + let macro_action = parse_macro(ac_params, s, repeat)?; + let macro_duration = match macro_action { + Action::RepeatableSequence { events } | Action::Sequence { events } => { + macro_sequence_event_total_duration(events) + } + _ => unreachable!("parse_macro should return sequence action"), + }; + Ok(s.a.sref(Action::MultipleActions(s.a.sref(s.a.sref_vec(vec![ + *macro_action, + Action::Custom( + s.a.sref(s.a.sref_slice(CustomAction::CancelMacroOnNextPress(macro_duration))), + ), + ]))))) +} + +fn parse_macro_cancel_on_next_press_cancel_on_release( + ac_params: &[SExpr], + s: &ParserState, + repeat: RepeatMacro, +) -> Result<&'static KanataAction> { + let macro_action = parse_macro(ac_params, s, repeat)?; + let macro_duration = match macro_action { + Action::RepeatableSequence { events } | Action::Sequence { events } => { + macro_sequence_event_total_duration(events) + } + _ => unreachable!("parse_macro should return sequence action"), + }; + Ok(s.a.sref(Action::MultipleActions(s.a.sref(s.a.sref_vec(vec![ + *macro_action, + Action::Custom(s.a.sref(s.a.sref_vec(vec![ + &CustomAction::CancelMacroOnRelease, + s.a.sref(CustomAction::CancelMacroOnNextPress(macro_duration)), + ]))), + ]))))) +} + +fn macro_sequence_event_total_duration(events: &[SequenceEvent]) -> u32 { + events.iter().fold(0, |duration, event| { + duration.saturating_add(match event { + SequenceEvent::Delay { duration: d } => *d, + _ => 1, + }) + }) +} + #[derive(PartialEq)] enum MacroNumberParseMode { Delay, diff --git a/parser/src/custom_action.rs b/parser/src/custom_action.rs index ac68dfd21..7dc4c0739 100644 --- a/parser/src/custom_action.rs +++ b/parser/src/custom_action.rs @@ -64,6 +64,7 @@ pub enum CustomAction { LiveReloadFile(String), Repeat, CancelMacroOnRelease, + CancelMacroOnNextPress(u32), DynamicMacroRecord(u16), DynamicMacroRecordStop(u16), DynamicMacroPlay(u16), diff --git a/src/kanata/mod.rs b/src/kanata/mod.rs index ed3c0b7aa..977f1e32e 100755 --- a/src/kanata/mod.rs +++ b/src/kanata/mod.rs @@ -225,6 +225,9 @@ pub struct Kanata { /// Various GUI-related options. pub gui_opts: CfgOptionsGui, pub allow_hardware_repeat: bool, + /// When > 0, it means macros should be cancelled on the next press. + /// Upon cancelling this should be set to 0. + pub macro_on_press_cancel_duration: u32, } #[derive(PartialEq, Clone, Copy)] @@ -422,6 +425,7 @@ impl Kanata { #[cfg(all(target_os = "windows", feature = "gui"))] gui_opts: cfg.options.gui_opts, allow_hardware_repeat: cfg.options.allow_hardware_repeat, + macro_on_press_cancel_duration: 0, }) } @@ -551,6 +555,7 @@ impl Kanata { #[cfg(all(target_os = "windows", feature = "gui"))] gui_opts: cfg.options.gui_opts, allow_hardware_repeat: cfg.options.allow_hardware_repeat, + macro_on_press_cancel_duration: 0, }) } @@ -641,6 +646,7 @@ impl Kanata { let cur_layer = self.layout.bm().current_layer(); self.prev_layer = cur_layer; self.print_layer(cur_layer); + self.macro_on_press_cancel_duration = 0; #[cfg(not(target_os = "linux"))] { @@ -676,6 +682,15 @@ impl Kanata { ) { self.dynamic_macros.insert(macro_id, recorded_macro); } + if self.macro_on_press_cancel_duration > 0 { + log::debug!("cancelling all macros: other press"); + self.macro_on_press_cancel_duration = 0; + let layout = self.layout.bm(); + layout.active_sequences.clear(); + layout.states.retain(|s| { + !matches!(s, State::FakeKey { .. } | State::RepeatingSequence { .. }) + }); + } Event::Press(0, evc) } KeyValue::Release => { @@ -787,6 +802,7 @@ impl Kanata { self.handle_move_mouse()?; self.tick_sequence_state()?; self.tick_idle_timeout(); + self.macro_on_press_cancel_duration = self.macro_on_press_cancel_duration.saturating_sub(1); tick_record_state(&mut self.dynamic_macro_record_state); zippy_tick(self.caps_word.is_some()); self.prev_keys.clear(); @@ -1517,6 +1533,9 @@ impl Kanata { &self.dynamic_macros, ); } + CustomAction::CancelMacroOnNextPress(duration) => { + self.macro_on_press_cancel_duration = *duration; + } CustomAction::SendArbitraryCode(code) => { self.kbd_out.write_code(*code as u32, KeyValue::Press)?; } @@ -1629,11 +1648,15 @@ impl Kanata { pbtn } CustomAction::CancelMacroOnRelease => { - log::debug!("cancelling all macros"); + log::debug!("cancelling all macros: releasable macro"); layout.active_sequences.clear(); - layout - .states - .retain(|s| !matches!(s, State::FakeKey { .. })); + self.macro_on_press_cancel_duration = 0; + layout.states.retain(|s| { + !matches!( + s, + State::FakeKey { .. } | State::RepeatingSequence { .. } + ) + }); pbtn } CustomAction::SendArbitraryCode(code) => { @@ -2045,6 +2068,7 @@ impl Kanata { && self.scroll_state.is_none() && self.hscroll_state.is_none() && self.move_mouse_state_vertical.is_none() + && self.macro_on_press_cancel_duration == 0 && self.move_mouse_state_horizontal.is_none() && self.dynamic_macro_replay_state.is_none() && self.caps_word.is_none() diff --git a/src/tests/sim_tests/macro_sim_tests.rs b/src/tests/sim_tests/macro_sim_tests.rs new file mode 100644 index 000000000..fc6321775 --- /dev/null +++ b/src/tests/sim_tests/macro_sim_tests.rs @@ -0,0 +1,145 @@ +use super::*; + +#[test] +fn macro_cancel_on_press() { + let cfg = "\ +(defsrc a b c) +(deflayer base (macro-cancel-on-press z 100 y) (macro x 100 w) c)"; + test_on_press(cfg); + let cfg = "\ +(defsrc a b c) +(deflayer base (macro-repeat-cancel-on-press z 100 y 100) (macro x 100 w) c)"; + test_on_press(cfg); +} + +fn test_on_press(cfg: &str) { + // Cancellation should happen. + let result = simulate(cfg, "d:a t:50 d:c t:100").to_ascii(); + assert_eq!("t:1ms dn:Z t:1ms up:Z t:48ms dn:C", result); + // Macro should complete if allowed to. + let result = simulate(cfg, "d:a u:a t:150 d:c t:100").to_ascii(); + assert_eq!( + "t:1ms dn:Z t:1ms up:Z t:101ms dn:Y t:1ms up:Y t:46ms dn:C", + result + ); + // The window for macro cancellation should not persist to a new macro that is not cancellable. + let result = simulate(cfg, "d:a t:120 d:b t:20 d:c t:100").to_ascii(); + assert_eq!( + "t:1ms dn:Z t:1ms up:Z t:101ms dn:Y t:1ms up:Y \ + t:17ms dn:X t:1ms up:X t:18ms dn:C t:83ms dn:W t:1ms up:W", + result + ); + let result = simulate(cfg, "d:a t:10 d:c u:c t:10 d:b t:20 d:c t:100").to_ascii(); + assert_eq!( + "t:1ms dn:Z t:1ms up:Z t:8ms dn:C t:1ms up:C t:10ms \ + dn:X t:1ms up:X t:18ms dn:C t:83ms dn:W t:1ms up:W", + result + ); +} + +#[test] +fn macro_release_cancel_and_cancel_on_press() { + let cfg = "\ +(defsrc a b c) +(deflayer base (macro-release-cancel-and-cancel-on-press z 100 y 100) (macro x 100 w) c)"; + test_release_and_on_press(cfg); + let cfg = "\ +(defsrc a b c) +(deflayer base (macro-repeat-release-cancel-and-cancel-on-press z 100 y 100) (macro x 100 w) c)"; + test_release_and_on_press(cfg); +} + +fn test_release_and_on_press(cfg: &str) { + // Cancellation should happen for press. + let result = simulate(cfg, "d:a t:50 d:c t:100").to_ascii(); + assert_eq!("t:1ms dn:Z t:1ms up:Z t:48ms dn:C", result); + // Cancellation should happen for release + let result = simulate(cfg, "d:a u:a t:150 d:c t:100").to_ascii(); + assert_eq!("t:1ms dn:Z t:1ms up:Z t:148ms dn:C", result); + // Macro should complete if allowed to. + let result = simulate(cfg, "d:a t:150 d:c t:100").to_ascii(); + assert_eq!( + "t:1ms dn:Z t:1ms up:Z t:101ms dn:Y t:1ms up:Y t:46ms dn:C", + result + ); + // The window for macro cancellation should not persist to a new macro that is not cancellable. + let result = simulate(cfg, "d:a t:120 d:b t:20 d:c t:100").to_ascii(); + assert_eq!( + "t:1ms dn:Z t:1ms up:Z t:101ms dn:Y t:1ms up:Y \ + t:17ms dn:X t:1ms up:X t:18ms dn:C t:83ms dn:W t:1ms up:W", + result + ); + let result = simulate(cfg, "d:a t:10 d:c u:c t:10 d:b t:20 d:c t:100").to_ascii(); + assert_eq!( + "t:1ms dn:Z t:1ms up:Z t:8ms dn:C t:1ms up:C t:10ms \ + dn:X t:1ms up:X t:18ms dn:C t:83ms dn:W t:1ms up:W", + result + ); + let result = simulate(cfg, "d:a u:a t:10 t:10 d:b u:b t:20 d:c t:100").to_ascii(); + assert_eq!( + "t:1ms dn:Z t:1ms up:Z t:19ms \ + dn:X t:1ms up:X t:18ms dn:C t:83ms dn:W t:1ms up:W", + result + ); +} + +#[test] +fn macro_repeat() { + let cfg = "\ +(defsrc a b c d) +(deflayer base + (macro-repeat Digit1 50) + (macro-repeat-release-cancel Digit1 50) + (macro-repeat-cancel-on-press Digit1 50) + (macro-repeat-release-cancel-and-cancel-on-press Digit1 50))"; + let result = simulate(cfg, "d:a t:125 u:a").to_ascii(); + assert_eq!( + "t:1ms dn:Kb1 t:1ms up:Kb1 t:52ms dn:Kb1 t:1ms up:Kb1 t:52ms dn:Kb1 t:1ms up:Kb1", + result + ); + let result = simulate(cfg, "d:b t:125 u:b").to_ascii(); + assert_eq!( + "t:1ms dn:Kb1 t:1ms up:Kb1 t:52ms dn:Kb1 t:1ms up:Kb1 t:52ms dn:Kb1 t:1ms up:Kb1", + result + ); + let result = simulate(cfg, "d:c t:125 u:c").to_ascii(); + assert_eq!( + "t:1ms dn:Kb1 t:1ms up:Kb1 t:52ms dn:Kb1 t:1ms up:Kb1 t:52ms dn:Kb1 t:1ms up:Kb1", + result + ); + let result = simulate(cfg, "d:d t:125 u:d").to_ascii(); + assert_eq!( + "t:1ms dn:Kb1 t:1ms up:Kb1 t:52ms dn:Kb1 t:1ms up:Kb1 t:52ms dn:Kb1 t:1ms up:Kb1", + result + ); +} + +#[test] +fn macro_release_cancel() { + let cfg = "\ +(defsrc a b c) +(deflayer base (macro-release-cancel z 100 y 100) (macro x 100 w) c)"; + test_release(cfg); + let cfg = "\ +(defsrc a b c) +(deflayer base (macro-repeat-release-cancel z 100 y 100) (macro x 100 w) c)"; + test_release(cfg); +} + +fn test_release(cfg: &str) { + // Cancellation should not happen for press. + let result = simulate(cfg, "d:a t:50 d:c t:100").to_ascii(); + assert_eq!( + "t:1ms dn:Z t:1ms up:Z t:48ms dn:C t:53ms dn:Y t:1ms up:Y", + result + ); + // Cancellation should happen for release + let result = simulate(cfg, "d:a u:a t:150 d:c t:100").to_ascii(); + assert_eq!("t:1ms dn:Z t:1ms up:Z t:148ms dn:C", result); + // Macro should complete if allowed to. + let result = simulate(cfg, "d:a t:150 d:c t:20").to_ascii(); + assert_eq!( + "t:1ms dn:Z t:1ms up:Z t:101ms dn:Y t:1ms up:Y t:46ms dn:C", + result + ); +} diff --git a/src/tests/sim_tests/mod.rs b/src/tests/sim_tests/mod.rs index e39a8819e..f3bb66ef1 100644 --- a/src/tests/sim_tests/mod.rs +++ b/src/tests/sim_tests/mod.rs @@ -16,6 +16,7 @@ mod block_keys_tests; mod capsword_sim_tests; mod chord_sim_tests; mod layer_sim_tests; +mod macro_sim_tests; mod override_tests; mod release_sim_tests; mod repeat_sim_tests;