From 0a93fa833ebbf650d7cd284f13b353eee393cc54 Mon Sep 17 00:00:00 2001 From: Alexis Sellier Date: Thu, 16 Feb 2023 22:57:46 +0100 Subject: [PATCH] cli: Simplify & improve CLI output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improvements to the CLI output as well as converging towards `termion` as a backend. The crates we were using for prompting and spinners were based on a terminal backend called `console`, which is not really used anywhere else. There were also limitations in terms of the output of these crates. Therefore, we switched to the `inquire` crate for prompting, using the `termion` backend, which is very standard. Additionally, we simplify the terminal output to use symbols: ! for Warning βœ“ for Success βœ— for Error ... Instead of a mix of words and symbols. We also include a modified version of the `yansi` crate which plays well with our table formatting functions by automatically truncating and padding output in a unicode-aware way. --- Cargo.lock | 136 ++++---- radicle-cli-test/src/lib.rs | 2 +- radicle-cli/Cargo.toml | 11 +- radicle-cli/examples/rad-auth.md | 8 +- radicle-cli/examples/rad-checkout.md | 2 +- radicle-cli/examples/rad-clone-unknown.md | 4 +- radicle-cli/examples/rad-clone.md | 12 +- radicle-cli/examples/rad-delegate.md | 4 +- radicle-cli/examples/rad-id-rebase.md | 73 +++-- radicle-cli/examples/rad-id.md | 48 +-- radicle-cli/examples/rad-init-sync.md | 4 +- radicle-cli/examples/rad-init.md | 2 +- radicle-cli/examples/rad-patch.md | 20 +- radicle-cli/examples/rad-rm.md | 6 +- radicle-cli/src/commands/auth.rs | 23 +- radicle-cli/src/commands/checkout.rs | 2 +- radicle-cli/src/commands/edit.rs | 6 +- radicle-cli/src/commands/id.rs | 8 +- radicle-cli/src/commands/init.rs | 10 +- radicle-cli/src/commands/issue.rs | 6 +- radicle-cli/src/commands/ls.rs | 4 +- radicle-cli/src/commands/merge.rs | 15 +- radicle-cli/src/commands/patch/common.rs | 6 +- radicle-cli/src/commands/patch/list.rs | 29 +- radicle-cli/src/commands/self.rs | 32 +- radicle-cli/src/terminal.rs | 23 +- radicle-cli/src/terminal/ansi.rs | 16 + radicle-cli/src/terminal/ansi/color.rs | 63 ++++ radicle-cli/src/terminal/ansi/paint.rs | 252 +++++++++++++++ radicle-cli/src/terminal/ansi/style.rs | 267 ++++++++++++++++ radicle-cli/src/terminal/ansi/tests.rs | 279 +++++++++++++++++ radicle-cli/src/terminal/ansi/windows.rs | 64 ++++ radicle-cli/src/terminal/cell.rs | 146 +++++++++ radicle-cli/src/terminal/editor.rs | 94 ++++++ radicle-cli/src/terminal/format.rs | 74 +++-- radicle-cli/src/terminal/io.rs | 363 +++++++--------------- radicle-cli/src/terminal/patch.rs | 14 +- radicle-cli/src/terminal/spinner.rs | 212 +++++++++---- radicle-cli/src/terminal/table.rs | 163 ++++++++-- radicle-cli/src/terminal/textbox.rs | 17 +- radicle-tools/Cargo.toml | 8 + radicle-tools/src/rad-cli-demo.rs | 74 +++++ radicle/src/identity/project.rs | 4 +- radicle/src/profile.rs | 4 +- 44 files changed, 2003 insertions(+), 607 deletions(-) create mode 100644 radicle-cli/src/terminal/ansi.rs create mode 100644 radicle-cli/src/terminal/ansi/color.rs create mode 100644 radicle-cli/src/terminal/ansi/paint.rs create mode 100644 radicle-cli/src/terminal/ansi/style.rs create mode 100644 radicle-cli/src/terminal/ansi/tests.rs create mode 100644 radicle-cli/src/terminal/ansi/windows.rs create mode 100644 radicle-cli/src/terminal/cell.rs create mode 100644 radicle-cli/src/terminal/editor.rs create mode 100644 radicle-tools/src/rad-cli-demo.rs diff --git a/Cargo.lock b/Cargo.lock index 9d8ecaa3..4e597384 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,19 +402,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a90734b3d5dcf656e7624cca6bce9c3a90ee11f900e80141a7427ccfb3d317" -[[package]] -name = "console" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d79fbe8970a77e3e34151cc13d3b3e248aa0faaecb9f6091fa07ebefe5ad60" -dependencies = [ - "encode_unicode", - "lazy_static", - "libc", - "unicode-width", - "windows-sys", -] - [[package]] name = "const-oid" version = "0.9.1" @@ -643,18 +630,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "dialoguer" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af3c796f3b0b408d9fd581611b47fa850821fcb84aa640b83a3c1a5be2d691f2" -dependencies = [ - "console", - "shell-words", - "tempfile", - "zeroize", -] - [[package]] name = "diff" version = "0.1.13" @@ -682,6 +657,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "dyn-clone" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b0705efd4599c15a38151f4721f7bc388306f61084d3bfd50bd07fbca5cb60" + [[package]] name = "ec25519" version = "0.1.0" @@ -757,12 +738,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "encode_unicode" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" - [[package]] name = "errno" version = "0.2.8" @@ -1173,18 +1148,6 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "indicatif" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d207dc617c7a380ab07ff572a6e52fa202a2a8f355860ac9c38e23f8196be1b" -dependencies = [ - "console", - "lazy_static", - "number_prefix", - "regex", -] - [[package]] name = "inout" version = "0.1.3" @@ -1194,6 +1157,23 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inquire" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a94f0659efe59329832ba0452d3ec753145fc1fb12a8e1d60de4ccf99f5364" +dependencies = [ + "bitflags", + "dyn-clone", + "lazy_static", + "newline-converter", + "tempfile", + "termion 1.5.6", + "thiserror", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "instant" version = "0.1.12" @@ -1447,6 +1427,15 @@ dependencies = [ "socket2", ] +[[package]] +name = "newline-converter" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f71d09d5c87634207f894c6b31b6a2b2c64ea3bdcf71bd5599fdbbe1600c00f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "noise-framework" version = "0.2.0" @@ -1558,10 +1547,10 @@ dependencies = [ ] [[package]] -name = "number_prefix" -version = "0.4.0" +name = "numtoa" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" [[package]] name = "once_cell" @@ -1833,12 +1822,12 @@ version = "0.8.0" dependencies = [ "anyhow", "chrono", - "console", - "dialoguer", - "indicatif", + "concolor", + "inquire", "json-color", "lexopt", "log", + "once_cell", "pretty_assertions", "radicle", "radicle-cli-test", @@ -1850,8 +1839,11 @@ dependencies = [ "serde_yaml", "similar", "tempfile", + "termion 2.0.1", "thiserror", "timeago", + "unicode-segmentation", + "unicode-width", "ureq", "zeroize", ] @@ -2064,6 +2056,7 @@ dependencies = [ "anyhow", "git-ref-format", "radicle", + "radicle-cli", ] [[package]] @@ -2146,6 +2139,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_termios" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f" +dependencies = [ + "redox_syscall", +] + [[package]] name = "regex" version = "1.7.1" @@ -2397,12 +2399,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-words" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" - [[package]] name = "shlex" version = "1.1.0" @@ -2632,6 +2628,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termion" +version = "1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "077185e2eac69c3f8379a4298e1e07cd36beb962290d4a51199acf0fdc10607e" +dependencies = [ + "libc", + "numtoa", + "redox_syscall", + "redox_termios", +] + +[[package]] +name = "termion" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "659c1f379f3408c7e5e84c7d0da6d93404e3800b6b9d063ba24436419302ec90" +dependencies = [ + "libc", + "numtoa", + "redox_syscall", + "redox_termios", +] + [[package]] name = "thiserror" version = "1.0.38" @@ -2919,6 +2939,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + [[package]] name = "unicode-width" version = "0.1.10" diff --git a/radicle-cli-test/src/lib.rs b/radicle-cli-test/src/lib.rs index b71bf1ee..de9f7fdb 100644 --- a/radicle-cli-test/src/lib.rs +++ b/radicle-cli-test/src/lib.rs @@ -9,7 +9,7 @@ use snapbox::{Assert, Substitutions}; use thiserror::Error; /// Error lines in the CLI are prefixed with this string. -const ERROR_PREFIX: &str = "=="; +const ERROR_PREFIX: &str = "βœ—"; #[derive(Error, Debug)] pub enum Error { diff --git a/radicle-cli/Cargo.toml b/radicle-cli/Cargo.toml index 4833d0d1..8bb84ae0 100644 --- a/radicle-cli/Cargo.toml +++ b/radicle-cli/Cargo.toml @@ -13,18 +13,21 @@ path = "src/main.rs" [dependencies] anyhow = { version = "1" } chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } -console = { version = "0.15" } -dialoguer = { version = "0.10.0" } -indicatif = { version = "0.16.2" } +concolor = { version = "0", features = ["api_unstable"] } +inquire = { version = "0.5.3", default-features = false, features = ["termion", "editor"] } json-color = { version = "0.7" } lexopt = { version = "0.2" } log = { version = "0.4", features = ["std"] } +once_cell = { version = "1.13" } serde = { version = "1.0" } serde_json = { version = "1" } serde_yaml = { version = "0.8" } similar = { version = "2.2.1" } +termion = { version = "2" } thiserror = { version = "1" } timeago = { version = "0.3", default-features = false } +unicode-width = { version = "0.1.10", default-features = false } +unicode-segmentation = { version = "1.7.1" } ureq = { version = "2.6.1", default-features = false, features = ["json"] } zeroize = { version = "1.1" } @@ -46,6 +49,6 @@ path = "../radicle-crypto" [dev-dependencies] pretty_assertions = { version = "1.3.0" } -tempfile = { version = "3.3.0" } radicle = { path = "../radicle", features = ["test"] } radicle-node = { path = "../radicle-node", features = ["test"] } +tempfile = { version = "3.3.0" } diff --git a/radicle-cli/examples/rad-auth.md b/radicle-cli/examples/rad-auth.md index e7930220..6967025b 100644 --- a/radicle-cli/examples/rad-auth.md +++ b/radicle-cli/examples/rad-auth.md @@ -6,12 +6,10 @@ $ rad auth Initializing your 🌱 profile and identity -ok Creating your 🌱 Ed25519 keypair... -ok Profile did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi created. +βœ“ Creating your 🌱 Ed25519 keypair... +βœ“ Your Radicle ID is did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi. This identifies your device. -Your radicle Node ID is z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi. This identifies your device. - -=> To create a radicle project, run `rad init` from a git repository. +πŸ‘‰ To create a radicle project, run `rad init` from a git repository. ``` You can get the above information at all times using the `self` command: diff --git a/radicle-cli/examples/rad-checkout.md b/radicle-cli/examples/rad-checkout.md index 29c71cff..4477493f 100644 --- a/radicle-cli/examples/rad-checkout.md +++ b/radicle-cli/examples/rad-checkout.md @@ -6,7 +6,7 @@ $ rad checkout rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji Initializing local checkout for 🌱 rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji (heartwood) -ok Performing checkout... +βœ“ Performing checkout... 🌱 Project checkout successful under ./heartwood diff --git a/radicle-cli/examples/rad-clone-unknown.md b/radicle-cli/examples/rad-clone-unknown.md index 58fe4055..13e2d98c 100644 --- a/radicle-cli/examples/rad-clone-unknown.md +++ b/radicle-cli/examples/rad-clone-unknown.md @@ -2,6 +2,6 @@ Trying to clone a repository that is not in our routing table returns an error: ``` $ rad clone rad:zVNuptPuk5XauitpCWSNVCXGGfXW -ok Tracking relationship established for rad:zVNuptPuk5XauitpCWSNVCXGGfXW -== Clone failed no seeds found for rad:zVNuptPuk5XauitpCWSNVCXGGfXW +βœ“ Tracking relationship established for rad:zVNuptPuk5XauitpCWSNVCXGGfXW +βœ— Clone failed: no seeds found for rad:zVNuptPuk5XauitpCWSNVCXGGfXW ``` diff --git a/radicle-cli/examples/rad-clone.md b/radicle-cli/examples/rad-clone.md index 78ce4e5d..27c62a29 100644 --- a/radicle-cli/examples/rad-clone.md +++ b/radicle-cli/examples/rad-clone.md @@ -3,12 +3,12 @@ To create a local copy of a repository on the radicle network, we use the ``` $ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji -ok Tracking relationship established for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji -ok Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MknSL…StBU8Vi.. -ok Forking under z6Mkt67…v4N1tRk.. -ok Creating checkout in ./heartwood.. -ok Remote z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi created -ok Remote-tracking branch z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master created for z6MknSL…StBU8Vi +βœ“ Tracking relationship established for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji +βœ“ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MknSL…StBU8Vi.. +βœ“ Forking under z6Mkt67…v4N1tRk.. +βœ“ Creating checkout in ./heartwood.. +βœ“ Remote z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi created +βœ“ Remote-tracking branch z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master created for z6MknSL…StBU8Vi 🌱 Project successfully cloned under [..]/heartwood/ diff --git a/radicle-cli/examples/rad-delegate.md b/radicle-cli/examples/rad-delegate.md index 5f07b847..f9519280 100644 --- a/radicle-cli/examples/rad-delegate.md +++ b/radicle-cli/examples/rad-delegate.md @@ -16,7 +16,7 @@ work. ``` $ rad delegate add did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji Added delegate 'did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG' -ok Update successful! +βœ“ Update successful! ``` Let's convince ourselves that there's another delegate. @@ -35,7 +35,7 @@ the torch and remove ourselves from the delegate set. ``` $ rad delegate remove did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji Removed delegate 'did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi' -ok Update successful! +βœ“ Update successful! ``` ``` diff --git a/radicle-cli/examples/rad-id-rebase.md b/radicle-cli/examples/rad-id-rebase.md index 7f5d7e6c..740e0d8c 100644 --- a/radicle-cli/examples/rad-id-rebase.md +++ b/radicle-cli/examples/rad-id-rebase.md @@ -6,10 +6,10 @@ delegates creating proposals concurrently. ``` $ rad id edit --title "Add Alice" --description "Add Alice as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm -ok Identity proposal '57332790a2eabc0b2fd8c7ff48c3579d5812d405' created 🌱 +βœ“ Identity proposal '57332790a2eabc0b2fd8c7ff48c3579d5812d405' created 🌱 title: Add Alice description: Add Alice as a delegate -status: open +status: ❲open❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -43,15 +43,15 @@ keys: [] Quorum Reached -βœ— no +πŸ‘Ž no ``` ``` $ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --no-confirm -ok Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' created 🌱 +βœ“ Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' created 🌱 title: Add Bob description: Add Bob as a delegate -status: open +status: ❲open❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -85,7 +85,7 @@ keys: [] Quorum Reached -βœ— no +πŸ‘Ž no ``` Now, if the first proposal was accepted and committed before the @@ -94,10 +94,10 @@ through that and see what happens. ``` $ rad id accept 57332790a2eabc0b2fd8c7ff48c3579d5812d405 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm -ok Accepted proposal βœ“ +βœ“ Accepted proposal βœ“ title: Add Alice description: Add Alice as a delegate -status: open +status: ❲open❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -133,15 +133,15 @@ keys: [] Quorum Reached -βœ“ yes +πŸ‘ yes ``` ``` $ rad id commit 57332790a2eabc0b2fd8c7ff48c3579d5812d405 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm -ok Committed new identity '29ae4b72f5a315328f06fbd68dc1c396a2d5c45e' 🌱 +βœ“ Committed new identity '29ae4b72f5a315328f06fbd68dc1c396a2d5c45e' 🌱 title: Add Alice description: Add Alice as a delegate -status: committed +status: ❲committed❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -177,20 +177,20 @@ keys: [] Quorum Reached -βœ“ yes +πŸ‘ yes ``` Now, when we go to accept the second proposal: ``` $ rad id accept c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm -** Warning: Revision is out of date -** Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f -=> Consider using 'rad id rebase' to update the proposal to the latest identity -ok Accepted proposal βœ“ +! Warning: Revision is out of date +! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f +πŸ‘‰ Consider using 'rad id rebase' to update the proposal to the latest identity +βœ“ Accepted proposal βœ“ title: Add Bob description: Add Bob as a delegate -status: open +status: ❲open❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -226,7 +226,7 @@ keys: [] Quorum Reached -βœ“ yes +πŸ‘ yes ``` Note that a warning was emitted: @@ -239,22 +239,21 @@ If we attempt to commit this revision, the command will fail: ``` $ rad id commit c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm -** Warning: Revision is out of date -** Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f -=> Consider using 'rad id rebase' to update the proposal to the latest identity -== Id failed -the identity hashes do match 'd96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f' for the revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1' +! Warning: Revision is out of date +! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f +πŸ‘‰ Consider using 'rad id rebase' to update the proposal to the latest identity +βœ— Id failed: the identity hashes do match 'd96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f' for the revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1' ``` So, let's fix this by running a rebase on the proposal's revision: ``` $ rad id rebase c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm -ok Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' rebased 🌱 -ok Revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4' +βœ“ Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' rebased 🌱 +βœ“ Revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4' title: Add Bob description: Add Bob as a delegate -status: open +status: ❲open❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -288,18 +287,18 @@ keys: [] Quorum Reached -βœ— no +πŸ‘Ž no ``` We can now update the proposal to have both keys in the delegates set: ``` $ rad id update c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4 --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm -ok Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' updated 🌱 -ok Revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6' +βœ“ Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' updated 🌱 +βœ“ Revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6' title: Add Bob description: Add Bob as a delegate -status: open +status: ❲open❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -333,7 +332,7 @@ keys: [] Quorum Reached -βœ— no +πŸ‘Ž no ``` Finally, we can accept and commit this proposal, creating the final @@ -343,10 +342,10 @@ $ rad id show c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --revisions ``` $ rad id accept c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 --no-confirm -ok Accepted proposal βœ“ +βœ“ Accepted proposal βœ“ title: Add Bob description: Add Bob as a delegate -status: open +status: ❲open❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -382,15 +381,15 @@ keys: [] Quorum Reached -βœ“ yes +πŸ‘ yes ``` ``` $ rad id commit c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 --no-confirm -ok Committed new identity '60de897bc24898f6908fd1272633c0b15aa4096f' 🌱 +βœ“ Committed new identity '60de897bc24898f6908fd1272633c0b15aa4096f' 🌱 title: Add Bob description: Add Bob as a delegate -status: committed +status: ❲committed❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -426,5 +425,5 @@ keys: [] Quorum Reached -βœ“ yes +πŸ‘ yes ``` diff --git a/radicle-cli/examples/rad-id.md b/radicle-cli/examples/rad-id.md index 272dff3d..cd802d4c 100644 --- a/radicle-cli/examples/rad-id.md +++ b/radicle-cli/examples/rad-id.md @@ -14,10 +14,10 @@ Let's add Bob as a delegate using their DID ``` $ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm -ok Identity proposal '06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39' created 🌱 +βœ“ Identity proposal '06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39' created 🌱 title: Add Bob description: Add Bob as a delegate -status: open +status: ❲open❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -51,7 +51,7 @@ keys: [] Quorum Reached -βœ— no +πŸ‘Ž no ``` Before moving on, let's take a few notes on this output. The first @@ -84,16 +84,16 @@ Finally, we can see whether the `Quorum` was reached: Quorum Reached - βœ— no + πŸ‘Ž no Let's see what happens when we reject the change: ``` $ rad id reject 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm -ok Rejected proposal βœ— +βœ“ Rejected proposal πŸ‘Ž title: Add Bob description: Add Bob as a delegate -status: open +status: ❲open❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -129,7 +129,7 @@ keys: [ Quorum Reached -βœ— no +πŸ‘Ž no ``` Our key was added to the `Rejected` set of `keys` and the `total` @@ -146,10 +146,10 @@ Instead, let's accept the proposal: ``` $ rad id accept 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm -ok Accepted proposal βœ“ +βœ“ Accepted proposal βœ“ title: Add Bob description: Add Bob as a delegate -status: open +status: ❲open❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -185,7 +185,7 @@ keys: [] Quorum Reached -βœ“ yes +πŸ‘ yes ``` Our key has changed from the `Rejected` set to the `Accepted` set @@ -202,16 +202,16 @@ As well as that, the `Quorum` has now been reached: Quorum Reached - βœ“ yes + πŸ‘ yes At this point, we can commit the proposal and update the identity: ``` $ rad id commit 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm -ok Committed new identity 'c96e764965aaeff1c6ea3e5b97e2b9828773c8b0' 🌱 +βœ“ Committed new identity 'c96e764965aaeff1c6ea3e5b97e2b9828773c8b0' 🌱 title: Add Bob description: Add Bob as a delegate -status: committed +status: ❲committed❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -247,7 +247,7 @@ keys: [] Quorum Reached -βœ“ yes +πŸ‘ yes ``` Let's say we decide to also change the `threshold`, we can do so using @@ -255,10 +255,10 @@ the `--threshold` option: ``` $ rad id edit --title "Update threshold" --description "Update to safer threshold" --threshold 2 --no-confirm -ok Identity proposal 'dc00640d3152ea5f1df59f39f2f5983d2ad21810' created 🌱 +βœ“ Identity proposal 'dc00640d3152ea5f1df59f39f2f5983d2ad21810' created 🌱 title: Update threshold description: Update to safer threshold -status: open +status: ❲open❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -292,17 +292,17 @@ keys: [] Quorum Reached -βœ— no +πŸ‘Ž no ``` But we change our minds and decide to close the proposal instead: ``` $ rad id close dc00640d3152ea5f1df59f39f2f5983d2ad21810 --no-confirm -ok Closed identity proposal 'dc00640d3152ea5f1df59f39f2f5983d2ad21810' +βœ“ Closed identity proposal 'dc00640d3152ea5f1df59f39f2f5983d2ad21810' title: Update threshold description: Update to safer threshold -status: closed +status: ❲closed❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -336,7 +336,7 @@ keys: [] Quorum Reached -βœ— no +πŸ‘Ž no ``` The proposal is now closed and cannot be committed. If at a later date @@ -348,8 +348,8 @@ Radicle identity, then we can use the list command: ``` $ rad id list -06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 "Add Bob" committed -dc00640d3152ea5f1df59f39f2f5983d2ad21810 "Update threshold" closed +06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 "Add Bob" ❲committed❳ +dc00640d3152ea5f1df59f39f2f5983d2ad21810 "Update threshold" ❲closed❳ ``` And if we want to view the latest state of any proposal we can use the @@ -359,7 +359,7 @@ show command: $ rad id show dc00640d3152ea5f1df59f39f2f5983d2ad21810 title: Update threshold description: Update to safer threshold -status: closed +status: ❲closed❳ author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi Document Diff @@ -393,7 +393,7 @@ keys: [] Quorum Reached -βœ— no +πŸ‘Ž no ``` On a final note, these examples used `--no-confirm`. The default mode diff --git a/radicle-cli/examples/rad-init-sync.md b/radicle-cli/examples/rad-init-sync.md index 1bf50fdf..84e9c39e 100644 --- a/radicle-cli/examples/rad-init-sync.md +++ b/radicle-cli/examples/rad-init-sync.md @@ -7,13 +7,13 @@ $ rad init --name heartwood --description "Radicle Heartwood Protocol & Stack" - Initializing local 🌱 project in . -ok Project heartwood created +βœ“ Project heartwood created { "name": "heartwood", "description": "Radicle Heartwood Protocol & Stack", "defaultBranch": "master" } -ok Syncing inventory.. +βœ“ Syncing inventory.. Your project id is rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji. You can show it any time by running: rad . diff --git a/radicle-cli/examples/rad-init.md b/radicle-cli/examples/rad-init.md index b8be0a27..5c3b2b5d 100644 --- a/radicle-cli/examples/rad-init.md +++ b/radicle-cli/examples/rad-init.md @@ -7,7 +7,7 @@ $ rad init --name heartwood --description "Radicle Heartwood Protocol & Stack" - Initializing local 🌱 project in . -ok Project heartwood created +βœ“ Project heartwood created { "name": "heartwood", "description": "Radicle Heartwood Protocol & Stack", diff --git a/radicle-cli/examples/rad-patch.md b/radicle-cli/examples/rad-patch.md index 1e3a7347..e414e5f1 100644 --- a/radicle-cli/examples/rad-patch.md +++ b/radicle-cli/examples/rad-patch.md @@ -29,8 +29,8 @@ $ rad patch open --message "define power requirements" --no-confirm 🌱 Creating patch for heartwood -ok Pushing HEAD to storage... -ok Analyzing remotes... +βœ“ Pushing HEAD to storage... +βœ“ Analyzing remotes... z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master (f2de534) <- z6MknSL…StBU8Vi/flux-capacitor-power (3e674d1) 1 commit(s) ahead, 0 commit(s) behind @@ -45,7 +45,7 @@ No description provided. ╰─────────────────────────────────── -ok Patch d4ef85f57a849bd845915d7a66a2192cd23811f6 created 🌱 +βœ“ Patch d4ef85f57a849bd845915d7a66a2192cd23811f6 created 🌱 ``` It will now be listed as one of the project's open patches. @@ -53,13 +53,13 @@ It will now be listed as one of the project's open patches. ``` $ rad patch -- YOU PROPOSED - +❲YOU PROPOSED❳ define power requirements d4ef85f57a8 R0 3e674d1 (flux-capacitor-power) ahead 1, behind 0 └─ * opened by did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (you) [..] └─ * patch id d4ef85f57a849bd845915d7a66a2192cd23811f6 -- OTHERS PROPOSED - +❲OTHERS PROPOSED❳ Nothing to show. @@ -98,14 +98,14 @@ $ rad patch update --message "Add README, just for the fun" --no-confirm d4ef85f 🌱 Updating patch for heartwood -ok Pushing HEAD to storage... -ok Analyzing remotes... +βœ“ Pushing HEAD to storage... +βœ“ Analyzing remotes... d4ef85f57a8 R0 (3e674d1) -> R1 (27857ec) 1 commit(s) ahead, 0 commit(s) behind -ok Patch d4ef85f57a849bd845915d7a66a2192cd23811f6 updated 🌱 +βœ“ Patch d4ef85f57a849bd845915d7a66a2192cd23811f6 updated 🌱 ``` @@ -122,6 +122,6 @@ Now, let's checkout the patch that we just created: ``` $ rad patch checkout d4ef85f57a849bd845915d7a66a2192cd23811f6 -ok Performing patch checkout... -ok Switched to branch patch/d4ef85f57a8 +βœ“ Performing patch checkout... +βœ“ Switched to branch patch/d4ef85f57a8 ``` diff --git a/radicle-cli/examples/rad-rm.md b/radicle-cli/examples/rad-rm.md index 435bbbd5..024df617 100644 --- a/radicle-cli/examples/rad-rm.md +++ b/radicle-cli/examples/rad-rm.md @@ -10,9 +10,9 @@ Now let's delete the `heartwood` project: ``` $ rad rm rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --no-confirm -** Warning: Failed to untrack repository: failed to connect to node: No such file or directory (os error 2) -** Warning: Make sure to untrack this repository when your node is running -ok Successfully removed project rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from storage +! Warning: Failed to untrack repository: failed to connect to node: No such file or directory (os error 2) +! Warning: Make sure to untrack this repository when your node is running +βœ“ Successfully removed project rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from storage ``` We can check our repositories again to see if it was deleted: diff --git a/radicle-cli/src/commands/auth.rs b/radicle-cli/src/commands/auth.rs index 228079b3..bdea54b7 100644 --- a/radicle-cli/src/commands/auth.rs +++ b/radicle-cli/src/commands/auth.rs @@ -80,20 +80,18 @@ pub fn init(options: Options) -> anyhow::Result<()> { } let home = profile::home()?; - let passphrase = term::read_passphrase(options.stdin, true)?; + let passphrase = if options.stdin { + term::passphrase_stdin() + } else { + term::passphrase_confirm() + }?; let spinner = term::spinner("Creating your 🌱 Ed25519 keypair..."); let profile = Profile::init(home, passphrase)?; spinner.finish(); term::success!( - "Profile {} created.", - term::format::highlight(profile.did().to_string()) - ); - - term::blank(); - term::info!( - "Your radicle Node ID is {}. This identifies your device.", - term::format::highlight(profile.id().to_string()) + "Your Radicle ID is {}. This identifies your device.", + term::format::highlight(profile.did()) ); term::blank(); @@ -108,6 +106,7 @@ pub fn init(options: Options) -> anyhow::Result<()> { pub fn authenticate(profile: &Profile, options: Options) -> anyhow::Result<()> { let agent = ssh::agent::Agent::connect()?; + // TODO: Only show this if we're not authenticated. term::headline(&format!( "🌱 Authenticating as {}", term::format::Identity::new(profile).styled() @@ -119,7 +118,11 @@ pub fn authenticate(profile: &Profile, options: Options) -> anyhow::Result<()> { // TODO: We should show the spinner on the passphrase prompt, // otherwise it seems like the passphrase is valid even if it isn't. - let passphrase = term::read_passphrase(options.stdin, false)?; + let passphrase = if options.stdin { + term::passphrase_stdin() + } else { + term::passphrase() + }?; let spinner = term::spinner("Unlocking..."); let mut agent = ssh::agent::Agent::connect()?; let secret = profile diff --git a/radicle-cli/src/commands/checkout.rs b/radicle-cli/src/commands/checkout.rs index af7252d1..068f2b50 100644 --- a/radicle-cli/src/commands/checkout.rs +++ b/radicle-cli/src/commands/checkout.rs @@ -97,7 +97,7 @@ pub fn execute(options: Options, profile: &Profile) -> anyhow::Result { .identity_of(&remote) .context("project could not be found in local storage")?; let payload = doc.project()?; - let path = PathBuf::from(payload.name().clone()); + let path = PathBuf::from(payload.name()); transport::local::register(storage.clone()); diff --git a/radicle-cli/src/commands/edit.rs b/radicle-cli/src/commands/edit.rs index bef43551..6964df41 100644 --- a/radicle-cli/src/commands/edit.rs +++ b/radicle-cli/src/commands/edit.rs @@ -71,8 +71,8 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { let repo = storage.repository(id)?; let payload = serde_json::to_string_pretty(&project.payload)?; - match term::Editor::new().edit(&payload)? { - Some(updated_payload) => { + match term::Editor::new().extension("json").edit(payload) { + Ok(Some(updated_payload)) => { project.payload = serde_json::from_str(&updated_payload)?; project.sign(&signer).and_then(|(_, sig)| { project.update( @@ -83,7 +83,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { ) })?; } - None => return Err(anyhow!("Operation aborted!")), + _ => return Err(anyhow!("Operation aborted!")), } term::success!("Update successful!"); diff --git a/radicle-cli/src/commands/id.rs b/radicle-cli/src/commands/id.rs index eff0cf66..55bf85f0 100644 --- a/radicle-cli/src/commands/id.rs +++ b/radicle-cli/src/commands/id.rs @@ -41,7 +41,7 @@ pub struct Metadata { impl Metadata { fn edit(self) -> anyhow::Result { let yaml = serde_yaml::to_string(&self)?; - match term::Editor::new().edit(&yaml)? { + match term::Editor::new().edit(yaml)? { Some(meta) => Ok(serde_yaml::from_str(&meta).context("failed to parse proposal meta")?), None => return Err(anyhow!("Operation aborted!")), } @@ -261,7 +261,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { let yes = confirm(interactive, "Are you sure you want to reject?"); if yes { proposal.reject(rid, &signer)?; - term::success!("Rejected proposal βœ—"); + term::success!("Rejected proposal πŸ‘Ž"); print(&proposal, &previous, None)?; } } @@ -578,9 +578,9 @@ fn print_revision(revision: &identity::Revision, previous: &Identity) -> an print!( "{}", if revision.is_quorum_reached(previous) { - term::format::positive("βœ“ yes") + term::format::positive("πŸ‘ yes") } else { - term::format::negative("βœ— no") + term::format::negative("πŸ‘Ž no") } ); term::blank(); diff --git a/radicle-cli/src/commands/init.rs b/radicle-cli/src/commands/init.rs index 2374cce8..3c0b53dd 100644 --- a/radicle-cli/src/commands/init.rs +++ b/radicle-cli/src/commands/init.rs @@ -162,9 +162,9 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()> term::headline(&format!( "Initializing local 🌱 project in {}", if path == cwd { - term::format::highlight(".") + term::format::highlight(".").to_string() } else { - term::format::highlight(path.display()) + term::format::highlight(path.display()).to_string() } )); @@ -184,14 +184,14 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()> let name = options.name.unwrap_or_else(|| { let default = path.file_name().map(|f| f.to_string_lossy().to_string()); - term::text_input("Name", default).unwrap() + term::input("Name", default).unwrap() }); let description = options .description - .unwrap_or_else(|| term::text_input("Description", None).unwrap()); + .unwrap_or_else(|| term::input("Description", None).unwrap()); let branch = options.branch.unwrap_or_else(|| { if interactive.yes() { - term::text_input("Default branch", Some(head)).unwrap() + term::input("Default branch", Some(head)).unwrap() } else { head } diff --git a/radicle-cli/src/commands/issue.rs b/radicle-cli/src/commands/issue.rs index 6d93bb7a..ccecffea 100644 --- a/radicle-cli/src/commands/issue.rs +++ b/radicle-cli/src/commands/issue.rs @@ -218,8 +218,8 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { } Operation::React { id, reaction } => { if let Ok(mut issue) = issues.get_mut(&id) { - let comment_id = term::comment_select(&issue).unwrap(); - issue.react(comment_id, reaction, &signer)?; + let (comment_id, _) = term::comment_select(&issue).unwrap(); + issue.react(*comment_id, reaction, &signer)?; } } Operation::Open { title, description } => { @@ -235,7 +235,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { description.unwrap_or("Enter a description...".to_owned()) ); - if let Some(text) = term::Editor::new().edit(&doc)? { + if let Ok(Some(text)) = term::Editor::new().edit(&doc) { let mut meta = String::new(); let mut frontmatter = false; let mut lines = text.lines(); diff --git a/radicle-cli/src/commands/ls.rs b/radicle-cli/src/commands/ls.rs index 21626647..70c7c027 100644 --- a/radicle-cli/src/commands/ls.rs +++ b/radicle-cli/src/commands/ls.rs @@ -53,8 +53,8 @@ pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> { let head = term::format::oid(head); table.push([ term::format::bold(proj.name()), - term::format::tertiary(id.urn()), - term::format::secondary(head), + term::format::tertiary(id.urn().as_str()), + term::format::secondary(head.as_str()), term::format::italic(proj.description()), ]); }); diff --git a/radicle-cli/src/commands/merge.rs b/radicle-cli/src/commands/merge.rs index 9f2ac1e5..ff7fff3e 100644 --- a/radicle-cli/src/commands/merge.rs +++ b/radicle-cli/src/commands/merge.rs @@ -222,8 +222,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { .dim() .italic() .to_string(), - MergeStyle::Commit => term::format::style(merge_style.to_string()) - .yellow() + MergeStyle::Commit => term::format::yellow(merge_style.to_string()) .italic() .to_string(), }; @@ -312,19 +311,13 @@ fn merge_commit( writeln!(&mut merge_msg, "{}", MERGE_HELP_MSG.trim())?; // Offer user the chance to edit the message before committing. - let merge_msg = match term::Editor::new() - .require_save(true) - .trim_newlines(true) - .extension(".git-commit") - .edit(&merge_msg) - .unwrap() - { - Some(s) => s + let merge_msg = match term::Editor::new().extension("git-commit").edit(merge_msg) { + Ok(Some(s)) => s .lines() .filter(|l| !l.starts_with('#')) .collect::>() .join("\n"), - None => anyhow::bail!("user aborted merge"), + _ => anyhow::bail!("user aborted merge"), }; // Empty message aborts merge. diff --git a/radicle-cli/src/commands/patch/common.rs b/radicle-cli/src/commands/patch/common.rs index 5536805a..3e53ff52 100644 --- a/radicle-cli/src/commands/patch/common.rs +++ b/radicle-cli/src/commands/patch/common.rs @@ -127,7 +127,7 @@ pub fn pretty_sync_status( ) -> anyhow::Result { let (a, b) = repo.graph_ahead_behind(revision_oid, head_oid)?; if a == 0 && b == 0 { - return Ok(term::format::dim("up to date")); + return Ok(term::format::dim("up to date").to_string()); } let ahead = term::format::positive(a); @@ -143,7 +143,7 @@ pub fn pretty_commit_version( revision_oid: &Oid, repo: &Option, ) -> anyhow::Result { - let mut oid = term::format::secondary(term::format::oid(*revision_oid)); + let mut oid = term::format::secondary(term::format::oid(*revision_oid)).to_string(); let mut branches: Vec = vec![]; if let Some(repo) = repo { @@ -216,6 +216,8 @@ pub fn push_to_storage( if options.verbose { spinner.finish(); term::blob(output); + + return Ok(()); } } spinner.finish(); diff --git a/radicle-cli/src/commands/patch/list.rs b/radicle-cli/src/commands/patch/list.rs index 540a2f00..9585b28f 100644 --- a/radicle-cli/src/commands/patch/list.rs +++ b/radicle-cli/src/commands/patch/list.rs @@ -5,6 +5,7 @@ use radicle::git; use radicle::prelude::*; use radicle::profile::Profile; use radicle::storage::git::Repository; +use unicode_width::UnicodeWidthStr; use crate::terminal as term; @@ -39,10 +40,7 @@ pub fn run( } } term::blank(); - term::print(format!( - "-{}-", - term::format::badge_secondary("YOU PROPOSED") - )); + term::print(term::format::badge_secondary("YOU PROPOSED")); if own.is_empty() { term::blank(); @@ -55,10 +53,7 @@ pub fn run( } } term::blank(); - term::print(format!( - "-{}-", - term::format::badge_secondary("OTHERS PROPOSED") - )); + term::print(term::format::badge_secondary("OTHERS PROPOSED")); if other.is_empty() { term::blank(); @@ -94,11 +89,9 @@ fn print( )]; if you { - author_info.push(term::format::secondary("(you)")); + author_info.push(term::format::secondary("(you)").to_string()); } - author_info.push(term::format::dim(term::format::timestamp( - &patch.timestamp(), - ))); + author_info.push(term::format::dim(term::format::timestamp(&patch.timestamp())).to_string()); let (_, revision) = patch .latest() @@ -120,17 +113,17 @@ fn print( let mut badges = Vec::new(); if peer.delegate { - badges.push(term::format::secondary("(delegate)")); + badges.push(term::format::secondary("(delegate)").to_string()); } if peer.id == *whoami { - badges.push(term::format::secondary("(you)")); + badges.push(term::format::secondary("(you)").to_string()); } timeline.push(( merge.timestamp, format!( "{}{} by {} {}", - " ".repeat(term::text_width(prefix)), + " ".repeat(prefix.width()), term::format::secondary(term::format::dim("βœ“ merged")), term::format::tertiary(peer.id), badges.join(" "), @@ -147,17 +140,17 @@ fn print( let mut badges = Vec::new(); if peer.delegate { - badges.push(term::format::secondary("(delegate)")); + badges.push(term::format::secondary("(delegate)").to_string()); } if peer.id == *whoami { - badges.push(term::format::secondary("(you)")); + badges.push(term::format::secondary("(you)").to_string()); } timeline.push(( review.timestamp(), format!( "{}{} by {} {}", - " ".repeat(term::text_width(prefix)), + " ".repeat(prefix.width()), verdict, term::format::tertiary(reviewer), badges.join(" "), diff --git a/radicle-cli/src/commands/self.rs b/radicle-cli/src/commands/self.rs index 4771de76..a466cd51 100644 --- a/radicle-cli/src/commands/self.rs +++ b/radicle-cli/src/commands/self.rs @@ -78,33 +78,45 @@ fn all(profile: &Profile) -> anyhow::Result<()> { let mut table = term::Table::default(); let did = profile.did(); - table.push(["ID", &term::format::tertiary(did)]); + table.push([ + term::format::style("ID").to_string(), + term::format::tertiary(did).to_string(), + ]); let node_id = profile.id(); - table.push(["Node ID", &term::format::tertiary(node_id)]); + table.push([ + term::format::style("Node ID").to_string(), + term::format::tertiary(node_id).to_string(), + ]); let ssh_short = ssh::fmt::fingerprint(node_id); - table.push(["Key (hash)", &term::format::tertiary(ssh_short)]); + table.push([ + term::format::style("Key (hash)").to_string(), + term::format::tertiary(ssh_short).to_string(), + ]); let ssh_long = ssh::fmt::key(node_id); - table.push(["Key (full)", &term::format::tertiary(ssh_long)]); + table.push([ + term::format::style("Key (full)").to_string(), + term::format::tertiary(ssh_long).to_string(), + ]); let storage_path = profile.home.storage(); table.push([ - "Storage (git)", - &term::format::tertiary(storage_path.display()), + term::format::style("Storage (git)").to_string(), + term::format::tertiary(storage_path.display()).to_string(), ]); let keys_path = profile.home.keys(); table.push([ - "Storage (keys)", - &term::format::tertiary(keys_path.display()), + term::format::style("Storage (keys)").to_string(), + term::format::tertiary(keys_path.display()).to_string(), ]); let node_path = profile.home.node(); table.push([ - "Node (socket)", - &term::format::tertiary(node_path.join("radicle.sock").display()), + term::format::style("Node (socket)").to_string(), + term::format::tertiary(node_path.join("radicle.sock").display()).to_string(), ]); table.render(); diff --git a/radicle-cli/src/terminal.rs b/radicle-cli/src/terminal.rs index f437c585..e1af1ac2 100644 --- a/radicle-cli/src/terminal.rs +++ b/radicle-cli/src/terminal.rs @@ -1,6 +1,9 @@ +pub mod ansi; pub mod args; +pub mod cell; pub mod cob; pub mod command; +pub mod editor; pub mod format; pub mod io; pub mod patch; @@ -11,12 +14,12 @@ pub mod textbox; use std::ffi::OsString; use std::process; -use dialoguer::console::style; use radicle::profile::Profile; +pub use ansi::{paint, Paint}; pub use args::{Args, Error, Help}; -pub use console::measure_text_width as text_width; -pub use dialoguer::Editor; +pub use editor::Editor; +pub use inquire::ui::Styled; pub use io::*; pub use spinner::{spinner, Spinner}; pub use table::Table; @@ -97,14 +100,14 @@ where }; eprintln!( "{} {} {} {}", - style("==").red(), - style("Error:").red(), - style(format!("rad-{}:", help.name)).red(), - style(&err).red() + Paint::red("=="), + Paint::red("Error:"), + Paint::red(format!("rad-{}:", help.name)), + Paint::red(err.to_string()), ); if let Some(Error::WithHint { hint, .. }) = err.downcast_ref::() { - eprintln!("{}", style(hint).yellow()); + eprintln!("{}", Paint::yellow(hint)); } process::exit(1); @@ -173,3 +176,7 @@ impl From for Interactive { } } } + +pub fn style(item: T) -> Paint { + paint(item) +} diff --git a/radicle-cli/src/terminal/ansi.rs b/radicle-cli/src/terminal/ansi.rs new file mode 100644 index 00000000..1fb22463 --- /dev/null +++ b/radicle-cli/src/terminal/ansi.rs @@ -0,0 +1,16 @@ +//! A dead simple ANSI terminal color painting library. +//! +//! This library is a port of the `yansi` crate. +//! Copyright (c) 2017 Sergio Benitez +//! +mod color; +mod paint; +mod style; +#[cfg(test)] +mod tests; +mod windows; + +pub use color::Color; +pub use paint::paint; +pub use paint::Paint; +pub use style::Style; diff --git a/radicle-cli/src/terminal/ansi/color.rs b/radicle-cli/src/terminal/ansi/color.rs new file mode 100644 index 00000000..34aca0b3 --- /dev/null +++ b/radicle-cli/src/terminal/ansi/color.rs @@ -0,0 +1,63 @@ +use std::fmt; + +use super::{Paint, Style}; + +/// An enum representing an ANSI color code. +#[derive(Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)] +pub enum Color { + /// No color has been set. Nothing is changed when applied. + #[default] + Unset, + /// Black #0 (foreground code `30`, background code `40`). + Black, + /// Red: #1 (foreground code `31`, background code `41`). + Red, + /// Green: #2 (foreground code `32`, background code `42`). + Green, + /// Yellow: #3 (foreground code `33`, background code `43`). + Yellow, + /// Blue: #4 (foreground code `34`, background code `44`). + Blue, + /// Magenta: #5 (foreground code `35`, background code `45`). + Magenta, + /// Cyan: #6 (foreground code `36`, background code `46`). + Cyan, + /// White: #7 (foreground code `37`, background code `47`). + White, + /// A color number from 0 to 255, for use in 256-color terminals. + Fixed(u8), + /// A 24-bit RGB color, as specified by ISO-8613-3. + RGB(u8, u8, u8), +} + +impl Color { + /// Constructs a new `Paint` structure that encapsulates `item` with the + /// foreground color set to the color `self`. + #[inline] + pub fn paint(self, item: T) -> Paint { + Paint::new(item).fg(self) + } + + /// Constructs a new `Style` structure with the foreground color set to the + /// color `self`. + #[inline] + pub const fn style(self) -> Style { + Style::new(self) + } + + pub(crate) fn ansi_fmt(&self, f: &mut dyn fmt::Write) -> fmt::Result { + match *self { + Color::Unset => Ok(()), + Color::Black => write!(f, "0"), + Color::Red => write!(f, "1"), + Color::Green => write!(f, "2"), + Color::Yellow => write!(f, "3"), + Color::Blue => write!(f, "4"), + Color::Magenta => write!(f, "5"), + Color::Cyan => write!(f, "6"), + Color::White => write!(f, "7"), + Color::Fixed(num) => write!(f, "8;5;{num}"), + Color::RGB(r, g, b) => write!(f, "8;2;{r};{g};{b}"), + } + } +} diff --git a/radicle-cli/src/terminal/ansi/paint.rs b/radicle-cli/src/terminal/ansi/paint.rs new file mode 100644 index 00000000..ec4f0a96 --- /dev/null +++ b/radicle-cli/src/terminal/ansi/paint.rs @@ -0,0 +1,252 @@ +use std::fmt; + +use unicode_width::UnicodeWidthStr; + +use super::color::Color; +use super::style::{Property, Style}; + +/// A structure encapsulating an item and styling. +#[derive(Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)] +pub struct Paint { + pub item: T, + pub style: Style, +} + +impl Paint<&str> { + /// Return plain content. + pub fn content(&self) -> &str { + self.item + } +} + +impl Paint { + /// Return plain content. + pub fn content(&self) -> &str { + self.item.as_str() + } +} + +impl From for Paint { + fn from(value: T) -> Self { + Self::new(value) + } +} + +impl From<&str> for Paint { + fn from(item: &str) -> Self { + Self::new(item.to_string()) + } +} + +impl Paint { + /// Constructs a new `Paint` structure encapsulating `item` with no set + /// styling. + #[inline] + pub const fn new(item: T) -> Paint { + Paint { + item, + style: Style { + foreground: Color::Unset, + background: Color::Unset, + properties: Property::new(), + wrap: false, + }, + } + } + + /// Constructs a new _wrapping_ `Paint` structure encapsulating `item` with + /// default styling. + /// + /// A wrapping `Paint` converts all color resets written out by the internal + /// value to the styling of itself. This allows for seamless color wrapping + /// of other colored text. + /// + /// # Performance + /// + /// In order to wrap an internal value, the internal value must first be + /// written out to a local buffer and examined. As a result, displaying a + /// wrapped value is likely to result in a heap allocation and copy. + #[inline] + pub const fn wrapping(item: T) -> Paint { + Paint::new(item).wrap() + } + + /// Constructs a new `Paint` structure encapsulating `item` with the + /// foreground color set to the RGB color `r`, `g`, `b`. + #[inline] + pub const fn rgb(r: u8, g: u8, b: u8, item: T) -> Paint { + Paint::new(item).fg(Color::RGB(r, g, b)) + } + + /// Constructs a new `Paint` structure encapsulating `item` with the + /// foreground color set to the fixed 8-bit color `color`. + #[inline] + pub const fn fixed(color: u8, item: T) -> Paint { + Paint::new(item).fg(Color::Fixed(color)) + } + + pub const fn red(item: T) -> Paint { + Paint::new(item).fg(Color::Red) + } + + pub const fn black(item: T) -> Paint { + Paint::new(item).fg(Color::Black) + } + + pub const fn yellow(item: T) -> Paint { + Paint::new(item).fg(Color::Yellow) + } + + pub const fn green(item: T) -> Paint { + Paint::new(item).fg(Color::Green) + } + + pub const fn cyan(item: T) -> Paint { + Paint::new(item).fg(Color::Cyan) + } + + pub const fn blue(item: T) -> Paint { + Paint::new(item).fg(Color::Blue) + } + + pub const fn magenta(item: T) -> Paint { + Paint::new(item).fg(Color::Magenta) + } + + pub const fn white(item: T) -> Paint { + Paint::new(item).fg(Color::White) + } + + /// Retrieves the style currently set on `self`. + #[inline] + pub const fn style(&self) -> Style { + self.style + } + + /// Retrieves a borrow to the inner item. + #[inline] + pub const fn inner(&self) -> &T { + &self.item + } + + /// Sets the style of `self` to `style`. + #[inline] + pub fn with_style(mut self, style: Style) -> Paint { + self.style = style; + self + } + + /// Makes `self` a _wrapping_ `Paint`. + /// + /// A wrapping `Paint` converts all color resets written out by the internal + /// value to the styling of itself. This allows for seamless color wrapping + /// of other colored text. + /// + /// # Performance + /// + /// In order to wrap an internal value, the internal value must first be + /// written out to a local buffer and examined. As a result, displaying a + /// wrapped value is likely to result in a heap allocation and copy. + #[inline] + pub const fn wrap(mut self) -> Paint { + self.style.wrap = true; + self + } + + /// Sets the foreground to `color`. + #[inline] + pub const fn fg(mut self, color: Color) -> Paint { + self.style.foreground = color; + self + } + + /// Sets the background to `color`. + #[inline] + pub const fn bg(mut self, color: Color) -> Paint { + self.style.background = color; + self + } + + pub fn bold(mut self) -> Self { + self.style.properties.set(Property::BOLD); + self + } + + pub fn dim(mut self) -> Self { + self.style.properties.set(Property::DIM); + self + } + + pub fn italic(mut self) -> Self { + self.style.properties.set(Property::ITALIC); + self + } + + pub fn underline(mut self) -> Self { + self.style.properties.set(Property::UNDERLINE); + self + } + + pub fn invert(mut self) -> Self { + self.style.properties.set(Property::INVERT); + self + } + + pub fn strikethrough(mut self) -> Self { + self.style.properties.set(Property::STRIKETHROUGH); + self + } + + pub fn blink(mut self) -> Self { + self.style.properties.set(Property::BLINK); + self + } + + pub fn hidden(mut self) -> Self { + self.style.properties.set(Property::HIDDEN); + self + } +} + +impl UnicodeWidthStr for Paint { + fn width(&self) -> usize { + self.item.width() + } + + fn width_cjk(&self) -> usize { + self.item.width_cjk() + } +} + +impl fmt::Display for Paint { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if Paint::is_enabled() && self.style.wrap { + let mut prefix = String::new(); + prefix.push_str("\x1B[0m"); + self.style.fmt_prefix(&mut prefix)?; + self.style.fmt_prefix(f)?; + + let item = format!("{}", self.item).replace("\x1B[0m", &prefix); + fmt::Display::fmt(&item, f)?; + self.style.fmt_suffix(f) + } else if Paint::is_enabled() { + self.style.fmt_prefix(f)?; + fmt::Display::fmt(&self.item, f)?; + self.style.fmt_suffix(f) + } else { + fmt::Display::fmt(&self.item, f) + } + } +} + +impl Paint<()> { + /// Returns `true` if coloring is enabled and `false` otherwise. + pub fn is_enabled() -> bool { + concolor::get(concolor::Stream::Stdout).ansi_color() + } +} + +/// Shorthand for [`Paint::new`]. +pub fn paint(item: T) -> Paint { + Paint::new(item) +} diff --git a/radicle-cli/src/terminal/ansi/style.rs b/radicle-cli/src/terminal/ansi/style.rs new file mode 100644 index 00000000..e17bb14c --- /dev/null +++ b/radicle-cli/src/terminal/ansi/style.rs @@ -0,0 +1,267 @@ +use std::fmt::{self, Display}; +use std::hash::{Hash, Hasher}; +use std::ops::BitOr; + +use super::{Color, Paint}; + +#[derive(Default, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)] +pub struct Property(u8); + +impl Property { + pub const BOLD: Self = Property(1 << 0); + pub const DIM: Self = Property(1 << 1); + pub const ITALIC: Self = Property(1 << 2); + pub const UNDERLINE: Self = Property(1 << 3); + pub const BLINK: Self = Property(1 << 4); + pub const INVERT: Self = Property(1 << 5); + pub const HIDDEN: Self = Property(1 << 6); + pub const STRIKETHROUGH: Self = Property(1 << 7); + + pub const fn new() -> Self { + Property(0) + } + + #[inline(always)] + pub const fn contains(self, other: Property) -> bool { + (other.0 & self.0) == other.0 + } + + #[inline(always)] + pub fn set(&mut self, other: Property) { + self.0 |= other.0; + } + + #[inline(always)] + pub fn iter(self) -> Iter { + Iter { + index: 0, + properties: self, + } + } +} + +impl BitOr for Property { + type Output = Self; + + #[inline(always)] + fn bitor(self, rhs: Self) -> Self { + Property(self.0 | rhs.0) + } +} + +pub struct Iter { + index: u8, + properties: Property, +} + +impl Iterator for Iter { + type Item = usize; + + fn next(&mut self) -> Option { + while self.index < 8 { + let index = self.index; + self.index += 1; + + if self.properties.contains(Property(1 << index)) { + return Some(index as usize); + } + } + + None + } +} + +/// Represents a set of styling options. +#[repr(packed)] +#[derive(Default, Debug, Eq, Ord, PartialOrd, Copy, Clone)] +pub struct Style { + pub(crate) foreground: Color, + pub(crate) background: Color, + pub(crate) properties: Property, + pub(crate) wrap: bool, +} + +impl PartialEq for Style { + fn eq(&self, other: &Style) -> bool { + self.foreground == other.foreground + && self.background == other.background + && self.properties == other.properties + } +} + +impl Hash for Style { + fn hash(&self, state: &mut H) { + self.foreground.hash(state); + self.background.hash(state); + self.properties.hash(state); + } +} + +#[inline] +fn write_spliced(c: &mut bool, f: &mut dyn fmt::Write, t: T) -> fmt::Result { + if *c { + write!(f, ";{t}") + } else { + *c = true; + write!(f, "{t}") + } +} + +impl Style { + /// Default style with the foreground set to `color` and no other set + /// properties. + #[inline] + pub const fn new(color: Color) -> Style { + // Avoiding `Default::default` since unavailable as `const` + Self { + foreground: color, + background: Color::Unset, + properties: Property::new(), + wrap: false, + } + } + + /// Sets the foreground to `color`. + #[inline] + pub const fn fg(mut self, color: Color) -> Style { + self.foreground = color; + self + } + + /// Sets the background to `color`. + #[inline] + pub const fn bg(mut self, color: Color) -> Style { + self.background = color; + self + } + + /// Sets `self` to be wrapping. + /// + /// A wrapping `Style` converts all color resets written out by the internal + /// value to the styling of itself. This allows for seamless color wrapping + /// of other colored text. + /// + /// # Performance + /// + /// In order to wrap an internal value, the internal value must first be + /// written out to a local buffer and examined. As a result, displaying a + /// wrapped value is likely to result in a heap allocation and copy. + #[inline] + pub const fn wrap(mut self) -> Style { + self.wrap = true; + self + } + + pub fn bold(mut self) -> Self { + self.properties.set(Property::BOLD); + self + } + + pub fn dim(mut self) -> Self { + self.properties.set(Property::DIM); + self + } + + pub fn italic(mut self) -> Self { + self.properties.set(Property::ITALIC); + self + } + + pub fn underline(mut self) -> Self { + self.properties.set(Property::UNDERLINE); + self + } + + pub fn invert(mut self) -> Self { + self.properties.set(Property::INVERT); + self + } + + pub fn strikethrough(mut self) -> Self { + self.properties.set(Property::STRIKETHROUGH); + self + } + + /// Constructs a new `Paint` structure that encapsulates `item` with the + /// style set to `self`. + #[inline] + pub fn paint(self, item: T) -> Paint { + Paint::new(item).with_style(self) + } + + /// Returns the foreground color of `self`. + #[inline] + pub const fn fg_color(&self) -> Color { + self.foreground + } + + /// Returns the foreground color of `self`. + #[inline] + pub const fn bg_color(&self) -> Color { + self.background + } + + /// Returns `true` if `self` is wrapping. + #[inline] + pub const fn is_wrapping(&self) -> bool { + self.wrap + } + + #[inline(always)] + fn is_plain(&self) -> bool { + self == &Style::default() + } + + /// Writes the ANSI code prefix for the currently set styles. + /// + /// This method is intended to be used inside of [`fmt::Display`] and + /// [`fmt::Debug`] implementations for custom or specialized use-cases. Most + /// users should use [`Paint`] for all painting needs. + /// + /// This method writes the ANSI code prefix irrespective of whether painting + /// is currently enabled or disabled. To write the prefix only if painting + /// is enabled, condition a call to this method on [`Paint::is_enabled()`]. + pub fn fmt_prefix(&self, f: &mut dyn fmt::Write) -> fmt::Result { + // A user may just want a code-free string when no styles are applied. + if self.is_plain() { + return Ok(()); + } + + let mut splice = false; + write!(f, "\x1B[")?; + + for i in self.properties.iter() { + let k = if i >= 5 { i + 2 } else { i + 1 }; + write_spliced(&mut splice, f, k)?; + } + + if self.background != Color::Unset { + write_spliced(&mut splice, f, "4")?; + self.background.ansi_fmt(f)?; + } + + if self.foreground != Color::Unset { + write_spliced(&mut splice, f, "3")?; + self.foreground.ansi_fmt(f)?; + } + + // All the codes end with an `m`. + write!(f, "m") + } + + /// Writes the ANSI code suffix for the currently set styles. + /// + /// This method is intended to be used inside of [`fmt::Display`] and + /// [`fmt::Debug`] implementations for custom or specialized use-cases. Most + /// users should use [`Paint`] for all painting needs. + /// + /// This method writes the ANSI code suffix irrespective of whether painting + /// is currently enabled or disabled. To write the suffix only if painting + /// is enabled, condition a call to this method on [`Paint::is_enabled()`]. + pub fn fmt_suffix(&self, f: &mut dyn fmt::Write) -> fmt::Result { + if self.is_plain() { + return Ok(()); + } + write!(f, "\x1B[0m") + } +} diff --git a/radicle-cli/src/terminal/ansi/tests.rs b/radicle-cli/src/terminal/ansi/tests.rs new file mode 100644 index 00000000..56be8db5 --- /dev/null +++ b/radicle-cli/src/terminal/ansi/tests.rs @@ -0,0 +1,279 @@ +use std::sync::Mutex; + +use super::Color::*; +use super::Paint; + +/// Ensures tests are running serially. +static SERIAL: Mutex<()> = Mutex::new(()); + +#[test] +fn colors_enabled() { + let _guard = SERIAL.lock(); + + concolor::set(concolor::ColorChoice::Always); + + assert_eq!( + Paint::new("text/plain").to_string(), + "text/plain".to_string() + ); + assert_eq!( + Paint::red("hi").to_string(), + "\x1B[31mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::black("hi").to_string(), + "\x1B[30mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::yellow("hi").bold().to_string(), + "\x1B[1;33mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::new("hi").fg(Yellow).bold().to_string(), + "\x1B[1;33mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::blue("hi").underline().to_string(), + "\x1B[4;34mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::green("hi").bold().underline().to_string(), + "\x1B[1;4;32mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::green("hi").underline().bold().to_string(), + "\x1B[1;4;32mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::magenta("hi").bg(White).to_string(), + "\x1B[47;35mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::red("hi").bg(Blue).fg(Yellow).to_string(), + "\x1B[44;33mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::cyan("hi").bg(Blue).fg(Yellow).to_string(), + "\x1B[44;33mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::cyan("hi").bold().bg(White).to_string(), + "\x1B[1;47;36mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::cyan("hi").underline().bg(White).to_string(), + "\x1B[4;47;36mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::cyan("hi").bold().underline().bg(White).to_string(), + "\x1B[1;4;47;36mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::cyan("hi").underline().bold().bg(White).to_string(), + "\x1B[1;4;47;36mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::fixed(100, "hi").to_string(), + "\x1B[38;5;100mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::fixed(100, "hi").bg(Magenta).to_string(), + "\x1B[45;38;5;100mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::fixed(100, "hi").bg(Fixed(200)).to_string(), + "\x1B[48;5;200;38;5;100mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::rgb(70, 130, 180, "hi").to_string(), + "\x1B[38;2;70;130;180mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::rgb(70, 130, 180, "hi").bg(Blue).to_string(), + "\x1B[44;38;2;70;130;180mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::blue("hi").bg(RGB(70, 130, 180)).to_string(), + "\x1B[48;2;70;130;180;34mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::rgb(70, 130, 180, "hi") + .bg(RGB(5, 10, 15)) + .to_string(), + "\x1B[48;2;5;10;15;38;2;70;130;180mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::new("hi").bold().to_string(), + "\x1B[1mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::new("hi").underline().to_string(), + "\x1B[4mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::new("hi").bold().underline().to_string(), + "\x1B[1;4mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::new("hi").dim().to_string(), + "\x1B[2mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::new("hi").italic().to_string(), + "\x1B[3mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::new("hi").blink().to_string(), + "\x1B[5mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::new("hi").invert().to_string(), + "\x1B[7mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::new("hi").hidden().to_string(), + "\x1B[8mhi\x1B[0m".to_string() + ); + assert_eq!( + Paint::new("hi").strikethrough().to_string(), + "\x1B[9mhi\x1B[0m".to_string() + ); +} + +#[test] +fn colors_disabled() { + let _guard = SERIAL.lock(); + + concolor::set(concolor::ColorChoice::Never); + + assert_eq!( + Paint::new("text/plain").to_string(), + "text/plain".to_string() + ); + assert_eq!(Paint::red("hi").to_string(), "hi".to_string()); + assert_eq!(Paint::black("hi").to_string(), "hi".to_string()); + assert_eq!(Paint::yellow("hi").bold().to_string(), "hi".to_string()); + assert_eq!( + Paint::new("hi").fg(Yellow).bold().to_string(), + "hi".to_string() + ); + assert_eq!(Paint::blue("hi").underline().to_string(), "hi".to_string()); + assert_eq!( + Paint::green("hi").bold().underline().to_string(), + "hi".to_string() + ); + assert_eq!( + Paint::green("hi").underline().bold().to_string(), + "hi".to_string() + ); + assert_eq!(Paint::magenta("hi").bg(White).to_string(), "hi".to_string()); + assert_eq!( + Paint::red("hi").bg(Blue).fg(Yellow).to_string(), + "hi".to_string() + ); + assert_eq!( + Paint::cyan("hi").bg(Blue).fg(Yellow).to_string(), + "hi".to_string() + ); + assert_eq!( + Paint::cyan("hi").bold().bg(White).to_string(), + "hi".to_string() + ); + assert_eq!( + Paint::cyan("hi").underline().bg(White).to_string(), + "hi".to_string() + ); + assert_eq!( + Paint::cyan("hi").bold().underline().bg(White).to_string(), + "hi".to_string() + ); + assert_eq!( + Paint::cyan("hi").underline().bold().bg(White).to_string(), + "hi".to_string() + ); + assert_eq!(Paint::fixed(100, "hi").to_string(), "hi".to_string()); + assert_eq!( + Paint::fixed(100, "hi").bg(Magenta).to_string(), + "hi".to_string() + ); + assert_eq!( + Paint::fixed(100, "hi").bg(Fixed(200)).to_string(), + "hi".to_string() + ); + assert_eq!(Paint::rgb(70, 130, 180, "hi").to_string(), "hi".to_string()); + assert_eq!( + Paint::rgb(70, 130, 180, "hi").bg(Blue).to_string(), + "hi".to_string() + ); + assert_eq!( + Paint::blue("hi").bg(RGB(70, 130, 180)).to_string(), + "hi".to_string() + ); + assert_eq!( + Paint::blue("hi").bg(RGB(70, 130, 180)).wrap().to_string(), + "hi".to_string() + ); + assert_eq!( + Paint::rgb(70, 130, 180, "hi") + .bg(RGB(5, 10, 15)) + .to_string(), + "hi".to_string() + ); + assert_eq!(Paint::new("hi").bold().to_string(), "hi".to_string()); + assert_eq!(Paint::new("hi").underline().to_string(), "hi".to_string()); + assert_eq!( + Paint::new("hi").bold().underline().to_string(), + "hi".to_string() + ); + assert_eq!(Paint::new("hi").dim().to_string(), "hi".to_string()); + assert_eq!(Paint::new("hi").italic().to_string(), "hi".to_string()); + assert_eq!(Paint::new("hi").blink().to_string(), "hi".to_string()); + assert_eq!(Paint::new("hi").invert().to_string(), "hi".to_string()); + assert_eq!(Paint::new("hi").hidden().to_string(), "hi".to_string()); + assert_eq!( + Paint::new("hi").strikethrough().to_string(), + "hi".to_string() + ); + assert_eq!( + Paint::new("hi").strikethrough().wrap().to_string(), + "hi".to_string() + ); +} + +#[test] +fn wrapping() { + let _guard = SERIAL.lock(); + let inner = || format!("{} b {}", Paint::red("a"), Paint::green("c")); + let inner2 = || format!("0 {} 1", Paint::magenta(&inner()).wrap()); + + concolor::set(concolor::ColorChoice::Always); + + assert_eq!( + Paint::new("text/plain").wrap().to_string(), + "text/plain".to_string() + ); + assert_eq!(Paint::new(&inner()).wrap().to_string(), inner()); + assert_eq!( + Paint::new(&inner()).wrap().to_string(), + "\u{1b}[31ma\u{1b}[0m b \u{1b}[32mc\u{1b}[0m".to_string() + ); + assert_eq!( + Paint::new(&inner()).fg(Blue).wrap().to_string(), + "\u{1b}[34m\u{1b}[31ma\u{1b}[0m\u{1b}[34m b \ + \u{1b}[32mc\u{1b}[0m\u{1b}[34m\u{1b}[0m" + .to_string() + ); + assert_eq!(Paint::new(&inner2()).wrap().to_string(), inner2()); + assert_eq!( + Paint::new(&inner2()).wrap().to_string(), + "0 \u{1b}[35m\u{1b}[31ma\u{1b}[0m\u{1b}[35m b \ + \u{1b}[32mc\u{1b}[0m\u{1b}[35m\u{1b}[0m 1" + .to_string() + ); + assert_eq!( + Paint::new(&inner2()).fg(Blue).wrap().to_string(), + "\u{1b}[34m0 \u{1b}[35m\u{1b}[31ma\u{1b}[0m\u{1b}[34m\u{1b}[35m b \ + \u{1b}[32mc\u{1b}[0m\u{1b}[34m\u{1b}[35m\u{1b}[0m\u{1b}[34m 1\u{1b}[0m" + .to_string() + ); +} diff --git a/radicle-cli/src/terminal/ansi/windows.rs b/radicle-cli/src/terminal/ansi/windows.rs new file mode 100644 index 00000000..711e6d5f --- /dev/null +++ b/radicle-cli/src/terminal/ansi/windows.rs @@ -0,0 +1,64 @@ +#[cfg(windows)] +mod windows_console { + use std::os::raw::c_void; + + #[allow(non_camel_case_types)] + type c_ulong = u32; + #[allow(non_camel_case_types)] + type c_int = i32; + type DWORD = c_ulong; + type LPDWORD = *mut DWORD; + type HANDLE = *mut c_void; + type BOOL = c_int; + + const ENABLE_VIRTUAL_TERMINAL_PROCESSING: DWORD = 0x0004; + const STD_OUTPUT_HANDLE: DWORD = 0xFFFFFFF5; + const STD_ERROR_HANDLE: DWORD = 0xFFFFFFF4; + const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE; + const FALSE: BOOL = 0; + const TRUE: BOOL = 1; + + // This is the win32 console API, taken from the 'winapi' crate. + extern "system" { + fn GetStdHandle(nStdHandle: DWORD) -> HANDLE; + fn GetConsoleMode(hConsoleHandle: HANDLE, lpMode: LPDWORD) -> BOOL; + fn SetConsoleMode(hConsoleHandle: HANDLE, dwMode: DWORD) -> BOOL; + } + + unsafe fn get_handle(handle_num: DWORD) -> Result { + match GetStdHandle(handle_num) { + handle if handle == INVALID_HANDLE_VALUE => Err(()), + handle => Ok(handle), + } + } + + unsafe fn enable_vt(handle: HANDLE) -> Result<(), ()> { + let mut dw_mode: DWORD = 0; + if GetConsoleMode(handle, &mut dw_mode) == FALSE { + return Err(()); + } + + dw_mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING; + match SetConsoleMode(handle, dw_mode) { + result if result == TRUE => Ok(()), + _ => Err(()), + } + } + + unsafe fn enable_ansi_colors_raw() -> Result { + let stdout_handle = get_handle(STD_OUTPUT_HANDLE)?; + let stderr_handle = get_handle(STD_ERROR_HANDLE)?; + + enable_vt(stdout_handle)?; + if stdout_handle != stderr_handle { + enable_vt(stderr_handle)?; + } + + Ok(true) + } + + #[inline] + pub fn enable_ansi_colors() -> bool { + unsafe { enable_ansi_colors_raw().unwrap_or(false) } + } +} diff --git a/radicle-cli/src/terminal/cell.rs b/radicle-cli/src/terminal/cell.rs new file mode 100644 index 00000000..8ba9831d --- /dev/null +++ b/radicle-cli/src/terminal/cell.rs @@ -0,0 +1,146 @@ +use std::fmt::Display; + +use super::Paint; + +use unicode_width::UnicodeWidthStr; + +/// Text that can be displayed on the terminal, measured, truncated and padded. +pub trait Cell: Display { + /// Type after truncation. + type Truncated: Cell; + /// Type after padding. + type Padded: Cell; + + /// Cell display width in number of terminal columns. + fn width(&self) -> usize; + /// Truncate cell if longer than given width. Shows the delimiter if truncated. + fn truncate(&self, width: usize, delim: &str) -> Self::Truncated; + /// Pad the cell so that it is the given width, while keeping the content left-aligned. + fn pad(&self, width: usize) -> Self::Padded; +} + +impl Cell for Paint { + type Truncated = Self; + type Padded = Self; + + fn width(&self) -> usize { + UnicodeWidthStr::width(self.content()) + } + + fn truncate(&self, width: usize, delim: &str) -> Self { + Self { + item: self.item.truncate(width, delim), + style: self.style, + } + } + + fn pad(&self, width: usize) -> Self { + Self { + item: self.item.pad(width), + style: self.style, + } + } +} + +impl Cell for Paint<&str> { + type Truncated = Paint; + type Padded = Paint; + + fn width(&self) -> usize { + Cell::width(self.item) + } + + fn truncate(&self, width: usize, delim: &str) -> Paint { + Paint { + item: self.item.truncate(width, delim), + style: self.style, + } + } + + fn pad(&self, width: usize) -> Paint { + Paint { + item: self.item.pad(width), + style: self.style, + } + } +} + +impl Cell for String { + type Truncated = Self; + type Padded = Self; + + fn width(&self) -> usize { + Cell::width(self.as_str()) + } + + fn truncate(&self, width: usize, delim: &str) -> Self { + self.as_str().truncate(width, delim) + } + + fn pad(&self, width: usize) -> Self { + self.as_str().pad(width) + } +} + +impl Cell for str { + type Truncated = String; + type Padded = String; + + fn width(&self) -> usize { + UnicodeWidthStr::width(self) + } + + fn truncate(&self, width: usize, delim: &str) -> String { + use unicode_segmentation::UnicodeSegmentation as _; + + if width < Cell::width(self) { + let d = Cell::width(delim); + if width < d { + // If we can't even fit the delimiter, just return an empty string. + return String::new(); + } + // Find the unicode byte boundary where the display width is the largest, + // while being smaller than the given max width. + let mut cols = 0; // Number of visual columns we need. + let mut boundary = 0; // Boundary in bytes. + for g in self.graphemes(true) { + let c = Cell::width(g); + if cols + c + d > width { + break; + } + boundary += g.len(); + cols += c; + } + format!("{}{delim}", &self[..boundary]) + } else { + self.to_owned() + } + } + + fn pad(&self, max: usize) -> String { + let width = Cell::width(self); + + if width < max { + format!("{self}{}", " ".repeat(max - width)) + } else { + self.to_owned() + } + } +} + +impl Cell for &T { + type Truncated = T::Truncated; + type Padded = T::Padded; + + fn width(&self) -> usize { + T::width(self) + } + + fn truncate(&self, width: usize, delim: &str) -> Self::Truncated { + T::truncate(self, width, delim) + } + + fn pad(&self, width: usize) -> Self::Padded { + T::pad(self, width) + } +} diff --git a/radicle-cli/src/terminal/editor.rs b/radicle-cli/src/terminal/editor.rs new file mode 100644 index 00000000..7aafafe1 --- /dev/null +++ b/radicle-cli/src/terminal/editor.rs @@ -0,0 +1,94 @@ +use std::ffi::OsString; +use std::io::Write; +use std::path::PathBuf; +use std::process; +use std::{env, fs, io}; + +pub const COMMENT_FILE: &str = "RAD_COMMENT"; + +/// Allows for text input in the configured editor. +pub struct Editor { + path: PathBuf, +} + +impl Drop for Editor { + fn drop(&mut self) { + fs::remove_file(&self.path).ok(); + } +} + +impl Default for Editor { + fn default() -> Self { + Self::new() + } +} + +impl Editor { + /// Create a new editor. + pub fn new() -> Self { + let path = env::temp_dir().join(COMMENT_FILE); + + Self { path } + } + + /// Set the file extension. + pub fn extension(mut self, ext: &str) -> Self { + let ext = ext.trim_start_matches('.'); + + self.path.set_extension(ext); + self + } + + /// Open the editor and return the edited text. + /// + /// If the text hasn't changed from the initial contents of the editor, + /// return `None`. + pub fn edit(&mut self, initial: impl ToString) -> io::Result> { + let initial = initial.to_string(); + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .open(&self.path)?; + + if file.metadata()?.len() == 0 { + file.write_all(initial.as_bytes())?; + if !initial.ends_with('\n') { + file.write_all(b"\n")?; + } + file.flush()?; + } + + let Some(cmd) = self::default_editor() else { + return Err( + io::Error::new( + io::ErrorKind::NotFound, + "editor not configured: the `EDITOR` environment variable is not set" + ) + ); + }; + process::Command::new(cmd).arg(&self.path).spawn()?.wait()?; + + let text = fs::read_to_string(&self.path)?; + let text = text.strip_prefix(&initial).unwrap_or(&text); + + if text.trim().is_empty() { + return Ok(None); + } + Ok(Some(text.to_owned())) + } +} + +/// Get the default editor command. +pub fn default_editor() -> Option { + if let Ok(visual) = env::var("VISUAL") { + if !visual.is_empty() { + return Some(visual.into()); + } + } + if let Ok(editor) = env::var("EDITOR") { + if !editor.is_empty() { + return Some(editor.into()); + } + } + None +} diff --git a/radicle-cli/src/terminal/format.rs b/radicle-cli/src/terminal/format.rs index a5ec7fe7..a72b8dbb 100644 --- a/radicle-cli/src/terminal/format.rs +++ b/radicle-cli/src/terminal/format.rs @@ -1,6 +1,6 @@ use std::{fmt, time}; -pub use dialoguer::console::style; +pub use crate::terminal::{style, Paint}; use radicle::cob::{ObjectId, Timestamp}; use radicle::node::NodeId; @@ -88,58 +88,74 @@ impl<'a> fmt::Display for Identity<'a> { } } -pub fn negative(msg: D) -> String { - style(msg).red().bright().to_string() +pub fn negative(msg: D) -> Paint { + Paint::red(msg).bold() } -pub fn positive(msg: D) -> String { - style(msg).green().bright().to_string() +pub fn positive(msg: D) -> Paint { + Paint::green(msg).bold() } -pub fn secondary(msg: D) -> String { - style(msg).blue().bright().to_string() +pub fn secondary(msg: D) -> Paint { + Paint::blue(msg).bold() } -pub fn tertiary(msg: D) -> String { - style(msg).cyan().to_string() +pub fn tertiary(msg: D) -> Paint { + Paint::cyan(msg) } -pub fn tertiary_bold(msg: D) -> String { - style(msg).cyan().bold().to_string() +pub fn tertiary_bold(msg: D) -> Paint { + Paint::cyan(msg).bold() } -pub fn yellow(msg: D) -> String { - style(msg).yellow().to_string() +pub fn yellow(msg: D) -> Paint { + Paint::yellow(msg) } -pub fn highlight(input: D) -> String { - style(input).green().bright().to_string() +pub fn highlight(input: D) -> Paint { + Paint::green(input).bold() } -pub fn badge_primary(input: D) -> String { - style(format!(" {input} ")).magenta().reverse().to_string() +pub fn badge_primary(input: D) -> Paint { + if Paint::is_enabled() { + Paint::magenta(format!(" {input} ")).invert() + } else { + Paint::new(format!("❲{input}❳")) + } } -pub fn badge_positive(input: D) -> String { - style(format!(" {input} ")).green().reverse().to_string() +pub fn badge_positive(input: D) -> Paint { + if Paint::is_enabled() { + Paint::green(format!(" {input} ")).invert() + } else { + Paint::new(format!("❲{input}❳")) + } } -pub fn badge_negative(input: D) -> String { - style(format!(" {input} ")).red().reverse().to_string() +pub fn badge_negative(input: D) -> Paint { + if Paint::is_enabled() { + Paint::red(format!(" {input} ")).invert() + } else { + Paint::new(format!("❲{input}❳")) + } } -pub fn badge_secondary(input: D) -> String { - style(format!(" {input} ")).blue().reverse().to_string() +pub fn badge_secondary(input: D) -> Paint { + if Paint::is_enabled() { + Paint::blue(format!(" {input} ")).invert() + } else { + Paint::new(format!("❲{input}❳")) + } } -pub fn bold(input: D) -> String { - style(input).white().bright().bold().to_string() +pub fn bold(input: D) -> Paint { + Paint::white(input).bold() } -pub fn dim(input: D) -> String { - style(input).dim().to_string() +pub fn dim(input: D) -> Paint { + Paint::new(input).dim() } -pub fn italic(input: D) -> String { - style(input).italic().dim().to_string() +pub fn italic(input: D) -> Paint { + Paint::new(input).italic().dim() } diff --git a/radicle-cli/src/terminal/io.rs b/radicle-cli/src/terminal/io.rs index b3fe4c45..96e72b39 100644 --- a/radicle-cli/src/terminal/io.rs +++ b/radicle-cli/src/terminal/io.rs @@ -1,24 +1,39 @@ use std::fmt; -use std::str::FromStr; -use dialoguer::{console::style, console::Style, theme::ColorfulTheme, Input, Password}; +use inquire::ui::{ErrorMessageRenderConfig, StyleSheet, Styled}; +use inquire::InquireError; +use inquire::{ui::Color, ui::RenderConfig, Confirm, CustomType, Password, Select}; +use once_cell::sync::Lazy; use radicle::cob::issue::Issue; -use radicle::cob::thread::CommentId; -use radicle::crypto::ssh::keystore::Passphrase; +use radicle::cob::thread::{Comment, CommentId}; +use radicle::crypto::ssh::keystore::{MemorySigner, Passphrase}; use radicle::crypto::Signer; use radicle::profile; use radicle::profile::Profile; -use radicle_crypto::ssh::keystore::MemorySigner; - use super::command; use super::format; use super::spinner::spinner; use super::Error; +use super::{style, Paint}; +pub const ERROR_PREFIX: Paint<&str> = Paint::red("βœ—"); pub const TAB: &str = " "; +/// Render configuration. +pub static CONFIG: Lazy = Lazy::new(|| RenderConfig { + prompt: StyleSheet::new().with_fg(Color::LightCyan), + prompt_prefix: Styled::new("?").with_fg(Color::LightBlue), + answered_prompt_prefix: Styled::new("βœ“").with_fg(Color::LightGreen), + answer: StyleSheet::new(), + highlighted_option_prefix: Styled::new("*").with_fg(Color::LightYellow), + help_message: StyleSheet::new().with_fg(Color::DarkGrey), + error_message: ErrorMessageRenderConfig::default_colored() + .with_prefix(Styled::new("βœ—").with_fg(Color::LightRed)), + ..RenderConfig::default_colored() +}); + #[macro_export] macro_rules! info { ($($arg:tt)*) => ({ @@ -45,17 +60,15 @@ pub use success; pub use tip; pub fn success_args(args: fmt::Arguments) { - println!("{} {args}", style("ok").green().reverse()); + println!("{} {args}", Paint::green("βœ“")); } pub fn tip_args(args: fmt::Arguments) { - println!("{} {}", style("=>").blue(), style(format!("{args}")).dim()); + println!("πŸ‘‰ {}", style(format!("{args}")).italic()); } -pub fn width() -> Option { - console::Term::stdout() - .size_checked() - .map(|(_, cols)| cols as usize) +pub fn columns() -> Option { + termion::terminal_size().map(|(cols, _)| cols as usize).ok() } pub fn headline(headline: &str) { @@ -66,7 +79,7 @@ pub fn headline(headline: &str) { pub fn header(header: &str) { println!(); - println!("{}", style(format::yellow(header)).bold().underlined()); + println!("{}", style(format::yellow(header)).bold().underline()); println!(); } @@ -95,9 +108,9 @@ pub fn help(name: &str, version: &str, description: &str, usage: &str) { pub fn usage(name: &str, usage: &str) { println!( "{} {}\n{}", - style("==").red(), - style(format!("Error: rad-{name}: invalid usage")).red(), - style(prefixed(TAB, usage)).red().dim() + ERROR_PREFIX, + Paint::red(format!("Error: rad-{name}: invalid usage")), + Paint::red(prefixed(TAB, usage)).dim() ); } @@ -115,57 +128,39 @@ pub fn subcommand(msg: impl fmt::Display) { pub fn warning(warning: &str) { println!( - "{} {} {}", - style("**").yellow(), - style("Warning:").yellow().bold(), - style(warning).yellow() + "{} {} {warning}", + Paint::yellow("!"), + Paint::yellow("Warning:").bold(), ); } pub fn error(error: impl fmt::Display) { - println!("{} {}", style("==").red(), style(error).red()); + println!("{ERROR_PREFIX} {error}"); } pub fn fail(header: &str, error: &anyhow::Error) { let err = error.to_string(); let err = err.trim_end(); - let separator = if err.len() > 160 || err.contains('\n') { - "\n" - } else { - " " - }; + let separator = if err.contains('\n') { ":\n" } else { ": " }; println!( - "{} {}{}{}", - style("==").red(), - style(header).red().reverse(), - separator, - style(error).red().bold(), + "{ERROR_PREFIX} {}{}{error}", + Paint::red(header).bold(), + Paint::red(separator), ); - let cause = error.root_cause(); - if cause.to_string() != error.to_string() { - println!( - "{} {}", - style("==").red().dim(), - style(error.root_cause()).red().dim() - ); - blank(); - } - if let Some(Error::WithHint { hint, .. }) = error.downcast_ref::() { - println!("{} {}", style("==").yellow(), style(hint).yellow(),); + println!("{} {}", Paint::yellow("Γ—"), Paint::yellow(hint)); blank(); } } pub fn ask(prompt: D, default: bool) -> bool { - dialoguer::Confirm::new() - .with_prompt(format!("{} {}", style(" β€·".to_owned()).cyan(), prompt)) - .wait_for_newline(false) - .default(true) - .default(default) - .interact() + let prompt = format!("{} {}", Paint::blue("?".to_owned()), prompt); + + Confirm::new(&prompt) + .with_default(default) + .prompt() .unwrap_or_default() } @@ -182,209 +177,95 @@ pub fn signer(profile: &Profile) -> anyhow::Result> { if let Ok(signer) = profile.signer() { return Ok(signer); } - - let passphrase = secret_input(); + let passphrase = passphrase()?; let spinner = spinner("Unsealing key..."); let signer = MemorySigner::load(&profile.keystore, passphrase)?; spinner.finish(); + Ok(signer.boxed()) } -pub fn theme() -> ColorfulTheme { - ColorfulTheme { - success_prefix: style("ok".to_owned()).for_stderr().green().reverse(), - prompt_prefix: style(" β€·".to_owned()).cyan().dim().for_stderr(), - prompt_suffix: style("Β·".to_owned()).cyan().for_stderr(), - prompt_style: Style::new().cyan().bold().for_stderr(), - active_item_style: Style::new().for_stderr().yellow().reverse(), - active_item_prefix: style("*".to_owned()).yellow().for_stderr(), - picked_item_prefix: style("*".to_owned()).yellow().for_stderr(), - inactive_item_prefix: style(" ".to_string()).for_stderr(), - inactive_item_style: Style::new().yellow().for_stderr(), - error_prefix: style("β€Ή Error:".to_owned()).red().for_stderr(), - success_suffix: style("Β·".to_owned()).cyan().for_stderr(), - - ..ColorfulTheme::default() - } -} - -pub fn text_input(message: &str, default: Option) -> anyhow::Result +pub fn input(message: &str, default: Option) -> anyhow::Result where S: fmt::Display + std::str::FromStr + Clone, E: fmt::Debug + fmt::Display, { - let theme = theme(); - let mut input: Input = Input::with_theme(&theme); - + let input = CustomType::::new(message).with_render_config(*CONFIG); let value = match default { - Some(default) => input - .with_prompt(message) - .with_initial_text(default.to_string()) - .interact_text()?, - None => input.with_prompt(message).interact_text()?, + Some(default) => input.with_default(default).prompt()?, + None => input.prompt()?, }; Ok(value) } -#[derive(Debug, Default, Clone)] -pub struct Optional { - option: Option, -} - -impl fmt::Display for Optional { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if let Some(val) = &self.option { - write!(f, "{val}") - } else { - write!(f, "") - } +pub fn passphrase() -> Result { + if let Some(p) = profile::env::passphrase() { + Ok(p) + } else { + Ok(Passphrase::from( + Password::new("Passphrase:") + .with_render_config(*CONFIG) + .with_display_mode(inquire::PasswordDisplayMode::Masked) + .without_confirmation() + .prompt()?, + )) } } -impl FromStr for Optional { - type Err = ::Err; - - fn from_str(s: &str) -> Result { - if s.is_empty() { - return Ok(Optional { option: None }); - } - let val: T = s.parse()?; - - Ok(Self { option: Some(val) }) +pub fn passphrase_confirm() -> Result { + if let Some(p) = profile::env::passphrase() { + Ok(p) + } else { + Ok(Passphrase::from( + Password::new("Passphrase:") + .with_render_config(*CONFIG) + .with_display_mode(inquire::PasswordDisplayMode::Masked) + .with_custom_confirmation_message("Repeat passphrase:") + .with_custom_confirmation_error_message("The passphrases don't match.") + .prompt()?, + )) } } -pub fn text_input_optional( - message: &str, - initial: Option, -) -> anyhow::Result> -where - S: fmt::Display + fmt::Debug + FromStr + Clone, - E: fmt::Debug + fmt::Display, -{ - let theme = theme(); - let mut input: Input> = Input::with_theme(&theme); - - if let Some(init) = initial { - input.with_initial_text(init); - } - let value = input - .with_prompt(message) - .allow_empty(true) - .interact_text()?; - - Ok(value.option) -} - -pub fn secret_input() -> Passphrase { - secret_input_with_prompt("Passphrase") -} - -// TODO: This prompt shows success just for entering a password, -// even if the password is later found out to be wrong. -// We should handle this differently. -pub fn secret_input_with_prompt(prompt: &str) -> Passphrase { - Passphrase::from( - Password::with_theme(&theme()) - .allow_empty_password(true) - .with_prompt(prompt) - .interact() - .unwrap(), - ) -} - -pub fn secret_input_with_confirmation() -> Passphrase { - Passphrase::from( - Password::with_theme(&theme()) - .with_prompt("Passphrase") - .with_confirmation("Repeat passphrase", "Error: the passphrases don't match.") - .interact() - .unwrap(), - ) -} - -pub fn secret_stdin() -> Result { +pub fn passphrase_stdin() -> Result { let mut input = String::new(); std::io::stdin().read_line(&mut input)?; Ok(Passphrase::from(input.trim_end().to_owned())) } -pub fn read_passphrase(stdin: bool, confirm: bool) -> Result { - let passphrase = match profile::env::read_passphrase() { - Some(input) => input, - None => { - if stdin { - secret_stdin()? - } else if confirm { - secret_input_with_confirmation() - } else { - secret_input() - } - } - }; - - Ok(passphrase) -} - -pub fn select<'a, T>(options: &'a [T], active: &'a T) -> Option<&'a T> +pub fn select<'a, T>( + prompt: &str, + options: &'a [T], + active: &'a T, +) -> Result, InquireError> where T: fmt::Display + Eq + PartialEq, { - let theme = theme(); let active = options.iter().position(|o| o == active); - let mut selection = dialoguer::Select::with_theme(&theme); + let selection = + Select::new(prompt, options.iter().collect::>()).with_render_config(*CONFIG); if let Some(active) = active { - selection.default(active); + selection.with_starting_cursor(active).prompt_skippable() + } else { + selection.prompt_skippable() } - let result = selection - .items(&options.iter().map(|p| p.to_string()).collect::>()) - .interact_opt() - .unwrap(); - - result.map(|i| &options[i]) } -pub fn select_with_prompt<'a, T>(prompt: &str, options: &'a [T], active: &'a T) -> Option<&'a T> -where - T: fmt::Display + Eq + PartialEq, -{ - let theme = theme(); - let active = options.iter().position(|o| o == active); - let mut selection = dialoguer::Select::with_theme(&theme); +pub fn comment_select(issue: &Issue) -> Option<(&CommentId, &Comment)> { + let comments = issue.comments().collect::>(); + let selection = Select::new( + "Which comment do you want to react to?", + (0..comments.len()).collect(), + ) + .with_render_config(*CONFIG) + .with_formatter(&|i| comments[i.index].1.body().to_owned()) + .prompt() + .ok()?; - selection.with_prompt(prompt); - - if let Some(active) = active { - selection.default(active); - } - let result = selection - .items(&options.iter().map(|p| p.to_string()).collect::>()) - .interact_opt() - .unwrap(); - - result.map(|i| &options[i]) -} - -pub fn comment_select(issue: &Issue) -> Option { - let selection = dialoguer::Select::with_theme(&theme()) - .with_prompt("Which comment do you want to react to?") - .item(issue.description().unwrap_or_default()) - .items( - &issue - .comments() - .map(|(_, i)| i.body().to_owned()) - .collect::>(), - ) - .default(0) - .interact_opt() - .unwrap(); - - selection - .and_then(|n| issue.comments().nth(n)) - .map(|(id, _)| *id) + comments.get(selection).copied() } pub fn markdown(content: &str) { @@ -393,10 +274,6 @@ pub fn markdown(content: &str) { } } -fn _info(args: std::fmt::Arguments) { - println!("{args}"); -} - pub mod proposal { use std::fmt::Write as _; @@ -406,44 +283,44 @@ pub mod proposal { identity::Identity, }; - use super::{super::format, theme}; + use super::*; + use crate::terminal::format; pub fn revision_select( proposal: &Proposal, ) -> Option<(&identity::RevisionId, &identity::Revision)> { - let selection = dialoguer::Select::with_theme(&theme()) - .with_prompt("Which revision do you want to select?") - .items( - &proposal - .revisions() - .map(|(rid, _)| rid.to_string()) - .collect::>(), - ) - .default(0) - .interact_opt() - .unwrap(); + let revisions = proposal.revisions().collect::>(); + let selection = Select::new( + "Which revision do you want to select?", + (0..revisions.len()).collect(), + ) + .with_vim_mode(true) + .with_formatter(&|ix| revisions[ix.index].0.to_string()) + .with_render_config(*CONFIG) + .prompt() + .ok()?; - selection.and_then(|n| proposal.revisions().nth(n)) + revisions.get(selection).copied() } pub fn revision_commit_select<'a>( proposal: &'a Proposal, previous: &'a Identity, ) -> Option<(&'a identity::RevisionId, &'a identity::Revision)> { - let selection = dialoguer::Select::with_theme(&theme()) - .with_prompt("Which revision do you want to commit?") - .items( - &proposal - .revisions() - .filter(|(_, r)| r.is_quorum_reached(previous)) - .map(|(rid, _)| rid.to_string()) - .collect::>(), - ) - .default(0) - .interact_opt() - .unwrap(); + let revisions = proposal + .revisions() + .filter(|(_, r)| r.is_quorum_reached(previous)) + .collect::>(); + let selection = Select::new( + "Which revision do you want to commit?", + (0..revisions.len()).collect(), + ) + .with_formatter(&|ix| revisions[ix.index].0.to_string()) + .with_render_config(*CONFIG) + .prompt() + .ok()?; - selection.and_then(|n| proposal.revisions().nth(n)) + revisions.get(selection).copied() } pub fn diff(proposal: &identity::Revision, previous: &Identity) -> anyhow::Result { diff --git a/radicle-cli/src/terminal/patch.rs b/radicle-cli/src/terminal/patch.rs index db33464e..39cf0cc5 100644 --- a/radicle-cli/src/terminal/patch.rs +++ b/radicle-cli/src/terminal/patch.rs @@ -1,6 +1,7 @@ use radicle::git; use crate::terminal as term; +use crate::terminal::cell::Cell as _; /// The user supplied `Patch` description. #[derive(Clone, Debug, PartialEq, Eq)] @@ -18,15 +19,14 @@ impl Message { pub fn get(self, help: &str) -> String { let comment = match self { Message::Edit => term::Editor::new() - .require_save(true) - .trim_newlines(true) - .extension(".markdown") + .extension("markdown") .edit(help) - .unwrap(), + .ok() + .flatten(), Message::Blank => None, Message::Text(c) => Some(c), }; - let comment = comment.unwrap_or_default().replace(help, ""); + let comment = comment.unwrap_or_default(); let comment = comment.trim(); comment.to_owned() @@ -57,7 +57,7 @@ pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> { .unwrap_or_else(|| commit.message_bytes()); table.push([ term::format::secondary(term::format::oid(commit.id())), - term::format::italic(String::from_utf8_lossy(message)), + term::format::italic(String::from_utf8_lossy(message).to_string()), ]); } table.render(); @@ -100,6 +100,6 @@ pub fn print_title_desc(title: &str, description: &str) { term::blank(); term::print(term::format::dim(format!( "β•°{}", - "─".repeat(term::text_width(title_pretty) - 1) + "─".repeat(title_pretty.to_string().width() - 1) ))); } diff --git a/radicle-cli/src/terminal/spinner.rs b/radicle-cli/src/terminal/spinner.rs index 036d971f..02e3e5db 100644 --- a/radicle-cli/src/terminal/spinner.rs +++ b/radicle-cli/src/terminal/spinner.rs @@ -1,77 +1,171 @@ -use dialoguer::console::style; -use indicatif::{ProgressBar, ProgressFinish, ProgressStyle}; +use std::io::Write; +use std::mem::ManuallyDrop; +use std::sync::{Arc, Mutex}; +use std::{fmt, io, thread, time}; -use crate::terminal as term; +use crate::terminal::io::ERROR_PREFIX; +use crate::terminal::Paint; -pub struct Spinner { - progress: ProgressBar, - message: String, +/// How much time to wait between spinner animation updates. +pub const DEFAULT_TICK: time::Duration = time::Duration::from_millis(99); +/// The spinner animation strings. +pub const DEFAULT_STYLE: [Paint<&'static str>; 4] = [ + Paint::magenta("β—’"), + Paint::cyan("β—£"), + Paint::magenta("β—€"), + Paint::blue("β—₯"), +]; + +struct Progress { + state: State, + message: Paint, } -impl Drop for Spinner { - fn drop(&mut self) { - // TODO: Set error that will be output on fail. - if !self.progress.is_finished() { - self.set_failed(); +impl Progress { + fn new(message: Paint) -> Self { + Self { + state: State::Running { cursor: 0 }, + message, } } } -impl Spinner { - pub fn finish(&self) { - self.progress.finish_and_clear(); - term::success!("{}", &self.message); - } +enum State { + Running { cursor: usize }, + Canceled, + Done, + Error, +} - pub fn done(self) { - self.progress.finish_and_clear(); - term::info!("{}", &self.message); - } +/// A progress spinner. +pub struct Spinner { + progress: Arc>, + handle: ManuallyDrop>, +} - pub fn failed(mut self) { - self.set_failed(); - } - - pub fn error(mut self, msg: impl ToString) { - let msg = msg.to_string(); - - self.message = format!("{} error: {}", self.message, msg); - self.set_failed(); - } - - pub fn clear(self) { - self.progress.finish_and_clear(); - } - - pub fn message(&mut self, msg: impl ToString) { - let msg = msg.to_string(); - - self.progress.set_message(msg.clone()); - self.message = msg; - } - - pub fn set_failed(&mut self) { - self.progress.finish_and_clear(); - term::println(style("!!").red().reverse(), &self.message); +impl Drop for Spinner { + fn drop(&mut self) { + if let Ok(mut progress) = self.progress.lock() { + if let State::Running { .. } = progress.state { + progress.state = State::Canceled; + } + } + unsafe { ManuallyDrop::take(&mut self.handle) } + .join() + .unwrap(); } } +impl Spinner { + /// Mark the spinner as successfully completed. + pub fn finish(self) { + if let Ok(mut progress) = self.progress.lock() { + progress.state = State::Done; + } + } + + /// Mark the spinner as failed. This cancels the spinner. + pub fn failed(self) { + if let Ok(mut progress) = self.progress.lock() { + progress.state = State::Error; + } + } + + /// Cancel the spinner with an error. + pub fn error(self, msg: impl fmt::Display) { + if let Ok(mut progress) = self.progress.lock() { + progress.state = State::Error; + progress.message = Paint::new(format!( + "{} {} {}", + progress.message, + Paint::red("error:"), + msg + )); + } + } + + /// Set the spinner's message. + pub fn message(&mut self, msg: impl fmt::Display) { + let msg = msg.to_string(); + + if let Ok(mut progress) = self.progress.lock() { + progress.message = Paint::new(msg); + } + } +} + +/// Create a new spinner with the given message. pub fn spinner(message: impl ToString) -> Spinner { let message = message.to_string(); - let style = ProgressStyle::default_spinner() - .tick_strings(&[ - &style("\\ ").yellow().to_string(), - &style("| ").yellow().to_string(), - &style("/ ").yellow().to_string(), - &style("| ").yellow().to_string(), - ]) - .template("{spinner} {msg}") - .on_finish(ProgressFinish::AndClear); + let progress = Arc::new(Mutex::new(Progress::new(Paint::new(message)))); + let handle = thread::spawn({ + let progress = progress.clone(); - let progress = ProgressBar::new(!0); - progress.set_style(style); - progress.enable_steady_tick(99); - progress.set_message(message.clone()); + move || { + let mut stdout = io::stdout(); + let mut stderr = termion::cursor::HideCursor::from(io::stderr()); - Spinner { message, progress } + loop { + let Ok(mut progress) = progress.lock() else { + break; + }; + match &mut *progress { + Progress { + state: State::Running { cursor }, + message, + } => { + let spinner = DEFAULT_STYLE[*cursor]; + + write!( + stderr, + "{}{}{spinner} {message}", + termion::cursor::Save, + termion::clear::AfterCursor, + ) + .ok(); + + write!(stderr, "{}", termion::cursor::Restore).ok(); + + *cursor += 1; + *cursor %= DEFAULT_STYLE.len(); + } + Progress { + state: State::Done, + message, + } => { + write!(stderr, "{}", termion::clear::AfterCursor).ok(); + writeln!(stdout, "{} {message}", Paint::green("βœ“")).ok(); + break; + } + Progress { + state: State::Canceled, + message, + } => { + write!(stderr, "{}", termion::clear::AfterCursor).ok(); + writeln!( + stdout, + "{ERROR_PREFIX} {message} {}", + Paint::red("") + ) + .ok(); + break; + } + Progress { + state: State::Error, + message, + } => { + writeln!(stdout, "{ERROR_PREFIX} {message}").ok(); + break; + } + } + drop(progress); + thread::sleep(DEFAULT_TICK); + } + } + }); + + Spinner { + progress, + handle: ManuallyDrop::new(handle), + } } diff --git a/radicle-cli/src/terminal/table.rs b/radicle-cli/src/terminal/table.rs index 887155b2..9be8f34f 100644 --- a/radicle-cli/src/terminal/table.rs +++ b/radicle-cli/src/terminal/table.rs @@ -9,18 +9,31 @@ //! t.push(["aphid", "lacewing"]); //! t.push(["spider mite", "ladybug"]); //! t.render(); -//! // pest biological control -//! // aphid ladybug -//! // spider mite persimilis //! ``` - -use std::fmt::Write; +//! Output: +//! ``` plain +//! pest biological control +//! aphid ladybug +//! spider mite persimilis +//! ``` +use std::io; use crate::terminal as term; +use crate::terminal::cell::Cell; + +/// Used to specify maximum width or height. +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +pub struct Max { + width: Option, + height: Option, +} #[derive(Debug, Default)] pub struct TableOptions { + /// Whether the table should be allowed to overflow. pub overflow: bool, + /// The maximum width and height. + pub max: Max, } #[derive(Debug)] @@ -49,16 +62,20 @@ impl Table { } } - pub fn push(&mut self, row: [impl ToString; W]) { + pub fn push(&mut self, row: [impl Cell; W]) { let row = row.map(|s| s.to_string()); for (i, cell) in row.iter().enumerate() { - self.widths[i] = self.widths[i].max(console::measure_text_width(cell)); + self.widths[i] = self.widths[i].max(cell.width()); } self.rows.push(row); } pub fn render(self) { - let width = term::width(); // Terminal width. + self.write(io::stdout()).ok(); + } + + pub fn write(self, mut writer: T) -> io::Result<()> { + let width = self.opts.max.width.or_else(term::columns); for row in &self.rows { let mut output = String::new(); @@ -66,27 +83,25 @@ impl Table { for (i, cell) in row.iter().enumerate() { if i == cells - 1 || self.opts.overflow { - write!(output, "{cell}").ok(); + output.push_str(cell.to_string().as_str()); } else { - write!( - output, - "{} ", - console::pad_str(cell, self.widths[i], console::Alignment::Left, None) - ) - .ok(); + output.push_str(cell.pad(self.widths[i]).as_str()); + output.push(' '); } } let output = output.trim_end(); - println!( + writeln!( + writer, "{}", if let Some(width) = width { - console::truncate_str(output, width - 1, "…") + output.truncate(width, "…") } else { output.into() } - ); + )?; } + Ok(()) } pub fn render_tree(self) { @@ -97,12 +112,116 @@ impl Table { print!("└── "); } for (i, cell) in row.iter().enumerate() { - print!( - "{} ", - console::pad_str(cell, self.widths[i], console::Alignment::Left, None) - ); + print!("{} ", cell.pad(self.widths[i])); } println!(); } } } + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_truncate() { + assert_eq!("🍍".truncate(1, "…"), String::from("…")); + assert_eq!("🍍".truncate(1, ""), String::from("")); + assert_eq!("🍍🍍".truncate(2, "…"), String::from("…")); + assert_eq!("🍍🍍".truncate(3, "…"), String::from("πŸβ€¦")); + assert_eq!("🍍".truncate(1, "🍎"), String::from("")); + assert_eq!("🍍".truncate(2, "🍎"), String::from("🍍")); + assert_eq!("🍍🍍".truncate(3, "🍎"), String::from("🍎")); + assert_eq!("🍍🍍🍍".truncate(4, "🍎"), String::from("🍍🍎")); + assert_eq!("hello".truncate(3, "…"), String::from("he…")); + } + + #[test] + fn test_table() { + let mut s = Vec::new(); + let mut t = Table::new(TableOptions::default()); + + t.push(["pineapple", "rosemary"]); + t.push(["apples", "pears"]); + t.write(&mut s).unwrap(); + + #[rustfmt::skip] + assert_eq!( + String::from_utf8_lossy(&s), + [ + "pineapple rosemary\n", + "apples pears\n" + ].join("") + ); + } + + #[test] + fn test_table_truncate() { + let mut s = Vec::new(); + let mut t = Table::new(TableOptions { + max: Max { + width: Some(16), + height: None, + }, + ..TableOptions::default() + }); + + t.push(["pineapple", "rosemary"]); + t.push(["apples", "pears"]); + t.write(&mut s).unwrap(); + + #[rustfmt::skip] + assert_eq!( + String::from_utf8_lossy(&s), + [ + "pineapple rosem…\n", + "apples pears\n" + ].join("") + ); + } + + #[test] + fn test_table_unicode() { + let mut s = Vec::new(); + let mut t = Table::new(TableOptions::default()); + + t.push(["🍍pineapple", "__rosemary", "__sage"]); + t.push(["__pears", "🍎apples", "🍌bananas"]); + t.write(&mut s).unwrap(); + + #[rustfmt::skip] + assert_eq!( + String::from_utf8_lossy(&s), + [ + "🍍pineapple __rosemary __sage\n", + "__pears 🍎apples 🍌bananas\n" + ].join("") + ); + } + + #[test] + fn test_table_unicode_truncate() { + let mut s = Vec::new(); + let mut t = Table::new(TableOptions { + max: Max { + width: Some(16), + height: None, + }, + ..TableOptions::default() + }); + + t.push(["🍍pineapple", "__rosemary"]); + t.push(["__pears", "🍎apples"]); + t.write(&mut s).unwrap(); + + #[rustfmt::skip] + assert_eq!( + String::from_utf8_lossy(&s), + [ + "🍍pineapple __r…\n", + "__pears 🍎a…\n" + ].join("") + ); + } +} diff --git a/radicle-cli/src/terminal/textbox.rs b/radicle-cli/src/terminal/textbox.rs index 0746b4f1..62241327 100644 --- a/radicle-cli/src/terminal/textbox.rs +++ b/radicle-cli/src/terminal/textbox.rs @@ -1,6 +1,7 @@ use std::fmt; use crate::terminal as term; +use crate::terminal::cell::Cell as _; pub struct TextBox { pub body: String, @@ -32,14 +33,8 @@ impl TextBox { impl fmt::Display for TextBox { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut width = self - .body - .lines() - .map(console::measure_text_width) - .max() - .unwrap_or(0) - + 2; - if let Some(max) = term::width() { + let mut width = self.body.lines().map(|l| l.width()).max().unwrap_or(0) + 2; + if let Some(max) = term::columns() { if width + 2 > max { width = max - 2 } @@ -53,11 +48,7 @@ impl fmt::Display for TextBox { writeln!(f, "β”Œ{}{}┐", connector, "─".repeat(header_width))?; for l in self.body.lines() { - writeln!( - f, - "β”‚ {}β”‚", - console::pad_str(l, width - 1, console::Alignment::Left, Some("…")) - )?; + writeln!(f, "β”‚ {}β”‚", l.pad(width - 1))?; } let (connector, footer_width) = if !self.last { diff --git a/radicle-tools/Cargo.toml b/radicle-tools/Cargo.toml index c6b8fc61..43c21678 100644 --- a/radicle-tools/Cargo.toml +++ b/radicle-tools/Cargo.toml @@ -13,6 +13,10 @@ git-ref-format = { version = "0", features = ["serde", "macro"] } version = "0" path = "../radicle" +[dependencies.radicle-cli] +version = "0" +path = "../radicle-cli" + [[bin]] name = "rad-init" path = "src/rad-init.rs" @@ -32,3 +36,7 @@ path = "src/rad-push.rs" [[bin]] name = "rad-agent" path = "src/rad-agent.rs" + +[[bin]] +name = "rad-cli-demo" +path = "src/rad-cli-demo.rs" diff --git a/radicle-tools/src/rad-cli-demo.rs b/radicle-tools/src/rad-cli-demo.rs new file mode 100644 index 00000000..777c39d3 --- /dev/null +++ b/radicle-tools/src/rad-cli-demo.rs @@ -0,0 +1,74 @@ +use std::{thread, time}; + +use radicle_cli::terminal; + +fn main() -> anyhow::Result<()> { + let demo = terminal::io::select( + "Choose something to try out:", + &[ + "spinner", + "spinner-drop", + "spinner-error", + "editor", + "prompt", + ], + &"spinner", + )?; + + match demo { + Some(&"editor") => { + let output = terminal::editor::Editor::new() + .extension("rs") + .edit("// Enter code here."); + + match output { + Ok(Some(s)) => { + terminal::info!("You entered:"); + terminal::blob(s); + } + Ok(None) => { + terminal::info!("You didn't enter anything."); + } + Err(e) => { + return Err(e.into()); + } + } + } + Some(&"spinner") => { + let mut spinner = terminal::spinner("Spinning turbines.."); + thread::sleep(time::Duration::from_secs(1)); + spinner.message("Still spinning.."); + thread::sleep(time::Duration::from_secs(1)); + spinner.message("Almost done.."); + thread::sleep(time::Duration::from_secs(1)); + spinner.message("Done."); + + spinner.finish(); + } + Some(&"spinner-drop") => { + let _spinner = terminal::spinner("Spinning turbines.."); + thread::sleep(time::Duration::from_secs(3)); + } + Some(&"spinner-error") => { + let spinner = terminal::spinner("Spinning turbines.."); + thread::sleep(time::Duration::from_secs(3)); + spinner.error("broken turbine"); + } + Some(&"prompt") => { + let fruit = terminal::io::select( + "Enter your favorite fruit:", + &["apple", "pear", "banana", "strawberry"], + &"apple", + )?; + + if let Some(fruit) = fruit { + terminal::success!("You have chosen '{fruit}'"); + } else { + terminal::info!("Ok, bye."); + } + } + _ => {} + } + + Ok(()) +} diff --git a/radicle/src/identity/project.rs b/radicle/src/identity/project.rs index 4489763d..7b243d32 100644 --- a/radicle/src/identity/project.rs +++ b/radicle/src/identity/project.rs @@ -178,12 +178,12 @@ impl Project { } #[inline] - pub fn name(&self) -> &String { + pub fn name(&self) -> &str { &self.name } #[inline] - pub fn description(&self) -> &String { + pub fn description(&self) -> &str { &self.description } diff --git a/radicle/src/profile.rs b/radicle/src/profile.rs index d0389303..de763d33 100644 --- a/radicle/src/profile.rs +++ b/radicle/src/profile.rs @@ -34,7 +34,7 @@ pub mod env { /// Passphrase for the encrypted radicle secret key. pub const RAD_PASSPHRASE: &str = "RAD_PASSPHRASE"; - pub fn read_passphrase() -> Option { + pub fn passphrase() -> Option { let Ok(passphrase) = std::env::var(RAD_PASSPHRASE) else { return None; }; @@ -109,7 +109,7 @@ impl Profile { } pub fn signer(&self) -> Result, Error> { - if let Some(passphrase) = env::read_passphrase() { + if let Some(passphrase) = env::passphrase() { let signer = keystore::MemorySigner::load(&self.keystore, passphrase)?; return Ok(signer.boxed()); }