on master help sign in
Macros (#1234)
e91d357Omnikar
book/src/keymap.md+2
@@ -77,6 +77,8 @@
77| `Alt-c` | Change selection (delete and enter insert mode, without yanking) | `change_selection_noyank` |
77| `Alt-c` | Change selection (delete and enter insert mode, without yanking) | `change_selection_noyank` |
78| `Ctrl-a` | Increment object (number) under cursor | `increment` |
78| `Ctrl-a` | Increment object (number) under cursor | `increment` |
79| `Ctrl-x` | Decrement object (number) under cursor | `decrement` |
79| `Ctrl-x` | Decrement object (number) under cursor | `decrement` |
80+| `q` | Start/stop macro recording to the selected register | `record_macro` |
81+| `Q` | Play back a recorded macro from the selected register | `play_macro` |
80
82
81#### Shell
83#### Shell
82
84
helix-term/src/commands.rs+57−2
@@ -70,7 +70,7 @@ pub struct Context<'a> {
70impl<'a> Context<'a> {
70impl<'a> Context<'a> {
71 /// Push a new component onto the compositor.
71 /// Push a new component onto the compositor.
72 pub fn push_layer(&mut self, component: Box<dyn Component>) {
72 pub fn push_layer(&mut self, component: Box<dyn Component>) {
73− self.callback = Some(Box::new(|compositor: &mut Compositor| {
73+ self.callback = Some(Box::new(|compositor: &mut Compositor, _| {
74 compositor.push(component)
74 compositor.push(component)
75 }));
75 }));
76 }
76 }
@@ -395,6 +395,8 @@ impl MappableCommand {
395 rename_symbol, "Rename symbol",
395 rename_symbol, "Rename symbol",
396 increment, "Increment",
396 increment, "Increment",
397 decrement, "Decrement",
397 decrement, "Decrement",
398+ record_macro, "Record macro",
399+ play_macro, "Play macro",
398 );
400 );
399}
401}
400
402
@@ -3441,7 +3443,7 @@ fn apply_workspace_edit(
3441
3443
3442fn last_picker(cx: &mut Context) {
3444fn last_picker(cx: &mut Context) {
3443 // TODO: last picker does not seem to work well with buffer_picker
3445 // TODO: last picker does not seem to work well with buffer_picker
3444− cx.callback = Some(Box::new(|compositor: &mut Compositor| {
3446+ cx.callback = Some(Box::new(|compositor: &mut Compositor, _| {
3445 if let Some(picker) = compositor.last_picker.take() {
3447 if let Some(picker) = compositor.last_picker.take() {
3446 compositor.push(picker);
3448 compositor.push(picker);
3447 }
3449 }
@@ -5870,3 +5872,56 @@ fn increment_impl(cx: &mut Context, amount: i64) {
5870 doc.append_changes_to_history(view.id);
5872 doc.append_changes_to_history(view.id);
5871 }
5873 }
5872}
5874}
5875+
5876+fn record_macro(cx: &mut Context) {
5877+ if let Some((reg, mut keys)) = cx.editor.macro_recording.take() {
5878+ // Remove the keypress which ends the recording
5879+ keys.pop();
5880+ let s = keys
5881+ .into_iter()
5882+ .map(|key| format!("{}", key))
5883+ .collect::<Vec<_>>()
5884+ .join(" ");
5885+ cx.editor.registers.get_mut(reg).write(vec![s]);
5886+ cx.editor
5887+ .set_status(format!("Recorded to register {}", reg));
5888+ } else {
5889+ let reg = cx.register.take().unwrap_or('@');
5890+ cx.editor.macro_recording = Some((reg, Vec::new()));
5891+ cx.editor
5892+ .set_status(format!("Recording to register {}", reg));
5893+ }
5894+}
5895+
5896+fn play_macro(cx: &mut Context) {
5897+ let reg = cx.register.unwrap_or('@');
5898+ let keys = match cx
5899+ .editor
5900+ .registers
5901+ .get(reg)
5902+ .and_then(|reg| reg.read().get(0))
5903+ .context("Register empty")
5904+ .and_then(|s| {
5905+ s.split_whitespace()
5906+ .map(str::parse::<KeyEvent>)
5907+ .collect::<Result<Vec<_>, _>>()
5908+ .context("Failed to parse macro")
5909+ }) {
5910+ Ok(keys) => keys,
5911+ Err(e) => {
5912+ cx.editor.set_error(format!("{}", e));
5913+ return;
5914+ }
5915+ };
5916+ let count = cx.count();
5917+
5918+ cx.callback = Some(Box::new(
5919+ move |compositor: &mut Compositor, cx: &mut compositor::Context| {
5920+ for _ in 0..count {
5921+ for &key in keys.iter() {
5922+ compositor.handle_event(crossterm::event::Event::Key(key.into()), cx);
5923+ }
5924+ }
5925+ },
5926+ ));
5927+}
helix-term/src/compositor.rs+7−2
@@ -7,7 +7,7 @@ use helix_view::graphics::{CursorKind, Rect};
7use crossterm::event::Event;
7use crossterm::event::Event;
8use tui::buffer::Buffer as Surface;
8use tui::buffer::Buffer as Surface;
9
9
10−pub type Callback = Box<dyn FnOnce(&mut Compositor)>;
10+pub type Callback = Box<dyn FnOnce(&mut Compositor, &mut Context)>;
11
11
12// --> EventResult should have a callback that takes a context with methods like .popup(),
12// --> EventResult should have a callback that takes a context with methods like .popup(),
13// .prompt() etc. That way we can abstract it from the renderer.
13// .prompt() etc. That way we can abstract it from the renderer.
@@ -131,12 +131,17 @@ impl Compositor {
131 }
131 }
132
132
133 pub fn handle_event(&mut self, event: Event, cx: &mut Context) -> bool {
133 pub fn handle_event(&mut self, event: Event, cx: &mut Context) -> bool {
134+ // If it is a key event and a macro is being recorded, push the key event to the recording.
135+ if let (Event::Key(key), Some((_, keys))) = (event, &mut cx.editor.macro_recording) {
136+ keys.push(key.into());
137+ }
138+
134 // propagate events through the layers until we either find a layer that consumes it or we
139 // propagate events through the layers until we either find a layer that consumes it or we
135 // run out of layers (event bubbling)
140 // run out of layers (event bubbling)
136 for layer in self.layers.iter_mut().rev() {
141 for layer in self.layers.iter_mut().rev() {
137 match layer.handle_event(event, cx) {
142 match layer.handle_event(event, cx) {
138 EventResult::Consumed(Some(callback)) => {
143 EventResult::Consumed(Some(callback)) => {
139− callback(self);
144+ callback(self, cx);
140 return true;
145 return true;
141 }
146 }
142 EventResult::Consumed(None) => return true,
147 EventResult::Consumed(None) => return true,
helix-term/src/keymap.rs+3
@@ -593,6 +593,9 @@ impl Default for Keymaps {
593 // paste_all
593 // paste_all
594 "P" => paste_before,
594 "P" => paste_before,
595
595
596+ "q" => record_macro,
597+ "Q" => play_macro,
598+
596 ">" => indent,
599 ">" => indent,
597 "<" => unindent,
600 "<" => unindent,
598 "=" => format_selections,
601 "=" => format_selections,
helix-term/src/ui/editor.rs+20−2
@@ -1100,13 +1100,31 @@ impl Component for EditorView {
1100 disp.push_str(&s);
1100 disp.push_str(&s);
1101 }
1101 }
1102 }
1102 }
1103+ let style = cx.editor.theme.get("ui.text");
1104+ let macro_width = if cx.editor.macro_recording.is_some() {
1105+ 3
1106+ } else {
1107+ 0
1108+ };
1103 surface.set_string(
1109 surface.set_string(
1104− area.x + area.width.saturating_sub(key_width),
1110+ area.x + area.width.saturating_sub(key_width + macro_width),
1105 area.y + area.height.saturating_sub(1),
1111 area.y + area.height.saturating_sub(1),
1106 disp.get(disp.len().saturating_sub(key_width as usize)..)
1112 disp.get(disp.len().saturating_sub(key_width as usize)..)
1107 .unwrap_or(&disp),
1113 .unwrap_or(&disp),
1108− cx.editor.theme.get("ui.text"),
1114+ style,
1109 );
1115 );
1116+ if let Some((reg, _)) = cx.editor.macro_recording {
1117+ let disp = format!("[{}]", reg);
1118+ let style = style
1119+ .fg(helix_view::graphics::Color::Yellow)
1120+ .add_modifier(Modifier::BOLD);
1121+ surface.set_string(
1122+ area.x + area.width.saturating_sub(3),
1123+ area.y + area.height.saturating_sub(1),
1124+ &disp,
1125+ style,
1126+ );
1127+ }
1110 }
1128 }
1111
1129
1112 if let Some(completion) = self.completion.as_mut() {
1130 if let Some(completion) = self.completion.as_mut() {
helix-term/src/ui/menu.rs+1−1
@@ -190,7 +190,7 @@ impl<T: Item + 'static> Component for Menu<T> {
190 _ => return EventResult::Ignored,
190 _ => return EventResult::Ignored,
191 };
191 };
192
192
193− let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| {
193+ let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| {
194 // remove the layer
194 // remove the layer
195 compositor.pop();
195 compositor.pop();
196 })));
196 })));
helix-term/src/ui/picker.rs+1−1
@@ -404,7 +404,7 @@ impl<T: 'static> Component for Picker<T> {
404 _ => return EventResult::Ignored,
404 _ => return EventResult::Ignored,
405 };
405 };
406
406
407− let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| {
407+ let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| {
408 // remove the layer
408 // remove the layer
409 compositor.last_picker = compositor.pop();
409 compositor.last_picker = compositor.pop();
410 })));
410 })));
helix-term/src/ui/popup.rs+1−1
@@ -100,7 +100,7 @@ impl<T: Component> Component for Popup<T> {
100 _ => return EventResult::Ignored,
100 _ => return EventResult::Ignored,
101 };
101 };
102
102
103− let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| {
103+ let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| {
104 // remove the layer
104 // remove the layer
105 compositor.pop();
105 compositor.pop();
106 })));
106 })));
helix-term/src/ui/prompt.rs+1−1
@@ -426,7 +426,7 @@ impl Component for Prompt {
426 _ => return EventResult::Ignored,
426 _ => return EventResult::Ignored,
427 };
427 };
428
428
429− let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| {
429+ let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| {
430 // remove the layer
430 // remove the layer
431 compositor.pop();
431 compositor.pop();
432 })));
432 })));
helix-view/src/editor.rs+3
@@ -2,6 +2,7 @@ use crate::{
2 clipboard::{get_clipboard_provider, ClipboardProvider},
2 clipboard::{get_clipboard_provider, ClipboardProvider},
3 document::SCRATCH_BUFFER_NAME,
3 document::SCRATCH_BUFFER_NAME,
4 graphics::{CursorKind, Rect},
4 graphics::{CursorKind, Rect},
5+ input::KeyEvent,
5 theme::{self, Theme},
6 theme::{self, Theme},
6 tree::{self, Tree},
7 tree::{self, Tree},
7 Document, DocumentId, View, ViewId,
8 Document, DocumentId, View, ViewId,
@@ -160,6 +161,7 @@ pub struct Editor {
160 pub count: Option<std::num::NonZeroUsize>,
161 pub count: Option<std::num::NonZeroUsize>,
161 pub selected_register: Option<char>,
162 pub selected_register: Option<char>,
162 pub registers: Registers,
163 pub registers: Registers,
164+ pub macro_recording: Option<(char, Vec<KeyEvent>)>,
163 pub theme: Theme,
165 pub theme: Theme,
164 pub language_servers: helix_lsp::Registry,
166 pub language_servers: helix_lsp::Registry,
165 pub clipboard_provider: Box<dyn ClipboardProvider>,
167 pub clipboard_provider: Box<dyn ClipboardProvider>,
@@ -203,6 +205,7 @@ impl Editor {
203 documents: BTreeMap::new(),
205 documents: BTreeMap::new(),
204 count: None,
206 count: None,
205 selected_register: None,
207 selected_register: None,
208+ macro_recording: None,
206 theme: theme_loader.default(),
209 theme: theme_loader.default(),
207 language_servers,
210 language_servers,
208 syn_loader,
211 syn_loader,
helix-view/src/input.rs+20
@@ -234,6 +234,26 @@ impl From<crossterm::event::KeyEvent> for KeyEvent {
234 }
234 }
235}
235}
236
236
237+#[cfg(feature = "term")]
238+impl From<KeyEvent> for crossterm::event::KeyEvent {
239+ fn from(KeyEvent { code, modifiers }: KeyEvent) -> Self {
240+ if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) {
241+ // special case for Shift-Tab -> BackTab
242+ let mut modifiers = modifiers;
243+ modifiers.remove(KeyModifiers::SHIFT);
244+ crossterm::event::KeyEvent {
245+ code: crossterm::event::KeyCode::BackTab,
246+ modifiers: modifiers.into(),
247+ }
248+ } else {
249+ crossterm::event::KeyEvent {
250+ code: code.into(),
251+ modifiers: modifiers.into(),
252+ }
253+ }
254+ }
255+}
256+
237#[cfg(test)]
257#[cfg(test)]
238mod test {
258mod test {
239 use super::*;
259 use super::*;