cli: Simplify & improve CLI output

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.
This commit is contained in:
Alexis Sellier 2023-02-16 22:57:46 +01:00
parent 11da344e3a
commit 0a93fa833e
No known key found for this signature in database
44 changed files with 2003 additions and 607 deletions

136
Cargo.lock generated
View File

@ -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"

View File

@ -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 {

View File

@ -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" }

View File

@ -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:

View File

@ -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

View File

@ -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
```

View File

@ -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/

View File

@ -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!
```
```

View File

@ -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
```

View File

@ -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

View File

@ -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 .

View File

@ -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",

View File

@ -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) <- z6MknSLStBU8Vi/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
```

View File

@ -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:

View File

@ -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

View File

@ -97,7 +97,7 @@ pub fn execute(options: Options, profile: &Profile) -> anyhow::Result<PathBuf> {
.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());

View File

@ -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!");

View File

@ -41,7 +41,7 @@ pub struct Metadata {
impl Metadata {
fn edit(self) -> anyhow::Result<Self> {
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<Oid>) -> 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();

View File

@ -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
}

View File

@ -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();

View File

@ -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()),
]);
});

View File

@ -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::<Vec<_>>()
.join("\n"),
None => anyhow::bail!("user aborted merge"),
_ => anyhow::bail!("user aborted merge"),
};
// Empty message aborts merge.

View File

@ -127,7 +127,7 @@ pub fn pretty_sync_status(
) -> anyhow::Result<String> {
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<git::raw::Repository>,
) -> anyhow::Result<String> {
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<String> = 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();

View File

@ -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(" "),

View File

@ -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();

View File

@ -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::<Error>() {
eprintln!("{}", style(hint).yellow());
eprintln!("{}", Paint::yellow(hint));
}
process::exit(1);
@ -173,3 +176,7 @@ impl From<bool> for Interactive {
}
}
}
pub fn style<T>(item: T) -> Paint<T> {
paint(item)
}

View File

@ -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;

View File

@ -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<T>(self, item: T) -> Paint<T> {
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}"),
}
}
}

View File

@ -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<T> {
pub item: T,
pub style: Style,
}
impl Paint<&str> {
/// Return plain content.
pub fn content(&self) -> &str {
self.item
}
}
impl Paint<String> {
/// Return plain content.
pub fn content(&self) -> &str {
self.item.as_str()
}
}
impl<T> From<T> for Paint<T> {
fn from(value: T) -> Self {
Self::new(value)
}
}
impl From<&str> for Paint<String> {
fn from(item: &str) -> Self {
Self::new(item.to_string())
}
}
impl<T> Paint<T> {
/// Constructs a new `Paint` structure encapsulating `item` with no set
/// styling.
#[inline]
pub const fn new(item: T) -> Paint<T> {
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<T> {
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<T> {
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<T> {
Paint::new(item).fg(Color::Fixed(color))
}
pub const fn red(item: T) -> Paint<T> {
Paint::new(item).fg(Color::Red)
}
pub const fn black(item: T) -> Paint<T> {
Paint::new(item).fg(Color::Black)
}
pub const fn yellow(item: T) -> Paint<T> {
Paint::new(item).fg(Color::Yellow)
}
pub const fn green(item: T) -> Paint<T> {
Paint::new(item).fg(Color::Green)
}
pub const fn cyan(item: T) -> Paint<T> {
Paint::new(item).fg(Color::Cyan)
}
pub const fn blue(item: T) -> Paint<T> {
Paint::new(item).fg(Color::Blue)
}
pub const fn magenta(item: T) -> Paint<T> {
Paint::new(item).fg(Color::Magenta)
}
pub const fn white(item: T) -> Paint<T> {
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<T> {
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<T> {
self.style.wrap = true;
self
}
/// Sets the foreground to `color`.
#[inline]
pub const fn fg(mut self, color: Color) -> Paint<T> {
self.style.foreground = color;
self
}
/// Sets the background to `color`.
#[inline]
pub const fn bg(mut self, color: Color) -> Paint<T> {
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<T: UnicodeWidthStr> UnicodeWidthStr for Paint<T> {
fn width(&self) -> usize {
self.item.width()
}
fn width_cjk(&self) -> usize {
self.item.width_cjk()
}
}
impl<T: fmt::Display> fmt::Display for Paint<T> {
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<T>(item: T) -> Paint<T> {
Paint::new(item)
}

View File

@ -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<Self::Item> {
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<H: Hasher>(&self, state: &mut H) {
self.foreground.hash(state);
self.background.hash(state);
self.properties.hash(state);
}
}
#[inline]
fn write_spliced<T: Display>(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<T>(self, item: T) -> Paint<T> {
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")
}
}

View File

@ -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()
);
}

View File

@ -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<HANDLE, ()> {
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<bool, ()> {
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) }
}
}

View File

@ -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<String> {
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<String>;
type Padded = Paint<String>;
fn width(&self) -> usize {
Cell::width(self.item)
}
fn truncate(&self, width: usize, delim: &str) -> Paint<String> {
Paint {
item: self.item.truncate(width, delim),
style: self.style,
}
}
fn pad(&self, width: usize) -> Paint<String> {
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<T: Cell + ?Sized> 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)
}
}

View File

@ -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<Option<String>> {
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<OsString> {
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
}

View File

@ -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<D: std::fmt::Display>(msg: D) -> String {
style(msg).red().bright().to_string()
pub fn negative<D: std::fmt::Display>(msg: D) -> Paint<D> {
Paint::red(msg).bold()
}
pub fn positive<D: std::fmt::Display>(msg: D) -> String {
style(msg).green().bright().to_string()
pub fn positive<D: std::fmt::Display>(msg: D) -> Paint<D> {
Paint::green(msg).bold()
}
pub fn secondary<D: std::fmt::Display>(msg: D) -> String {
style(msg).blue().bright().to_string()
pub fn secondary<D: std::fmt::Display>(msg: D) -> Paint<D> {
Paint::blue(msg).bold()
}
pub fn tertiary<D: std::fmt::Display>(msg: D) -> String {
style(msg).cyan().to_string()
pub fn tertiary<D: std::fmt::Display>(msg: D) -> Paint<D> {
Paint::cyan(msg)
}
pub fn tertiary_bold<D: std::fmt::Display>(msg: D) -> String {
style(msg).cyan().bold().to_string()
pub fn tertiary_bold<D: std::fmt::Display>(msg: D) -> Paint<D> {
Paint::cyan(msg).bold()
}
pub fn yellow<D: std::fmt::Display>(msg: D) -> String {
style(msg).yellow().to_string()
pub fn yellow<D: std::fmt::Display>(msg: D) -> Paint<D> {
Paint::yellow(msg)
}
pub fn highlight<D: std::fmt::Display>(input: D) -> String {
style(input).green().bright().to_string()
pub fn highlight<D: std::fmt::Debug + std::fmt::Display>(input: D) -> Paint<D> {
Paint::green(input).bold()
}
pub fn badge_primary<D: std::fmt::Display>(input: D) -> String {
style(format!(" {input} ")).magenta().reverse().to_string()
pub fn badge_primary<D: std::fmt::Display>(input: D) -> Paint<String> {
if Paint::is_enabled() {
Paint::magenta(format!(" {input} ")).invert()
} else {
Paint::new(format!("{input}"))
}
}
pub fn badge_positive<D: std::fmt::Display>(input: D) -> String {
style(format!(" {input} ")).green().reverse().to_string()
pub fn badge_positive<D: std::fmt::Display>(input: D) -> Paint<String> {
if Paint::is_enabled() {
Paint::green(format!(" {input} ")).invert()
} else {
Paint::new(format!("{input}"))
}
}
pub fn badge_negative<D: std::fmt::Display>(input: D) -> String {
style(format!(" {input} ")).red().reverse().to_string()
pub fn badge_negative<D: std::fmt::Display>(input: D) -> Paint<String> {
if Paint::is_enabled() {
Paint::red(format!(" {input} ")).invert()
} else {
Paint::new(format!("{input}"))
}
}
pub fn badge_secondary<D: std::fmt::Display>(input: D) -> String {
style(format!(" {input} ")).blue().reverse().to_string()
pub fn badge_secondary<D: std::fmt::Display>(input: D) -> Paint<String> {
if Paint::is_enabled() {
Paint::blue(format!(" {input} ")).invert()
} else {
Paint::new(format!("{input}"))
}
}
pub fn bold<D: std::fmt::Display>(input: D) -> String {
style(input).white().bright().bold().to_string()
pub fn bold<D: std::fmt::Display>(input: D) -> Paint<D> {
Paint::white(input).bold()
}
pub fn dim<D: std::fmt::Display>(input: D) -> String {
style(input).dim().to_string()
pub fn dim<D: std::fmt::Display>(input: D) -> Paint<D> {
Paint::new(input).dim()
}
pub fn italic<D: std::fmt::Display>(input: D) -> String {
style(input).italic().dim().to_string()
pub fn italic<D: std::fmt::Display>(input: D) -> Paint<D> {
Paint::new(input).italic().dim()
}

View File

@ -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<RenderConfig> = 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<usize> {
console::Term::stdout()
.size_checked()
.map(|(_, cols)| cols as usize)
pub fn columns() -> Option<usize> {
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::<Error>() {
println!("{} {}", style("==").yellow(), style(hint).yellow(),);
println!("{} {}", Paint::yellow("×"), Paint::yellow(hint));
blank();
}
}
pub fn ask<D: fmt::Display>(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<Box<dyn Signer>> {
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<S, E>(message: &str, default: Option<S>) -> anyhow::Result<S>
pub fn input<S, E>(message: &str, default: Option<S>) -> anyhow::Result<S>
where
S: fmt::Display + std::str::FromStr<Err = E> + Clone,
E: fmt::Debug + fmt::Display,
{
let theme = theme();
let mut input: Input<S> = Input::with_theme(&theme);
let input = CustomType::<S>::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<T> {
option: Option<T>,
}
impl<T: fmt::Display> fmt::Display for Optional<T> {
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<Passphrase, anyhow::Error> {
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<T: FromStr> FromStr for Optional<T> {
type Err = <T as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.is_empty() {
return Ok(Optional { option: None });
}
let val: T = s.parse()?;
Ok(Self { option: Some(val) })
pub fn passphrase_confirm() -> Result<Passphrase, anyhow::Error> {
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<S, E>(
message: &str,
initial: Option<String>,
) -> anyhow::Result<Option<S>>
where
S: fmt::Display + fmt::Debug + FromStr<Err = E> + Clone,
E: fmt::Debug + fmt::Display,
{
let theme = theme();
let mut input: Input<Optional<S>> = 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<Passphrase, anyhow::Error> {
pub fn passphrase_stdin() -> Result<Passphrase, anyhow::Error> {
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<Passphrase, anyhow::Error> {
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<Option<&'a T>, 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::<Vec<_>>()).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::<Vec<_>>())
.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::<Vec<_>>();
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::<Vec<_>>())
.interact_opt()
.unwrap();
result.map(|i| &options[i])
}
pub fn comment_select(issue: &Issue) -> Option<CommentId> {
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::<Vec<_>>(),
)
.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::<Vec<_>>(),
)
.default(0)
.interact_opt()
.unwrap();
let revisions = proposal.revisions().collect::<Vec<_>>();
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<Oid>,
) -> 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::<Vec<_>>(),
)
.default(0)
.interact_opt()
.unwrap();
let revisions = proposal
.revisions()
.filter(|(_, r)| r.is_quorum_reached(previous))
.collect::<Vec<_>>();
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<Oid>) -> anyhow::Result<String> {

View File

@ -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)
)));
}

View File

@ -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<String>,
}
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<String>) -> 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<Mutex<Progress>>,
handle: ManuallyDrop<thread::JoinHandle<()>>,
}
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("<canceled>")
)
.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),
}
}

View File

@ -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<usize>,
height: Option<usize>,
}
#[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<const W: usize> Table<W> {
}
}
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<T: io::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<const W: usize> Table<W> {
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<const W: usize> Table<W> {
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("")
);
}
}

View File

@ -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 {

View File

@ -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"

View File

@ -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(())
}

View File

@ -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
}

View File

@ -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<super::Passphrase> {
pub fn passphrase() -> Option<super::Passphrase> {
let Ok(passphrase) = std::env::var(RAD_PASSPHRASE) else {
return None;
};
@ -109,7 +109,7 @@ impl Profile {
}
pub fn signer(&self) -> Result<Box<dyn Signer>, 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());
}