mirror of https://github.com/helix-editor/helix
Macros (#1234)
* Macros WIP `helix_term::compositor::Callback` changed to take a `&mut Context` as a parameter for use by `play_macro` * Default to `@` register for macros * Import `KeyEvent` * Special-case shift-tab -> backtab in `KeyEvent` conversion * Move key recording to the compositor * Add comment * Add persistent display of macro recording status When macro recording is active, the pending keys display will be shifted 3 characters left, and the register being recorded to will be displayed between brackets — e.g., `[@]` — right of the pending keys display. * Fix/add documentationpull/1255/head
parent
3156577fbf
commit
e91d357fae
|
@ -77,6 +77,8 @@
|
||||||
| `Alt-c` | Change selection (delete and enter insert mode, without yanking) | `change_selection_noyank` |
|
| `Alt-c` | Change selection (delete and enter insert mode, without yanking) | `change_selection_noyank` |
|
||||||
| `Ctrl-a` | Increment object (number) under cursor | `increment` |
|
| `Ctrl-a` | Increment object (number) under cursor | `increment` |
|
||||||
| `Ctrl-x` | Decrement object (number) under cursor | `decrement` |
|
| `Ctrl-x` | Decrement object (number) under cursor | `decrement` |
|
||||||
|
| `q` | Start/stop macro recording to the selected register | `record_macro` |
|
||||||
|
| `Q` | Play back a recorded macro from the selected register | `play_macro` |
|
||||||
|
|
||||||
#### Shell
|
#### Shell
|
||||||
|
|
||||||
|
|
|
@ -70,7 +70,7 @@ pub struct Context<'a> {
|
||||||
impl<'a> Context<'a> {
|
impl<'a> Context<'a> {
|
||||||
/// Push a new component onto the compositor.
|
/// Push a new component onto the compositor.
|
||||||
pub fn push_layer(&mut self, component: Box<dyn Component>) {
|
pub fn push_layer(&mut self, component: Box<dyn Component>) {
|
||||||
self.callback = Some(Box::new(|compositor: &mut Compositor| {
|
self.callback = Some(Box::new(|compositor: &mut Compositor, _| {
|
||||||
compositor.push(component)
|
compositor.push(component)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@ -395,6 +395,8 @@ impl MappableCommand {
|
||||||
rename_symbol, "Rename symbol",
|
rename_symbol, "Rename symbol",
|
||||||
increment, "Increment",
|
increment, "Increment",
|
||||||
decrement, "Decrement",
|
decrement, "Decrement",
|
||||||
|
record_macro, "Record macro",
|
||||||
|
play_macro, "Play macro",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3441,7 +3443,7 @@ fn apply_workspace_edit(
|
||||||
|
|
||||||
fn last_picker(cx: &mut Context) {
|
fn last_picker(cx: &mut Context) {
|
||||||
// TODO: last picker does not seem to work well with buffer_picker
|
// TODO: last picker does not seem to work well with buffer_picker
|
||||||
cx.callback = Some(Box::new(|compositor: &mut Compositor| {
|
cx.callback = Some(Box::new(|compositor: &mut Compositor, _| {
|
||||||
if let Some(picker) = compositor.last_picker.take() {
|
if let Some(picker) = compositor.last_picker.take() {
|
||||||
compositor.push(picker);
|
compositor.push(picker);
|
||||||
}
|
}
|
||||||
|
@ -5870,3 +5872,56 @@ fn increment_impl(cx: &mut Context, amount: i64) {
|
||||||
doc.append_changes_to_history(view.id);
|
doc.append_changes_to_history(view.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record_macro(cx: &mut Context) {
|
||||||
|
if let Some((reg, mut keys)) = cx.editor.macro_recording.take() {
|
||||||
|
// Remove the keypress which ends the recording
|
||||||
|
keys.pop();
|
||||||
|
let s = keys
|
||||||
|
.into_iter()
|
||||||
|
.map(|key| format!("{}", key))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
cx.editor.registers.get_mut(reg).write(vec![s]);
|
||||||
|
cx.editor
|
||||||
|
.set_status(format!("Recorded to register {}", reg));
|
||||||
|
} else {
|
||||||
|
let reg = cx.register.take().unwrap_or('@');
|
||||||
|
cx.editor.macro_recording = Some((reg, Vec::new()));
|
||||||
|
cx.editor
|
||||||
|
.set_status(format!("Recording to register {}", reg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_macro(cx: &mut Context) {
|
||||||
|
let reg = cx.register.unwrap_or('@');
|
||||||
|
let keys = match cx
|
||||||
|
.editor
|
||||||
|
.registers
|
||||||
|
.get(reg)
|
||||||
|
.and_then(|reg| reg.read().get(0))
|
||||||
|
.context("Register empty")
|
||||||
|
.and_then(|s| {
|
||||||
|
s.split_whitespace()
|
||||||
|
.map(str::parse::<KeyEvent>)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.context("Failed to parse macro")
|
||||||
|
}) {
|
||||||
|
Ok(keys) => keys,
|
||||||
|
Err(e) => {
|
||||||
|
cx.editor.set_error(format!("{}", e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let count = cx.count();
|
||||||
|
|
||||||
|
cx.callback = Some(Box::new(
|
||||||
|
move |compositor: &mut Compositor, cx: &mut compositor::Context| {
|
||||||
|
for _ in 0..count {
|
||||||
|
for &key in keys.iter() {
|
||||||
|
compositor.handle_event(crossterm::event::Event::Key(key.into()), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ use helix_view::graphics::{CursorKind, Rect};
|
||||||
use crossterm::event::Event;
|
use crossterm::event::Event;
|
||||||
use tui::buffer::Buffer as Surface;
|
use tui::buffer::Buffer as Surface;
|
||||||
|
|
||||||
pub type Callback = Box<dyn FnOnce(&mut Compositor)>;
|
pub type Callback = Box<dyn FnOnce(&mut Compositor, &mut Context)>;
|
||||||
|
|
||||||
// --> EventResult should have a callback that takes a context with methods like .popup(),
|
// --> EventResult should have a callback that takes a context with methods like .popup(),
|
||||||
// .prompt() etc. That way we can abstract it from the renderer.
|
// .prompt() etc. That way we can abstract it from the renderer.
|
||||||
|
@ -131,12 +131,17 @@ impl Compositor {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_event(&mut self, event: Event, cx: &mut Context) -> bool {
|
pub fn handle_event(&mut self, event: Event, cx: &mut Context) -> bool {
|
||||||
|
// If it is a key event and a macro is being recorded, push the key event to the recording.
|
||||||
|
if let (Event::Key(key), Some((_, keys))) = (event, &mut cx.editor.macro_recording) {
|
||||||
|
keys.push(key.into());
|
||||||
|
}
|
||||||
|
|
||||||
// propagate events through the layers until we either find a layer that consumes it or we
|
// propagate events through the layers until we either find a layer that consumes it or we
|
||||||
// run out of layers (event bubbling)
|
// run out of layers (event bubbling)
|
||||||
for layer in self.layers.iter_mut().rev() {
|
for layer in self.layers.iter_mut().rev() {
|
||||||
match layer.handle_event(event, cx) {
|
match layer.handle_event(event, cx) {
|
||||||
EventResult::Consumed(Some(callback)) => {
|
EventResult::Consumed(Some(callback)) => {
|
||||||
callback(self);
|
callback(self, cx);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
EventResult::Consumed(None) => return true,
|
EventResult::Consumed(None) => return true,
|
||||||
|
|
|
@ -593,6 +593,9 @@ impl Default for Keymaps {
|
||||||
// paste_all
|
// paste_all
|
||||||
"P" => paste_before,
|
"P" => paste_before,
|
||||||
|
|
||||||
|
"q" => record_macro,
|
||||||
|
"Q" => play_macro,
|
||||||
|
|
||||||
">" => indent,
|
">" => indent,
|
||||||
"<" => unindent,
|
"<" => unindent,
|
||||||
"=" => format_selections,
|
"=" => format_selections,
|
||||||
|
|
|
@ -1100,13 +1100,31 @@ impl Component for EditorView {
|
||||||
disp.push_str(&s);
|
disp.push_str(&s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let style = cx.editor.theme.get("ui.text");
|
||||||
|
let macro_width = if cx.editor.macro_recording.is_some() {
|
||||||
|
3
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
surface.set_string(
|
surface.set_string(
|
||||||
area.x + area.width.saturating_sub(key_width),
|
area.x + area.width.saturating_sub(key_width + macro_width),
|
||||||
area.y + area.height.saturating_sub(1),
|
area.y + area.height.saturating_sub(1),
|
||||||
disp.get(disp.len().saturating_sub(key_width as usize)..)
|
disp.get(disp.len().saturating_sub(key_width as usize)..)
|
||||||
.unwrap_or(&disp),
|
.unwrap_or(&disp),
|
||||||
cx.editor.theme.get("ui.text"),
|
style,
|
||||||
);
|
);
|
||||||
|
if let Some((reg, _)) = cx.editor.macro_recording {
|
||||||
|
let disp = format!("[{}]", reg);
|
||||||
|
let style = style
|
||||||
|
.fg(helix_view::graphics::Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
surface.set_string(
|
||||||
|
area.x + area.width.saturating_sub(3),
|
||||||
|
area.y + area.height.saturating_sub(1),
|
||||||
|
&disp,
|
||||||
|
style,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(completion) = self.completion.as_mut() {
|
if let Some(completion) = self.completion.as_mut() {
|
||||||
|
|
|
@ -190,7 +190,7 @@ impl<T: Item + 'static> Component for Menu<T> {
|
||||||
_ => return EventResult::Ignored,
|
_ => return EventResult::Ignored,
|
||||||
};
|
};
|
||||||
|
|
||||||
let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| {
|
let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| {
|
||||||
// remove the layer
|
// remove the layer
|
||||||
compositor.pop();
|
compositor.pop();
|
||||||
})));
|
})));
|
||||||
|
|
|
@ -404,7 +404,7 @@ impl<T: 'static> Component for Picker<T> {
|
||||||
_ => return EventResult::Ignored,
|
_ => return EventResult::Ignored,
|
||||||
};
|
};
|
||||||
|
|
||||||
let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| {
|
let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| {
|
||||||
// remove the layer
|
// remove the layer
|
||||||
compositor.last_picker = compositor.pop();
|
compositor.last_picker = compositor.pop();
|
||||||
})));
|
})));
|
||||||
|
|
|
@ -100,7 +100,7 @@ impl<T: Component> Component for Popup<T> {
|
||||||
_ => return EventResult::Ignored,
|
_ => return EventResult::Ignored,
|
||||||
};
|
};
|
||||||
|
|
||||||
let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| {
|
let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| {
|
||||||
// remove the layer
|
// remove the layer
|
||||||
compositor.pop();
|
compositor.pop();
|
||||||
})));
|
})));
|
||||||
|
|
|
@ -426,7 +426,7 @@ impl Component for Prompt {
|
||||||
_ => return EventResult::Ignored,
|
_ => return EventResult::Ignored,
|
||||||
};
|
};
|
||||||
|
|
||||||
let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| {
|
let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| {
|
||||||
// remove the layer
|
// remove the layer
|
||||||
compositor.pop();
|
compositor.pop();
|
||||||
})));
|
})));
|
||||||
|
|
|
@ -2,6 +2,7 @@ use crate::{
|
||||||
clipboard::{get_clipboard_provider, ClipboardProvider},
|
clipboard::{get_clipboard_provider, ClipboardProvider},
|
||||||
document::SCRATCH_BUFFER_NAME,
|
document::SCRATCH_BUFFER_NAME,
|
||||||
graphics::{CursorKind, Rect},
|
graphics::{CursorKind, Rect},
|
||||||
|
input::KeyEvent,
|
||||||
theme::{self, Theme},
|
theme::{self, Theme},
|
||||||
tree::{self, Tree},
|
tree::{self, Tree},
|
||||||
Document, DocumentId, View, ViewId,
|
Document, DocumentId, View, ViewId,
|
||||||
|
@ -160,6 +161,7 @@ pub struct Editor {
|
||||||
pub count: Option<std::num::NonZeroUsize>,
|
pub count: Option<std::num::NonZeroUsize>,
|
||||||
pub selected_register: Option<char>,
|
pub selected_register: Option<char>,
|
||||||
pub registers: Registers,
|
pub registers: Registers,
|
||||||
|
pub macro_recording: Option<(char, Vec<KeyEvent>)>,
|
||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
pub language_servers: helix_lsp::Registry,
|
pub language_servers: helix_lsp::Registry,
|
||||||
pub clipboard_provider: Box<dyn ClipboardProvider>,
|
pub clipboard_provider: Box<dyn ClipboardProvider>,
|
||||||
|
@ -203,6 +205,7 @@ impl Editor {
|
||||||
documents: BTreeMap::new(),
|
documents: BTreeMap::new(),
|
||||||
count: None,
|
count: None,
|
||||||
selected_register: None,
|
selected_register: None,
|
||||||
|
macro_recording: None,
|
||||||
theme: theme_loader.default(),
|
theme: theme_loader.default(),
|
||||||
language_servers,
|
language_servers,
|
||||||
syn_loader,
|
syn_loader,
|
||||||
|
|
|
@ -234,6 +234,26 @@ impl From<crossterm::event::KeyEvent> for KeyEvent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "term")]
|
||||||
|
impl From<KeyEvent> for crossterm::event::KeyEvent {
|
||||||
|
fn from(KeyEvent { code, modifiers }: KeyEvent) -> Self {
|
||||||
|
if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) {
|
||||||
|
// special case for Shift-Tab -> BackTab
|
||||||
|
let mut modifiers = modifiers;
|
||||||
|
modifiers.remove(KeyModifiers::SHIFT);
|
||||||
|
crossterm::event::KeyEvent {
|
||||||
|
code: crossterm::event::KeyCode::BackTab,
|
||||||
|
modifiers: modifiers.into(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
crossterm::event::KeyEvent {
|
||||||
|
code: code.into(),
|
||||||
|
modifiers: modifiers.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
Loading…
Reference in New Issue