From 718ee3d545a06dde7efd19ac022661c345a682e3 Mon Sep 17 00:00:00 2001 From: Maxim Zhiburt Date: Thu, 1 Dec 2022 18:32:10 +0300 Subject: [PATCH] [MVP][WIP] `less` like pager (#6984) Run it as `explore`. #### example ```nu ls | explore ``` Configuration points in `config.nu` file. ``` # A 'explore' utility config explore_config: { highlight: { bg: 'yellow', fg: 'black' } status_bar: { bg: '#C4C9C6', fg: '#1D1F21' } command_bar: { fg: '#C4C9C6' } split_line: '#404040' cursor: true # selected_column: 'blue' # selected_row: { fg: 'yellow', bg: '#C1C2A3' } # selected_cell: { fg: 'white', bg: '#777777' } # line_shift: false, # line_index: false, # line_head_top: false, # line_head_bottom: false, } ``` You can start without a pipeline and type `explore` and it'll give you a few tips. ![image](https://user-images.githubusercontent.com/343840/205088971-a8c0262f-f222-4641-b13a-027fbd4f5e1a.png) If you type `:help` you an see the help screen with some information on what tui keybindings are available. ![image](https://user-images.githubusercontent.com/343840/205089461-c4c54217-7ec4-4fa0-96c0-643d68dc0062.png) From the `:help` screen you can now hit `i` and that puts you in `cursor` aka `inspection` mode and you can move the cursor left right up down and it you put it on an area such as `[table 5 rows]` and hit the enter key, you'll see something like this, which shows all the `:` commands. If you hit `esc` it will take you to the previous screen. ![image](https://user-images.githubusercontent.com/343840/205090155-3558a14b-87b7-4072-8dfb-dc8cc2ef4943.png) If you then type `:try` you'll get this type of window where you can type in the top portion and see results in the bottom. ![image](https://user-images.githubusercontent.com/343840/205089185-3c065551-0792-43d6-a13c-a52762856209.png) The `:nu` command is interesting because you can type pipelines like `:nu ls | sort-by type size` or another pipeline of your choosing such as `:nu sys` and that will show the table that looks like this, which we're calling "table mode". ![image](https://user-images.githubusercontent.com/343840/205090809-e686ff0f-6d0b-4347-8ed0-8c59adfbd741.png) If you hit the `t` key it will now transpose the view to look like this. ![image](https://user-images.githubusercontent.com/343840/205090948-a834d7f2-1713-4dfe-92fe-5432f287df3d.png) In table mode or transposed table mode you can use the `i` key to inspect any collapsed field like `{record 8 fields}`, `[table 16 rows]`, `[list x]`, etc. One of the original benefits was that when you're in a view that has a lot of columns, `explore` gives you the ability to scroll left, right, up, and down. `explore` is also smart enough to know when you're in table mode versus preview mode. If you do `open Cargo.toml | explore` you get this. ![image](https://user-images.githubusercontent.com/343840/205091822-cac79130-3a52-4ca8-9210-eba5be30ed58.png) If you type `open --raw Cargo.toml | explore` you get this where you can scroll left, right, up, down. This is called preview mode. ![image](https://user-images.githubusercontent.com/343840/205091990-69455191-ab78-4fea-a961-feafafc16d70.png) When you're in table mode, you can also type `:preview`. So, with `open --raw Cargo.toml | explore`, if you type `:preview`, it will look like this. ![image](https://user-images.githubusercontent.com/343840/205092569-436aa55a-0474-48d5-ab71-baddb1f43027.png) Signed-off-by: Maxim Zhiburt Co-authored-by: Darren Schroeder <343840+fdncred@users.noreply.github.com> --- Cargo.lock | 50 +- crates/nu-color-config/src/color_config.rs | 182 +-- crates/nu-color-config/src/nu_style.rs | 270 +++- crates/nu-command/Cargo.toml | 1 + crates/nu-command/src/default_context.rs | 1 + crates/nu-command/src/viewers/explore.rs | 213 ++++ crates/nu-command/src/viewers/mod.rs | 2 + crates/nu-explore/.gitignore | 22 + crates/nu-explore/Cargo.toml | 22 + crates/nu-explore/LICENSE | 21 + crates/nu-explore/src/command.rs | 248 ++++ crates/nu-explore/src/commands/help.rs | 246 ++++ crates/nu-explore/src/commands/mod.rs | 71 ++ crates/nu-explore/src/commands/nu.rs | 153 +++ crates/nu-explore/src/commands/preview.rs | 81 ++ crates/nu-explore/src/commands/quit.rs | 50 + crates/nu-explore/src/commands/try.rs | 70 ++ crates/nu-explore/src/events.rs | 51 + crates/nu-explore/src/lib.rs | 60 + crates/nu-explore/src/nu_common/command.rs | 42 + crates/nu-explore/src/nu_common/mod.rs | 21 + crates/nu-explore/src/nu_common/table.rs | 844 +++++++++++++ crates/nu-explore/src/nu_common/value.rs | 170 +++ crates/nu-explore/src/pager.rs | 1094 +++++++++++++++++ crates/nu-explore/src/views/coloredtextw.rs | 140 +++ crates/nu-explore/src/views/information.rs | 76 ++ crates/nu-explore/src/views/interative.rs | 216 ++++ crates/nu-explore/src/views/mod.rs | 104 ++ crates/nu-explore/src/views/preview.rs | 175 +++ crates/nu-explore/src/views/record/mod.rs | 661 ++++++++++ crates/nu-explore/src/views/record/tablew.rs | 574 +++++++++ crates/nu-protocol/src/config.rs | 14 + crates/nu-table/src/lib.rs | 29 + .../src/sample_config/default_config.nu | 17 + 34 files changed, 5774 insertions(+), 217 deletions(-) create mode 100644 crates/nu-command/src/viewers/explore.rs create mode 100644 crates/nu-explore/.gitignore create mode 100644 crates/nu-explore/Cargo.toml create mode 100644 crates/nu-explore/LICENSE create mode 100644 crates/nu-explore/src/command.rs create mode 100644 crates/nu-explore/src/commands/help.rs create mode 100644 crates/nu-explore/src/commands/mod.rs create mode 100644 crates/nu-explore/src/commands/nu.rs create mode 100644 crates/nu-explore/src/commands/preview.rs create mode 100644 crates/nu-explore/src/commands/quit.rs create mode 100644 crates/nu-explore/src/commands/try.rs create mode 100644 crates/nu-explore/src/events.rs create mode 100644 crates/nu-explore/src/lib.rs create mode 100644 crates/nu-explore/src/nu_common/command.rs create mode 100644 crates/nu-explore/src/nu_common/mod.rs create mode 100644 crates/nu-explore/src/nu_common/table.rs create mode 100644 crates/nu-explore/src/nu_common/value.rs create mode 100644 crates/nu-explore/src/pager.rs create mode 100644 crates/nu-explore/src/views/coloredtextw.rs create mode 100644 crates/nu-explore/src/views/information.rs create mode 100644 crates/nu-explore/src/views/interative.rs create mode 100644 crates/nu-explore/src/views/mod.rs create mode 100644 crates/nu-explore/src/views/preview.rs create mode 100644 crates/nu-explore/src/views/record/mod.rs create mode 100644 crates/nu-explore/src/views/record/tablew.rs diff --git a/Cargo.lock b/Cargo.lock index 4ca32a9a1..936ba17be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,15 @@ dependencies = [ "ansitok", ] +[[package]] +name = "ansi-str" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b1ed1c166829a0ccb5d79caa0f75cb4abd4adb2ce2c096755b7ad5ffdb0990" +dependencies = [ + "ansitok", +] + [[package]] name = "ansitok" version = "0.2.0" @@ -443,6 +452,12 @@ dependencies = [ "zip", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" @@ -2597,6 +2612,7 @@ dependencies = [ "nu-ansi-term", "nu-color-config", "nu-engine", + "nu-explore", "nu-glob", "nu-json", "nu-parser", @@ -2664,6 +2680,23 @@ dependencies = [ "sysinfo", ] +[[package]] +name = "nu-explore" +version = "0.72.1" +dependencies = [ + "ansi-str 0.7.2", + "crossterm 0.24.0", + "nu-ansi-term", + "nu-color-config", + "nu-engine", + "nu-parser", + "nu-protocol", + "nu-table", + "strip-ansi-escapes", + "terminal_size 0.2.1", + "tui", +] + [[package]] name = "nu-glob" version = "0.72.1" @@ -3129,7 +3162,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1526bb6aa9f10ec339fb10360f22c57edf81d5678d0278e93bc12a47ffbe4b01" dependencies = [ - "ansi-str", + "ansi-str 0.5.0", "ansitok", "bytecount", "fnv", @@ -4831,7 +4864,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56c3ee73732ffceaea7b8f6b719ce3bb17f253fa27461ffeaf568ebd0cdb4b85" dependencies = [ - "ansi-str", + "ansi-str 0.5.0", "papergrid", "tabled_derive", "unicode-width", @@ -5128,6 +5161,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" +[[package]] +name = "tui" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccdd26cbd674007e649a272da4475fb666d3aa0ad0531da7136db6fab0e5bad1" +dependencies = [ + "bitflags", + "cassowary", + "crossterm 0.25.0", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "typed-arena" version = "1.7.0" diff --git a/crates/nu-color-config/src/color_config.rs b/crates/nu-color-config/src/color_config.rs index 8dd5edc64..52dc45d45 100644 --- a/crates/nu-color-config/src/color_config.rs +++ b/crates/nu-color-config/src/color_config.rs @@ -1,175 +1,19 @@ -use crate::nu_style::{color_from_hex, color_string_to_nustyle}; +use crate::nu_style::{color_from_hex, color_string_to_nustyle, lookup_style}; use nu_ansi_term::{Color, Style}; -use nu_protocol::Config; +use nu_protocol::{Config, Value}; use nu_table::{Alignment, TextStyle}; use std::collections::HashMap; pub fn lookup_ansi_color_style(s: &str) -> Style { if s.starts_with('#') { - match color_from_hex(s) { - Ok(c) => match c { - Some(c) => c.normal(), - None => Style::default(), - }, - Err(_) => Style::default(), - } + color_from_hex(s) + .ok() + .and_then(|c| c.map(|c| c.normal())) + .unwrap_or_default() } else if s.starts_with('{') { color_string_to_nustyle(s.to_string()) } else { - match s { - "g" | "green" => Color::Green.normal(), - "gb" | "green_bold" => Color::Green.bold(), - "gu" | "green_underline" => Color::Green.underline(), - "gi" | "green_italic" => Color::Green.italic(), - "gd" | "green_dimmed" => Color::Green.dimmed(), - "gr" | "green_reverse" => Color::Green.reverse(), - "gbl" | "green_blink" => Color::Green.blink(), - "gst" | "green_strike" => Color::Green.strikethrough(), - - "lg" | "light_green" => Color::LightGreen.normal(), - "lgb" | "light_green_bold" => Color::LightGreen.bold(), - "lgu" | "light_green_underline" => Color::LightGreen.underline(), - "lgi" | "light_green_italic" => Color::LightGreen.italic(), - "lgd" | "light_green_dimmed" => Color::LightGreen.dimmed(), - "lgr" | "light_green_reverse" => Color::LightGreen.reverse(), - "lgbl" | "light_green_blink" => Color::LightGreen.blink(), - "lgst" | "light_green_strike" => Color::LightGreen.strikethrough(), - - "r" | "red" => Color::Red.normal(), - "rb" | "red_bold" => Color::Red.bold(), - "ru" | "red_underline" => Color::Red.underline(), - "ri" | "red_italic" => Color::Red.italic(), - "rd" | "red_dimmed" => Color::Red.dimmed(), - "rr" | "red_reverse" => Color::Red.reverse(), - "rbl" | "red_blink" => Color::Red.blink(), - "rst" | "red_strike" => Color::Red.strikethrough(), - - "lr" | "light_red" => Color::LightRed.normal(), - "lrb" | "light_red_bold" => Color::LightRed.bold(), - "lru" | "light_red_underline" => Color::LightRed.underline(), - "lri" | "light_red_italic" => Color::LightRed.italic(), - "lrd" | "light_red_dimmed" => Color::LightRed.dimmed(), - "lrr" | "light_red_reverse" => Color::LightRed.reverse(), - "lrbl" | "light_red_blink" => Color::LightRed.blink(), - "lrst" | "light_red_strike" => Color::LightRed.strikethrough(), - - "u" | "blue" => Color::Blue.normal(), - "ub" | "blue_bold" => Color::Blue.bold(), - "uu" | "blue_underline" => Color::Blue.underline(), - "ui" | "blue_italic" => Color::Blue.italic(), - "ud" | "blue_dimmed" => Color::Blue.dimmed(), - "ur" | "blue_reverse" => Color::Blue.reverse(), - "ubl" | "blue_blink" => Color::Blue.blink(), - "ust" | "blue_strike" => Color::Blue.strikethrough(), - - "lu" | "light_blue" => Color::LightBlue.normal(), - "lub" | "light_blue_bold" => Color::LightBlue.bold(), - "luu" | "light_blue_underline" => Color::LightBlue.underline(), - "lui" | "light_blue_italic" => Color::LightBlue.italic(), - "lud" | "light_blue_dimmed" => Color::LightBlue.dimmed(), - "lur" | "light_blue_reverse" => Color::LightBlue.reverse(), - "lubl" | "light_blue_blink" => Color::LightBlue.blink(), - "lust" | "light_blue_strike" => Color::LightBlue.strikethrough(), - - "b" | "black" => Color::Black.normal(), - "bb" | "black_bold" => Color::Black.bold(), - "bu" | "black_underline" => Color::Black.underline(), - "bi" | "black_italic" => Color::Black.italic(), - "bd" | "black_dimmed" => Color::Black.dimmed(), - "br" | "black_reverse" => Color::Black.reverse(), - "bbl" | "black_blink" => Color::Black.blink(), - "bst" | "black_strike" => Color::Black.strikethrough(), - - "ligr" | "light_gray" => Color::LightGray.normal(), - "ligrb" | "light_gray_bold" => Color::LightGray.bold(), - "ligru" | "light_gray_underline" => Color::LightGray.underline(), - "ligri" | "light_gray_italic" => Color::LightGray.italic(), - "ligrd" | "light_gray_dimmed" => Color::LightGray.dimmed(), - "ligrr" | "light_gray_reverse" => Color::LightGray.reverse(), - "ligrbl" | "light_gray_blink" => Color::LightGray.blink(), - "ligrst" | "light_gray_strike" => Color::LightGray.strikethrough(), - - "y" | "yellow" => Color::Yellow.normal(), - "yb" | "yellow_bold" => Color::Yellow.bold(), - "yu" | "yellow_underline" => Color::Yellow.underline(), - "yi" | "yellow_italic" => Color::Yellow.italic(), - "yd" | "yellow_dimmed" => Color::Yellow.dimmed(), - "yr" | "yellow_reverse" => Color::Yellow.reverse(), - "ybl" | "yellow_blink" => Color::Yellow.blink(), - "yst" | "yellow_strike" => Color::Yellow.strikethrough(), - - "ly" | "light_yellow" => Color::LightYellow.normal(), - "lyb" | "light_yellow_bold" => Color::LightYellow.bold(), - "lyu" | "light_yellow_underline" => Color::LightYellow.underline(), - "lyi" | "light_yellow_italic" => Color::LightYellow.italic(), - "lyd" | "light_yellow_dimmed" => Color::LightYellow.dimmed(), - "lyr" | "light_yellow_reverse" => Color::LightYellow.reverse(), - "lybl" | "light_yellow_blink" => Color::LightYellow.blink(), - "lyst" | "light_yellow_strike" => Color::LightYellow.strikethrough(), - - "p" | "purple" => Color::Purple.normal(), - "pb" | "purple_bold" => Color::Purple.bold(), - "pu" | "purple_underline" => Color::Purple.underline(), - "pi" | "purple_italic" => Color::Purple.italic(), - "pd" | "purple_dimmed" => Color::Purple.dimmed(), - "pr" | "purple_reverse" => Color::Purple.reverse(), - "pbl" | "purple_blink" => Color::Purple.blink(), - "pst" | "purple_strike" => Color::Purple.strikethrough(), - - "lp" | "light_purple" => Color::LightPurple.normal(), - "lpb" | "light_purple_bold" => Color::LightPurple.bold(), - "lpu" | "light_purple_underline" => Color::LightPurple.underline(), - "lpi" | "light_purple_italic" => Color::LightPurple.italic(), - "lpd" | "light_purple_dimmed" => Color::LightPurple.dimmed(), - "lpr" | "light_purple_reverse" => Color::LightPurple.reverse(), - "lpbl" | "light_purple_blink" => Color::LightPurple.blink(), - "lpst" | "light_purple_strike" => Color::LightPurple.strikethrough(), - - "c" | "cyan" => Color::Cyan.normal(), - "cb" | "cyan_bold" => Color::Cyan.bold(), - "cu" | "cyan_underline" => Color::Cyan.underline(), - "ci" | "cyan_italic" => Color::Cyan.italic(), - "cd" | "cyan_dimmed" => Color::Cyan.dimmed(), - "cr" | "cyan_reverse" => Color::Cyan.reverse(), - "cbl" | "cyan_blink" => Color::Cyan.blink(), - "cst" | "cyan_strike" => Color::Cyan.strikethrough(), - - "lc" | "light_cyan" => Color::LightCyan.normal(), - "lcb" | "light_cyan_bold" => Color::LightCyan.bold(), - "lcu" | "light_cyan_underline" => Color::LightCyan.underline(), - "lci" | "light_cyan_italic" => Color::LightCyan.italic(), - "lcd" | "light_cyan_dimmed" => Color::LightCyan.dimmed(), - "lcr" | "light_cyan_reverse" => Color::LightCyan.reverse(), - "lcbl" | "light_cyan_blink" => Color::LightCyan.blink(), - "lcst" | "light_cyan_strike" => Color::LightCyan.strikethrough(), - - "w" | "white" => Color::White.normal(), - "wb" | "white_bold" => Color::White.bold(), - "wu" | "white_underline" => Color::White.underline(), - "wi" | "white_italic" => Color::White.italic(), - "wd" | "white_dimmed" => Color::White.dimmed(), - "wr" | "white_reverse" => Color::White.reverse(), - "wbl" | "white_blink" => Color::White.blink(), - "wst" | "white_strike" => Color::White.strikethrough(), - - "dgr" | "dark_gray" => Color::DarkGray.normal(), - "dgrb" | "dark_gray_bold" => Color::DarkGray.bold(), - "dgru" | "dark_gray_underline" => Color::DarkGray.underline(), - "dgri" | "dark_gray_italic" => Color::DarkGray.italic(), - "dgrd" | "dark_gray_dimmed" => Color::DarkGray.dimmed(), - "dgrr" | "dark_gray_reverse" => Color::DarkGray.reverse(), - "dgrbl" | "dark_gray_blink" => Color::DarkGray.blink(), - "dgrst" | "dark_gray_strike" => Color::DarkGray.strikethrough(), - - "def" | "default" => Color::Default.normal(), - "defb" | "default_bold" => Color::Default.bold(), - "defu" | "default_underline" => Color::Default.underline(), - "defi" | "default_italic" => Color::Default.italic(), - "defd" | "default_dimmed" => Color::Default.dimmed(), - "defr" | "default_reverse" => Color::Default.reverse(), - - _ => Color::White.normal(), - } + lookup_style(s) } } @@ -226,6 +70,18 @@ pub fn get_color_config(config: &Config) -> HashMap { hm } +pub fn get_color_map(colors: &HashMap) -> HashMap { + let mut hm: HashMap = HashMap::new(); + + for (key, value) in colors { + if let Value::String { val, .. } = value { + update_hashmap(key, val, &mut hm); + } + } + + hm +} + // This function will assign a text style to a primitive, or really any string that's // in the hashmap. The hashmap actually contains the style to be applied. pub fn style_primitive(primitive: &str, color_hm: &HashMap) -> TextStyle { diff --git a/crates/nu-color-config/src/nu_style.rs b/crates/nu-color-config/src/nu_style.rs index 24eace487..b55ce9763 100644 --- a/crates/nu-color-config/src/nu_style.rs +++ b/crates/nu-color-config/src/nu_style.rs @@ -9,62 +9,17 @@ pub struct NuStyle { } pub fn parse_nustyle(nu_style: NuStyle) -> Style { - // get the nu_ansi_term::Color foreground color - let fg_color = match nu_style.fg { - Some(fg) => color_from_hex(&fg).unwrap_or_default(), - _ => None, - }; - // get the nu_ansi_term::Color background color - let bg_color = match nu_style.bg { - Some(bg) => color_from_hex(&bg).unwrap_or_default(), - _ => None, - }; - // get the attributes - let color_attr = match nu_style.attr { - Some(attr) => attr, - _ => "".to_string(), + let mut style = Style { + foreground: nu_style.fg.and_then(|fg| lookup_color_str(&fg)), + background: nu_style.bg.and_then(|bg| lookup_color_str(&bg)), + ..Default::default() }; - // setup the attributes available in nu_ansi_term::Style - let mut bold = false; - let mut dimmed = false; - let mut italic = false; - let mut underline = false; - let mut blink = false; - let mut reverse = false; - let mut hidden = false; - let mut strikethrough = false; - - // since we can combine styles like bold-italic, iterate through the chars - // and set the bools for later use in the nu_ansi_term::Style application - for ch in color_attr.to_lowercase().chars() { - match ch { - 'l' => blink = true, - 'b' => bold = true, - 'd' => dimmed = true, - 'h' => hidden = true, - 'i' => italic = true, - 'r' => reverse = true, - 's' => strikethrough = true, - 'u' => underline = true, - 'n' => (), - _ => (), - } + if let Some(attrs) = nu_style.attr { + fill_modifiers(&attrs, &mut style) } - // here's where we build the nu_ansi_term::Style - Style { - foreground: fg_color, - background: bg_color, - is_blink: blink, - is_bold: bold, - is_dimmed: dimmed, - is_hidden: hidden, - is_italic: italic, - is_reverse: reverse, - is_strikethrough: strikethrough, - is_underline: underline, - } + style } pub fn color_string_to_nustyle(color_string: String) -> Style { @@ -101,3 +56,214 @@ pub fn color_from_hex( ))) } } + +pub fn lookup_style(s: &str) -> Style { + match s { + "g" | "green" => Color::Green.normal(), + "gb" | "green_bold" => Color::Green.bold(), + "gu" | "green_underline" => Color::Green.underline(), + "gi" | "green_italic" => Color::Green.italic(), + "gd" | "green_dimmed" => Color::Green.dimmed(), + "gr" | "green_reverse" => Color::Green.reverse(), + "gbl" | "green_blink" => Color::Green.blink(), + "gst" | "green_strike" => Color::Green.strikethrough(), + + "lg" | "light_green" => Color::LightGreen.normal(), + "lgb" | "light_green_bold" => Color::LightGreen.bold(), + "lgu" | "light_green_underline" => Color::LightGreen.underline(), + "lgi" | "light_green_italic" => Color::LightGreen.italic(), + "lgd" | "light_green_dimmed" => Color::LightGreen.dimmed(), + "lgr" | "light_green_reverse" => Color::LightGreen.reverse(), + "lgbl" | "light_green_blink" => Color::LightGreen.blink(), + "lgst" | "light_green_strike" => Color::LightGreen.strikethrough(), + + "r" | "red" => Color::Red.normal(), + "rb" | "red_bold" => Color::Red.bold(), + "ru" | "red_underline" => Color::Red.underline(), + "ri" | "red_italic" => Color::Red.italic(), + "rd" | "red_dimmed" => Color::Red.dimmed(), + "rr" | "red_reverse" => Color::Red.reverse(), + "rbl" | "red_blink" => Color::Red.blink(), + "rst" | "red_strike" => Color::Red.strikethrough(), + + "lr" | "light_red" => Color::LightRed.normal(), + "lrb" | "light_red_bold" => Color::LightRed.bold(), + "lru" | "light_red_underline" => Color::LightRed.underline(), + "lri" | "light_red_italic" => Color::LightRed.italic(), + "lrd" | "light_red_dimmed" => Color::LightRed.dimmed(), + "lrr" | "light_red_reverse" => Color::LightRed.reverse(), + "lrbl" | "light_red_blink" => Color::LightRed.blink(), + "lrst" | "light_red_strike" => Color::LightRed.strikethrough(), + + "u" | "blue" => Color::Blue.normal(), + "ub" | "blue_bold" => Color::Blue.bold(), + "uu" | "blue_underline" => Color::Blue.underline(), + "ui" | "blue_italic" => Color::Blue.italic(), + "ud" | "blue_dimmed" => Color::Blue.dimmed(), + "ur" | "blue_reverse" => Color::Blue.reverse(), + "ubl" | "blue_blink" => Color::Blue.blink(), + "ust" | "blue_strike" => Color::Blue.strikethrough(), + + "lu" | "light_blue" => Color::LightBlue.normal(), + "lub" | "light_blue_bold" => Color::LightBlue.bold(), + "luu" | "light_blue_underline" => Color::LightBlue.underline(), + "lui" | "light_blue_italic" => Color::LightBlue.italic(), + "lud" | "light_blue_dimmed" => Color::LightBlue.dimmed(), + "lur" | "light_blue_reverse" => Color::LightBlue.reverse(), + "lubl" | "light_blue_blink" => Color::LightBlue.blink(), + "lust" | "light_blue_strike" => Color::LightBlue.strikethrough(), + + "b" | "black" => Color::Black.normal(), + "bb" | "black_bold" => Color::Black.bold(), + "bu" | "black_underline" => Color::Black.underline(), + "bi" | "black_italic" => Color::Black.italic(), + "bd" | "black_dimmed" => Color::Black.dimmed(), + "br" | "black_reverse" => Color::Black.reverse(), + "bbl" | "black_blink" => Color::Black.blink(), + "bst" | "black_strike" => Color::Black.strikethrough(), + + "ligr" | "light_gray" => Color::LightGray.normal(), + "ligrb" | "light_gray_bold" => Color::LightGray.bold(), + "ligru" | "light_gray_underline" => Color::LightGray.underline(), + "ligri" | "light_gray_italic" => Color::LightGray.italic(), + "ligrd" | "light_gray_dimmed" => Color::LightGray.dimmed(), + "ligrr" | "light_gray_reverse" => Color::LightGray.reverse(), + "ligrbl" | "light_gray_blink" => Color::LightGray.blink(), + "ligrst" | "light_gray_strike" => Color::LightGray.strikethrough(), + + "y" | "yellow" => Color::Yellow.normal(), + "yb" | "yellow_bold" => Color::Yellow.bold(), + "yu" | "yellow_underline" => Color::Yellow.underline(), + "yi" | "yellow_italic" => Color::Yellow.italic(), + "yd" | "yellow_dimmed" => Color::Yellow.dimmed(), + "yr" | "yellow_reverse" => Color::Yellow.reverse(), + "ybl" | "yellow_blink" => Color::Yellow.blink(), + "yst" | "yellow_strike" => Color::Yellow.strikethrough(), + + "ly" | "light_yellow" => Color::LightYellow.normal(), + "lyb" | "light_yellow_bold" => Color::LightYellow.bold(), + "lyu" | "light_yellow_underline" => Color::LightYellow.underline(), + "lyi" | "light_yellow_italic" => Color::LightYellow.italic(), + "lyd" | "light_yellow_dimmed" => Color::LightYellow.dimmed(), + "lyr" | "light_yellow_reverse" => Color::LightYellow.reverse(), + "lybl" | "light_yellow_blink" => Color::LightYellow.blink(), + "lyst" | "light_yellow_strike" => Color::LightYellow.strikethrough(), + + "p" | "purple" => Color::Purple.normal(), + "pb" | "purple_bold" => Color::Purple.bold(), + "pu" | "purple_underline" => Color::Purple.underline(), + "pi" | "purple_italic" => Color::Purple.italic(), + "pd" | "purple_dimmed" => Color::Purple.dimmed(), + "pr" | "purple_reverse" => Color::Purple.reverse(), + "pbl" | "purple_blink" => Color::Purple.blink(), + "pst" | "purple_strike" => Color::Purple.strikethrough(), + + "lp" | "light_purple" => Color::LightPurple.normal(), + "lpb" | "light_purple_bold" => Color::LightPurple.bold(), + "lpu" | "light_purple_underline" => Color::LightPurple.underline(), + "lpi" | "light_purple_italic" => Color::LightPurple.italic(), + "lpd" | "light_purple_dimmed" => Color::LightPurple.dimmed(), + "lpr" | "light_purple_reverse" => Color::LightPurple.reverse(), + "lpbl" | "light_purple_blink" => Color::LightPurple.blink(), + "lpst" | "light_purple_strike" => Color::LightPurple.strikethrough(), + + "c" | "cyan" => Color::Cyan.normal(), + "cb" | "cyan_bold" => Color::Cyan.bold(), + "cu" | "cyan_underline" => Color::Cyan.underline(), + "ci" | "cyan_italic" => Color::Cyan.italic(), + "cd" | "cyan_dimmed" => Color::Cyan.dimmed(), + "cr" | "cyan_reverse" => Color::Cyan.reverse(), + "cbl" | "cyan_blink" => Color::Cyan.blink(), + "cst" | "cyan_strike" => Color::Cyan.strikethrough(), + + "lc" | "light_cyan" => Color::LightCyan.normal(), + "lcb" | "light_cyan_bold" => Color::LightCyan.bold(), + "lcu" | "light_cyan_underline" => Color::LightCyan.underline(), + "lci" | "light_cyan_italic" => Color::LightCyan.italic(), + "lcd" | "light_cyan_dimmed" => Color::LightCyan.dimmed(), + "lcr" | "light_cyan_reverse" => Color::LightCyan.reverse(), + "lcbl" | "light_cyan_blink" => Color::LightCyan.blink(), + "lcst" | "light_cyan_strike" => Color::LightCyan.strikethrough(), + + "w" | "white" => Color::White.normal(), + "wb" | "white_bold" => Color::White.bold(), + "wu" | "white_underline" => Color::White.underline(), + "wi" | "white_italic" => Color::White.italic(), + "wd" | "white_dimmed" => Color::White.dimmed(), + "wr" | "white_reverse" => Color::White.reverse(), + "wbl" | "white_blink" => Color::White.blink(), + "wst" | "white_strike" => Color::White.strikethrough(), + + "dgr" | "dark_gray" => Color::DarkGray.normal(), + "dgrb" | "dark_gray_bold" => Color::DarkGray.bold(), + "dgru" | "dark_gray_underline" => Color::DarkGray.underline(), + "dgri" | "dark_gray_italic" => Color::DarkGray.italic(), + "dgrd" | "dark_gray_dimmed" => Color::DarkGray.dimmed(), + "dgrr" | "dark_gray_reverse" => Color::DarkGray.reverse(), + "dgrbl" | "dark_gray_blink" => Color::DarkGray.blink(), + "dgrst" | "dark_gray_strike" => Color::DarkGray.strikethrough(), + + "def" | "default" => Color::Default.normal(), + "defb" | "default_bold" => Color::Default.bold(), + "defu" | "default_underline" => Color::Default.underline(), + "defi" | "default_italic" => Color::Default.italic(), + "defd" | "default_dimmed" => Color::Default.dimmed(), + "defr" | "default_reverse" => Color::Default.reverse(), + + _ => Color::White.normal(), + } +} + +pub fn lookup_color(s: &str) -> Option { + let color = match s { + "g" | "green" => Color::Green, + "lg" | "light_green" => Color::LightGreen, + "r" | "red" => Color::Red, + "lr" | "light_red" => Color::LightRed, + "u" | "blue" => Color::Blue, + "lu" | "light_blue" => Color::LightBlue, + "b" | "black" => Color::Black, + "ligr" | "light_gray" => Color::LightGray, + "y" | "yellow" => Color::Yellow, + "ly" | "light_yellow" => Color::LightYellow, + "p" | "purple" => Color::Purple, + "lp" | "light_purple" => Color::LightPurple, + "c" | "cyan" => Color::Cyan, + "lc" | "light_cyan" => Color::LightCyan, + "w" | "white" => Color::White, + "dgr" | "dark_gray" => Color::DarkGray, + "def" | "default" => Color::Default, + _ => return None, + }; + + Some(color) +} + +fn fill_modifiers(attrs: &str, style: &mut Style) { + // setup the attributes available in nu_ansi_term::Style + // + // since we can combine styles like bold-italic, iterate through the chars + // and set the bools for later use in the nu_ansi_term::Style application + for ch in attrs.to_lowercase().chars() { + match ch { + 'l' => style.is_blink = true, + 'b' => style.is_bold = true, + 'd' => style.is_dimmed = true, + 'h' => style.is_hidden = true, + 'i' => style.is_italic = true, + 'r' => style.is_reverse = true, + 's' => style.is_strikethrough = true, + 'u' => style.is_underline = true, + 'n' => (), + _ => (), + } + } +} + +fn lookup_color_str(s: &str) -> Option { + if s.starts_with('#') { + color_from_hex(s).ok().and_then(|c| c) + } else { + lookup_color(s) + } +} diff --git a/crates/nu-command/Cargo.toml b/crates/nu-command/Cargo.toml index 46b25b45a..fd95483aa 100644 --- a/crates/nu-command/Cargo.toml +++ b/crates/nu-command/Cargo.toml @@ -23,6 +23,7 @@ nu-system = { path = "../nu-system", version = "0.72.1" } nu-table = { path = "../nu-table", version = "0.72.1" } nu-term-grid = { path = "../nu-term-grid", version = "0.72.1" } nu-utils = { path = "../nu-utils", version = "0.72.1" } +nu-explore = { path = "../nu-explore", version = "0.72.1" } nu-ansi-term = "0.46.0" num-format = { version = "0.4.3" } diff --git a/crates/nu-command/src/default_context.rs b/crates/nu-command/src/default_context.rs index 8a82ded26..4fd26832c 100644 --- a/crates/nu-command/src/default_context.rs +++ b/crates/nu-command/src/default_context.rs @@ -356,6 +356,7 @@ pub fn create_default_context() -> EngineState { bind_command! { Griddle, Table, + Explore, }; // Conversions diff --git a/crates/nu-command/src/viewers/explore.rs b/crates/nu-command/src/viewers/explore.rs new file mode 100644 index 000000000..09186d621 --- /dev/null +++ b/crates/nu-command/src/viewers/explore.rs @@ -0,0 +1,213 @@ +use std::collections::HashMap; + +use nu_ansi_term::{Color, Style}; +use nu_color_config::{get_color_config, get_color_map}; +use nu_engine::CallExt; +use nu_explore::{StyleConfig, TableConfig, TableSplitLines, ViewConfig}; +use nu_protocol::{ + ast::Call, + engine::{Command, EngineState, Stack}, + Category, Example, PipelineData, ShellError, Signature, SyntaxShape, Value, +}; + +/// A `less` like program to render a [Value] as a table. +#[derive(Clone)] +pub struct Explore; + +impl Command for Explore { + fn name(&self) -> &str { + "explore" + } + + fn usage(&self) -> &str { + "Explore acts as a table pager, just like `less` does for text" + } + + fn signature(&self) -> nu_protocol::Signature { + // todo: Fix error message when it's empty + // if we set h i short flags it panics???? + + Signature::build("explore") + .named( + "head", + SyntaxShape::Boolean, + "Setting it to false makes it doesn't show column headers", + None, + ) + .switch("index", "A flag to show a index beside the rows", Some('i')) + .switch( + "reverse", + "Makes it start from the end. (like `more`)", + Some('r'), + ) + .switch("peek", "Return a last seen cell content", Some('p')) + .category(Category::Viewers) + } + + fn extra_usage(&self) -> &str { + r#"Press <:> then to get a help menu."# + } + + fn run( + &self, + engine_state: &EngineState, + stack: &mut Stack, + call: &Call, + input: PipelineData, + ) -> Result { + let show_head: bool = call.get_flag(engine_state, stack, "head")?.unwrap_or(true); + let show_index: bool = call.has_flag("index"); + let is_reverse: bool = call.has_flag("reverse"); + let peek_value: bool = call.has_flag("peek"); + let table_cfg = TableConfig { + show_index, + show_head, + peek_value, + reverse: is_reverse, + show_help: false, + }; + + let ctrlc = engine_state.ctrlc.clone(); + + let config = engine_state.get_config(); + let color_hm = get_color_config(config); + let style = theme_from_config(&config.explore_config); + + let view_cfg = ViewConfig::new(config, &color_hm, &style); + + let result = nu_explore::run_pager(engine_state, stack, ctrlc, table_cfg, view_cfg, input); + + match result { + Ok(Some(value)) => Ok(PipelineData::Value(value, None)), + Ok(None) => Ok(PipelineData::Value(Value::default(), None)), + Err(err) => Ok(PipelineData::Value( + Value::Error { error: err.into() }, + None, + )), + } + } + + fn examples(&self) -> Vec { + vec![ + Example { + description: "List the files in current directory, an looking at them via explore.", + example: r#"ls | explore"#, + result: None, + }, + Example { + description: "Inspect system information (explore with index).", + example: r#"sys | explore -i"#, + result: None, + }, + Example { + description: "Inspect $nu information (explore with no column names).", + example: r#"$nu | explore --head false"#, + result: None, + }, + Example { + description: "Inspect $nu information and return an entity where you've stopped.", + example: r#"$nu | explore --peek"#, + result: None, + }, + ] + } +} + +fn theme_from_config(config: &HashMap) -> StyleConfig { + let colors = get_color_map(config); + + let mut style = default_style(); + + if let Some(s) = colors.get("status_bar") { + style.status_bar = *s; + } + + if let Some(s) = colors.get("command_bar") { + style.cmd_bar = *s; + } + + if let Some(s) = colors.get("split_line") { + style.split_line = *s; + } + + if let Some(s) = colors.get("highlight") { + style.highlight = *s; + } + + if let Some(s) = colors.get("selected_cell") { + style.selected_cell = Some(*s); + } + + if let Some(s) = colors.get("selected_row") { + style.selected_row = Some(*s); + } + + if let Some(s) = colors.get("selected_column") { + style.selected_column = Some(*s); + } + + if let Some(show_cursor) = config.get("cursor").and_then(|v| v.as_bool().ok()) { + style.show_cursow = show_cursor; + } + + if let Some(b) = config.get("line_head_top").and_then(|v| v.as_bool().ok()) { + style.split_lines.header_top = b; + } + + if let Some(b) = config + .get("line_head_bottom") + .and_then(|v| v.as_bool().ok()) + { + style.split_lines.header_bottom = b; + } + + if let Some(b) = config.get("line_shift").and_then(|v| v.as_bool().ok()) { + style.split_lines.shift_line = b; + } + + if let Some(b) = config.get("line_index").and_then(|v| v.as_bool().ok()) { + style.split_lines.index_line = b; + } + + style +} + +fn default_style() -> StyleConfig { + StyleConfig { + status_bar: Style { + background: Some(Color::Rgb(196, 201, 198)), + foreground: Some(Color::Rgb(29, 31, 33)), + ..Default::default() + }, + highlight: Style { + background: Some(Color::Yellow), + foreground: Some(Color::Black), + ..Default::default() + }, + split_line: Style { + foreground: Some(Color::Rgb(64, 64, 64)), + ..Default::default() + }, + cmd_bar: Style { + foreground: Some(Color::Rgb(196, 201, 198)), + ..Default::default() + }, + status_error: Style { + background: Some(Color::Red), + foreground: Some(Color::White), + ..Default::default() + }, + status_info: Style::default(), + status_warn: Style::default(), + selected_cell: None, + selected_column: None, + selected_row: None, + show_cursow: true, + split_lines: TableSplitLines { + header_bottom: true, + header_top: true, + index_line: true, + shift_line: true, + }, + } +} diff --git a/crates/nu-command/src/viewers/mod.rs b/crates/nu-command/src/viewers/mod.rs index 6aa301e19..d48de600f 100644 --- a/crates/nu-command/src/viewers/mod.rs +++ b/crates/nu-command/src/viewers/mod.rs @@ -1,6 +1,8 @@ +mod explore; mod griddle; mod icons; mod table; +pub use explore::Explore; pub use griddle::Griddle; pub use table::Table; diff --git a/crates/nu-explore/.gitignore b/crates/nu-explore/.gitignore new file mode 100644 index 000000000..4c234e523 --- /dev/null +++ b/crates/nu-explore/.gitignore @@ -0,0 +1,22 @@ +/target +/scratch +**/*.rs.bk +history.txt +tests/fixtures/nuplayground +crates/*/target + +# Debian/Ubuntu +debian/.debhelper/ +debian/debhelper-build-stamp +debian/files +debian/nu.substvars +debian/nu/ + +# macOS junk +.DS_Store + +# JetBrains' IDE items +.idea/* + +# VSCode's IDE items +.vscode/* diff --git a/crates/nu-explore/Cargo.toml b/crates/nu-explore/Cargo.toml new file mode 100644 index 000000000..ebd20b50b --- /dev/null +++ b/crates/nu-explore/Cargo.toml @@ -0,0 +1,22 @@ +[package] +authors = ["The Nushell Project Developers"] +description = "Nushell table printing" +repository = "https://github.com/nushell/nushell/tree/main/crates/nu-explore" +edition = "2021" +license = "MIT" +name = "nu-explore" +version = "0.72.1" + +[dependencies] +nu-ansi-term = "0.46.0" +nu-protocol = { path = "../nu-protocol", version = "0.72.1" } +nu-parser = { path = "../nu-parser", version = "0.72.1" } +nu-color-config = { path = "../nu-color-config", version = "0.72.1" } +nu-engine = { path = "../nu-engine", version = "0.72.1" } +nu-table = { path = "../nu-table", version = "0.72.1" } + +terminal_size = "0.2.1" +strip-ansi-escapes = "0.1.1" +crossterm = "0.24.0" +tui = "0.19.0" +ansi-str = "0.7.2" diff --git a/crates/nu-explore/LICENSE b/crates/nu-explore/LICENSE new file mode 100644 index 000000000..08090de57 --- /dev/null +++ b/crates/nu-explore/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 - 2022 The Nushell Project Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/nu-explore/src/command.rs b/crates/nu-explore/src/command.rs new file mode 100644 index 000000000..1c68e9420 --- /dev/null +++ b/crates/nu-explore/src/command.rs @@ -0,0 +1,248 @@ +use std::collections::HashMap; + +use crate::{ + commands::{ + HelpCmd, HelpManual, NuCmd, PreviewCmd, QuitCmd, SimpleCommand, TryCmd, ViewCommand, + }, + views::View, + TableConfig, +}; + +#[derive(Clone)] +pub enum Command { + Reactive(Box), + View { + cmd: Box, + is_light: bool, + }, +} + +pub struct CommandList { + commands: HashMap<&'static str, Command>, + aliases: HashMap<&'static str, &'static str>, +} + +macro_rules! cmd_view { + ($object:expr, $light:expr) => {{ + let object = $object; + + let name = object.name(); + + let cmd = Box::new(ViewCmd(object)) as Box; + let cmd = Command::View { + cmd, + is_light: $light, + }; + + (name, cmd) + }}; + ($object:expr) => { + cmd_view!($object, false) + }; +} + +macro_rules! cmd_react { + ($object:expr) => {{ + let object = $object; + + let name = object.name(); + let cmd = Command::Reactive(Box::new($object) as Box); + + (name, cmd) + }}; +} + +impl CommandList { + pub fn create_commands(table_cfg: TableConfig) -> Vec<(&'static str, Command)> { + vec![ + cmd_view!(NuCmd::new(table_cfg)), + cmd_view!(TryCmd::new(table_cfg), true), + cmd_view!(PreviewCmd::new(), true), + cmd_react!(QuitCmd::default()), + ] + } + + pub fn create_aliases() -> [(&'static str, &'static str); 2] { + [("h", HelpCmd::NAME), ("q", QuitCmd::NAME)] + } + + pub fn new(table_cfg: TableConfig) -> Self { + let mut cmd_list = Self::create_commands(table_cfg); + let aliases = Self::create_aliases(); + + let help_cmd = create_help_command(&cmd_list, &aliases, table_cfg); + + cmd_list.push(cmd_view!(help_cmd, true)); + + Self { + commands: HashMap::from_iter(cmd_list), + aliases: HashMap::from_iter(aliases), + } + } + + pub fn find(&self, args: &str) -> Option> { + let cmd = args.split_once(' ').map_or(args, |(cmd, _)| cmd); + let args = &args[cmd.len()..]; + + let command = self.find_command(cmd); + parse_command(command, args) + } + + fn find_command(&self, cmd: &str) -> Option { + match self.commands.get(cmd).cloned() { + None => self + .aliases + .get(cmd) + .and_then(|cmd| self.commands.get(cmd).cloned()), + cmd => cmd, + } + } +} + +fn create_help_command( + commands: &[(&str, Command)], + aliases: &[(&str, &str)], + table_cfg: TableConfig, +) -> HelpCmd { + let help_manuals = create_help_manuals(commands); + HelpCmd::new(help_manuals, aliases, table_cfg) +} + +fn parse_command(command: Option, args: &str) -> Option> { + match command { + Some(mut cmd) => { + let result = match &mut cmd { + Command::Reactive(cmd) => cmd.parse(args), + Command::View { cmd, .. } => cmd.parse(args), + }; + + Some(result.map(|_| cmd)) + } + None => None, + } +} + +fn create_help_manuals(cmd_list: &[(&str, Command)]) -> Vec { + let mut help_manuals: Vec<_> = cmd_list + .iter() + .map(|(_, cmd)| cmd) + .map(create_help_manual) + .collect(); + + help_manuals.push(__create_help_manual( + HelpCmd::default().help(), + HelpCmd::NAME, + )); + + help_manuals +} + +fn create_help_manual(cmd: &Command) -> HelpManual { + let name = match cmd { + Command::Reactive(cmd) => cmd.name(), + Command::View { cmd, .. } => cmd.name(), + }; + + let manual = match cmd { + Command::Reactive(cmd) => cmd.help(), + Command::View { cmd, .. } => cmd.help(), + }; + + __create_help_manual(manual, name) +} + +fn __create_help_manual(manual: Option, name: &'static str) -> HelpManual { + match manual { + Some(manual) => manual, + None => HelpManual { + name, + description: "", + arguments: Vec::new(), + examples: Vec::new(), + }, + } +} + +// type helper to deal with `Box`es +#[derive(Clone)] +struct ViewCmd(C); + +impl ViewCommand for ViewCmd +where + C: ViewCommand, + C::View: View + 'static, +{ + type View = Box; + + fn name(&self) -> &'static str { + self.0.name() + } + + fn usage(&self) -> &'static str { + self.0.usage() + } + + fn help(&self) -> Option { + self.0.help() + } + + fn parse(&mut self, args: &str) -> std::io::Result<()> { + self.0.parse(args) + } + + fn spawn( + &mut self, + engine_state: &nu_protocol::engine::EngineState, + stack: &mut nu_protocol::engine::Stack, + value: Option, + ) -> std::io::Result { + let view = self.0.spawn(engine_state, stack, value)?; + Ok(Box::new(view) as Box) + } +} + +pub trait SCommand: SimpleCommand + SCommandClone {} + +impl SCommand for T where T: 'static + SimpleCommand + Clone {} + +pub trait SCommandClone { + fn clone_box(&self) -> Box; +} + +impl SCommandClone for T +where + T: 'static + SCommand + Clone, +{ + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +impl Clone for Box { + fn clone(&self) -> Box { + self.clone_box() + } +} + +pub trait VCommand: ViewCommand> + VCommandClone {} + +impl VCommand for T where T: 'static + ViewCommand> + Clone {} + +pub trait VCommandClone { + fn clone_box(&self) -> Box; +} + +impl VCommandClone for T +where + T: 'static + VCommand + Clone, +{ + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +impl Clone for Box { + fn clone(&self) -> Box { + self.clone_box() + } +} diff --git a/crates/nu-explore/src/commands/help.rs b/crates/nu-explore/src/commands/help.rs new file mode 100644 index 000000000..02e1afdcd --- /dev/null +++ b/crates/nu-explore/src/commands/help.rs @@ -0,0 +1,246 @@ +use std::{ + collections::HashMap, + io::{self, Result}, +}; + +use nu_protocol::{ + engine::{EngineState, Stack}, + Value, +}; + +use crate::{nu_common::NuSpan, pager::TableConfig, views::RecordView}; + +use super::{HelpExample, HelpManual, ViewCommand}; + +#[derive(Debug, Default, Clone)] +pub struct HelpCmd { + input_command: String, + table_cfg: TableConfig, + supported_commands: Vec, + aliases: HashMap>, +} + +impl HelpCmd { + pub const NAME: &'static str = "help"; + + pub fn new( + commands: Vec, + aliases: &[(&str, &str)], + table_cfg: TableConfig, + ) -> Self { + let aliases = collect_aliases(aliases); + + Self { + input_command: String::new(), + supported_commands: commands, + aliases, + table_cfg, + } + } +} + +fn collect_aliases(aliases: &[(&str, &str)]) -> HashMap> { + let mut out_aliases: HashMap> = HashMap::new(); + for (name, cmd) in aliases { + out_aliases + .entry(cmd.to_string()) + .and_modify(|list| list.push(name.to_string())) + .or_insert_with(|| vec![name.to_string()]); + } + out_aliases +} + +impl ViewCommand for HelpCmd { + type View = RecordView<'static>; + + fn name(&self) -> &'static str { + Self::NAME + } + + fn usage(&self) -> &'static str { + "" + } + + fn help(&self) -> Option { + Some(HelpManual { + name: "help", + description: "Looks up a help information about a command or a `explore`", + arguments: vec![], + examples: vec![ + HelpExample { + example: "help", + description: "Open a help information about the `explore`", + }, + HelpExample { + example: "help nu", + description: "Find a help list of `nu` command", + }, + HelpExample { + example: "help help", + description: "...It was supposed to be hidden....until...now...", + }, + ], + }) + } + + fn parse(&mut self, args: &str) -> Result<()> { + self.input_command = args.trim().to_owned(); + + Ok(()) + } + + fn spawn(&mut self, _: &EngineState, _: &mut Stack, _: Option) -> Result { + if self.input_command.is_empty() { + let (headers, data) = help_frame_data(&self.supported_commands, &self.aliases); + let view = RecordView::new(headers, data, self.table_cfg); + return Ok(view); + } + + let manual = self + .supported_commands + .iter() + .find(|manual| manual.name == self.input_command) + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "a given command was not found"))?; + + let aliases = self + .aliases + .get(manual.name) + .map(|l| l.as_slice()) + .unwrap_or(&[]); + let (headers, data) = help_manual_data(manual, aliases); + let view = RecordView::new(headers, data, self.table_cfg); + + Ok(view) + } +} + +fn help_frame_data( + supported_commands: &[HelpManual], + aliases: &HashMap>, +) -> (Vec, Vec>) { + macro_rules! null { + () => { + Value::Nothing { + span: NuSpan::unknown(), + } + }; + } + + macro_rules! nu_str { + ($text:expr) => { + Value::String { + val: $text.to_string(), + span: NuSpan::unknown(), + } + }; + } + + let commands = supported_commands + .iter() + .map(|manual| { + let aliases = aliases + .get(manual.name) + .map(|l| l.as_slice()) + .unwrap_or(&[]); + + let (cols, mut vals) = help_manual_data(manual, aliases); + let vals = vals.remove(0); + Value::Record { + cols, + vals, + span: NuSpan::unknown(), + } + }) + .collect(); + let commands = Value::List { + vals: commands, + span: NuSpan::unknown(), + }; + + let headers = vec!["name", "mode", "information", "description"]; + + #[rustfmt::skip] + let shortcuts = [ + (":", "view", commands, "Run a command"), + ("/", "view", null!(), "Search via pattern"), + ("?", "view", null!(), "Search via pattern but results will be reversed when you press "), + ("n", "view", null!(), "Gets to the next found element in search"), + ("i", "view", null!(), "Turn on a cursor mode so you can inspect values"), + ("t", "view", null!(), "Transpose table, so columns became rows and vice versa"), + ("Up", "", null!(), "Moves to an element above"), + ("Down", "", null!(), "Moves to an element bellow"), + ("Left", "", null!(), "Moves to an element to the left"), + ("Right", "", null!(), "Moves to an element to the right"), + ("PgDown", "view", null!(), "Moves to an a bunch of elements bellow"), + ("PgUp", "view", null!(), "Moves to an a bunch of elements above"), + ("Esc", "", null!(), "Exits a cursor mode. Exists an expected element."), + ("Enter", "cursor", null!(), "Inspect a chosen element"), + ]; + + let headers = headers.iter().map(|s| s.to_string()).collect(); + let data = shortcuts + .iter() + .map(|(name, mode, info, desc)| { + vec![nu_str!(name), nu_str!(mode), info.clone(), nu_str!(desc)] + }) + .collect(); + + (headers, data) +} + +fn help_manual_data(manual: &HelpManual, aliases: &[String]) -> (Vec, Vec>) { + macro_rules! nu_str { + ($text:expr) => { + Value::String { + val: $text.to_string(), + span: NuSpan::unknown(), + } + }; + } + + let arguments = manual + .arguments + .iter() + .map(|e| Value::Record { + cols: vec![String::from("example"), String::from("description")], + vals: vec![nu_str!(e.example), nu_str!(e.description)], + span: NuSpan::unknown(), + }) + .collect(); + + let arguments = Value::List { + vals: arguments, + span: NuSpan::unknown(), + }; + + let examples = manual + .examples + .iter() + .map(|e| Value::Record { + cols: vec![String::from("example"), String::from("description")], + vals: vec![nu_str!(e.example), nu_str!(e.description)], + span: NuSpan::unknown(), + }) + .collect(); + + let examples = Value::List { + vals: examples, + span: NuSpan::unknown(), + }; + + let name = nu_str!(manual.name); + let aliases = nu_str!(aliases.join(", ")); + let desc = nu_str!(manual.description); + + let headers = vec![ + String::from("name"), + String::from("aliases"), + String::from("arguments"), + String::from("examples"), + String::from("description"), + ]; + + let data = vec![vec![name, aliases, arguments, examples, desc]]; + + (headers, data) +} diff --git a/crates/nu-explore/src/commands/mod.rs b/crates/nu-explore/src/commands/mod.rs new file mode 100644 index 000000000..8a5259af7 --- /dev/null +++ b/crates/nu-explore/src/commands/mod.rs @@ -0,0 +1,71 @@ +use nu_protocol::{ + engine::{EngineState, Stack}, + Value, +}; + +use super::pager::{Pager, Transition}; + +use std::io::Result; + +mod help; +mod nu; +mod preview; +mod quit; +mod r#try; + +pub use help::HelpCmd; +pub use nu::NuCmd; +pub use preview::PreviewCmd; +pub use quit::QuitCmd; +pub use r#try::TryCmd; + +pub trait SimpleCommand { + fn name(&self) -> &'static str; + + fn usage(&self) -> &'static str; + + fn help(&self) -> Option; + + fn parse(&mut self, args: &str) -> Result<()>; + + fn react( + &mut self, + engine_state: &EngineState, + stack: &mut Stack, + pager: &mut Pager<'_>, + value: Option, + ) -> Result; +} + +pub trait ViewCommand { + type View; + + fn name(&self) -> &'static str; + + fn usage(&self) -> &'static str; + + fn help(&self) -> Option; + + fn parse(&mut self, args: &str) -> Result<()>; + + fn spawn( + &mut self, + engine_state: &EngineState, + stack: &mut Stack, + value: Option, + ) -> Result; +} + +#[derive(Debug, Default, Clone)] +pub struct HelpManual { + pub name: &'static str, + pub description: &'static str, + pub arguments: Vec, + pub examples: Vec, +} + +#[derive(Debug, Default, Clone)] +pub struct HelpExample { + pub example: &'static str, + pub description: &'static str, +} diff --git a/crates/nu-explore/src/commands/nu.rs b/crates/nu-explore/src/commands/nu.rs new file mode 100644 index 000000000..18a605c6e --- /dev/null +++ b/crates/nu-explore/src/commands/nu.rs @@ -0,0 +1,153 @@ +use std::io::{self, Result}; + +use nu_protocol::{ + engine::{EngineState, Stack}, + PipelineData, Value, +}; + +use crate::{ + nu_common::{collect_pipeline, run_nu_command}, + pager::TableConfig, + views::{Preview, RecordView, View}, +}; + +use super::{HelpExample, HelpManual, ViewCommand}; + +#[derive(Debug, Default, Clone)] +pub struct NuCmd { + command: String, + table_cfg: TableConfig, +} + +impl NuCmd { + pub fn new(table_cfg: TableConfig) -> Self { + Self { + command: String::new(), + table_cfg, + } + } + + pub const NAME: &'static str = "nu"; +} + +impl ViewCommand for NuCmd { + type View = NuView<'static>; + + fn name(&self) -> &'static str { + Self::NAME + } + + fn usage(&self) -> &'static str { + "" + } + + fn help(&self) -> Option { + Some(HelpManual { + name: "nu", + description: "Run a nu command. You can use a presented table as an input", + arguments: vec![], + examples: vec![ + HelpExample { + example: "where type == 'file'", + description: "Filter data to get only entries with a type being a 'file'", + }, + HelpExample { + example: "get scope | get examples", + description: "Get a inner values", + }, + HelpExample { + example: "open Cargo.toml", + description: "Open a Cargo.toml file", + }, + ], + }) + } + + fn parse(&mut self, args: &str) -> Result<()> { + self.command = args.trim().to_owned(); + + Ok(()) + } + + fn spawn( + &mut self, + engine_state: &EngineState, + stack: &mut Stack, + value: Option, + ) -> Result { + let value = value.unwrap_or_default(); + + let pipeline = PipelineData::Value(value, None); + let pipeline = run_nu_command(engine_state, stack, &self.command, pipeline) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + + let (columns, values) = collect_pipeline(pipeline); + + let has_single_value = values.len() == 1 && values[0].len() == 1; + let is_simple_type = !matches!(&values[0][0], Value::List { .. } | Value::Record { .. }); + if has_single_value && is_simple_type { + let config = &engine_state.config; + let text = values[0][0].into_abbreviated_string(config); + return Ok(NuView::Preview(Preview::new(&text))); + } + + let view = RecordView::new(columns, values, self.table_cfg); + + Ok(NuView::Records(view)) + } +} + +pub enum NuView<'a> { + Records(RecordView<'a>), + Preview(Preview), +} + +impl View for NuView<'_> { + fn draw( + &mut self, + f: &mut crate::pager::Frame, + area: tui::layout::Rect, + cfg: &crate::ViewConfig, + layout: &mut crate::views::Layout, + ) { + match self { + NuView::Records(v) => v.draw(f, area, cfg, layout), + NuView::Preview(v) => v.draw(f, area, cfg, layout), + } + } + + fn handle_input( + &mut self, + engine_state: &EngineState, + stack: &mut Stack, + layout: &crate::views::Layout, + info: &mut crate::pager::ViewInfo, + key: crossterm::event::KeyEvent, + ) -> Option { + match self { + NuView::Records(v) => v.handle_input(engine_state, stack, layout, info, key), + NuView::Preview(v) => v.handle_input(engine_state, stack, layout, info, key), + } + } + + fn show_data(&mut self, i: usize) -> bool { + match self { + NuView::Records(v) => v.show_data(i), + NuView::Preview(v) => v.show_data(i), + } + } + + fn collect_data(&self) -> Vec { + match self { + NuView::Records(v) => v.collect_data(), + NuView::Preview(v) => v.collect_data(), + } + } + + fn exit(&mut self) -> Option { + match self { + NuView::Records(v) => v.exit(), + NuView::Preview(v) => v.exit(), + } + } +} diff --git a/crates/nu-explore/src/commands/preview.rs b/crates/nu-explore/src/commands/preview.rs new file mode 100644 index 000000000..4eba5b47b --- /dev/null +++ b/crates/nu-explore/src/commands/preview.rs @@ -0,0 +1,81 @@ +use std::io::Result; + +use nu_color_config::get_color_config; +use nu_protocol::{ + engine::{EngineState, Stack}, + Value, +}; + +use crate::{ + nu_common::{self, collect_input}, + views::Preview, +}; + +use super::{HelpManual, ViewCommand}; + +#[derive(Default, Clone)] +pub struct PreviewCmd; + +impl PreviewCmd { + pub fn new() -> Self { + Self + } +} + +impl PreviewCmd { + pub const NAME: &'static str = "preview"; +} + +impl ViewCommand for PreviewCmd { + type View = Preview; + + fn name(&self) -> &'static str { + Self::NAME + } + + fn usage(&self) -> &'static str { + "" + } + + fn help(&self) -> Option { + Some(HelpManual { + name: "preview", + description: "Preview current value/table if any is currently in use", + arguments: vec![], + examples: vec![], + }) + } + + fn parse(&mut self, _: &str) -> Result<()> { + Ok(()) + } + + fn spawn( + &mut self, + engine_state: &EngineState, + _stack: &mut Stack, + value: Option, + ) -> Result { + let value = match value { + Some(value) => { + let (cols, vals) = collect_input(value.clone()); + + let has_no_head = cols.is_empty() || (cols.len() == 1 && cols[0].is_empty()); + let has_single_value = vals.len() == 1 && vals[0].len() == 1; + if !has_no_head && has_single_value { + let config = engine_state.get_config(); + vals[0][0].into_abbreviated_string(config) + } else { + let ctrlc = engine_state.ctrlc.clone(); + let config = engine_state.get_config(); + let color_hm = get_color_config(config); + + nu_common::try_build_table(ctrlc, config, &color_hm, value) + } + } + None => String::new(), + }; + + Ok(Preview::new(&value)) + } +} diff --git a/crates/nu-explore/src/commands/quit.rs b/crates/nu-explore/src/commands/quit.rs new file mode 100644 index 000000000..f3b03f140 --- /dev/null +++ b/crates/nu-explore/src/commands/quit.rs @@ -0,0 +1,50 @@ +use std::io::Result; + +use nu_protocol::{ + engine::{EngineState, Stack}, + Value, +}; + +use crate::pager::{Pager, Transition}; + +use super::{HelpManual, SimpleCommand}; + +#[derive(Default, Clone)] +pub struct QuitCmd; + +impl QuitCmd { + pub const NAME: &'static str = "quit"; +} + +impl SimpleCommand for QuitCmd { + fn name(&self) -> &'static str { + Self::NAME + } + + fn usage(&self) -> &'static str { + "" + } + + fn help(&self) -> Option { + Some(HelpManual { + name: "quit", + description: "Quit", + arguments: vec![], + examples: vec![], + }) + } + + fn parse(&mut self, _: &str) -> Result<()> { + Ok(()) + } + + fn react( + &mut self, + _: &EngineState, + _: &mut Stack, + _: &mut Pager<'_>, + _: Option, + ) -> Result { + Ok(Transition::Exit) + } +} diff --git a/crates/nu-explore/src/commands/try.rs b/crates/nu-explore/src/commands/try.rs new file mode 100644 index 000000000..2dd56c29b --- /dev/null +++ b/crates/nu-explore/src/commands/try.rs @@ -0,0 +1,70 @@ +use std::io::Result; + +use nu_protocol::{ + engine::{EngineState, Stack}, + Value, +}; + +use crate::{pager::TableConfig, views::InteractiveView}; + +use super::{HelpExample, HelpManual, ViewCommand}; + +#[derive(Debug, Default, Clone)] +pub struct TryCmd { + command: String, + table_cfg: TableConfig, +} + +impl TryCmd { + pub fn new(table_cfg: TableConfig) -> Self { + Self { + command: String::new(), + table_cfg, + } + } + + pub const NAME: &'static str = "try"; +} + +impl ViewCommand for TryCmd { + type View = InteractiveView<'static>; + + fn name(&self) -> &'static str { + Self::NAME + } + + fn usage(&self) -> &'static str { + "" + } + + fn help(&self) -> Option { + Some(HelpManual { + name: "try", + description: "Opens a dynamic REPL to run nu commands", + arguments: vec![], + examples: vec![HelpExample { + example: "try open Cargo.toml", + description: "Optionally you can provide a command which will be run right away", + }], + }) + } + + fn parse(&mut self, args: &str) -> Result<()> { + self.command = args.trim().to_owned(); + + Ok(()) + } + + fn spawn( + &mut self, + _: &EngineState, + _: &mut Stack, + value: Option, + ) -> Result { + let value = value.unwrap_or_default(); + let mut view = InteractiveView::new(value, self.table_cfg); + view.init(self.command.clone()); + + Ok(view) + } +} diff --git a/crates/nu-explore/src/events.rs b/crates/nu-explore/src/events.rs new file mode 100644 index 000000000..002d61e42 --- /dev/null +++ b/crates/nu-explore/src/events.rs @@ -0,0 +1,51 @@ +use std::{ + io::Result, + time::{Duration, Instant}, +}; + +use crossterm::event::{poll, read, Event, KeyEvent}; + +pub struct UIEvents { + tick_rate: Duration, +} + +pub struct Cfg { + pub tick_rate: Duration, +} + +impl Default for Cfg { + fn default() -> Cfg { + Cfg { + tick_rate: Duration::from_millis(250), + } + } +} + +impl UIEvents { + pub fn new() -> UIEvents { + UIEvents::with_config(Cfg::default()) + } + + pub fn with_config(config: Cfg) -> UIEvents { + UIEvents { + tick_rate: config.tick_rate, + } + } + + pub fn next(&self) -> Result> { + let now = Instant::now(); + match poll(self.tick_rate) { + Ok(true) => match read()? { + Event::Key(event) => Ok(Some(event)), + _ => { + let time_spent = now.elapsed(); + let rest = self.tick_rate - time_spent; + + Self { tick_rate: rest }.next() + } + }, + Ok(false) => Ok(None), + Err(err) => Err(err), + } + } +} diff --git a/crates/nu-explore/src/lib.rs b/crates/nu-explore/src/lib.rs new file mode 100644 index 000000000..cb5bb88fe --- /dev/null +++ b/crates/nu-explore/src/lib.rs @@ -0,0 +1,60 @@ +mod command; +mod commands; +mod events; +mod nu_common; +mod pager; +mod views; + +use std::io; + +use nu_common::{collect_pipeline, CtrlC}; +use nu_protocol::{ + engine::{EngineState, Stack}, + PipelineData, Value, +}; +use pager::{Page, Pager}; +use terminal_size::{Height, Width}; +use views::{InformationView, Preview, RecordView}; + +pub use pager::{StyleConfig, TableConfig, TableSplitLines, ViewConfig}; + +pub fn run_pager( + engine_state: &EngineState, + stack: &mut Stack, + ctrlc: CtrlC, + table_cfg: TableConfig, + view_cfg: ViewConfig, + input: PipelineData, +) -> io::Result> { + let commands = command::CommandList::new(table_cfg); + + let mut p = Pager::new(table_cfg, view_cfg.clone()); + + let (columns, data) = collect_pipeline(input); + + let has_no_input = columns.is_empty() && data.is_empty(); + if has_no_input { + let view = Some(Page::new(InformationView, true)); + return p.run(engine_state, stack, ctrlc, view, commands); + } + + let has_single_value = data.len() == 1 && data[0].len() == 1; + let is_simple_type = !matches!(&data[0][0], Value::List { .. } | Value::Record { .. }); + if has_single_value && is_simple_type { + let text = data[0][0].into_abbreviated_string(view_cfg.config); + + let view = Some(Page::new(Preview::new(&text), true)); + return p.run(engine_state, stack, ctrlc, view, commands); + } + + let mut view = RecordView::new(columns, data, table_cfg); + + if table_cfg.reverse { + if let Some((Width(w), Height(h))) = terminal_size::terminal_size() { + view.reverse(w, h); + } + } + + let view = Some(Page::new(view, false)); + p.run(engine_state, stack, ctrlc, view, commands) +} diff --git a/crates/nu-explore/src/nu_common/command.rs b/crates/nu-explore/src/nu_common/command.rs new file mode 100644 index 000000000..527fe89d9 --- /dev/null +++ b/crates/nu-explore/src/nu_common/command.rs @@ -0,0 +1,42 @@ +use nu_engine::eval_block; +use nu_parser::parse; +use nu_protocol::{ + engine::{EngineState, Stack, StateWorkingSet}, + PipelineData, ShellError, +}; + +pub fn run_nu_command( + engine_state: &EngineState, + stack: &mut Stack, + cmd: &str, + current: PipelineData, +) -> std::result::Result { + let engine_state = engine_state.clone(); + eval_source2(&engine_state, stack, cmd.as_bytes(), "", current) +} + +fn eval_source2( + engine_state: &EngineState, + stack: &mut Stack, + source: &[u8], + fname: &str, + input: PipelineData, +) -> Result { + let (block, _) = { + let mut working_set = StateWorkingSet::new(engine_state); + let (output, err) = parse( + &mut working_set, + Some(fname), // format!("entry #{}", entry_num) + source, + false, + &[], + ); + if let Some(err) = err { + return Err(ShellError::IOError(err.to_string())); + } + + (output, working_set.render()) + }; + + eval_block(engine_state, stack, &block, input, false, false) +} diff --git a/crates/nu-explore/src/nu_common/mod.rs b/crates/nu-explore/src/nu_common/mod.rs new file mode 100644 index 000000000..ba6a3d3c0 --- /dev/null +++ b/crates/nu-explore/src/nu_common/mod.rs @@ -0,0 +1,21 @@ +mod command; +mod table; +mod value; + +use std::{ + collections::HashMap, + sync::{atomic::AtomicBool, Arc}, +}; + +use nu_table::TextStyle; + +pub use nu_ansi_term::{Color as NuColor, Style as NuStyle}; +pub use nu_protocol::{Config as NuConfig, Span as NuSpan}; + +pub type NuText = (String, TextStyle); +pub type CtrlC = Option>; +pub type NuStyleTable = HashMap; + +pub use command::run_nu_command; +pub use table::try_build_table; +pub use value::{collect_input, collect_pipeline}; diff --git a/crates/nu-explore/src/nu_common/table.rs b/crates/nu-explore/src/nu_common/table.rs new file mode 100644 index 000000000..10d5d9c2a --- /dev/null +++ b/crates/nu-explore/src/nu_common/table.rs @@ -0,0 +1,844 @@ +use nu_color_config::{get_color_config, style_primitive}; +use nu_engine::column::get_columns; +use nu_protocol::{ast::PathMember, Config, ShellError, Span, TableIndexMode, Value}; +use nu_table::{string_width, Alignment, Alignments, Table as NuTable, TableTheme, TextStyle}; +use std::sync::Arc; +use std::{ + cmp::max, + collections::HashMap, + sync::atomic::{AtomicBool, Ordering}, +}; + +const INDEX_COLUMN_NAME: &str = "index"; + +type NuText = (String, TextStyle); +type NuColorMap = HashMap; +use crate::nu_common::{NuConfig, NuStyleTable}; + +pub fn try_build_table( + ctrlc: Option>, + config: &NuConfig, + color_hm: &NuStyleTable, + value: Value, +) -> String { + let theme = load_theme_from_config(config); + + match value { + Value::List { vals, span } => try_build_list(vals, &ctrlc, config, span, color_hm, theme), + Value::Record { cols, vals, span } => { + try_build_map(cols, vals, span, ctrlc, config, color_hm) + } + val => value_to_styled_string(&val, config, color_hm).0, + } +} + +fn try_build_map( + cols: Vec, + vals: Vec, + span: Span, + ctrlc: Option>, + config: &NuConfig, + color_hm: &HashMap, +) -> String { + let result = build_expanded_table( + cols.clone(), + vals.clone(), + span, + ctrlc, + config, + usize::MAX, + None, + false, + "", + ); + match result { + Ok(Some(result)) => result, + Ok(None) | Err(_) => { + value_to_styled_string(&Value::Record { cols, vals, span }, config, color_hm).0 + } + } +} + +fn try_build_list( + vals: Vec, + ctrlc: &Option>, + config: &NuConfig, + span: Span, + color_hm: &HashMap, + theme: TableTheme, +) -> String { + let table = convert_to_table2( + 0, + vals.iter(), + ctrlc.clone(), + config, + span, + color_hm, + &theme, + None, + false, + "", + usize::MAX, + ); + match table { + Ok(Some(table)) => { + let val = table.draw_table( + config, + color_hm, + Alignments::default(), + &theme, + usize::MAX, + false, + ); + + match val { + Some(result) => result, + None => value_to_styled_string(&Value::List { vals, span }, config, color_hm).0, + } + } + Ok(None) | Err(_) => { + // it means that the list is empty + value_to_styled_string(&Value::List { vals, span }, config, color_hm).0 + } + } +} + +#[allow(clippy::too_many_arguments)] +pub fn build_expanded_table( + cols: Vec, + vals: Vec, + span: Span, + ctrlc: Option>, + config: &Config, + term_width: usize, + expand_limit: Option, + flatten: bool, + flatten_sep: &str, +) -> Result, ShellError> { + let theme = load_theme_from_config(config); + let color_hm = get_color_config(config); + let alignments = Alignments::default(); + + // calculate the width of a key part + the rest of table so we know the rest of the table width available for value. + let key_width = cols.iter().map(|col| string_width(col)).max().unwrap_or(0); + let key = NuTable::create_cell(" ".repeat(key_width), TextStyle::default()); + let key_table = NuTable::new(vec![vec![key]], (1, 2), term_width, false, false); + let key_width = key_table + .draw_table(config, &color_hm, alignments, &theme, usize::MAX, false) + .map(|table| string_width(&table)) + .unwrap_or(0); + + // 3 - count borders (left, center, right) + // 2 - padding + if key_width + 3 + 2 > term_width { + return Ok(None); + } + + let remaining_width = term_width - key_width - 3 - 2; + + let mut data = Vec::with_capacity(cols.len()); + for (key, value) in cols.into_iter().zip(vals) { + // handle CTRLC event + if let Some(ctrlc) = &ctrlc { + if ctrlc.load(Ordering::SeqCst) { + return Ok(None); + } + } + + let is_limited = matches!(expand_limit, Some(0)); + let mut is_expanded = false; + let value = if is_limited { + value_to_styled_string(&value, config, &color_hm).0 + } else { + let deep = expand_limit.map(|i| i - 1); + + match value { + Value::List { vals, .. } => { + let table = convert_to_table2( + 0, + vals.iter(), + ctrlc.clone(), + config, + span, + &color_hm, + &theme, + deep, + flatten, + flatten_sep, + remaining_width, + )?; + + match table { + Some(mut table) => { + // controll width via removing table columns. + let theme = load_theme_from_config(config); + table.truncate(remaining_width, &theme); + + is_expanded = true; + + let val = table.draw_table( + config, + &color_hm, + alignments, + &theme, + remaining_width, + false, + ); + match val { + Some(result) => result, + None => return Ok(None), + } + } + None => { + // it means that the list is empty + let value = Value::List { vals, span }; + value_to_styled_string(&value, config, &color_hm).0 + } + } + } + Value::Record { cols, vals, span } => { + let result = build_expanded_table( + cols.clone(), + vals.clone(), + span, + ctrlc.clone(), + config, + remaining_width, + deep, + flatten, + flatten_sep, + )?; + + match result { + Some(result) => { + is_expanded = true; + result + } + None => { + let failed_value = value_to_styled_string( + &Value::Record { cols, vals, span }, + config, + &color_hm, + ); + + nu_table::wrap_string(&failed_value.0, remaining_width) + } + } + } + val => { + let text = value_to_styled_string(&val, config, &color_hm).0; + nu_table::wrap_string(&text, remaining_width) + } + } + }; + + // we want to have a key being aligned to 2nd line, + // we could use Padding for it but, + // the easiest way to do so is just push a new_line char before + let mut key = key; + if !key.is_empty() && is_expanded && theme.has_top_line() { + key.insert(0, '\n'); + } + + let key = NuTable::create_cell(key, TextStyle::default_field()); + let val = NuTable::create_cell(value, TextStyle::default()); + + let row = vec![key, val]; + data.push(row); + } + + let data_len = data.len(); + let table = NuTable::new(data, (data_len, 2), term_width, false, false); + + let table_s = table + .clone() + .draw_table(config, &color_hm, alignments, &theme, term_width, false); + + let table = match table_s { + Some(s) => { + // check whether we need to expand table or not, + // todo: we can make it more effitient + + const EXPAND_TREASHHOLD: f32 = 0.80; + + let width = string_width(&s); + let used_percent = width as f32 / term_width as f32; + + if width < term_width && used_percent > EXPAND_TREASHHOLD { + table.draw_table(config, &color_hm, alignments, &theme, term_width, true) + } else { + Some(s) + } + } + None => None, + }; + + Ok(table) +} + +#[allow(clippy::too_many_arguments)] +#[allow(clippy::into_iter_on_ref)] +fn convert_to_table2<'a>( + row_offset: usize, + input: impl Iterator + ExactSizeIterator + Clone, + ctrlc: Option>, + config: &Config, + head: Span, + color_hm: &NuColorMap, + theme: &TableTheme, + deep: Option, + flatten: bool, + flatten_sep: &str, + available_width: usize, +) -> Result, ShellError> { + const PADDING_SPACE: usize = 2; + const SPLIT_LINE_SPACE: usize = 1; + const ADDITIONAL_CELL_SPACE: usize = PADDING_SPACE + SPLIT_LINE_SPACE; + const TRUNCATE_CELL_WIDTH: usize = 3; + const MIN_CELL_CONTENT_WIDTH: usize = 1; + const OK_CELL_CONTENT_WIDTH: usize = 25; + + if input.len() == 0 { + return Ok(None); + } + + // 2 - split lines + let mut available_width = available_width.saturating_sub(SPLIT_LINE_SPACE + SPLIT_LINE_SPACE); + if available_width < MIN_CELL_CONTENT_WIDTH { + return Ok(None); + } + + let headers = get_columns(input.clone()); + + let with_index = match config.table_index_mode { + TableIndexMode::Always => true, + TableIndexMode::Never => false, + TableIndexMode::Auto => headers.iter().any(|header| header == INDEX_COLUMN_NAME), + }; + + // The header with the INDEX is removed from the table headers since + // it is added to the natural table index + let headers: Vec<_> = headers + .into_iter() + .filter(|header| header != INDEX_COLUMN_NAME) + .collect(); + + let with_header = !headers.is_empty(); + + let mut data = vec![vec![]; input.len()]; + if !headers.is_empty() { + data.push(vec![]); + }; + + if with_index { + let mut column_width = 0; + + if with_header { + data[0].push(NuTable::create_cell("#", header_style(color_hm))); + } + + for (row, item) in input.clone().into_iter().enumerate() { + let row = if with_header { row + 1 } else { row }; + + if let Some(ctrlc) = &ctrlc { + if ctrlc.load(Ordering::SeqCst) { + return Ok(None); + } + } + + if let Value::Error { error } = item { + return Err(error.clone()); + } + + let index = row + row_offset; + let text = matches!(item, Value::Record { .. }) + .then(|| lookup_index_value(item, config).unwrap_or_else(|| index.to_string())) + .unwrap_or_else(|| index.to_string()); + + let value = make_index_string(text, color_hm); + + let width = string_width(&value.0); + column_width = max(column_width, width); + + let value = NuTable::create_cell(value.0, value.1); + data[row].push(value); + } + + if column_width + ADDITIONAL_CELL_SPACE > available_width { + available_width = 0; + } else { + available_width -= column_width + ADDITIONAL_CELL_SPACE; + } + } + + if !with_header { + for (row, item) in input.into_iter().enumerate() { + if let Some(ctrlc) = &ctrlc { + if ctrlc.load(Ordering::SeqCst) { + return Ok(None); + } + } + + if let Value::Error { error } = item { + return Err(error.clone()); + } + + let value = convert_to_table2_entry( + item, + config, + &ctrlc, + color_hm, + theme, + deep, + flatten, + flatten_sep, + available_width, + ); + + let value = NuTable::create_cell(value.0, value.1); + data[row].push(value); + } + + let count_columns = if with_index { 2 } else { 1 }; + let size = (data.len(), count_columns); + let table = NuTable::new(data, size, usize::MAX, with_header, with_index); + + return Ok(Some(table)); + } + + let mut widths = Vec::new(); + let mut truncate = false; + let count_columns = headers.len(); + for (col, header) in headers.into_iter().enumerate() { + let is_last_col = col + 1 == count_columns; + + let mut nessary_space = PADDING_SPACE; + if !is_last_col { + nessary_space += SPLIT_LINE_SPACE; + } + + if available_width == 0 || available_width <= nessary_space { + // MUST NEVER HAPPEN (ideally) + // but it does... + + truncate = true; + break; + } + + available_width -= nessary_space; + + let mut column_width = string_width(&header); + + data[0].push(NuTable::create_cell(&header, header_style(color_hm))); + + for (row, item) in input.clone().into_iter().enumerate() { + if let Some(ctrlc) = &ctrlc { + if ctrlc.load(Ordering::SeqCst) { + return Ok(None); + } + } + + if let Value::Error { error } = item { + return Err(error.clone()); + } + + let value = create_table2_entry( + item, + &header, + head, + config, + &ctrlc, + color_hm, + theme, + deep, + flatten, + flatten_sep, + available_width, + ); + + let value_width = string_width(&value.0); + column_width = max(column_width, value_width); + + let value = NuTable::create_cell(value.0, value.1); + + data[row + 1].push(value); + } + + if column_width >= available_width + || (!is_last_col && column_width + nessary_space >= available_width) + { + // so we try to do soft landing + // by doing a truncating in case there will be enough space for it. + + column_width = string_width(&header); + + for (row, item) in input.clone().into_iter().enumerate() { + if let Some(ctrlc) = &ctrlc { + if ctrlc.load(Ordering::SeqCst) { + return Ok(None); + } + } + + let value = create_table2_entry_basic(item, &header, head, config, color_hm); + let value = wrap_nu_text(value, available_width); + + let value_width = string_width(&value.0); + column_width = max(column_width, value_width); + + let value = NuTable::create_cell(value.0, value.1); + + *data[row + 1].last_mut().expect("unwrap") = value; + } + } + + let is_suitable_for_wrap = + available_width >= string_width(&header) && available_width >= OK_CELL_CONTENT_WIDTH; + if column_width >= available_width && is_suitable_for_wrap { + // so we try to do soft landing ONCE AGAIN + // but including a wrap + + column_width = string_width(&header); + + for (row, item) in input.clone().into_iter().enumerate() { + if let Some(ctrlc) = &ctrlc { + if ctrlc.load(Ordering::SeqCst) { + return Ok(None); + } + } + + let value = create_table2_entry_basic(item, &header, head, config, color_hm); + let value = wrap_nu_text(value, OK_CELL_CONTENT_WIDTH); + + let value = NuTable::create_cell(value.0, value.1); + + *data[row + 1].last_mut().expect("unwrap") = value; + } + } + + if column_width > available_width { + // remove just added column + for row in &mut data { + row.pop(); + } + + available_width += nessary_space; + + truncate = true; + break; + } + + available_width -= column_width; + widths.push(column_width); + } + + if truncate { + if available_width <= TRUNCATE_CELL_WIDTH + PADDING_SPACE { + // back up by removing last column. + // it's ALWAYS MUST has us enough space for a shift column + while let Some(width) = widths.pop() { + for row in &mut data { + row.pop(); + } + + available_width += width + PADDING_SPACE + SPLIT_LINE_SPACE; + + if available_width > TRUNCATE_CELL_WIDTH + PADDING_SPACE { + break; + } + } + } + + // this must be a RARE case or even NEVER happen, + // but we do check it just in case. + if widths.is_empty() { + return Ok(None); + } + + let shift = NuTable::create_cell(String::from("..."), TextStyle::default()); + for row in &mut data { + row.push(shift.clone()); + } + + widths.push(3); + } + + let count_columns = widths.len() + with_index as usize; + let count_rows = data.len(); + let size = (count_rows, count_columns); + + let table = NuTable::new(data, size, usize::MAX, with_header, with_index); + + Ok(Some(table)) +} + +fn lookup_index_value(item: &Value, config: &Config) -> Option { + item.get_data_by_key(INDEX_COLUMN_NAME) + .map(|value| value.into_string("", config)) +} + +fn header_style(color_hm: &NuColorMap) -> TextStyle { + TextStyle { + alignment: Alignment::Center, + color_style: Some(color_hm["header"]), + } +} + +#[allow(clippy::too_many_arguments)] +fn create_table2_entry_basic( + item: &Value, + header: &str, + head: Span, + config: &Config, + color_hm: &NuColorMap, +) -> NuText { + match item { + Value::Record { .. } => { + let val = header.to_owned(); + let path = PathMember::String { val, span: head }; + let val = item.clone().follow_cell_path(&[path], false); + + match val { + Ok(val) => value_to_styled_string(&val, config, color_hm), + Err(_) => error_sign(color_hm), + } + } + _ => value_to_styled_string(item, config, color_hm), + } +} + +#[allow(clippy::too_many_arguments)] +fn create_table2_entry( + item: &Value, + header: &str, + head: Span, + config: &Config, + ctrlc: &Option>, + color_hm: &NuColorMap, + theme: &TableTheme, + deep: Option, + flatten: bool, + flatten_sep: &str, + width: usize, +) -> NuText { + match item { + Value::Record { .. } => { + let val = header.to_owned(); + let path = PathMember::String { val, span: head }; + let val = item.clone().follow_cell_path(&[path], false); + + match val { + Ok(val) => convert_to_table2_entry( + &val, + config, + ctrlc, + color_hm, + theme, + deep, + flatten, + flatten_sep, + width, + ), + Err(_) => wrap_nu_text(error_sign(color_hm), width), + } + } + _ => convert_to_table2_entry( + item, + config, + ctrlc, + color_hm, + theme, + deep, + flatten, + flatten_sep, + width, + ), + } +} + +fn error_sign(color_hm: &HashMap) -> (String, TextStyle) { + make_styled_string(String::from("❎"), "empty", color_hm, 0) +} + +fn wrap_nu_text(mut text: NuText, width: usize) -> NuText { + text.0 = nu_table::wrap_string(&text.0, width); + text +} + +#[allow(clippy::too_many_arguments)] +fn convert_to_table2_entry( + item: &Value, + config: &Config, + ctrlc: &Option>, + color_hm: &NuColorMap, + theme: &TableTheme, + deep: Option, + flatten: bool, + flatten_sep: &str, + width: usize, +) -> NuText { + let is_limit_reached = matches!(deep, Some(0)); + if is_limit_reached { + return wrap_nu_text(value_to_styled_string(item, config, color_hm), width); + } + + match &item { + Value::Record { span, cols, vals } => { + if cols.is_empty() && vals.is_empty() { + wrap_nu_text(value_to_styled_string(item, config, color_hm), width) + } else { + let table = convert_to_table2( + 0, + std::iter::once(item), + ctrlc.clone(), + config, + *span, + color_hm, + theme, + deep.map(|i| i - 1), + flatten, + flatten_sep, + width, + ); + + let inner_table = table.map(|table| { + table.and_then(|table| { + let alignments = Alignments::default(); + table.draw_table(config, color_hm, alignments, theme, usize::MAX, false) + }) + }); + + if let Ok(Some(table)) = inner_table { + (table, TextStyle::default()) + } else { + // error so back down to the default + wrap_nu_text(value_to_styled_string(item, config, color_hm), width) + } + } + } + Value::List { vals, span } => { + let is_simple_list = vals + .iter() + .all(|v| !matches!(v, Value::Record { .. } | Value::List { .. })); + + if flatten && is_simple_list { + wrap_nu_text( + convert_value_list_to_string(vals, config, color_hm, flatten_sep), + width, + ) + } else { + let table = convert_to_table2( + 0, + vals.iter(), + ctrlc.clone(), + config, + *span, + color_hm, + theme, + deep.map(|i| i - 1), + flatten, + flatten_sep, + width, + ); + + let inner_table = table.map(|table| { + table.and_then(|table| { + let alignments = Alignments::default(); + table.draw_table(config, color_hm, alignments, theme, usize::MAX, false) + }) + }); + if let Ok(Some(table)) = inner_table { + (table, TextStyle::default()) + } else { + // error so back down to the default + + wrap_nu_text(value_to_styled_string(item, config, color_hm), width) + } + } + } + _ => wrap_nu_text(value_to_styled_string(item, config, color_hm), width), // unknown type. + } +} + +fn convert_value_list_to_string( + vals: &[Value], + config: &Config, + color_hm: &NuColorMap, + flatten_sep: &str, +) -> NuText { + let mut buf = Vec::new(); + for value in vals { + let (text, _) = value_to_styled_string(value, config, color_hm); + + buf.push(text); + } + let text = buf.join(flatten_sep); + (text, TextStyle::default()) +} + +fn value_to_styled_string(value: &Value, config: &Config, color_hm: &NuColorMap) -> NuText { + let float_precision = config.float_precision as usize; + make_styled_string( + value.into_abbreviated_string(config), + &value.get_type().to_string(), + color_hm, + float_precision, + ) +} + +fn make_styled_string( + text: String, + text_type: &str, + color_hm: &NuColorMap, + float_precision: usize, +) -> NuText { + if text_type == "float" { + // set dynamic precision from config + let precise_number = match convert_with_precision(&text, float_precision) { + Ok(num) => num, + Err(e) => e.to_string(), + }; + (precise_number, style_primitive(text_type, color_hm)) + } else { + (text, style_primitive(text_type, color_hm)) + } +} + +fn make_index_string(text: String, color_hm: &NuColorMap) -> NuText { + let style = TextStyle::new() + .alignment(Alignment::Right) + .style(color_hm["row_index"]); + (text, style) +} + +fn convert_with_precision(val: &str, precision: usize) -> Result { + // vall will always be a f64 so convert it with precision formatting + let val_float = match val.trim().parse::() { + Ok(f) => f, + Err(e) => { + return Err(ShellError::GenericError( + format!("error converting string [{}] to f64", &val), + "".to_string(), + None, + Some(e.to_string()), + Vec::new(), + )); + } + }; + Ok(format!("{:.prec$}", val_float, prec = precision)) +} + +fn load_theme_from_config(config: &Config) -> TableTheme { + match config.table_mode.as_str() { + "basic" => nu_table::TableTheme::basic(), + "thin" => nu_table::TableTheme::thin(), + "light" => nu_table::TableTheme::light(), + "compact" => nu_table::TableTheme::compact(), + "with_love" => nu_table::TableTheme::with_love(), + "compact_double" => nu_table::TableTheme::compact_double(), + "rounded" => nu_table::TableTheme::rounded(), + "reinforced" => nu_table::TableTheme::reinforced(), + "heavy" => nu_table::TableTheme::heavy(), + "none" => nu_table::TableTheme::none(), + _ => nu_table::TableTheme::rounded(), + } +} diff --git a/crates/nu-explore/src/nu_common/value.rs b/crates/nu-explore/src/nu_common/value.rs new file mode 100644 index 000000000..dfe61141c --- /dev/null +++ b/crates/nu-explore/src/nu_common/value.rs @@ -0,0 +1,170 @@ +use nu_engine::get_columns; +use nu_protocol::{ast::PathMember, PipelineData, Value}; + +use super::NuSpan; + +pub fn collect_pipeline(input: PipelineData) -> (Vec, Vec>) { + match input { + PipelineData::Value(value, ..) => collect_input(value), + PipelineData::ListStream(mut stream, ..) => { + let mut records = vec![]; + for item in stream.by_ref() { + records.push(item); + } + + let mut cols = get_columns(&records); + let data = convert_records_to_dataset(&cols, records); + + // trying to deal with 'not standart input' + if cols.is_empty() && !data.is_empty() { + let min_column_length = data.iter().map(|row| row.len()).min().unwrap_or(0); + if min_column_length > 0 { + cols = (0..min_column_length).map(|i| i.to_string()).collect(); + } + } + + (cols, data) + } + PipelineData::ExternalStream { + stdout, + stderr, + exit_code, + metadata, + span, + .. + } => { + let mut columns = vec![]; + let mut data = vec![]; + + if let Some(stdout) = stdout { + let value = stdout.into_string().map_or_else( + |error| Value::Error { error }, + |string| Value::string(string.item, span), + ); + + columns.push(String::from("stdout")); + data.push(vec![value]); + } + + if let Some(stderr) = stderr { + let value = stderr.into_string().map_or_else( + |error| Value::Error { error }, + |string| Value::string(string.item, span), + ); + + columns.push(String::from("stderr")); + data.push(vec![value]); + } + + if let Some(exit_code) = exit_code { + let list = exit_code.collect::>(); + + columns.push(String::from("exit_code")); + data.push(list); + } + + if metadata.is_some() { + columns.push(String::from("metadata")); + data.push(vec![Value::Record { + cols: vec![String::from("data_source")], + vals: vec![Value::String { + val: String::from("ls"), + span, + }], + span, + }]); + } + + (columns, data) + } + } +} + +/// Try to build column names and a table grid. +pub fn collect_input(value: Value) -> (Vec, Vec>) { + match value { + Value::Record { cols, vals, .. } => (cols, vec![vals]), + Value::List { vals, .. } => { + let mut columns = get_columns(&vals); + let data = convert_records_to_dataset(&columns, vals); + + if columns.is_empty() && !data.is_empty() { + columns = vec![String::from("")]; + } + + (columns, data) + } + Value::String { val, span } => { + let lines = val + .lines() + .map(|line| Value::String { + val: line.to_string(), + span, + }) + .map(|val| vec![val]) + .collect(); + + (vec![String::from("")], lines) + } + Value::Nothing { .. } => (vec![], vec![]), + value => (vec![String::from("")], vec![vec![value]]), + } +} + +fn convert_records_to_dataset(cols: &Vec, records: Vec) -> Vec> { + if !cols.is_empty() { + create_table_for_record(cols, &records) + } else if cols.is_empty() && records.is_empty() { + vec![] + } else if cols.len() == records.len() { + vec![records] + } else { + // I am not sure whether it's good to return records as its length LIKELY will not match columns, + // which makes no scense...... + // + // BUT... + // we can represent it as a list; which we do + + records.into_iter().map(|record| vec![record]).collect() + } +} + +fn create_table_for_record(headers: &[String], items: &[Value]) -> Vec> { + let mut data = vec![Vec::new(); items.len()]; + + for (i, item) in items.iter().enumerate() { + let row = record_create_row(headers, item); + data[i] = row; + } + + data +} + +fn record_create_row(headers: &[String], item: &Value) -> Vec { + let mut rows = vec![Value::default(); headers.len()]; + + for (i, header) in headers.iter().enumerate() { + let value = record_lookup_value(item, header); + rows[i] = value; + } + + rows +} + +fn record_lookup_value(item: &Value, header: &str) -> Value { + match item { + Value::Record { .. } => { + let path = PathMember::String { + val: header.to_owned(), + span: NuSpan::unknown(), + }; + + let value = item.clone().follow_cell_path(&[path], false); + match value { + Ok(value) => value, + Err(_) => item.clone(), + } + } + item => item.clone(), + } +} diff --git a/crates/nu-explore/src/pager.rs b/crates/nu-explore/src/pager.rs new file mode 100644 index 000000000..a15e45386 --- /dev/null +++ b/crates/nu-explore/src/pager.rs @@ -0,0 +1,1094 @@ +use std::{ + borrow::Cow, + cmp::min, + io::{self, Result, Stdout}, + sync::atomic::Ordering, +}; + +use crossterm::{ + event::{KeyCode, KeyEvent, KeyModifiers}, + execute, + terminal::{ + disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, + LeaveAlternateScreen, + }, +}; +use nu_color_config::style_primitive; +use nu_protocol::{ + engine::{EngineState, Stack}, + Value, +}; +use nu_table::{string_width, Alignment, TextStyle}; +use tui::{ + backend::CrosstermBackend, + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::Span, + widgets::{Block, Borders, Widget}, +}; + +use crate::{ + command::{Command, CommandList}, + nu_common::{CtrlC, NuColor, NuConfig, NuStyle, NuStyleTable, NuText}, +}; + +use super::{ + events::UIEvents, + views::{Layout, View}, +}; + +pub type Frame<'a> = tui::Frame<'a, CrosstermBackend>; +pub type Terminal = tui::Terminal>; + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum Transition { + Ok, + Exit, + Cmd { + command: Cow<'static, str>, + args: Option, + }, +} + +impl Transition { + pub fn command(cmd: impl Into>, args: Option) -> Self { + Self::Cmd { + command: cmd.into(), + args, + } + } +} + +#[derive(Debug, Clone)] +pub struct ViewConfig<'a> { + pub config: &'a NuConfig, + pub color_hm: &'a NuStyleTable, + pub theme: &'a StyleConfig, +} + +impl<'a> ViewConfig<'a> { + pub fn new(config: &'a NuConfig, color_hm: &'a NuStyleTable, theme: &'a StyleConfig) -> Self { + Self { + config, + color_hm, + theme, + } + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct TableConfig { + pub show_index: bool, + pub show_head: bool, + pub reverse: bool, + pub peek_value: bool, + pub show_help: bool, +} + +pub fn run_pager( + engine_state: &EngineState, + stack: &mut Stack, + ctrlc: CtrlC, + pager: &mut Pager, + view: Option, + commands: CommandList, +) -> Result> { + // setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, Clear(ClearType::All))?; + + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut info = ViewInfo { + status: Some(Report::default()), + ..Default::default() + }; + + let result = render_ui( + &mut terminal, + engine_state, + stack, + ctrlc, + pager, + &mut info, + view, + commands, + )?; + + // restore terminal + disable_raw_mode()?; + execute!(io::stdout(), LeaveAlternateScreen)?; + + Ok(result) +} + +#[allow(clippy::too_many_arguments)] +fn render_ui( + term: &mut Terminal, + engine_state: &EngineState, + stack: &mut Stack, + ctrlc: CtrlC, + pager: &mut Pager<'_>, + info: &mut ViewInfo, + mut view: Option, + commands: CommandList, +) -> Result> { + let events = UIEvents::new(); + let mut view_stack = Vec::new(); + + // let mut command_view = None; + loop { + // handle CTRLC event + if let Some(ctrlc) = ctrlc.clone() { + if ctrlc.load(Ordering::SeqCst) { + break Ok(None); + } + } + + let mut layout = Layout::default(); + { + let info = info.clone(); + term.draw(|f| { + let area = f.size(); + let available_area = + Rect::new(area.x, area.y, area.width, area.height.saturating_sub(2)); + + if let Some(p) = &mut view { + p.view.draw(f, available_area, &pager.view_cfg, &mut layout); + + if pager.table_cfg.show_help { + pager.table_cfg.show_help = false; + } + } + + if let Some(report) = info.status { + let last_2nd_line = area.bottom().saturating_sub(2); + let area = Rect::new(area.left(), last_2nd_line, area.width, 1); + render_status_bar(f, area, report, pager.view_cfg.theme); + } + + { + let last_line = area.bottom().saturating_sub(1); + let area = Rect::new(area.left(), last_line, area.width, 1); + render_cmd_bar(f, area, pager, info.report, pager.view_cfg.theme); + } + + highlight_search_results(f, pager, &layout, pager.view_cfg.theme.highlight); + set_cursor_cmd_bar(f, area, pager); + })?; + } + + let (exited, force) = handle_events( + engine_state, + stack, + &events, + &layout, + info, + &mut pager.search_buf, + &mut pager.cmd_buf, + view.as_mut().map(|p| &mut p.view), + ); + if exited { + if force { + break Ok(try_to_peek_value(pager, view.as_mut().map(|p| &mut p.view))); + } else { + // try to pop the view stack + if let Some(v) = view_stack.pop() { + view = Some(v); + } + } + } + + if pager.cmd_buf.run_cmd { + let args = pager.cmd_buf.buf_cmd2.clone(); + pager.cmd_buf.run_cmd = false; + pager.cmd_buf.buf_cmd2 = String::new(); + + let command = commands.find(&args); + let result = handle_command( + engine_state, + stack, + pager, + &mut view, + &mut view_stack, + command, + &args, + ); + + match result { + Ok(false) => {} + Ok(true) => break Ok(try_to_peek_value(pager, view.as_mut().map(|p| &mut p.view))), + Err(err) => info.report = Some(Report::error(err)), + } + } + } +} + +fn handle_command( + engine_state: &EngineState, + stack: &mut Stack, + pager: &mut Pager, + view: &mut Option, + view_stack: &mut Vec, + command: Option>, + args: &str, +) -> std::result::Result { + match command { + Some(Ok(command)) => { + run_command(engine_state, stack, pager, view, view_stack, command, args) + } + Some(Err(err)) => Err(format!( + "Error: command {:?} was not provided with correct arguments: {}", + args, err + )), + None => Err(format!("Error: command {:?} was not recognized", args)), + } +} + +fn run_command( + engine_state: &EngineState, + stack: &mut Stack, + pager: &mut Pager, + view: &mut Option, + view_stack: &mut Vec, + command: Command, + args: &str, +) -> std::result::Result { + match command { + Command::Reactive(mut command) => { + // what we do we just replace the view. + let value = view.as_mut().and_then(|p| p.view.exit()); + let result = command.react(engine_state, stack, pager, value); + match result { + Ok(transition) => match transition { + Transition::Ok => Ok(false), + Transition::Exit => Ok(true), + Transition::Cmd { .. } => todo!("not used so far"), + }, + Err(err) => Err(format!("Error: command {:?} failed: {}", args, err)), + } + } + Command::View { mut cmd, is_light } => { + // what we do we just replace the view. + let value = view.as_mut().and_then(|p| p.view.exit()); + let result = cmd.spawn(engine_state, stack, value); + match result { + Ok(new_view) => { + if let Some(view) = view.take() { + if !view.is_light { + view_stack.push(view); + } + } + + *view = Some(Page::raw(new_view, is_light)); + Ok(false) + } + Err(err) => Err(format!("Error: command {:?} failed: {}", args, err)), + } + } + } +} + +fn set_cursor_cmd_bar(f: &mut Frame, area: Rect, pager: &Pager) { + if pager.cmd_buf.is_cmd_input { + // todo: deal with a situation where we exeed the bar width + let next_pos = (pager.cmd_buf.buf_cmd2.len() + 1) as u16; + // 1 skips a ':' char + if next_pos < area.width { + f.set_cursor(next_pos as u16, area.height - 1); + } + } else if pager.search_buf.is_search_input { + // todo: deal with a situation where we exeed the bar width + let next_pos = (pager.search_buf.buf_cmd_input.len() + 1) as u16; + // 1 skips a ':' char + if next_pos < area.width { + f.set_cursor(next_pos as u16, area.height - 1); + } + } +} + +fn try_to_peek_value(pager: &mut Pager, view: Option<&mut V>) -> Option +where + V: View, +{ + if pager.table_cfg.peek_value { + view.and_then(|v| v.exit()) + } else { + None + } +} + +fn render_status_bar(f: &mut Frame, area: Rect, report: Report, theme: &StyleConfig) { + let msg_style = report_msg_style(&report, theme, theme.status_bar); + let status_bar = StatusBar::new(report, theme.status_bar, msg_style); + f.render_widget(status_bar, area); +} + +fn report_msg_style(report: &Report, theme: &StyleConfig, style: NuStyle) -> NuStyle { + if matches!(report.level, Severentity::Info) { + style + } else { + report_level_style(report.level, theme) + } +} + +fn render_cmd_bar( + f: &mut Frame, + area: Rect, + pager: &Pager, + report: Option, + theme: &StyleConfig, +) { + if let Some(report) = report { + let style = report_msg_style(&report, theme, theme.cmd_bar); + f.render_widget(CmdBar::new(&report.message, &report.context, style), area); + return; + } + + if pager.cmd_buf.is_cmd_input { + render_cmd_bar_cmd(f, area, pager, theme); + return; + } + + if pager.search_buf.is_search_input || !pager.search_buf.buf_cmd_input.is_empty() { + render_cmd_bar_search(f, area, pager, theme); + } +} + +fn render_cmd_bar_search(f: &mut Frame, area: Rect, pager: &Pager<'_>, theme: &StyleConfig) { + if pager.search_buf.search_results.is_empty() && !pager.search_buf.is_search_input { + let message = format!("Pattern not found: {}", pager.search_buf.buf_cmd_input); + let style = NuStyle { + background: Some(NuColor::Red), + foreground: Some(NuColor::White), + ..Default::default() + }; + + f.render_widget(CmdBar::new(&message, "", style), area); + return; + } + + let prefix = if pager.search_buf.is_reversed { + '?' + } else { + '/' + }; + let text = format!("{}{}", prefix, pager.search_buf.buf_cmd_input); + let info = if pager.search_buf.search_results.is_empty() { + String::from("[0/0]") + } else { + let index = pager.search_buf.search_index + 1; + let total = pager.search_buf.search_results.len(); + format!("[{}/{}]", index, total) + }; + + f.render_widget(CmdBar::new(&text, &info, theme.cmd_bar), area); +} + +fn render_cmd_bar_cmd(f: &mut Frame, area: Rect, pager: &Pager, theme: &StyleConfig) { + let mut input = pager.cmd_buf.buf_cmd2.as_str(); + if input.len() > area.width as usize + 1 { + // in such case we take last max_cmd_len chars + let take_bytes = input + .chars() + .rev() + .take(area.width.saturating_sub(1) as usize) + .map(|c| c.len_utf8()) + .sum::(); + let skip = input.len() - take_bytes; + + input = &input[skip..]; + } + + let prefix = ':'; + let text = format!("{}{}", prefix, input); + f.render_widget(CmdBar::new(&text, "", theme.cmd_bar), area); +} + +fn highlight_search_results(f: &mut Frame, pager: &Pager, layout: &Layout, style: NuStyle) { + if pager.search_buf.search_results.is_empty() { + return; + } + + let hightlight_block = Block::default().style(nu_style_to_tui(style)); + + for e in &layout.data { + if let Some(p) = e.text.find(&pager.search_buf.buf_cmd_input) { + // if p > e.width as usize { + // // we probably need to handle it somehow + // break; + // } + + // todo: might be not UTF-8 friendly + let w = pager.search_buf.buf_cmd_input.len() as u16; + let area = Rect::new(e.area.x + p as u16, e.area.y, w, 1); + f.render_widget(hightlight_block.clone(), area); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn handle_events( + engine_state: &EngineState, + stack: &mut Stack, + events: &UIEvents, + layout: &Layout, + info: &mut ViewInfo, + search: &mut SearchBuf, + command: &mut CommandBuf, + mut view: Option<&mut V>, +) -> (bool, bool) +where + V: View, +{ + let key = match events.next() { + Ok(Some(key)) => key, + _ => return (false, false), + }; + + if handle_exit_key_event(&key) { + return (true, true); + } + + if handle_general_key_events1(&key, search, command, view.as_deref_mut()) { + return (false, false); + } + + if let Some(view) = &mut view { + let t = view.handle_input(engine_state, stack, layout, info, key); + match t { + Some(Transition::Exit) => return (true, false), + Some(Transition::Cmd { .. }) => { + // todo: handle it + return (false, false); + } + Some(Transition::Ok) => return (false, false), + None => {} + } + } + + // was not handled so we must check our default controlls + + handle_general_key_events2(&key, search, command, view, info); + + (false, false) +} + +fn handle_exit_key_event(key: &KeyEvent) -> bool { + matches!( + key, + KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::CONTROL, + } | KeyEvent { + code: KeyCode::Char('z'), + modifiers: KeyModifiers::CONTROL, + } + ) +} + +fn handle_general_key_events1( + key: &KeyEvent, + search: &mut SearchBuf, + command: &mut CommandBuf, + view: Option<&mut V>, +) -> bool +where + V: View, +{ + if search.is_search_input { + return search_input_key_event(search, view, key); + } + + if command.is_cmd_input { + return cmd_input_key_event(command, key); + } + + false +} + +fn handle_general_key_events2( + key: &KeyEvent, + search: &mut SearchBuf, + command: &mut CommandBuf, + view: Option<&mut V>, + info: &mut ViewInfo, +) where + V: View, +{ + match key.code { + KeyCode::Char('?') => { + search.buf_cmd_input = String::new(); + search.is_search_input = true; + search.is_reversed = true; + + info.report = None; + } + KeyCode::Char('/') => { + search.buf_cmd_input = String::new(); + search.is_search_input = true; + search.is_reversed = false; + + info.report = None; + } + KeyCode::Char(':') => { + command.buf_cmd2 = String::new(); + command.is_cmd_input = true; + command.cmd_exec_info = None; + + info.report = None; + } + KeyCode::Char('n') => { + if !search.search_results.is_empty() { + if search.buf_cmd_input.is_empty() { + search.buf_cmd_input = search.buf_cmd.clone(); + } + + if search.search_index + 1 == search.search_results.len() { + search.search_index = 0 + } else { + search.search_index += 1; + } + + let pos = search.search_results[search.search_index]; + if let Some(view) = view { + view.show_data(pos); + } + } + } + _ => {} + } +} + +fn search_input_key_event( + buf: &mut SearchBuf, + view: Option<&mut impl View>, + key: &KeyEvent, +) -> bool { + match &key.code { + KeyCode::Esc => { + buf.buf_cmd_input = String::new(); + + if let Some(view) = view { + if !buf.buf_cmd.is_empty() { + let data = view.collect_data().into_iter().map(|(text, _)| text); + buf.search_results = search_pattern(data, &buf.buf_cmd, buf.is_reversed); + buf.search_index = 0; + } + } + + buf.is_search_input = false; + + true + } + KeyCode::Enter => { + buf.buf_cmd = buf.buf_cmd_input.clone(); + buf.is_search_input = false; + + true + } + KeyCode::Backspace => { + if buf.buf_cmd_input.is_empty() { + buf.is_search_input = false; + buf.is_reversed = false; + } else { + buf.buf_cmd_input.pop(); + + if let Some(view) = view { + if !buf.buf_cmd_input.is_empty() { + let data = view.collect_data().into_iter().map(|(text, _)| text); + buf.search_results = + search_pattern(data, &buf.buf_cmd_input, buf.is_reversed); + buf.search_index = 0; + + if !buf.search_results.is_empty() { + let pos = buf.search_results[buf.search_index]; + view.show_data(pos); + } + } + } + } + + true + } + KeyCode::Char(c) => { + buf.buf_cmd_input.push(*c); + + if let Some(view) = view { + if !buf.buf_cmd_input.is_empty() { + let data = view.collect_data().into_iter().map(|(text, _)| text); + buf.search_results = search_pattern(data, &buf.buf_cmd_input, buf.is_reversed); + buf.search_index = 0; + + if !buf.search_results.is_empty() { + let pos = buf.search_results[buf.search_index]; + view.show_data(pos); + } + } + } + + true + } + _ => false, + } +} + +fn search_pattern(data: impl Iterator, pat: &str, rev: bool) -> Vec { + let mut matches = Vec::new(); + for (row, text) in data.enumerate() { + if text.contains(pat) { + matches.push(row); + } + } + + if !rev { + matches.sort(); + } else { + matches.sort_by(|a, b| b.cmp(a)); + } + + matches +} + +fn cmd_input_key_event(buf: &mut CommandBuf, key: &KeyEvent) -> bool { + match &key.code { + KeyCode::Esc => { + buf.is_cmd_input = false; + buf.buf_cmd2 = String::new(); + true + } + KeyCode::Enter => { + buf.is_cmd_input = false; + buf.run_cmd = true; + buf.cmd_history.push(buf.buf_cmd2.clone()); + buf.cmd_history_pos = buf.cmd_history.len(); + true + } + KeyCode::Backspace => { + if buf.buf_cmd2.is_empty() { + buf.is_cmd_input = false; + } else { + buf.buf_cmd2.pop(); + buf.cmd_history_allow = false; + } + + true + } + KeyCode::Char(c) => { + buf.buf_cmd2.push(*c); + buf.cmd_history_allow = false; + true + } + KeyCode::Down if buf.buf_cmd2.is_empty() || buf.cmd_history_allow => { + if !buf.cmd_history.is_empty() { + buf.cmd_history_allow = true; + buf.cmd_history_pos = min( + buf.cmd_history_pos + 1, + buf.cmd_history.len().saturating_sub(1), + ); + buf.buf_cmd2 = buf.cmd_history[buf.cmd_history_pos].clone(); + } + + true + } + KeyCode::Up if buf.buf_cmd2.is_empty() || buf.cmd_history_allow => { + if !buf.cmd_history.is_empty() { + buf.cmd_history_allow = true; + buf.cmd_history_pos = buf.cmd_history_pos.saturating_sub(1); + buf.buf_cmd2 = buf.cmd_history[buf.cmd_history_pos].clone(); + } + + true + } + _ => true, + } +} + +#[derive(Debug, Clone)] +pub struct Pager<'a> { + cmd_buf: CommandBuf, + search_buf: SearchBuf, + table_cfg: TableConfig, + view_cfg: ViewConfig<'a>, +} + +#[derive(Debug, Clone, Default)] +struct SearchBuf { + buf_cmd: String, + buf_cmd_input: String, + search_results: Vec, + search_index: usize, + is_reversed: bool, + is_search_input: bool, +} + +#[derive(Debug, Clone, Default)] +struct CommandBuf { + is_cmd_input: bool, + run_cmd: bool, + buf_cmd2: String, + cmd_history: Vec, + cmd_history_allow: bool, + cmd_history_pos: usize, + cmd_exec_info: Option, +} + +#[derive(Debug, Default, Clone)] +pub struct StyleConfig { + pub status_info: NuStyle, + pub status_warn: NuStyle, + pub status_error: NuStyle, + pub status_bar: NuStyle, + pub cmd_bar: NuStyle, + pub split_line: NuStyle, + pub highlight: NuStyle, + pub selected_cell: Option, + pub selected_column: Option, + pub selected_row: Option, + pub show_cursow: bool, + pub split_lines: TableSplitLines, +} + +#[derive(Debug, Default, Clone)] +pub struct TableSplitLines { + pub header_top: bool, + pub header_bottom: bool, + pub shift_line: bool, + pub index_line: bool, +} + +impl<'a> Pager<'a> { + pub fn new(table_cfg: TableConfig, view_cfg: ViewConfig<'a>) -> Self { + Self { + cmd_buf: CommandBuf::default(), + search_buf: SearchBuf::default(), + table_cfg, + view_cfg, + } + } + + pub fn run( + &mut self, + engine_state: &EngineState, + stack: &mut Stack, + ctrlc: CtrlC, + view: Option, + commands: CommandList, + ) -> Result> { + run_pager(engine_state, stack, ctrlc, self, view, commands) + } +} + +struct StatusBar { + report: Report, + style: NuStyle, + message_style: NuStyle, +} + +impl StatusBar { + fn new(report: Report, style: NuStyle, message_style: NuStyle) -> Self { + Self { + report, + style, + message_style, + } + } +} + +impl Widget for StatusBar { + fn render(self, area: Rect, buf: &mut Buffer) { + let block_style = nu_style_to_tui(self.style); + let text_style = nu_style_to_tui(self.style).add_modifier(Modifier::BOLD); + let message_style = nu_style_to_tui(self.message_style).add_modifier(Modifier::BOLD); + + // colorize the line + let block = Block::default() + .borders(Borders::empty()) + .style(block_style); + block.render(area, buf); + + if !self.report.message.is_empty() { + let width = area.width.saturating_sub(3 + 12 + 12 + 12); + let name = nu_table::string_truncate(&self.report.message, width as usize); + let span = Span::styled(name, message_style); + buf.set_span(area.left(), area.y, &span, width); + } + + if !self.report.context2.is_empty() { + let span = Span::styled(&self.report.context2, text_style); + let span_w = self.report.context2.len() as u16; + let span_x = area.right().saturating_sub(3 + 12 + span_w); + buf.set_span(span_x, area.y, &span, span_w); + } + + if !self.report.context.is_empty() { + let span = Span::styled(&self.report.context, text_style); + let span_w = self.report.context.len() as u16; + let span_x = area.right().saturating_sub(span_w); + buf.set_span(span_x, area.y, &span, span_w); + } + } +} + +fn report_level_style(level: Severentity, theme: &StyleConfig) -> NuStyle { + match level { + Severentity::Info => theme.status_info, + Severentity::Warn => theme.status_warn, + Severentity::Err => theme.status_error, + } +} + +#[derive(Debug)] +struct CmdBar<'a> { + text: &'a str, + information: &'a str, + style: NuStyle, +} + +impl<'a> CmdBar<'a> { + fn new(text: &'a str, information: &'a str, style: NuStyle) -> Self { + Self { + text, + information, + style, + } + } +} + +impl Widget for CmdBar<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let text_style = nu_style_to_tui(self.style).add_modifier(Modifier::BOLD); + + // colorize the line + let block = Block::default() + .borders(Borders::empty()) + .style(Style::default()); + block.render(area, buf); + + let span = Span::styled(self.text, text_style); + let w = string_width(self.text); + buf.set_span(area.x, area.y, &span, w as u16); + + let span = Span::styled(self.information, text_style); + let w = string_width(self.information); + buf.set_span( + area.right().saturating_sub(12).saturating_sub(w as u16), + area.y, + &span, + w as u16, + ); + } +} + +pub fn nu_style_to_tui(style: NuStyle) -> tui::style::Style { + let mut out = tui::style::Style::default(); + if let Some(clr) = style.background { + out.bg = nu_ansi_color_to_tui_color(clr); + } + + if let Some(clr) = style.foreground { + out.fg = nu_ansi_color_to_tui_color(clr); + } + + if style.is_blink { + out.add_modifier |= Modifier::SLOW_BLINK; + } + + if style.is_bold { + out.add_modifier |= Modifier::BOLD; + } + + if style.is_dimmed { + out.add_modifier |= Modifier::DIM; + } + + if style.is_hidden { + out.add_modifier |= Modifier::HIDDEN; + } + + if style.is_italic { + out.add_modifier |= Modifier::ITALIC; + } + + if style.is_reverse { + out.add_modifier |= Modifier::REVERSED; + } + + if style.is_underline { + out.add_modifier |= Modifier::UNDERLINED; + } + + out +} + +#[derive(Debug, Default, Clone)] +pub struct ViewInfo { + pub cursor: Option, + pub status: Option, + pub report: Option, +} + +#[derive(Debug, Clone)] +pub struct Report { + pub message: String, + pub level: Severentity, + pub context: String, + pub context2: String, +} + +impl Report { + pub fn new(message: String, level: Severentity, context: String, context2: String) -> Self { + Self { + message, + level, + context, + context2, + } + } + + pub fn error(message: impl Into) -> Self { + Self::new( + message.into(), + Severentity::Err, + String::new(), + String::new(), + ) + } +} + +impl Default for Report { + fn default() -> Self { + Self::new( + String::new(), + Severentity::Info, + String::new(), + String::new(), + ) + } +} + +#[derive(Debug, Clone, Copy)] +pub enum Severentity { + Info, + #[allow(dead_code)] + Warn, + Err, +} + +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Position { + pub x: u16, + pub y: u16, +} + +impl Position { + pub fn new(x: u16, y: u16) -> Self { + Self { x, y } + } +} + +pub fn text_style_to_tui_style(style: TextStyle) -> tui::style::Style { + let mut out = tui::style::Style::default(); + if let Some(style) = style.color_style { + if let Some(clr) = style.background { + out.bg = nu_ansi_color_to_tui_color(clr); + } + + if let Some(clr) = style.foreground { + out.fg = nu_ansi_color_to_tui_color(clr); + } + } + + out +} + +pub fn nu_ansi_color_to_tui_color(clr: NuColor) -> Option { + use NuColor::*; + + let clr = match clr { + Black => Color::Black, + DarkGray => Color::DarkGray, + Red => Color::Red, + LightRed => Color::LightRed, + Green => Color::Green, + LightGreen => Color::LightGreen, + Yellow => Color::Yellow, + LightYellow => Color::LightYellow, + Blue => Color::Blue, + LightBlue => Color::LightBlue, + Magenta => Color::Magenta, + LightMagenta => Color::LightMagenta, + Cyan => Color::Cyan, + LightCyan => Color::LightCyan, + White => Color::White, + Fixed(i) => Color::Indexed(i), + Rgb(r, g, b) => tui::style::Color::Rgb(r, g, b), + LightGray => Color::Gray, + LightPurple => Color::LightMagenta, + Purple => Color::Magenta, + Default => return None, + }; + + Some(clr) +} + +pub fn make_styled_string( + text: String, + text_type: &str, + col: usize, + with_index: bool, + color_hm: &NuStyleTable, + float_precision: usize, +) -> NuText { + if col == 0 && with_index { + ( + text, + TextStyle { + alignment: Alignment::Right, + color_style: Some(color_hm["row_index"]), + }, + ) + } else if text_type == "float" { + // set dynamic precision from config + let precise_number = match convert_with_precision(&text, float_precision) { + Ok(num) => num, + Err(e) => e.to_string(), + }; + (precise_number, style_primitive(text_type, color_hm)) + } else { + (text, style_primitive(text_type, color_hm)) + } +} + +fn convert_with_precision(val: &str, precision: usize) -> Result { + // vall will always be a f64 so convert it with precision formatting + match val.trim().parse::() { + Ok(f) => Ok(format!("{:.prec$}", f, prec = precision)), + Err(err) => { + let message = format!("error converting string [{}] to f64; {}", &val, err); + Err(io::Error::new(io::ErrorKind::Other, message)) + } + } +} + +pub struct Page { + pub view: Box, + pub is_light: bool, +} + +impl Page { + pub fn raw(view: Box, is_light: bool) -> Self { + Self { view, is_light } + } + + pub fn new(view: V, is_light: bool) -> Self + where + V: View + 'static, + { + Self::raw(Box::new(view), is_light) + } +} diff --git a/crates/nu-explore/src/views/coloredtextw.rs b/crates/nu-explore/src/views/coloredtextw.rs new file mode 100644 index 000000000..342da1ffd --- /dev/null +++ b/crates/nu-explore/src/views/coloredtextw.rs @@ -0,0 +1,140 @@ +use std::borrow::Cow; + +use ansi_str::{get_blocks, AnsiStr}; +use tui::{ + layout::Rect, + style::{Color, Modifier, Style}, + widgets::Widget, +}; + +pub struct ColoredTextW<'a> { + text: &'a str, + col: usize, +} + +impl<'a> ColoredTextW<'a> { + pub fn new(text: &'a str, col: usize) -> Self { + Self { text, col } + } + + pub fn what(&self, area: Rect) -> String { + let text = cut_string(self.text, area, self.col); + text.ansi_strip().into_owned() + } +} + +impl Widget for ColoredTextW<'_> { + fn render(self, area: Rect, buf: &mut tui::buffer::Buffer) { + let text = cut_string(self.text, area, self.col); + + let mut offset = 0; + for block in get_blocks(&text) { + let text = block.text(); + let style = style_to_tui(block.style()); + + let x = area.x + offset as u16; + let (o, _) = buf.set_stringn(x, area.y, text, area.width as usize, style); + + offset = o + } + } +} + +fn cut_string(text: &str, area: Rect, skip: usize) -> Cow<'_, str> { + let mut text = Cow::Borrowed(text); + + if skip > 0 { + let n = text + .ansi_strip() + .chars() + .map(|c| c.len_utf8()) + .take(skip) + .sum::(); + + let s = text.ansi_get(n..).expect("must be OK").into_owned(); + text = Cow::Owned(s); + } + if !text.is_empty() && text.len() > area.width as usize { + let n = text + .ansi_strip() + .chars() + .map(|c| c.len_utf8()) + .take(area.width as usize) + .sum::(); + + let s = text.ansi_get(..n).expect("must be ok").into_owned(); + text = Cow::Owned(s); + } + + text +} + +fn style_to_tui(style: ansi_str::Style) -> Style { + let mut out = Style::default(); + if let Some(clr) = style.background() { + out.bg = ansi_color_to_tui_color(clr); + } + + if let Some(clr) = style.foreground() { + out.fg = ansi_color_to_tui_color(clr); + } + + if style.is_slow_blink() || style.is_rapid_blink() { + out.add_modifier |= Modifier::SLOW_BLINK; + } + + if style.is_bold() { + out.add_modifier |= Modifier::BOLD; + } + + if style.is_faint() { + out.add_modifier |= Modifier::DIM; + } + + if style.is_hide() { + out.add_modifier |= Modifier::HIDDEN; + } + + if style.is_italic() { + out.add_modifier |= Modifier::ITALIC; + } + + if style.is_inverse() { + out.add_modifier |= Modifier::REVERSED; + } + + if style.is_underline() { + out.add_modifier |= Modifier::UNDERLINED; + } + + out +} + +fn ansi_color_to_tui_color(clr: ansi_str::Color) -> Option { + use ansi_str::Color::*; + + let clr = match clr { + Black => Color::Black, + BrightBlack => Color::DarkGray, + Red => Color::Red, + BrightRed => Color::LightRed, + Green => Color::Green, + BrightGreen => Color::LightGreen, + Yellow => Color::Yellow, + BrightYellow => Color::LightYellow, + Blue => Color::Blue, + BrightBlue => Color::LightBlue, + Magenta => Color::Magenta, + BrightMagenta => Color::LightMagenta, + Cyan => Color::Cyan, + BrightCyan => Color::LightCyan, + White => Color::White, + Fixed(i) => Color::Indexed(i), + Rgb(r, g, b) => Color::Rgb(r, g, b), + BrightWhite => Color::Gray, + BrightPurple => Color::LightMagenta, + Purple => Color::Magenta, + }; + + Some(clr) +} diff --git a/crates/nu-explore/src/views/information.rs b/crates/nu-explore/src/views/information.rs new file mode 100644 index 000000000..9a0082697 --- /dev/null +++ b/crates/nu-explore/src/views/information.rs @@ -0,0 +1,76 @@ +use crossterm::event::KeyEvent; +use nu_protocol::engine::{EngineState, Stack}; +use nu_table::TextStyle; +use tui::{layout::Rect, widgets::Paragraph}; + +use crate::{ + nu_common::NuText, + pager::{Frame, Transition, ViewConfig, ViewInfo}, +}; + +use super::{Layout, View}; + +#[derive(Debug, Default)] +pub struct InformationView; + +impl InformationView { + const MESSAGE: [&'static str; 7] = [ + "Explore", + "", + "Explore helps you dynamically navigate through your data", + "", + "type :help for help", + "type :q to exit", + "type :try to enter a REPL", + ]; +} + +impl View for InformationView { + fn draw(&mut self, f: &mut Frame, area: Rect, _: &ViewConfig, layout: &mut Layout) { + let count_lines = Self::MESSAGE.len() as u16; + + if area.height < count_lines { + return; + } + + let centerh = area.height / 2; + let centerw = area.width / 2; + + let mut y = centerh.saturating_sub(count_lines); + for mut line in Self::MESSAGE { + let mut line_width = line.len() as u16; + if line_width > area.width { + line_width = area.width; + line = &line[..area.width as usize]; + } + + let x = centerw.saturating_sub(line_width / 2); + let area = Rect::new(area.x + x, area.y + y, line_width, 1); + + let paragraph = Paragraph::new(line); + f.render_widget(paragraph, area); + + layout.push(line, area.x, area.y, area.width, area.height); + + y += 1; + } + } + + fn handle_input( + &mut self, + _: &EngineState, + _: &mut Stack, + _: &Layout, + _: &mut ViewInfo, + _: KeyEvent, + ) -> Option { + None + } + + fn collect_data(&self) -> Vec { + Self::MESSAGE + .into_iter() + .map(|line| (line.to_owned(), TextStyle::default())) + .collect::>() + } +} diff --git a/crates/nu-explore/src/views/interative.rs b/crates/nu-explore/src/views/interative.rs new file mode 100644 index 000000000..17091988f --- /dev/null +++ b/crates/nu-explore/src/views/interative.rs @@ -0,0 +1,216 @@ +use std::cmp::min; + +use crossterm::event::{KeyCode, KeyEvent}; +use nu_protocol::{ + engine::{EngineState, Stack}, + PipelineData, Value, +}; +use tui::{ + layout::Rect, + style::{Modifier, Style}, + widgets::{BorderType, Borders, Paragraph}, +}; + +use crate::{ + nu_common::{collect_pipeline, run_nu_command}, + pager::{Frame, Report, TableConfig, Transition, ViewConfig, ViewInfo}, +}; + +use super::{record::RecordView, Layout, View}; + +pub struct InteractiveView<'a> { + input: Value, + command: String, + table: Option>, + view_mode: bool, + // todo: impl Debug for it + table_cfg: TableConfig, +} + +impl<'a> InteractiveView<'a> { + pub fn new(input: Value, table_cfg: TableConfig) -> Self { + Self { + input, + table_cfg, + table: None, + view_mode: false, + command: String::new(), + } + } + + pub fn init(&mut self, command: String) { + self.command = command; + } +} + +impl View for InteractiveView<'_> { + fn draw(&mut self, f: &mut Frame, area: Rect, cfg: &ViewConfig, layout: &mut Layout) { + let cmd_block = tui::widgets::Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain); + let cmd_area = Rect::new(area.x + 1, area.y, area.width - 2, 3); + + let cmd_block = if self.view_mode { + cmd_block + } else { + cmd_block + .border_style(Style::default().add_modifier(Modifier::BOLD)) + .border_type(BorderType::Double) + }; + + f.render_widget(cmd_block, cmd_area); + + let cmd_input_area = Rect::new( + cmd_area.x + 2, + cmd_area.y + 1, + cmd_area.width - 2 - 2 - 1, + 1, + ); + + let mut input = self.command.as_str(); + + let max_cmd_len = min(input.len() as u16, cmd_input_area.width); + if input.len() as u16 > max_cmd_len { + // in such case we take last max_cmd_len chars + let take_bytes = input + .chars() + .rev() + .take(max_cmd_len as usize) + .map(|c| c.len_utf8()) + .sum::(); + let skip = input.len() - take_bytes; + + input = &input[skip..]; + } + + let cmd_input = Paragraph::new(input); + + f.render_widget(cmd_input, cmd_input_area); + + if !self.view_mode { + let cur_w = area.x + 1 + 1 + 1 + max_cmd_len as u16; + let cur_w_max = area.x + 1 + 1 + 1 + area.width - 2 - 1 - 1 - 1 - 1; + if cur_w < cur_w_max { + f.set_cursor(area.x + 1 + 1 + 1 + max_cmd_len as u16, area.y + 1); + } + } + + let table_block = tui::widgets::Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain); + let table_area = Rect::new(area.x + 1, area.y + 3, area.width - 2, area.height - 3); + + let table_block = if self.view_mode { + table_block + .border_style(Style::default().add_modifier(Modifier::BOLD)) + .border_type(BorderType::Double) + } else { + table_block + }; + + f.render_widget(table_block, table_area); + + if let Some(table) = &mut self.table { + let area = Rect::new( + area.x + 2, + area.y + 4, + area.width - 3 - 1, + area.height - 3 - 1 - 1, + ); + + table.draw(f, area, cfg, layout); + } + } + + fn handle_input( + &mut self, + engine_state: &EngineState, + stack: &mut Stack, + layout: &Layout, + info: &mut ViewInfo, + key: KeyEvent, + ) -> Option { + if self.view_mode { + let table = self + .table + .as_mut() + .expect("we know that we have a table cause of a flag"); + + let was_at_the_top = table.get_layer_last().index_row == 0 && table.cursor.y == 0; + + if was_at_the_top && matches!(key.code, KeyCode::Up | KeyCode::PageUp) { + self.view_mode = false; + return Some(Transition::Ok); + } + + let result = table.handle_input(engine_state, stack, layout, info, key); + + return match result { + Some(Transition::Ok | Transition::Cmd { .. }) => Some(Transition::Ok), + Some(Transition::Exit) => { + self.view_mode = false; + Some(Transition::Ok) + } + None => None, + }; + } + + match &key.code { + KeyCode::Esc => Some(Transition::Exit), + KeyCode::Backspace => { + if !self.command.is_empty() { + self.command.pop(); + } + + Some(Transition::Ok) + } + KeyCode::Char(c) => { + self.command.push(*c); + Some(Transition::Ok) + } + KeyCode::Down => { + if self.table.is_some() { + self.view_mode = true; + } + + Some(Transition::Ok) + } + KeyCode::Enter => { + let pipeline = PipelineData::Value(self.input.clone(), None); + let pipeline = run_nu_command(engine_state, stack, &self.command, pipeline); + + match pipeline { + Ok(pipeline_data) => { + let (columns, values) = collect_pipeline(pipeline_data); + let view = RecordView::new(columns, values, self.table_cfg); + + self.table = Some(view); + + // in case there was a error before wanna reset it. + info.report = Some(Report::default()); + } + Err(err) => { + info.report = Some(Report::error(format!("Error: {}", err))); + } + } + + Some(Transition::Ok) + } + _ => None, + } + } + + fn exit(&mut self) -> Option { + self.table.as_mut().and_then(|v| v.exit()) + } + + fn collect_data(&self) -> Vec { + self.table + .as_ref() + .map_or_else(Vec::new, |v| v.collect_data()) + } + + fn show_data(&mut self, i: usize) -> bool { + self.table.as_mut().map_or(false, |v| v.show_data(i)) + } +} diff --git a/crates/nu-explore/src/views/mod.rs b/crates/nu-explore/src/views/mod.rs new file mode 100644 index 000000000..ea4aef36f --- /dev/null +++ b/crates/nu-explore/src/views/mod.rs @@ -0,0 +1,104 @@ +mod coloredtextw; +mod information; +mod interative; +mod preview; +mod record; + +use crossterm::event::KeyEvent; +use nu_protocol::{ + engine::{EngineState, Stack}, + Value, +}; +use tui::layout::Rect; + +use super::{ + nu_common::NuText, + pager::{Frame, Transition, ViewConfig, ViewInfo}, +}; + +pub use information::InformationView; +pub use interative::InteractiveView; +pub use preview::Preview; +pub use record::{RecordView, RecordViewState}; + +#[derive(Debug, Default)] +pub struct Layout { + pub data: Vec, +} + +impl Layout { + fn push(&mut self, text: &str, x: u16, y: u16, width: u16, height: u16) { + self.data.push(ElementInfo::new(text, x, y, width, height)); + } +} + +#[derive(Debug, Default, Clone)] +pub struct ElementInfo { + // todo: make it a Cow + pub text: String, + pub area: Rect, +} + +impl ElementInfo { + pub fn new(text: impl Into, x: u16, y: u16, width: u16, height: u16) -> Self { + Self { + text: text.into(), + area: Rect::new(x, y, width, height), + } + } +} + +pub trait View { + fn draw(&mut self, f: &mut Frame, area: Rect, cfg: &ViewConfig, layout: &mut Layout); + + fn handle_input( + &mut self, + engine_state: &EngineState, + stack: &mut Stack, + layout: &Layout, + info: &mut ViewInfo, + key: KeyEvent, + ) -> Option; + + fn show_data(&mut self, _: usize) -> bool { + false + } + + fn collect_data(&self) -> Vec { + Vec::new() + } + + fn exit(&mut self) -> Option { + None + } +} + +impl View for Box { + fn draw(&mut self, f: &mut Frame, area: Rect, cfg: &ViewConfig, layout: &mut Layout) { + self.as_mut().draw(f, area, cfg, layout) + } + + fn handle_input( + &mut self, + engine_state: &EngineState, + stack: &mut Stack, + layout: &Layout, + info: &mut ViewInfo, + key: KeyEvent, + ) -> Option { + self.as_mut() + .handle_input(engine_state, stack, layout, info, key) + } + + fn collect_data(&self) -> Vec { + self.as_ref().collect_data() + } + + fn exit(&mut self) -> Option { + self.as_mut().exit() + } + + fn show_data(&mut self, i: usize) -> bool { + self.as_mut().show_data(i) + } +} diff --git a/crates/nu-explore/src/views/preview.rs b/crates/nu-explore/src/views/preview.rs new file mode 100644 index 000000000..d7c958e78 --- /dev/null +++ b/crates/nu-explore/src/views/preview.rs @@ -0,0 +1,175 @@ +use std::cmp::{max, min}; + +use crossterm::event::{KeyCode, KeyEvent}; +use nu_protocol::{ + engine::{EngineState, Stack}, + Value, +}; +use nu_table::TextStyle; +use tui::layout::Rect; + +use crate::{ + nu_common::{NuSpan, NuText}, + pager::{Frame, Report, Severentity, Transition, ViewConfig, ViewInfo}, +}; + +use super::{coloredtextw::ColoredTextW, Layout, View}; + +// todo: Add wrap option +#[derive(Debug)] +pub struct Preview { + lines: Vec, + i_row: usize, + i_col: usize, + screen_size: u16, +} + +impl Preview { + pub fn new(value: &str) -> Self { + let lines: Vec = value + .lines() + .map(|line| line.replace('\t', " ")) // tui: doesn't support TAB + .collect(); + + Self { + lines, + i_col: 0, + i_row: 0, + screen_size: 0, + } + } +} + +impl View for Preview { + fn draw(&mut self, f: &mut Frame, area: Rect, _: &ViewConfig, layout: &mut Layout) { + if self.i_row >= self.lines.len() { + f.render_widget(tui::widgets::Clear, area); + return; + } + + let lines = &self.lines[self.i_row..]; + for (i, line) in lines.iter().enumerate().take(area.height as usize) { + let text = ColoredTextW::new(line, self.i_col); + + let area = Rect::new(area.x, area.y + i as u16, area.width, 1); + + let s = text.what(area); + layout.push(&s, area.x, area.y, area.width, area.height); + + f.render_widget(text, area) + } + + self.screen_size = area.width; + } + + fn handle_input( + &mut self, + _: &EngineState, + _: &mut Stack, + layout: &Layout, + info: &mut ViewInfo, // add this arg to draw too? + key: KeyEvent, + ) -> Option { + match key.code { + KeyCode::Left => { + if self.i_col > 0 { + self.i_col -= max(1, self.screen_size as usize / 2); + } + + Some(Transition::Ok) + } + KeyCode::Right => { + self.i_col += max(1, self.screen_size as usize / 2); + + Some(Transition::Ok) + } + KeyCode::Up => { + let page_size = layout.data.len(); + let max = self.lines.len().saturating_sub(page_size); + let was_end = self.i_row == max; + + if max != 0 && was_end { + info.status = Some(Report::default()); + } + + self.i_row = self.i_row.saturating_sub(1); + + Some(Transition::Ok) + } + KeyCode::Down => { + let page_size = layout.data.len(); + let max = self.lines.len().saturating_sub(page_size); + self.i_row = min(self.i_row + 1, max); + + let is_end = self.i_row == max; + if is_end { + let report = Report::new( + String::from("END"), + Severentity::Info, + String::new(), + String::new(), + ); + + info.status = Some(report); + } + + Some(Transition::Ok) + } + KeyCode::PageUp => { + let page_size = layout.data.len(); + let max = self.lines.len().saturating_sub(page_size); + let was_end = self.i_row == max; + + if max != 0 && was_end { + info.status = Some(Report::default()); + } + + self.i_row = self.i_row.saturating_sub(page_size); + + Some(Transition::Ok) + } + KeyCode::PageDown => { + let page_size = layout.data.len(); + let max = self.lines.len().saturating_sub(page_size); + self.i_row = min(self.i_row + page_size, max); + + let is_end = self.i_row == max; + if is_end { + let report = Report::new( + String::from("END"), + Severentity::Info, + String::new(), + String::new(), + ); + + info.status = Some(report); + } + + Some(Transition::Ok) + } + KeyCode::Esc => Some(Transition::Exit), + _ => None, + } + } + + fn collect_data(&self) -> Vec { + self.lines + .iter() + .map(|line| (line.to_owned(), TextStyle::default())) + .collect::>() + } + + fn show_data(&mut self, row: usize) -> bool { + // we can only go to the appropriate line, but we can't target column + // + // todo: improve somehow? + + self.i_row = row; + true + } + + fn exit(&mut self) -> Option { + let text = self.lines.join("\n"); + Some(Value::string(text, NuSpan::unknown())) + } +} diff --git a/crates/nu-explore/src/views/record/mod.rs b/crates/nu-explore/src/views/record/mod.rs new file mode 100644 index 000000000..5f32968c5 --- /dev/null +++ b/crates/nu-explore/src/views/record/mod.rs @@ -0,0 +1,661 @@ +mod tablew; + +use std::{borrow::Cow, cmp::min, collections::HashMap}; + +use crossterm::event::{KeyCode, KeyEvent}; +use nu_protocol::{ + engine::{EngineState, Stack}, + Value, +}; +use tui::{layout::Rect, widgets::Block}; + +use crate::{ + nu_common::{collect_input, NuConfig, NuSpan, NuStyleTable, NuText}, + pager::{ + make_styled_string, nu_style_to_tui, Frame, Position, Report, Severentity, StyleConfig, + TableConfig, Transition, ViewConfig, ViewInfo, + }, + views::ElementInfo, +}; + +use self::tablew::{TableW, TableWState}; + +use super::{Layout, View}; + +#[derive(Debug, Clone)] +pub struct RecordView<'a> { + layer_stack: Vec>, + mode: UIMode, + cfg: TableConfig, + pub(crate) cursor: Position, + state: RecordViewState, +} + +impl<'a> RecordView<'a> { + pub fn new( + columns: impl Into>, + records: impl Into]>>, + table_cfg: TableConfig, + ) -> Self { + Self { + layer_stack: vec![RecordLayer::new(columns, records)], + mode: UIMode::View, + cursor: Position::new(0, 0), + cfg: table_cfg, + state: RecordViewState::default(), + } + } + + pub fn reverse(&mut self, width: u16, height: u16) { + let page_size = estimate_page_size(Rect::new(0, 0, width, height), self.cfg.show_head); + state_reverse_data(self, page_size as usize); + } + + // todo: rename to get_layer + pub fn get_layer_last(&self) -> &RecordLayer<'a> { + self.layer_stack + .last() + .expect("we guarantee that 1 entry is always in a list") + } + + pub fn get_layer_last_mut(&mut self) -> &mut RecordLayer<'a> { + self.layer_stack + .last_mut() + .expect("we guarantee that 1 entry is always in a list") + } + + fn create_tablew<'b>(&self, layer: &'b RecordLayer, view_cfg: &'b ViewConfig) -> TableW<'b> { + let data = convert_records_to_string(&layer.records, view_cfg.config, view_cfg.color_hm); + + let style = tablew::TableStyle { + show_index: self.cfg.show_index, + show_header: self.cfg.show_head, + splitline_style: view_cfg.theme.split_line, + header_bottom: view_cfg.theme.split_lines.header_bottom, + header_top: view_cfg.theme.split_lines.header_top, + index_line: view_cfg.theme.split_lines.index_line, + shift_line: view_cfg.theme.split_lines.shift_line, + }; + + let headers = layer.columns.as_ref(); + let color_hm = view_cfg.color_hm; + let i_row = layer.index_row; + let i_column = layer.index_column; + + TableW::new(headers, data, color_hm, i_row, i_column, style) + } +} + +impl View for RecordView<'_> { + fn draw(&mut self, f: &mut Frame, area: Rect, cfg: &ViewConfig, layout: &mut Layout) { + let layer = self.get_layer_last(); + let table = self.create_tablew(layer, cfg); + + let mut table_layout = TableWState::default(); + f.render_stateful_widget(table, area, &mut table_layout); + + *layout = table_layout.layout; + self.state = RecordViewState { + count_rows: table_layout.count_rows, + count_columns: table_layout.count_columns, + data_index: table_layout.data_index, + }; + + if self.mode == UIMode::Cursor { + let cursor = get_cursor(self); + highlight_cell(f, area, &self.state, cursor, cfg.theme); + } + } + + fn handle_input( + &mut self, + _: &EngineState, + _: &mut Stack, + _: &Layout, + info: &mut ViewInfo, + key: KeyEvent, + ) -> Option { + let result = match self.mode { + UIMode::View => handle_key_event_view_mode(self, &key), + UIMode::Cursor => { + // we handle a situation where we got resized and the old cursor is no longer valid + self.cursor = get_cursor(self); + + handle_key_event_cursor_mode(self, &key) + } + }; + + if matches!(&result, Some(Transition::Ok) | Some(Transition::Cmd { .. })) { + // update status bar + let report = + create_records_report(self.get_layer_last(), &self.state, self.mode, self.cursor); + + info.status = Some(report); + } + + result + } + + fn collect_data(&self) -> Vec { + let data = convert_records_to_string( + &self.get_layer_last().records, + &NuConfig::default(), + &HashMap::default(), + ); + + data.iter().flatten().cloned().collect() + } + + fn show_data(&mut self, pos: usize) -> bool { + let data = &self.get_layer_last().records; + + let mut i = 0; + for (row, cells) in data.iter().enumerate() { + if pos > i + cells.len() { + i += cells.len(); + continue; + } + + for (column, _) in cells.iter().enumerate() { + if i == pos { + let layer = self.get_layer_last_mut(); + layer.index_column = column; + layer.index_row = row; + + return true; + } + + i += 1; + } + } + + false + } + + fn exit(&mut self) -> Option { + Some(build_last_value(self)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum UIMode { + Cursor, + View, +} + +#[derive(Debug, Clone)] +pub struct RecordLayer<'a> { + columns: Cow<'a, [String]>, + records: Cow<'a, [Vec]>, + pub(crate) index_row: usize, + pub(crate) index_column: usize, + name: Option, + was_transposed: bool, +} + +impl<'a> RecordLayer<'a> { + fn new( + columns: impl Into>, + records: impl Into]>>, + ) -> Self { + Self { + columns: columns.into(), + records: records.into(), + index_row: 0, + index_column: 0, + name: None, + was_transposed: false, + } + } + + fn set_name(&mut self, name: impl Into) { + self.name = Some(name.into()); + } + + fn count_rows(&self) -> usize { + self.records.len() + } + + fn count_columns(&self) -> usize { + self.columns.len() + } + + fn get_current_value(&self, Position { x, y }: Position) -> Value { + let current_row = y as usize + self.index_row; + let current_column = x as usize + self.index_column; + + let row = self.records[current_row].clone(); + row[current_column].clone() + } + + fn get_current_header(&self, Position { x, .. }: Position) -> Option { + let col = x as usize + self.index_column; + + self.columns.get(col).map(|header| header.to_string()) + } +} + +#[derive(Debug, Default, Clone)] +pub struct RecordViewState { + count_rows: usize, + count_columns: usize, + data_index: HashMap<(usize, usize), ElementInfo>, +} + +fn handle_key_event_view_mode(view: &mut RecordView, key: &KeyEvent) -> Option { + match key.code { + KeyCode::Esc => { + if view.layer_stack.len() > 1 { + view.layer_stack.pop(); + Some(Transition::Ok) + } else { + Some(Transition::Exit) + } + } + KeyCode::Char('i') => { + view.mode = UIMode::Cursor; + view.cursor = Position::default(); + + Some(Transition::Ok) + } + KeyCode::Char('t') => { + let layer = view.get_layer_last_mut(); + layer.index_column = 0; + layer.index_row = 0; + + transpose_table(layer); + + Some(Transition::Ok) + } + KeyCode::Up => { + let layer = view.get_layer_last_mut(); + layer.index_row = layer.index_row.saturating_sub(1); + + Some(Transition::Ok) + } + KeyCode::Down => { + let layer = view.get_layer_last_mut(); + let max_index = layer.count_rows().saturating_sub(1); + layer.index_row = min(layer.index_row + 1, max_index); + + Some(Transition::Ok) + } + KeyCode::Left => { + let layer = view.get_layer_last_mut(); + layer.index_column = layer.index_column.saturating_sub(1); + + Some(Transition::Ok) + } + KeyCode::Right => { + let layer = view.get_layer_last_mut(); + let max_index = layer.count_columns().saturating_sub(1); + layer.index_column = min(layer.index_column + 1, max_index); + + Some(Transition::Ok) + } + KeyCode::PageUp => { + let count_rows = view.state.count_rows; + let layer = view.get_layer_last_mut(); + layer.index_row = layer.index_row.saturating_sub(count_rows as usize); + + Some(Transition::Ok) + } + KeyCode::PageDown => { + let count_rows = view.state.count_rows; + let layer = view.get_layer_last_mut(); + let max_index = layer.count_rows().saturating_sub(1); + layer.index_row = min(layer.index_row + count_rows as usize, max_index); + + Some(Transition::Ok) + } + _ => None, + } +} + +fn handle_key_event_cursor_mode(view: &mut RecordView, key: &KeyEvent) -> Option { + match key.code { + KeyCode::Esc => { + view.mode = UIMode::View; + view.cursor = Position::default(); + + Some(Transition::Ok) + } + KeyCode::Up => { + if view.cursor.y == 0 { + let layer = view.get_layer_last_mut(); + layer.index_row = layer.index_row.saturating_sub(1); + } else { + view.cursor.y -= 1 + } + + Some(Transition::Ok) + } + KeyCode::Down => { + let cursor = view.cursor; + let showed_rows = view.state.count_rows; + let layer = view.get_layer_last_mut(); + + let total_rows = layer.count_rows(); + let row_index = layer.index_row + cursor.y as usize + 1; + + if row_index < total_rows { + if cursor.y as usize + 1 == showed_rows { + layer.index_row += 1; + } else { + view.cursor.y += 1; + } + } + + Some(Transition::Ok) + } + KeyCode::Left => { + let cursor = view.cursor; + let layer = view.get_layer_last_mut(); + + if cursor.x == 0 { + layer.index_column = layer.index_column.saturating_sub(1); + } else { + view.cursor.x -= 1 + } + + Some(Transition::Ok) + } + KeyCode::Right => { + let cursor = view.cursor; + let showed_columns = view.state.count_columns; + let layer = view.get_layer_last_mut(); + + let total_columns = layer.count_columns(); + let column_index = layer.index_column + cursor.x as usize + 1; + + if column_index < total_columns { + if cursor.x as usize + 1 == showed_columns { + layer.index_column += 1; + } else { + view.cursor.x += 1; + } + } + + Some(Transition::Ok) + } + KeyCode::Enter => { + let next_layer = get_peeked_layer(view); + push_layer(view, next_layer); + Some(Transition::Ok) + } + _ => None, + } +} + +fn get_peeked_layer(view: &RecordView) -> RecordLayer<'static> { + let layer = view.get_layer_last(); + + let value = layer.get_current_value(view.cursor); + + let (columns, values) = collect_input(value); + + RecordLayer::new(columns, values) +} + +fn push_layer(view: &mut RecordView<'_>, mut next_layer: RecordLayer<'static>) { + let layer = view.get_layer_last(); + let header = layer.get_current_header(view.cursor); + + if let Some(header) = header { + next_layer.set_name(header); + } + + view.layer_stack.push(next_layer); + + view.mode = UIMode::View; + view.cursor = Position::default(); +} + +fn estimate_page_size(area: Rect, show_head: bool) -> u16 { + let mut available_height = area.height; + available_height -= 3; // status_bar + + if show_head { + available_height -= 3; // head + } + + available_height +} + +fn state_reverse_data(state: &mut RecordView<'_>, page_size: usize) { + let layer = state.get_layer_last_mut(); + let count_rows = layer.records.len(); + if count_rows > page_size as usize { + layer.index_row = count_rows - page_size as usize; + } +} + +fn convert_records_to_string( + records: &[Vec], + cfg: &NuConfig, + color_hm: &NuStyleTable, +) -> Vec> { + records + .iter() + .map(|row| { + row.iter() + .map(|value| { + let text = value.clone().into_abbreviated_string(cfg); + let tp = value.get_type().to_string(); + let float_precision = cfg.float_precision as usize; + + make_styled_string(text, &tp, 0, false, color_hm, float_precision) + }) + .collect::>() + }) + .collect::>() +} + +fn highlight_cell( + f: &mut Frame, + area: Rect, + state: &RecordViewState, + cursor: Position, + theme: &StyleConfig, +) { + let Position { x: column, y: row } = cursor; + + let info = state.data_index.get(&(row as usize, column as usize)); + + if let Some(info) = info { + if let Some(style) = theme.selected_column { + let hightlight_block = Block::default().style(nu_style_to_tui(style)); + let area = Rect::new(info.area.x, area.y, info.area.width, area.height); + f.render_widget(hightlight_block.clone(), area); + } + + if let Some(style) = theme.selected_row { + let hightlight_block = Block::default().style(nu_style_to_tui(style)); + let area = Rect::new(area.x, info.area.y, area.width, 1); + f.render_widget(hightlight_block.clone(), area); + } + + if let Some(style) = theme.selected_cell { + let hightlight_block = Block::default().style(nu_style_to_tui(style)); + let area = Rect::new(info.area.x, info.area.y, info.area.width, 1); + f.render_widget(hightlight_block.clone(), area); + } + + if theme.show_cursow { + f.set_cursor(info.area.x, info.area.y); + } + } +} + +fn get_cursor(v: &RecordView<'_>) -> Position { + let count_rows = v.state.count_rows as u16; + let count_columns = v.state.count_columns as u16; + + let mut cursor = v.cursor; + cursor.y = min(cursor.y, count_rows.saturating_sub(1) as u16); + cursor.x = min(cursor.x, count_columns.saturating_sub(1) as u16); + + cursor +} + +fn build_last_value(v: &RecordView) -> Value { + if v.mode == UIMode::Cursor { + peak_current_value(v) + } else if v.get_layer_last().count_rows() < 2 { + build_table_as_record(v) + } else { + build_table_as_list(v) + } +} + +fn peak_current_value(v: &RecordView) -> Value { + let layer = v.get_layer_last(); + let Position { x: column, y: row } = v.cursor; + let row = row as usize + layer.index_row; + let column = column as usize + layer.index_column; + let value = &layer.records[row][column]; + value.clone() +} + +fn build_table_as_list(v: &RecordView) -> Value { + let layer = v.get_layer_last(); + + let headers = layer.columns.to_vec(); + let vals = layer + .records + .iter() + .cloned() + .map(|vals| Value::Record { + cols: headers.clone(), + vals, + span: NuSpan::unknown(), + }) + .collect(); + + Value::List { + vals, + span: NuSpan::unknown(), + } +} + +fn build_table_as_record(v: &RecordView) -> Value { + let layer = v.get_layer_last(); + + let cols = layer.columns.to_vec(); + let vals = layer.records.get(0).map_or(Vec::new(), |row| row.clone()); + + Value::Record { + cols, + vals, + span: NuSpan::unknown(), + } +} + +fn create_records_report( + layer: &RecordLayer, + state: &RecordViewState, + mode: UIMode, + cursor: Position, +) -> Report { + let seen_rows = layer.index_row + state.count_rows; + let seen_rows = min(seen_rows, layer.count_rows()); + let percent_rows = get_percentage(seen_rows, layer.count_rows()); + let covered_percent = match percent_rows { + 100 => String::from("All"), + _ if layer.index_row == 0 => String::from("Top"), + value => format!("{}%", value), + }; + let title = if let Some(name) = &layer.name { + name.clone() + } else { + String::new() + }; + let cursor = { + if mode == UIMode::Cursor { + let row = layer.index_row + cursor.y as usize; + let column = layer.index_column + cursor.x as usize; + format!("{},{}", row, column) + } else { + format!("{},{}", layer.index_row, layer.index_column) + } + }; + + Report { + message: title, + context: covered_percent, + context2: cursor, + level: Severentity::Info, + } +} + +fn get_percentage(value: usize, max: usize) -> usize { + debug_assert!(value <= max, "{:?} {:?}", value, max); + + ((value as f32 / max as f32) * 100.0).floor() as usize +} + +fn transpose_table(layer: &mut RecordLayer<'_>) { + let count_rows = layer.count_rows(); + let count_columns = layer.count_columns(); + + if layer.was_transposed { + let data = match &mut layer.records { + Cow::Owned(data) => data, + Cow::Borrowed(_) => unreachable!("must never happen"), + }; + + let headers = pop_first_column(data); + let headers = headers + .into_iter() + .map(|value| match value { + Value::String { val, .. } => val, + _ => unreachable!("must never happen"), + }) + .collect(); + + let data = _transpose_table(data, count_rows, count_columns - 1); + + layer.records = Cow::Owned(data); + layer.columns = Cow::Owned(headers); + } else { + let mut data = _transpose_table(&layer.records, count_rows, count_columns); + + for (column, column_name) in layer.columns.iter().enumerate() { + let value = Value::String { + val: column_name.to_string(), + span: NuSpan::unknown(), + }; + + data[column].insert(0, value); + } + + layer.records = Cow::Owned(data); + layer.columns = (1..count_rows + 1 + 1).map(|i| i.to_string()).collect(); + } + + layer.was_transposed = !layer.was_transposed; +} + +fn pop_first_column(values: &mut [Vec]) -> Vec { + let mut data = vec![Value::default(); values.len()]; + for (row, values) in values.iter_mut().enumerate() { + data[row] = values.remove(0); + } + + data +} + +fn _transpose_table( + values: &[Vec], + count_rows: usize, + count_columns: usize, +) -> Vec> { + let mut data = vec![vec![Value::default(); count_rows]; count_columns]; + for (row, values) in values.iter().enumerate() { + for (column, value) in values.iter().enumerate() { + data[column][row] = value.to_owned(); + } + } + + data +} diff --git a/crates/nu-explore/src/views/record/tablew.rs b/crates/nu-explore/src/views/record/tablew.rs new file mode 100644 index 000000000..da399b1ad --- /dev/null +++ b/crates/nu-explore/src/views/record/tablew.rs @@ -0,0 +1,574 @@ +use std::{borrow::Cow, cmp::max, collections::HashMap}; + +use nu_table::{string_width, Alignment, TextStyle}; +use tui::{ + buffer::Buffer, + layout::Rect, + text::Span, + widgets::{Block, Borders, Paragraph, StatefulWidget, Widget}, +}; + +use crate::{ + nu_common::{NuStyle, NuStyleTable, NuText}, + pager::{nu_style_to_tui, text_style_to_tui_style}, + views::ElementInfo, +}; + +use super::Layout; + +pub struct TableW<'a> { + columns: Cow<'a, [String]>, + data: Cow<'a, [Vec]>, + index_row: usize, + index_column: usize, + style: TableStyle, + color_hm: &'a NuStyleTable, +} + +pub struct TableStyle { + pub show_index: bool, + pub show_header: bool, + pub splitline_style: NuStyle, + pub header_top: bool, + pub header_bottom: bool, + pub shift_line: bool, + pub index_line: bool, +} + +impl<'a> TableW<'a> { + #[allow(clippy::too_many_arguments)] + pub fn new( + columns: impl Into>, + data: impl Into]>>, + color_hm: &'a NuStyleTable, + index_row: usize, + index_column: usize, + style: TableStyle, + ) -> Self { + Self { + columns: columns.into(), + data: data.into(), + color_hm, + index_row, + index_column, + style, + } + } +} + +#[derive(Debug, Default)] +pub struct TableWState { + pub layout: Layout, + pub count_rows: usize, + pub count_columns: usize, + pub data_index: HashMap<(usize, usize), ElementInfo>, +} + +impl StatefulWidget for TableW<'_> { + type State = TableWState; + + fn render( + self, + area: tui::layout::Rect, + buf: &mut tui::buffer::Buffer, + state: &mut Self::State, + ) { + const CELL_PADDING_LEFT: u16 = 2; + const CELL_PADDING_RIGHT: u16 = 2; + + let show_index = self.style.show_index; + let show_head = self.style.show_header; + let splitline_s = self.style.splitline_style; + + let mut data_y = area.y; + let mut data_height = area.height; + let mut head_y = area.y; + if show_head { + data_y += 1; + data_height -= 1; + + if self.style.header_top { + data_y += 1; + data_height -= 1; + head_y += 1 + } + + if self.style.header_bottom { + data_y += 1; + data_height -= 1; + } + } + + if area.width == 0 || area.height == 0 { + return; + } + + let mut width = area.x; + + let mut data = &self.data[self.index_row..]; + if data.len() > data_height as usize { + data = &data[..data_height as usize]; + } + + // header lines + if show_head { + // fixme: color from config + let top = self.style.header_top; + let bottom = self.style.header_bottom; + + if top || bottom { + render_header_borders(buf, area, 0, 1, splitline_s, top, bottom); + } + } + + if show_index { + let area = Rect::new(width, data_y, area.width, data_height); + width += render_index(buf, area, self.color_hm, self.index_row); + + if self.style.index_line { + let show_head = show_head && self.style.header_bottom; + width += render_vertical(buf, width, data_y, data_height, show_head, splitline_s); + } + } + + let mut do_render_split_line = true; + let mut do_render_shift_column = false; + + state.count_rows = data.len(); + state.count_columns = 0; + + for (i, col) in (self.index_column..self.columns.len()).enumerate() { + let mut head = String::from(&self.columns[col]); + + let mut column = create_column(data, col); + + let column_width = calculate_column_width(&column); + let mut use_space = column_width as u16; + + if show_head { + let head_width = string_width(&head); + use_space = max(head_width as u16, use_space); + } + + { + let available_space = area.width - width; + let head = show_head.then_some(&mut head); + let control = truncate_column( + &mut column, + head, + available_space, + col + 1 == self.columns.len(), + PrintControl { + break_everything: false, + print_shift_column: false, + print_split_line: true, + width: use_space, + }, + ); + + use_space = control.width; + do_render_split_line = control.print_split_line; + do_render_shift_column = control.print_shift_column; + + if control.break_everything { + break; + } + } + + if show_head { + let header = &[head_row_text(&head, self.color_hm)]; + + let mut w = width; + w += render_space(buf, w, head_y, 1, CELL_PADDING_LEFT); + w += render_column(buf, w, head_y, use_space, header); + render_space(buf, w, head_y, 1, CELL_PADDING_RIGHT); + + let x = w - CELL_PADDING_RIGHT - use_space; + state.layout.push(&header[0].0, x, head_y, use_space, 1); + + // it would be nice to add it so it would be available on search + // state.state.data_index.insert((i, col), ElementInfo::new(text, x, data_y, use_space, 1)); + } + + width += render_space(buf, width, data_y, data_height, CELL_PADDING_LEFT); + width += render_column(buf, width, data_y, use_space, &column); + width += render_space(buf, width, data_y, data_height, CELL_PADDING_RIGHT); + + for (row, (text, _)) in column.iter().enumerate() { + let x = width - CELL_PADDING_RIGHT - use_space; + let y = data_y + row as u16; + state.layout.push(text, x, y, use_space, 1); + + let e = ElementInfo::new(text, x, y, use_space, 1); + state.data_index.insert((row, i), e); + } + + state.count_columns += 1; + + if do_render_shift_column { + break; + } + } + + if do_render_shift_column { + // we actually want to show a shift only in header. + // + // render_shift_column(buf, used_width, head_offset, available_height); + + if show_head { + width += render_space(buf, width, data_y, data_height, CELL_PADDING_LEFT); + width += render_shift_column(buf, width, head_y, 1, splitline_s); + width += render_space(buf, width, data_y, data_height, CELL_PADDING_RIGHT); + } + } + + if do_render_split_line && self.style.shift_line { + let show_head = show_head && self.style.header_bottom; + width += render_vertical(buf, width, data_y, data_height, show_head, splitline_s); + } + + // we try out best to cleanup the rest of the space cause it could be meassed. + let rest = area.width.saturating_sub(width); + if rest > 0 { + render_space(buf, width, data_y, data_height, rest); + if show_head { + render_space(buf, width, head_y, 1, rest); + } + } + } +} + +struct IndexColumn<'a> { + color_hm: &'a NuStyleTable, + start: usize, +} + +impl<'a> IndexColumn<'a> { + fn new(color_hm: &'a NuStyleTable, start: usize) -> Self { + Self { color_hm, start } + } + + fn estimate_width(&self, height: u16) -> usize { + let last_row = self.start + height as usize; + last_row.to_string().len() + } +} + +impl Widget for IndexColumn<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let style = nu_style_to_tui(self.color_hm["row_index"]); + + for row in 0..area.height { + let i = 1 + row as usize + self.start; + let text = i.to_string(); + + let p = Paragraph::new(text) + .style(style) + .alignment(tui::layout::Alignment::Right); + let area = Rect::new(area.x, area.y + row, area.width, 1); + + p.render(area, buf); + } + } +} + +fn render_header_borders( + buf: &mut Buffer, + area: Rect, + y: u16, + span: u16, + style: NuStyle, + top: bool, + bottom: bool, +) -> (u16, u16) { + let mut i = 0; + let mut borders = Borders::NONE; + if top { + borders |= Borders::TOP; + i += 1; + } + + if bottom { + borders |= Borders::BOTTOM; + i += 1; + } + + let block = Block::default() + .borders(borders) + .border_style(nu_style_to_tui(style)); + let height = i + span; + let area = Rect::new(area.x, area.y + y, area.width, height); + block.render(area, buf); + + // y pos of header text and next line + (height.saturating_sub(2), height) +} + +fn render_index(buf: &mut Buffer, area: Rect, color_hm: &NuStyleTable, start_index: usize) -> u16 { + const PADDING_LEFT: u16 = 2; + const PADDING_RIGHT: u16 = 1; + + let mut width = render_space(buf, area.x, area.y, area.height, PADDING_LEFT); + + let index = IndexColumn::new(color_hm, start_index); + let w = index.estimate_width(area.height) as u16; + let area = Rect::new(area.x + width, area.y, w, area.height); + + index.render(area, buf); + + width += w; + width += render_space(buf, area.x + width, area.y, area.height, PADDING_RIGHT); + + width +} + +fn render_vertical( + buf: &mut Buffer, + x: u16, + y: u16, + height: u16, + show_header: bool, + style: NuStyle, +) -> u16 { + render_vertical_split(buf, x, y, height, style); + + if show_header && y > 0 { + render_top_connector(buf, x, y - 1, style); + } + + // render_bottom_connector(buf, x, height + y); + + 1 +} + +fn render_vertical_split(buf: &mut Buffer, x: u16, y: u16, height: u16, style: NuStyle) { + let style = TextStyle { + alignment: Alignment::Left, + color_style: Some(style), + }; + + repeat_vertical(buf, x, y, 1, height, '│', style); +} + +fn render_space(buf: &mut Buffer, x: u16, y: u16, height: u16, padding: u16) -> u16 { + repeat_vertical(buf, x, y, padding, height, ' ', TextStyle::default()); + padding +} + +fn create_column(data: &[Vec], col: usize) -> Vec { + let mut column = vec![NuText::default(); data.len()]; + for (row, values) in data.iter().enumerate() { + if values.is_empty() { + debug_assert!(false, "must never happen?"); + continue; + } + + let value = &values[col]; + column[row] = value.clone(); + } + + column +} + +fn repeat_vertical( + buf: &mut tui::buffer::Buffer, + x_offset: u16, + y_offset: u16, + width: u16, + height: u16, + c: char, + style: TextStyle, +) { + let text = std::iter::repeat(c) + .take(width as usize) + .collect::(); + let style = text_style_to_tui_style(style); + let span = Span::styled(text, style); + + for row in 0..height { + buf.set_span(x_offset, y_offset + row as u16, &span, width); + } +} + +#[derive(Debug, Default, Copy, Clone)] +struct PrintControl { + width: u16, + break_everything: bool, + print_split_line: bool, + print_shift_column: bool, +} + +fn truncate_column( + column: &mut [NuText], + head: Option<&mut String>, + available_space: u16, + is_column_last: bool, + mut control: PrintControl, +) -> PrintControl { + const CELL_PADDING_LEFT: u16 = 2; + const CELL_PADDING_RIGHT: u16 = 2; + const VERTICAL_LINE_WIDTH: u16 = 1; + const CELL_MIN_WIDTH: u16 = 1; + + let min_space_cell = CELL_PADDING_LEFT + CELL_PADDING_RIGHT + CELL_MIN_WIDTH; + let min_space = min_space_cell + VERTICAL_LINE_WIDTH; + if available_space < min_space { + // if there's not enough space at all just return; doing our best + if available_space < VERTICAL_LINE_WIDTH { + control.print_split_line = false; + } + + control.break_everything = true; + return control; + } + + let column_taking_space = + control.width + CELL_PADDING_LEFT + CELL_PADDING_RIGHT + VERTICAL_LINE_WIDTH; + let is_enough_space = available_space > column_taking_space; + if !is_enough_space { + if is_column_last { + // we can do nothing about it we need to truncate. + // we assume that there's always at least space for padding and 1 symbol. (5 chars) + + let width = available_space + .saturating_sub(CELL_PADDING_LEFT + CELL_PADDING_RIGHT + VERTICAL_LINE_WIDTH); + if width == 0 { + control.break_everything = true; + return control; + } + + if let Some(head) = head { + truncate_str(head, width as usize); + } + + truncate_list(column, width as usize); + + control.width = width; + } else { + let min_space_2cells = min_space + min_space_cell; + if available_space > min_space_2cells { + let width = available_space.saturating_sub(min_space_2cells); + if width == 0 { + control.break_everything = true; + return control; + } + + truncate_list(column, width as usize); + + if let Some(head) = head { + truncate_str(head, width as usize); + } + + control.width = width; + control.print_shift_column = true; + } else { + control.break_everything = true; + control.print_shift_column = true; + } + } + } else if !is_column_last { + // even though we can safely render current column, + // we need to check whether there's enough space for AT LEAST a shift column + // (2 padding + 2 padding + 1 a char) + let left_space = available_space - column_taking_space; + if left_space < min_space { + let need_space = min_space_cell - left_space; + let min_left_width = 1; + let is_column_big_enough = control.width > need_space + min_left_width; + + if is_column_big_enough { + let width = control.width.saturating_sub(need_space); + if width == 0 { + control.break_everything = true; + return control; + } + + truncate_list(column, width as usize); + + if let Some(head) = head { + truncate_str(head, width as usize); + } + + control.width = width; + control.print_shift_column = true; + } + } + } + + control +} + +fn truncate_list(list: &mut [NuText], width: usize) { + for (text, _) in list { + truncate_str(text, width); + } +} + +fn truncate_str(text: &mut String, width: usize) { + if width == 0 { + text.clear(); + } else { + *text = nu_table::string_truncate(text, width - 1); + text.push('…'); + } +} + +fn render_shift_column(buf: &mut Buffer, x: u16, y: u16, height: u16, style: NuStyle) -> u16 { + let style = TextStyle { + alignment: Alignment::Left, + color_style: Some(style), + }; + + repeat_vertical(buf, x, y, 1, height, '…', style); + + 1 +} + +fn render_top_connector(buf: &mut Buffer, x: u16, y: u16, style: NuStyle) { + let style = nu_style_to_tui(style); + let span = Span::styled("┬", style); + buf.set_span(x, y, &span, 1); +} + +fn calculate_column_width(column: &[NuText]) -> usize { + column + .iter() + .map(|(text, _)| text) + .map(|text| string_width(text)) + .max() + .unwrap_or(0) +} + +fn render_column( + buf: &mut tui::buffer::Buffer, + x: u16, + y: u16, + available_width: u16, + rows: &[NuText], +) -> u16 { + for (row, (text, style)) in rows.iter().enumerate() { + let style = text_style_to_tui_style(*style); + let text = strip_string(text); + let span = Span::styled(text, style); + buf.set_span(x, y + row as u16, &span, available_width); + } + + available_width +} + +fn strip_string(text: &str) -> String { + strip_ansi_escapes::strip(text) + .ok() + .and_then(|s| String::from_utf8(s).ok()) + .unwrap_or_else(|| text.to_owned()) +} + +fn head_row_text(head: &str, color_hm: &NuStyleTable) -> NuText { + ( + String::from(head), + TextStyle { + alignment: Alignment::Center, + color_style: Some(color_hm["header"]), + }, + ) +} diff --git a/crates/nu-protocol/src/config.rs b/crates/nu-protocol/src/config.rs index 08c9c7eae..fbf0f36d5 100644 --- a/crates/nu-protocol/src/config.rs +++ b/crates/nu-protocol/src/config.rs @@ -87,6 +87,7 @@ pub struct Config { pub show_banner: bool, pub show_clickable_links_in_ls: bool, pub render_right_prompt_on_last_line: bool, + pub explore_config: HashMap, } impl Default for Config { @@ -125,6 +126,7 @@ impl Default for Config { show_banner: true, show_clickable_links_in_ls: true, render_right_prompt_on_last_line: false, + explore_config: HashMap::new(), } } } @@ -183,6 +185,11 @@ pub enum TrimStrategy { }, } +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ExploreConfig { + pub color_config: HashMap, +} + impl Value { pub fn into_config(self) -> Result { let v = self.as_record(); @@ -806,6 +813,13 @@ impl Value { eprintln!("$env.config.filesize_format is not a string") } } + "explore_config" => { + if let Ok(map) = create_map(value, &config) { + config.explore_config = map; + } else { + eprintln!("$env.config.explore_config is not a map") + } + } // End legacy options x => { eprintln!("$env.config.{} is an unknown config setting", x) diff --git a/crates/nu-table/src/lib.rs b/crates/nu-table/src/lib.rs index fdf93a329..574b4c887 100644 --- a/crates/nu-table/src/lib.rs +++ b/crates/nu-table/src/lib.rs @@ -24,3 +24,32 @@ pub fn wrap_string(text: &str, width: usize) -> String { .with(Width::wrap(width)) .to_string() } + +pub fn string_truncate(text: &str, width: usize) -> String { + // todo: change me... + + match text.lines().next() { + Some(first_line) => tabled::builder::Builder::from_iter([[first_line]]) + .build() + .with(tabled::Style::empty()) + .with(tabled::Padding::zero()) + .with(tabled::Width::truncate(width)) + .to_string(), + None => String::new(), + } +} + +pub fn string_wrap(text: &str, width: usize) -> String { + // todo: change me... + + if text.is_empty() { + return String::new(); + } + + tabled::builder::Builder::from_iter([[text]]) + .build() + .with(tabled::Style::empty()) + .with(tabled::Padding::zero()) + .with(tabled::Width::wrap(width)) + .to_string() +} diff --git a/crates/nu-utils/src/sample_config/default_config.nu b/crates/nu-utils/src/sample_config/default_config.nu index 351a6faa4..8f2cf3374 100644 --- a/crates/nu-utils/src/sample_config/default_config.nu +++ b/crates/nu-utils/src/sample_config/default_config.nu @@ -292,6 +292,23 @@ let-env config = { shell_integration: true # enables terminal markers and a workaround to arrow keys stop working issue show_banner: true # true or false to enable or disable the banner render_right_prompt_on_last_line: false # true or false to enable or disable right prompt to be rendered on last line of the prompt. + + # A 'explore' utility config + explore_config: { + highlight: { bg: 'yellow', fg: 'black' } + status_bar: { bg: '#C4C9C6', fg: '#1D1F21' } + command_bar: { fg: '#C4C9C6' } + split_line: '#404040' + cursor: true + # selected_column: 'blue' + # selected_row: { fg: 'yellow', bg: '#C1C2A3' } + # selected_cell: { fg: 'white', bg: '#777777' } + # line_shift: false, + # line_index: false, + # line_head_top: false, + # line_head_bottom: false, + } + hooks: { pre_prompt: [{ $nothing # replace with source code to run before the prompt is shown