diff --git a/crates/nu-command/src/default_context.rs b/crates/nu-command/src/default_context.rs index 50d01c709..6d9ae4114 100644 --- a/crates/nu-command/src/default_context.rs +++ b/crates/nu-command/src/default_context.rs @@ -238,6 +238,7 @@ pub fn add_shell_command_context(mut engine_state: EngineState) -> EngineState { Du, Input, InputList, + InputListen, Kill, Sleep, TermSize, diff --git a/crates/nu-command/src/platform/input/input_listen.rs b/crates/nu-command/src/platform/input/input_listen.rs new file mode 100644 index 000000000..5ffa6f54a --- /dev/null +++ b/crates/nu-command/src/platform/input/input_listen.rs @@ -0,0 +1,439 @@ +use crossterm::event::{ + DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, + EnableMouseCapture, KeyCode, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, +}; +use crossterm::terminal; +use nu_engine::CallExt; +use nu_protocol::ast::Call; +use nu_protocol::engine::{Command, EngineState, Stack}; +use nu_protocol::{ + Category, IntoPipelineData, PipelineData, ShellError, Signature, Span, SyntaxShape, Type, Value, +}; +use num_traits::AsPrimitive; +use std::io::stdout; + +#[derive(Clone)] +pub struct InputListen; + +impl Command for InputListen { + fn name(&self) -> &str { + "input listen" + } + + fn search_terms(&self) -> Vec<&str> { + vec!["prompt", "interactive", "keycode"] + } + + fn signature(&self) -> Signature { + Signature::build(self.name()) + .category(Category::Platform) + .named( + "types", + SyntaxShape::List(Box::new(SyntaxShape::String)), + "Listen for event of specified types only (can be one of: focus, key, mouse, paste, resize)", + Some('t'), + ) + .switch( + "raw", + "Add raw_code field with numeric value of keycode and raw_flags with bit mask flags", + Some('r'), + ) + .input_output_types(vec![( + Type::Nothing, + Type::Record(vec![ + ("keycode".to_string(), Type::String), + ("modifiers".to_string(), Type::List(Box::new(Type::String))), + ]), + )]) + } + + fn usage(&self) -> &str { + "Listen for user interface event" + } + + fn extra_usage(&self) -> &str { + r#"There are 5 different type of events: focus, key, mouse, paste, resize. Each will produce a +corresponding record, distinguished by type field: + { type: focus event: (gained|lost) } + { type: key key_type: code: modifiers: [ ... ] } + { type: mouse col: row: kind: modifiers: [ ... ] } + { type: paste content: } + { type: resize col: row: } +There are 6 variants: shift, control, alt, super, hyper, meta. +There are 4 variants: + f - f1, f2, f3 ... keys + char - alphanumeric and special symbols (a, A, 1, $ ...) + media - dedicated media keys (play, pause, tracknext ...) + other - keys not falling under previous categories (up, down, backspace, enter ...)"# + } + + fn run( + &self, + engine_state: &EngineState, + stack: &mut Stack, + call: &Call, + _input: PipelineData, + ) -> Result { + let head = call.head; + let event_type_filter = get_event_type_filter(engine_state, stack, call, head)?; + let add_raw = call.has_flag("raw"); + + terminal::enable_raw_mode()?; + let console_state = event_type_filter.enable_events()?; + loop { + let event = crossterm::event::read().map_err(|_| { + ShellError::GenericError( + "Error with user input".to_string(), + "".to_string(), + Some(head), + None, + Vec::new(), + ) + })?; + let event = parse_event(head, &event, &event_type_filter, add_raw); + if let Some(event) = event { + terminal::disable_raw_mode()?; + console_state.restore(); + return Ok(event.into_pipeline_data()); + } + } + } +} + +fn get_event_type_filter( + engine_state: &EngineState, + stack: &mut Stack, + call: &Call, + head: Span, +) -> Result { + let event_type_filter = call.get_flag::(engine_state, stack, "types")?; + let event_type_filter = event_type_filter + .map(|list| EventTypeFilter::from_value(list, head)) + .transpose()? + .unwrap_or_else(EventTypeFilter::all); + Ok(event_type_filter) +} + +#[derive(Clone)] +struct EventTypeFilter { + listen_focus: bool, + listen_key: bool, + listen_mouse: bool, + listen_paste: bool, + listen_resize: bool, +} + +impl EventTypeFilter { + fn none() -> EventTypeFilter { + EventTypeFilter { + listen_focus: false, + listen_key: false, + listen_mouse: false, + listen_paste: false, + listen_resize: false, + } + } + + fn all() -> EventTypeFilter { + EventTypeFilter { + listen_focus: true, + listen_key: true, + listen_mouse: true, + listen_paste: true, + listen_resize: true, + } + } + + fn from_value(value: Value, head: Span) -> Result { + if let Value::List { vals, .. } = value { + let mut filter = Self::none(); + for event_type in vals { + if let Value::String { val, span } = event_type { + match val.as_str() { + "focus" => filter.listen_focus = true, + "key" => filter.listen_key = true, + "mouse" => filter.listen_mouse = true, + "paste" => filter.listen_paste = true, + "resize" => filter.listen_resize = true, + _ => return Err(Self::wrong_type_error(head, val.as_str(), span)), + } + } else { + return Err(Self::bad_list_error(head, &event_type)); + } + } + Ok(filter) + } else { + Err(Self::bad_list_error(head, &value)) + } + } + + fn wrong_type_error(head: Span, val: &str, val_span: Span) -> ShellError { + ShellError::UnsupportedInput( + format!("{} is not a valid event type", val), + "value originates from here".into(), + head, + val_span, + ) + } + + fn bad_list_error(head: Span, value: &Value) -> ShellError { + ShellError::UnsupportedInput( + "--types expects a list of strings".to_string(), + "value originates from here".into(), + head, + value.span().unwrap_or(head), + ) + } + + /// Enable capturing of all events allowed by this filter. + /// Call [`DeferredConsoleRestore::restore`] when done capturing events to restore + /// console state + fn enable_events(&self) -> Result { + if self.listen_mouse { + crossterm::execute!(stdout(), EnableMouseCapture)?; + } + + if self.listen_paste { + crossterm::execute!(stdout(), EnableBracketedPaste)?; + } + + if self.listen_focus { + crossterm::execute!(stdout(), crossterm::event::EnableFocusChange)?; + } + + Ok(DeferredConsoleRestore { + setup_event_types: self.clone(), + }) + } +} + +/// Promise to disable all event capturing previously enabled by [`EventTypeFilter::enable_events`] +struct DeferredConsoleRestore { + setup_event_types: EventTypeFilter, +} + +impl DeferredConsoleRestore { + /// Disable all event capturing flags set up by [`EventTypeFilter::enable_events`] + fn restore(self) { + if self.setup_event_types.listen_mouse { + let _ = crossterm::execute!(stdout(), DisableMouseCapture); + } + + if self.setup_event_types.listen_paste { + let _ = crossterm::execute!(stdout(), DisableBracketedPaste); + } + + if self.setup_event_types.listen_focus { + let _ = crossterm::execute!(stdout(), DisableFocusChange); + } + } +} + +fn parse_event( + head: Span, + event: &crossterm::event::Event, + filter: &EventTypeFilter, + add_raw: bool, +) -> Option { + match event { + crossterm::event::Event::FocusGained => { + create_focus_event(head, filter, FocusEventType::Gained) + } + crossterm::event::Event::FocusLost => { + create_focus_event(head, filter, FocusEventType::Lost) + } + crossterm::event::Event::Key(event) => create_key_event(head, filter, event, add_raw), + crossterm::event::Event::Mouse(event) => create_mouse_event(head, filter, event, add_raw), + crossterm::event::Event::Paste(content) => create_paste_event(head, filter, content), + crossterm::event::Event::Resize(cols, rows) => { + create_resize_event(head, filter, *cols, *rows) + } + } +} + +enum FocusEventType { + Gained, + Lost, +} + +impl FocusEventType { + fn string(self) -> String { + match self { + FocusEventType::Gained => "gained".to_string(), + FocusEventType::Lost => "lost".to_string(), + } + } +} + +fn create_focus_event( + head: Span, + filter: &EventTypeFilter, + event_type: FocusEventType, +) -> Option { + if filter.listen_focus { + let cols = vec!["type".to_string(), "event".to_string()]; + let vals = vec![ + Value::string("focus", head), + Value::string(event_type.string(), head), + ]; + + Some(Value::record(cols, vals, head)) + } else { + None + } +} + +fn create_key_event( + head: Span, + filter: &EventTypeFilter, + event: &crossterm::event::KeyEvent, + add_raw: bool, +) -> Option { + if filter.listen_key { + let crossterm::event::KeyEvent { + code: raw_code, + modifiers: raw_modifiers, + kind, + .. + } = event; + + // Ignore release events on windows. + // Refer to crossterm::event::PushKeyboardEnhancementFlags. According to the doc + // KeyEventKind and KeyEventState work correctly only on windows and with kitty + // keyboard protocol. Because of this `keybindings get` currently ignores anything + // but KeyEventKind::Press + if let KeyEventKind::Release | KeyEventKind::Repeat = kind { + return None; + } + + let mut cols = vec![ + "type".to_string(), + "key_type".to_string(), + "code".to_string(), + "modifiers".to_string(), + ]; + + let typ = Value::string("key".to_string(), head); + let (key, code) = get_keycode_name(head, raw_code); + let modifiers = parse_modifiers(head, raw_modifiers); + let mut vals = vec![typ, key, code, modifiers]; + + if add_raw { + if let KeyCode::Char(c) = raw_code { + cols.push("raw_code".to_string()); + vals.push(Value::int(c.as_(), head)); + } + cols.push("raw_modifiers".to_string()); + vals.push(Value::int(raw_modifiers.bits() as i64, head)); + } + + Some(Value::record(cols, vals, head)) + } else { + None + } +} + +fn get_keycode_name(head: Span, code: &KeyCode) -> (Value, Value) { + let (typ, code) = match code { + KeyCode::F(n) => ("f", n.to_string()), + KeyCode::Char(c) => ("char", c.to_string()), + KeyCode::Media(m) => ("media", format!("{m:?}").to_lowercase()), + KeyCode::Modifier(m) => ("modifier", format!("{m:?}").to_lowercase()), + _ => ("other", format!("{code:?}").to_lowercase()), + }; + (Value::string(typ, head), Value::string(code, head)) +} + +fn parse_modifiers(head: Span, modifiers: &KeyModifiers) -> Value { + const ALL_MODIFIERS: [KeyModifiers; 6] = [ + KeyModifiers::SHIFT, + KeyModifiers::CONTROL, + KeyModifiers::ALT, + KeyModifiers::SUPER, + KeyModifiers::HYPER, + KeyModifiers::META, + ]; + + let parsed_modifiers = ALL_MODIFIERS + .iter() + .filter(|m| modifiers.contains(**m)) + .map(|m| format!("{m:?}").to_lowercase()) + .map(|string| Value::string(string, head)) + .collect(); + + Value::list(parsed_modifiers, head) +} + +fn create_mouse_event( + head: Span, + filter: &EventTypeFilter, + event: &MouseEvent, + add_raw: bool, +) -> Option { + if filter.listen_mouse { + let mut cols = vec![ + "type".to_string(), + "col".to_string(), + "row".to_string(), + "kind".to_string(), + "modifiers".to_string(), + ]; + + let typ = Value::string("mouse".to_string(), head); + let col = Value::int(event.column as i64, head); + let row = Value::int(event.row as i64, head); + + let kind = match event.kind { + MouseEventKind::Down(btn) => format!("{btn:?}_down"), + MouseEventKind::Up(btn) => format!("{btn:?}_up"), + MouseEventKind::Drag(btn) => format!("{btn:?}_drag"), + MouseEventKind::Moved => "moved".to_string(), + MouseEventKind::ScrollDown => "scroll_down".to_string(), + MouseEventKind::ScrollUp => "scroll_up".to_string(), + }; + let kind = Value::string(kind, head); + let modifiers = parse_modifiers(head, &event.modifiers); + + let mut vals = vec![typ, col, row, kind, modifiers]; + + if add_raw { + cols.push("raw_modifiers".to_string()); + vals.push(Value::int(event.modifiers.bits() as i64, head)); + } + + Some(Value::record(cols, vals, head)) + } else { + None + } +} + +fn create_paste_event(head: Span, filter: &EventTypeFilter, content: &str) -> Option { + if filter.listen_paste { + let cols = vec!["type".to_string(), "content".to_string()]; + let vals = vec![Value::string("paste", head), Value::string(content, head)]; + + Some(Value::record(cols, vals, head)) + } else { + None + } +} + +fn create_resize_event( + head: Span, + filter: &EventTypeFilter, + columns: u16, + rows: u16, +) -> Option { + if filter.listen_resize { + let cols = vec!["type".to_string(), "col".to_string(), "row".to_string()]; + let vals = vec![ + Value::string("resize", head), + Value::int(columns as i64, head), + Value::int(rows as i64, head), + ]; + + Some(Value::record(cols, vals, head)) + } else { + None + } +} diff --git a/crates/nu-command/src/platform/input/mod.rs b/crates/nu-command/src/platform/input/mod.rs index 994a6d3e7..2dc3f01eb 100644 --- a/crates/nu-command/src/platform/input/mod.rs +++ b/crates/nu-command/src/platform/input/mod.rs @@ -1,5 +1,7 @@ mod input_; +mod input_listen; mod list; pub use input_::Input; +pub use input_listen::InputListen; pub use list::InputList; diff --git a/crates/nu-command/src/platform/mod.rs b/crates/nu-command/src/platform/mod.rs index 9756ccc96..c0907889a 100644 --- a/crates/nu-command/src/platform/mod.rs +++ b/crates/nu-command/src/platform/mod.rs @@ -13,6 +13,7 @@ pub use dir_info::{DirBuilder, DirInfo, FileInfo}; pub use du::Du; pub use input::Input; pub use input::InputList; +pub use input::InputListen; pub use kill::Kill; pub use sleep::Sleep; pub use term_size::TermSize; diff --git a/crates/nu-std/src/lib.rs b/crates/nu-std/src/lib.rs index 948d8ffd9..619c1cfa7 100644 --- a/crates/nu-std/src/lib.rs +++ b/crates/nu-std/src/lib.rs @@ -26,6 +26,7 @@ pub fn load_standard_library( ("log.nu", include_str!("../std/log.nu")), ("assert.nu", include_str!("../std/assert.nu")), ("xml.nu", include_str!("../std/xml.nu")), + ("input.nu", include_str!("../std/input.nu")), ]; let mut working_set = StateWorkingSet::new(engine_state); diff --git a/crates/nu-std/std/input.nu b/crates/nu-std/std/input.nu new file mode 100644 index 000000000..a323d7111 --- /dev/null +++ b/crates/nu-std/std/input.nu @@ -0,0 +1,64 @@ +def format-event [ ] { + let record = $in + + # Replace numeric value of raw_code with hex string + let record = match $record { + {raw_code: $code} => { + $record | update raw_code {|| $in | fmt | get upperhex} + } + _ => $record + } + + # Replace numeric value of raw_modifiers with binary string + let record = match $record { + {raw_modifiers: $flags} => { + $record | update raw_modifiers {|| $in | fmt | get binary} + } + _ => $record + } + + # Format into oneliner with `to nuon` and remove wrapping bracket pair + $record | to nuon | str substring 1..-1 +} + +# Display user interface events +# +# Press escape to stop +# +# To get individual events as records use "input listen" +export def display [ + --types(-t): list # Listen for event of specified types only (can be one of: focus, key, mouse, paste, resize) + --raw(-r) # Add raw_code field with numeric value of keycode and raw_flags with bit mask flags +] { + let arg_types = if $types == null { + [ key focus mouse paste resize ] + } else if 'key' not-in $types { + $types | append 'key' + } else { + $types + } + + # To get exit key 'escape' we need to read key + # type events, however user may filter them out + # using --types and they should not be displayed + let filter_keys = ($types != null and 'key' not-in $types) + + loop { + let next_key = if $raw { + input listen -t $arg_types -r + } else { + input listen -t $arg_types + } + + match $next_key { + {type: key key_type: other code: esc modifiers: []} => { + return + } + _ => { + if (not $filter_keys) or $next_key.type != 'key' { + $next_key | format-event | print + } + } + } + } +}