From d1b264354247f3d7134ec03e75e5296b274d0bb0 Mon Sep 17 00:00:00 2001 From: Antoine Stevan <44101798+amtoine@users.noreply.github.com> Date: Sat, 9 Sep 2023 17:04:12 +0200 Subject: [PATCH] add support from table / record transposition (#28) this PR adds the ability to tranpose the current view, if it's a table or a record, just as the built-in `transpose` command does. the default binding is `t`. the transposition is an *involution*, i.e. it is its self inverse, meaning that applying the tranposition twice give you back the original data. --- examples/config/default.nuon | 1 + src/config/mod.rs | 7 ++ src/handler.rs | 87 +++++++++++++- src/lib.rs | 9 +- src/nu/value.rs | 225 ++++++++++++++++++++++++++++++++++- src/ui.rs | 3 +- 6 files changed, 320 insertions(+), 12 deletions(-) diff --git a/examples/config/default.nuon b/examples/config/default.nuon index 4614916..e4d49b5 100644 --- a/examples/config/default.nuon +++ b/examples/config/default.nuon @@ -72,5 +72,6 @@ under: 'p', # peek only what's under the cursor view: 'v', # peek the current view, i.e. what is visible }, + tranpose: 't', # tranpose the data if it's a table or a record, this is an *involution* } } diff --git a/src/config/mod.rs b/src/config/mod.rs index f7b2cf6..39b76f4 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -104,6 +104,7 @@ pub struct KeyBindingsMap { /// go into PEEKING mode (see [crate::app::Mode::Peeking]) pub peek: KeyCode, pub peeking: PeekingBindingsMap, + pub transpose: KeyCode, } /// the layout of the application @@ -200,6 +201,7 @@ impl Default for Config { under: KeyCode::Char('p'), view: KeyCode::Char('v'), }, + transpose: KeyCode::Char('t'), }, } } @@ -558,6 +560,11 @@ impl Config { } } } + "transpose" => { + if let Some(val) = try_key(&value, &["keybindings", "tranpose"])? { + config.keybindings.transpose = val + } + } x => return Err(invalid_field(&["keybindings", x], Some(cell.span()))), } } diff --git a/src/handler.rs b/src/handler.rs index 27a3722..c471059 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -1,11 +1,15 @@ use crossterm::event::KeyEvent; -use nu_protocol::{ShellError, Span, Value}; +use nu_protocol::{ + ast::{CellPath, PathMember}, + ShellError, Span, Value, +}; use crate::{ app::{App, Mode}, config::Config, navigation::{self, Direction}, + nu::value::transpose, }; /// the result of a state transition @@ -14,7 +18,7 @@ pub enum TransitionResult { Quit, Continue, Return(Value), - Edit(Value), + Mutate(Value, CellPath), Error(String), } @@ -55,6 +59,34 @@ pub fn handle_key_events( return Ok(TransitionResult::Continue); } else if key_event.code == config.keybindings.navigation.left { navigation::go_back_in_data(app); + return Ok(TransitionResult::Continue); + } else if key_event.code == config.keybindings.transpose { + let mut path = app.position.clone(); + path.members.pop(); + + let view = app.value.clone().follow_cell_path(&path.members, false)?; + let transpose = transpose(&view); + + if transpose != view { + match transpose.clone() { + Value::Record { val: rec, .. } => { + *app.position.members.last_mut().unwrap() = PathMember::String { + val: rec.cols.get(0).unwrap_or(&"".to_string()).to_string(), + span: Span::unknown(), + optional: rec.cols.is_empty(), + }; + } + _ => { + *app.position.members.last_mut().unwrap() = PathMember::Int { + val: 0, + span: Span::unknown(), + optional: false, + }; + } + } + return Ok(TransitionResult::Mutate(transpose, path)); + } + return Ok(TransitionResult::Continue); } } @@ -67,7 +99,7 @@ pub fn handle_key_events( match app.editor.handle_key(&key_event.code) { Some(Some(v)) => { app.mode = Mode::Normal; - return Ok(TransitionResult::Edit(v)); + return Ok(TransitionResult::Mutate(v, app.position.clone())); } Some(None) => { app.mode = Mode::Normal; @@ -496,4 +528,53 @@ mod tests { ]; run_peeking_scenario(peek_at_the_bottom, &config, value); } + + #[test] + fn transpose_the_data() { + let config = Config::default(); + let kmap = config.clone().keybindings; + + let value = Value::test_record(record!( + "a" => Value::test_int(1), + "b" => Value::test_int(2), + "c" => Value::test_int(3), + )); + let mut app = App::from_value(value.clone()); + + assert!(!app.is_at_bottom()); + assert_eq!(app.position.members, to_path_member_vec(&[PM::S("a")])); + + let transitions = vec![ + (kmap.navigation.down, vec![PM::S("b")]), + (kmap.transpose, vec![PM::I(0)]), + (kmap.navigation.up, vec![PM::I(2)]), + (kmap.transpose, vec![PM::S("a")]), + ]; + + for (key, cell_path) in transitions { + let expected = to_path_member_vec(&cell_path); + match handle_key_events(KeyEvent::new(key, KeyModifiers::empty()), &mut app, &config) + .unwrap() + { + TransitionResult::Mutate(cell, path) => { + app.value = crate::nu::value::mutate_value_cell(&app.value, &path, &cell) + } + _ => {} + } + + assert!( + !app.is_at_bottom(), + "expected NOT to be at the bottom after pressing {}", + repr_keycode(&key) + ); + + assert_eq!( + app.position.members, + expected, + "expected to be at {:?}, found {:?}", + repr_path_member_vec(&expected), + repr_path_member_vec(&app.position.members) + ); + } + } } diff --git a/src/lib.rs b/src/lib.rs index 5aa8d58..38a5406 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,12 +69,9 @@ pub fn explore(call: &EvaluatedCall, input: Value) -> Result { match handle_key_events(key_event, &mut app, &config)? { TransitionResult::Quit => break, TransitionResult::Continue => {} - TransitionResult::Edit(cell) => { - app.value = crate::nu::value::mutate_value_cell( - &app.value, - &app.position, - &cell, - ) + TransitionResult::Mutate(cell, path) => { + app.value = + crate::nu::value::mutate_value_cell(&app.value, &path, &cell) } TransitionResult::Error(error) => { tui.draw(&mut app, &config, Some(&error))?; diff --git a/src/nu/value.rs b/src/nu/value.rs index 86e2c8f..c2cf890 100644 --- a/src/nu/value.rs +++ b/src/nu/value.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use nu_protocol::{ ast::{CellPath, PathMember}, - Record, Span, Type, Value, + record, Record, Span, Type, Value, }; pub(crate) fn mutate_value_cell(value: &Value, cell_path: &CellPath, cell: &Value) -> Value { @@ -120,10 +120,139 @@ pub(crate) fn is_table(value: &Value) -> bool { } } +/// this effectively implements the following idempotent `transpose` command written in Nushell +/// ```nushell +/// alias "core transpose" = transpose +/// +/// def transpose []: [table -> any, record -> table] { +/// let data = $in +/// +/// if ($data | columns) == (seq 1 ($data | columns | length) | into string) { +/// if ($data | columns | length) == 2 { +/// return ($data | core transpose --header-row | into record) +/// } else { +/// return ($data | core transpose --header-row) +/// } +/// } +/// +/// $data | core transpose | rename --block { +/// ($in | str replace "column" "" | into int) + 1 | into string +/// } +/// } +/// +/// #[test] +/// def transposition [] { +/// use std assert +/// +/// assert equal (ls | transpose explore | transpose) (ls) +/// assert equal (open Cargo.toml | transpose | transpose) (open Cargo.toml) +/// } +/// ``` +pub(crate) fn transpose(value: &Value) -> Value { + if is_table(value) { + let rows = match value { + Value::List { vals, .. } => vals, + _ => return value.clone(), + }; + + let full_columns = (1..=(rows[0].columns().len())) + .map(|i| format!("{i}")) + .collect::>(); + + if rows[0].columns() == full_columns { + if rows[0].columns().len() == 2 { + match value { + Value::List { vals: rows, .. } => { + let cols: Vec = rows + .iter() + .map(|row| row.get_data_by_key("1").unwrap().as_string().unwrap()) + .collect(); + + let vals: Vec = rows + .iter() + .map(|row| row.get_data_by_key("2").unwrap()) + .collect(); + + return Value::record(Record { cols, vals }, Span::unknown()); + } + _ => return value.clone(), + } + } else { + match value { + Value::List { vals, .. } => { + let mut rows = vec![]; + let cols: Vec = vals + .iter() + .map(|v| v.get_data_by_key("1").unwrap().as_string().unwrap()) + .collect(); + + for i in 0..(vals[0].columns().len() - 1) { + rows.push(Value::record( + Record { + cols: cols.clone(), + vals: vals + .iter() + .map(|v| v.get_data_by_key(&format!("{}", i + 2)).unwrap()) + .collect(), + }, + Span::unknown(), + )); + } + + return Value::list(rows, Span::unknown()); + } + _ => return value.clone(), + } + } + } + + match value { + Value::List { vals, .. } => { + let mut rows = vec![]; + for col in vals[0].columns() { + let mut cols = vec!["1".into()]; + let mut vs = vec![Value::string(col, Span::unknown())]; + + for (i, v) in vals.iter().enumerate() { + cols.push(format!("{}", i + 2)); + vs.push(v.get_data_by_key(col).unwrap()); + } + + rows.push(Value::record(Record { cols, vals: vs }, Span::unknown())); + } + + return Value::list(rows, Span::unknown()); + } + _ => return value.clone(), + } + } + + match value { + Value::Record { val: rec, .. } => { + let mut rows = vec![]; + for (col, val) in rec.iter() { + rows.push(Value::record( + record! { + "1" => Value::string(col, Span::unknown()), + "2" => val.clone(), + }, + Span::unknown(), + )); + } + + Value::list(rows, Span::unknown()) + } + _ => value.clone(), + } +} + #[cfg(test)] mod tests { use super::{is_table, mutate_value_cell}; - use crate::nu::cell_path::{to_path_member_vec, PM}; + use crate::nu::{ + cell_path::{to_path_member_vec, PM}, + value::transpose, + }; use nu_protocol::{ast::CellPath, record, Config, Value}; fn default_value_repr(value: &Value) -> String { @@ -368,4 +497,96 @@ mod tests { assert_eq!(is_table(&Value::test_int(0)), false); } + + #[test] + fn transposition() { + let record = Value::test_record(record! { + "a" => Value::test_int(1), + "b" => Value::test_int(2), + }); + let expected = Value::test_list(vec![ + Value::test_record(record! { + "1" => Value::test_string("a"), + "2" => Value::test_int(1), + }), + Value::test_record(record! { + "1" => Value::test_string("b"), + "2" => Value::test_int(2), + }), + ]); + let result = transpose(&record); + assert_eq!( + result, + expected, + "transposing {} should give {}, found {}", + default_value_repr(&record), + default_value_repr(&expected), + default_value_repr(&result) + ); + // make sure `transpose` is an *involution* + let result = transpose(&expected); + assert_eq!( + result, + record, + "transposing {} should give {}, found {}", + default_value_repr(&expected), + default_value_repr(&record), + default_value_repr(&result) + ); + + let table = Value::test_list(vec![ + Value::test_record(record! { + "a" => Value::test_int(1), + "b" => Value::test_int(2), + }), + Value::test_record(record! { + "a" => Value::test_int(3), + "b" => Value::test_int(4), + }), + ]); + let expected = Value::test_list(vec![ + Value::test_record(record! { + "1" => Value::test_string("a"), + "2" => Value::test_int(1), + "3" => Value::test_int(3), + }), + Value::test_record(record! { + "1" => Value::test_string("b"), + "2" => Value::test_int(2), + "3" => Value::test_int(4), + }), + ]); + let result = transpose(&table); + assert_eq!( + result, + expected, + "transposing {} should give {}, found {}", + default_value_repr(&table), + default_value_repr(&expected), + default_value_repr(&result) + ); + // make sure `transpose` is an *involution* + let result = transpose(&expected); + assert_eq!( + result, + table, + "transposing {} should give {}, found {}", + default_value_repr(&expected), + default_value_repr(&table), + default_value_repr(&result) + ); + + assert_eq!( + transpose(&Value::test_string("foo")), + Value::test_string("foo") + ); + + assert_eq!( + transpose(&Value::test_list(vec![ + Value::test_int(1), + Value::test_int(2) + ])), + Value::test_list(vec![Value::test_int(1), Value::test_int(2)]) + ); + } } diff --git a/src/ui.rs b/src/ui.rs index c5c47db..50e48c9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -538,7 +538,7 @@ fn render_status_bar(frame: &mut Frame<'_, B>, app: &App, config: &C let hints = match app.mode { Mode::Normal => format!( - "{} to {} | {}{}{}{} to move around | {} to peek | {} to quit", + "{} to {} | {}{}{}{} to move around | {} to peek | {} to transpose | {} to quit", repr_keycode(&config.keybindings.insert), Mode::Insert, repr_keycode(&config.keybindings.navigation.left), @@ -546,6 +546,7 @@ fn render_status_bar(frame: &mut Frame<'_, B>, app: &App, config: &C repr_keycode(&config.keybindings.navigation.up), repr_keycode(&config.keybindings.navigation.right), repr_keycode(&config.keybindings.peek), + repr_keycode(&config.keybindings.transpose), repr_keycode(&config.keybindings.quit), ), Mode::Insert => format!(