├── data
├── no-gradient.svg
├── invalid.ggr
├── test1.svg
├── Neon_Green.ggr
├── invalid.svg
└── gradients.svg
├── docs
└── images
│ ├── gradient-cli-1.png
│ └── gradient-cli-2.png
├── .github
├── FUNDING.yml
└── workflows
│ ├── release.yml
│ └── ci.yml
├── .gitignore
├── Makefile
├── Cargo.toml
├── LICENSE-MIT
├── src
├── util.rs
├── cli.rs
├── svg_gradient.rs
└── main.rs
├── README.md
├── tests
└── app.rs
├── LICENSE-APACHE
└── Cargo.lock
/data/no-gradient.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/images/gradient-cli-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mazznoer/gradient-rs/HEAD/docs/images/gradient-cli-1.png
--------------------------------------------------------------------------------
/docs/images/gradient-cli-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mazznoer/gradient-rs/HEAD/docs/images/gradient-cli-2.png
--------------------------------------------------------------------------------
/data/invalid.ggr:
--------------------------------------------------------------------------------
1 | GIMP Gradient
2 | Name: My Gradient
3 | 3
4 | 0.000000 0.672788 0.699499 0.000000 1.000000 0.000000 0.000000 0.129412 1.000000 0.000000 0.901961 1 0
5 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: mazznoer
4 | ko_fi: mazznoer
5 | liberapay: mazznoer
6 | custom: "https://paypal.me/mazznoer"
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | debug/
4 | target/
5 |
6 | # These are backup files generated by rustfmt
7 | **/*.rs.bk
8 |
9 | completions/
10 | /*.svg
11 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | SHELL := /bin/bash
2 |
3 | .PHONY: all check test
4 |
5 | all: check test
6 |
7 | check:
8 | cargo build --all-features && \
9 | cargo clippy --all-features -- -D warnings && \
10 | cargo fmt --all -- --check
11 |
12 | test:
13 | cargo test --all-features
14 |
--------------------------------------------------------------------------------
/data/test1.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/data/Neon_Green.ggr:
--------------------------------------------------------------------------------
1 | GIMP Gradient
2 | Name: Neon Green
3 | 4
4 | 0.000000 0.672788 0.699499 0.000000 1.000000 0.000000 0.000000 0.129412 1.000000 0.000000 0.901961 1 0
5 | 0.699499 0.737062 0.774624 0.129412 1.000000 0.000000 0.901961 0.823529 1.000000 0.807843 1.000000 1 0
6 | 0.774624 0.812187 0.849750 0.823529 1.000000 0.807843 1.000000 0.196078 1.000000 0.000000 0.901961 1 0
7 | 0.849750 0.874791 1.000000 0.196078 1.000000 0.000000 0.901961 0.031373 1.000000 0.000000 0.000000 1 0
8 |
--------------------------------------------------------------------------------
/data/invalid.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "gradient"
3 | version = "0.4.1"
4 | authors = ["Nor Khasyatillah "]
5 | edition = "2018"
6 | description = "A command line tool for playing with color gradients"
7 | keywords = ["color", "gradient", "colormap", "color-scheme", "gimp"]
8 | categories = ["command-line-utilities", "graphics"]
9 | readme = "README.md"
10 | repository = "https://github.com/mazznoer/gradient-rs"
11 | license = "MIT OR Apache-2.0"
12 | exclude = [
13 | ".github/*",
14 | "docs/*",
15 | "test_data/*",
16 | "tests/*",
17 | ]
18 |
19 | [profile.release]
20 | lto = true
21 | strip = true
22 |
23 | [dependencies]
24 | colorgrad = { version = "0.8", features = ["preset", "named-colors", "lab", "ggr"] }
25 | csscolorparser = { version = "0.8" }
26 | lexopt = { version = "0.3" }
27 | svg = { version = "0.18" }
28 | terminal_size = { version = "0.4" }
29 |
30 | [dev-dependencies]
31 | assert_cmd = { version = "2.0" }
32 |
--------------------------------------------------------------------------------
/LICENSE-MIT:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Nor Khasyatillah
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | permissions:
4 | contents: write
5 |
6 | on:
7 | push:
8 | tags:
9 | - v[0-9]+.*
10 |
11 | jobs:
12 | upload-assets:
13 | strategy:
14 | matrix:
15 | os:
16 | - ubuntu-latest
17 | - macos-latest
18 | - windows-latest
19 | runs-on: ${{ matrix.os }}
20 | steps:
21 | - uses: actions/checkout@v3
22 | - uses: taiki-e/upload-rust-binary-action@v1
23 | with:
24 | # (required)
25 | bin: gradient
26 | # (optional) On which platform to distribute the `.tar.gz` file.
27 | # [default value: unix]
28 | # [possible values: all, unix, windows, none]
29 | tar: unix
30 | # (optional) On which platform to distribute the `.zip` file.
31 | # [default value: windows]
32 | # [possible values: all, unix, windows, none]
33 | zip: windows
34 | # create dir
35 | leading-dir: true
36 | # additional files
37 | #include: target/completions
38 | env:
39 | OUT_DIR: target
40 | # (required)
41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
42 |
--------------------------------------------------------------------------------
/data/gradients.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | paths-ignore:
6 | - "**.md"
7 | pull_request:
8 | paths-ignore:
9 | - "**.md"
10 |
11 | env:
12 | CARGO_TERM_COLOR: always
13 |
14 | jobs:
15 | build:
16 | strategy:
17 | matrix:
18 | os: [macos-latest, windows-latest, ubuntu-latest]
19 | runs-on: ${{ matrix.os }}
20 |
21 | steps:
22 | - uses: actions/checkout@v4
23 |
24 | - name: Build
25 | run: |
26 | cargo build --all-features
27 | env:
28 | OUT_DIR: target
29 |
30 | - name: Run tests
31 | run: |
32 | cargo test --all-features
33 |
34 | - name: Run cargo clippy
35 | run: |
36 | cargo clippy --all-features -- -D warnings
37 |
38 | - name: Run cargo fmt
39 | run: |
40 | cargo fmt --all -- --check
41 |
42 | #- name: Check shell completions
43 | # if: matrix.os != 'windows-latest'
44 | # run: |
45 | # ls -A target/completions
46 |
47 | - name: Run gradient
48 | if: matrix.os != 'windows-latest'
49 | run: |
50 | echo "Preset"
51 | ./target/debug/gradient -p magma -W 45
52 | echo "Take colors"
53 | ./target/debug/gradient -p sinebow -t 10
54 | echo "CSS gradient"
55 | ./target/debug/gradient --css "deeppink, gold, seagreen" -W 45
56 |
57 | - name: Run gradient (Windows)
58 | if: matrix.os == 'windows-latest'
59 | run: |
60 | echo "Preset"
61 | target/debug/gradient -p magma -W 45
62 | echo "Take colors"
63 | target/debug/gradient -p sinebow -t 10
64 | echo "CSS gradient"
65 | target/debug/gradient --css "deeppink, gold, seagreen" -W 45
66 |
--------------------------------------------------------------------------------
/src/util.rs:
--------------------------------------------------------------------------------
1 | use crate::{Color, OutputColor};
2 |
3 | pub fn blend_color(fg: &Color, bg: &Color) -> Color {
4 | Color::new(
5 | ((1.0 - fg.a) * bg.r) + (fg.a * fg.r),
6 | ((1.0 - fg.a) * bg.g) + (fg.a * fg.g),
7 | ((1.0 - fg.a) * bg.b) + (fg.a * fg.b),
8 | 1.0,
9 | )
10 | }
11 |
12 | pub fn blend_on(fg: &mut Color, bg: &Color) {
13 | fg.r = ((1.0 - fg.a) * bg.r) + (fg.a * fg.r);
14 | fg.g = ((1.0 - fg.a) * bg.g) + (fg.a * fg.g);
15 | fg.b = ((1.0 - fg.a) * bg.b) + (fg.a * fg.b);
16 | fg.a = 1.0;
17 | }
18 |
19 | pub fn color_to_ansi(col: &Color, cb: &[Color; 2], width: usize) -> String {
20 | let mut ss = "".to_string();
21 | for i in 0..width {
22 | let chr = if (i & 1) == 0 { "\u{2580}" } else { "\u{2584}" };
23 | let [a, b, c, _] = blend_color(col, &cb[0]).to_rgba8();
24 | let [d, e, f, _] = blend_color(col, &cb[1]).to_rgba8();
25 | ss.push_str(&format!("\x1B[38;2;{a};{b};{c};48;2;{d};{e};{f}m{chr}"));
26 | }
27 | ss.push_str("\x1B[39;49m");
28 | ss
29 | }
30 |
31 | pub fn bold(s: &str) -> String {
32 | format!("\x1B[1m{s}\x1B[0m")
33 | }
34 |
35 | fn fmt_float(t: f32, precision: usize) -> String {
36 | let s = format!("{t:.precision$}");
37 | s.trim_end_matches('0').trim_end_matches('.').to_string()
38 | }
39 |
40 | fn fmt_alpha(alpha: f32) -> String {
41 | if alpha < 1.0 {
42 | format!(" / {}%", (alpha.max(0.0) * 100.0 + 0.5).floor())
43 | } else {
44 | "".into()
45 | }
46 | }
47 |
48 | fn to_hsv_str(col: &Color) -> String {
49 | let [h, s, v, alpha] = col.to_hsva();
50 | let h = if h.is_nan() {
51 | "none".into()
52 | } else {
53 | fmt_float(h, 2)
54 | };
55 | let s = (s * 100.0 + 0.5).floor();
56 | let v = (v * 100.0 + 0.5).floor();
57 | format!("hsv({h} {s}% {v}%{})", fmt_alpha(alpha))
58 | }
59 |
60 | pub fn format_color(col: &Color, format: OutputColor) -> String {
61 | match format {
62 | OutputColor::Hex => col.to_css_hex(),
63 | OutputColor::Rgb => col.to_css_rgb(),
64 | OutputColor::Hsl => col.to_css_hsl(),
65 | OutputColor::Hwb => col.to_css_hwb(),
66 | OutputColor::Hsv => to_hsv_str(col),
67 | OutputColor::Lab => col.to_css_lab(),
68 | OutputColor::Lch => col.to_css_lch(),
69 | OutputColor::Oklab => col.to_css_oklab(),
70 | OutputColor::Oklch => col.to_css_oklch(),
71 | }
72 | }
73 |
74 | #[cfg(test)]
75 | mod tests {
76 | use super::*;
77 |
78 | #[test]
79 | fn test() {
80 | assert_eq!(fmt_alpha(0.0), " / 0%");
81 | assert_eq!(fmt_alpha(0.5), " / 50%");
82 | assert_eq!(fmt_alpha(1.0), "");
83 | assert_eq!(fmt_alpha(1.2), "");
84 |
85 | let red = Color::new(1.0, 0.0, 0.0, 1.0);
86 | assert_eq!(format_color(&red, OutputColor::Hex), "#ff0000");
87 | assert_eq!(format_color(&red, OutputColor::Rgb), "rgb(255 0 0)");
88 | assert_eq!(format_color(&red, OutputColor::Hsl), "hsl(0 100% 50%)");
89 | assert_eq!(format_color(&red, OutputColor::Hsv), "hsv(0 100% 100%)");
90 | assert_eq!(format_color(&red, OutputColor::Hwb), "hwb(0 0% 0%)");
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # `gradient`
2 |
3 | [](https://github.com/mazznoer/gradient-rs/actions)
4 | [](https://crates.io/crates/gradient)
5 |
6 | A command-line tool for playing with color gradients.
7 |
8 | 
9 |
10 | ## Features
11 |
12 | * Lots of preset gradients.
13 | * Custom gradient.
14 | * Read gradients from SVG & GIMP gradient (ggr) file.
15 | * Display gradient in the terminal.
16 | * Get colors from gradient.
17 |
18 | ## Installation
19 |
20 | Pre-compiled binaries for Linux, macOS and Windows is avaliable on [release page](https://github.com/mazznoer/gradient-rs/releases).
21 |
22 | ### Cargo
23 |
24 | `gradient` can be installed using [cargo](https://www.rust-lang.org/tools/install).
25 |
26 | ```shell
27 | cargo install gradient
28 | ```
29 |
30 | ### NetBSD
31 |
32 | On NetBSD, a pre-compiled binary is available from the official repositories.
33 | To install it, simply run:
34 |
35 | ```shell
36 | pkgin install gradient
37 | ```
38 |
39 | ## Usage
40 |
41 | ```
42 | gradient [OPTIONS]
43 | ```
44 |
45 | ### Options:
46 |
47 | * `-W`, `--width` `` : Gradient display width (default: terminal width)
48 | * `-H`, `--height` `` : Gradient display height (default: 2)
49 | * `-b`, `--background` `` : Background color (default: checkerboard)
50 | * `--cb-color` `` `` : Checkerboard color
51 | * `-s`, `--sample` ``... : Get color(s) at specific position
52 | * `-t`, `--take` `` : Get N colors evenly spaced across gradient
53 | * `-o`, `--format` `` : Output color format (default: hex) [hex, rgb, rgb255, hsl, hsv, hwb]
54 | * `-a`, `--array` : Print colors as array
55 |
56 | ### Preset gradient
57 |
58 | * `-p`, `--preset` `` : Using the preset gradient
59 | * `-l`, `--list-presets` : Lists all available preset gradient names
60 |
61 | ### Custom gradient
62 |
63 | * `-c`, `--custom` ``... : Create custom gradient
64 | * `-m`, `--blend-mode` `` : Custom gradient blending mode (default: oklab) [rgb, linear-rgb, hsv, oklab]
65 | * `-i`, `--interpolation` `` : Custom gradient interpolation mode (default: catmull-rom) [linear, basis, catmull-rom]
66 | * `-P`, `--position` ``... : Custom gradient color position
67 |
68 | ### Gradient file
69 |
70 | * `-f`, `--file` ``... : Read gradient from SVG or GIMP gradient (ggr) file(s)
71 | * `--ggr-fg` `` : GGR foreground color (default: black)
72 | * `--ggr-bg` `` : GGR background color (default: white)
73 | * `--svg-id` `` : Pick one SVG gradient by ID
74 |
75 | `COLOR` can be specified using [CSS color format](https://www.w3.org/TR/css-color-4/).
76 |
77 | ## Usage Examples
78 |
79 | Get 100 colors (evenly spaced accross gradient domain) from rainbow preset gradient.
80 |
81 | ```shell
82 | gradient -p rainbow -t 100
83 | ```
84 |
85 | Display all gradients from svg file.
86 |
87 | ```shell
88 | gradient -f file.svg
89 | ```
90 |
91 | Create custom gradient.
92 |
93 | ```shell
94 | gradient -c gold ff4700 'rgb(90,230,170)' 'hsl(340,50%,50%)' 'hsv(270,60%,70%)' 'hwb(230,50%,0%)'
95 | ```
96 |
97 | **TODO** add more examples
98 |
99 |
--------------------------------------------------------------------------------
/tests/app.rs:
--------------------------------------------------------------------------------
1 | use assert_cmd::Command;
2 |
3 | fn gradient() -> Command {
4 | let mut cmd = Command::cargo_bin("gradient").unwrap();
5 | cmd.current_dir(env!("CARGO_MANIFEST_DIR"));
6 | cmd
7 | }
8 |
9 | #[test]
10 | fn basic() {
11 | gradient().assert().failure();
12 |
13 | gradient().arg("--list-presets").assert().success();
14 |
15 | gradient().arg("--named-colors").assert().success();
16 |
17 | gradient().arg("--preset").arg("magma").assert().success();
18 |
19 | gradient()
20 | .arg("--preset")
21 | .arg("rainbow")
22 | .arg("--sample")
23 | .args(&["0", "0.35", "0.77"])
24 | .assert()
25 | .success()
26 | .stdout("#6e40aa\n#fb9633\n#1cbccc\n");
27 |
28 | gradient()
29 | .arg("--css")
30 | .arg("#f05, rgb(0, 255, 90)")
31 | .arg("--take")
32 | .arg("5")
33 | .arg("--array")
34 | .assert()
35 | .success()
36 | .stdout(concat!(
37 | r##"["#ff0055", "#ed7458", "#d0a95a", "#a0d55b", "#00ff5a"]"##,
38 | "\n"
39 | ));
40 |
41 | gradient()
42 | .arg("--custom")
43 | .arg("hwb(75, 25%, 10%)")
44 | .arg("#bad455")
45 | .arg("goldenrod")
46 | .arg("--cb-color")
47 | .arg("#000")
48 | .arg("red")
49 | .assert()
50 | .success();
51 |
52 | gradient()
53 | .args(&[
54 | "--custom",
55 | "gold,purple,red",
56 | "--position",
57 | "0,70,100",
58 | "--blend-mode",
59 | "lab",
60 | "--interpolation",
61 | "basis",
62 | ])
63 | .assert()
64 | .success();
65 |
66 | gradient()
67 | .arg("-c")
68 | .arg("#46f, #ab7, #abc456")
69 | .arg("-P")
70 | .arg("0, 73,100 ")
71 | .arg("-s")
72 | .arg(" 0,73.0, 100 , 120")
73 | .assert()
74 | .success()
75 | .stdout("#4466ff\n#aabb77\n#abc456\n#abc456\n");
76 |
77 | gradient()
78 | .arg("--custom")
79 | .arg("red, rgb(0,255,0), #00f")
80 | .arg("--position=-5,5,10")
81 | .arg("--sample=-5,10,5")
82 | .assert()
83 | .success()
84 | .stdout("#ff0000\n#0000ff\n#00ff00\n");
85 |
86 | gradient()
87 | .arg("--file")
88 | .arg("data/gradients.svg")
89 | .arg("data/Neon_Green.ggr")
90 | .assert()
91 | .success();
92 | }
93 |
94 | #[test]
95 | fn others() {
96 | // contains invalid gradient
97 | gradient()
98 | .arg("-f")
99 | .arg("data/test1.svg")
100 | .assert()
101 | .failure();
102 |
103 | // #grad-1 is a valid gradient
104 | gradient()
105 | .arg("-f")
106 | .arg("data/test1.svg")
107 | .arg("--svg-id")
108 | .arg("grad-1")
109 | .assert()
110 | .success();
111 |
112 | // #grad-0 is an invalid gradient
113 | gradient()
114 | .arg("-f")
115 | .arg("data/test1.svg")
116 | .arg("--svg-id")
117 | .arg("grad-0")
118 | .assert()
119 | .failure();
120 | }
121 |
122 | #[test]
123 | fn invalid() {
124 | // invalid preset name
125 | gradient().arg("--preset").arg("sunset").assert().failure();
126 |
127 | // conflicting arguments [--preset, --custom, --css]
128 |
129 | gradient()
130 | .arg("--preset")
131 | .arg("plasma")
132 | .arg("--custom")
133 | .arg("red")
134 | .arg("blue")
135 | .assert()
136 | .failure();
137 |
138 | gradient()
139 | .arg("--preset")
140 | .arg("plasma")
141 | .arg("--css")
142 | .arg("red,blue")
143 | .assert()
144 | .failure();
145 |
146 | gradient()
147 | .arg("--custom")
148 | .arg("red")
149 | .arg("blue")
150 | .arg("--css")
151 | .arg("red,blue")
152 | .assert()
153 | .failure();
154 |
155 | // conflicting arguments [--take, --sample]
156 |
157 | gradient()
158 | .arg("--preset")
159 | .arg("plasma")
160 | .arg("--take")
161 | .arg("5")
162 | .arg("--sample")
163 | .arg("0.1")
164 | .arg("0.73")
165 | .assert()
166 | .failure();
167 |
168 | // invalid CSS gradient
169 | gradient()
170 | .arg("--css")
171 | .arg("red, 25%, 70%, blue")
172 | .assert()
173 | .failure();
174 |
175 | // invalid position
176 |
177 | gradient()
178 | .arg("--custom")
179 | .arg("red, lime")
180 | .arg("--position")
181 | .arg("0, 0.5, 1")
182 | .assert()
183 | .failure();
184 |
185 | // invalid SVG gradient
186 | gradient()
187 | .arg("--file")
188 | .arg("data/invalid.svg")
189 | .assert()
190 | .failure();
191 |
192 | // invalid GIMP gradient
193 | gradient()
194 | .arg("--file")
195 | .arg("data/invalid.ggr")
196 | .assert()
197 | .failure()
198 | .stderr("data/invalid.ggr (invalid GIMP gradient)\n");
199 |
200 | // SVG without gradient
201 | gradient()
202 | .arg("--file")
203 | .arg("data/no-gradient.svg")
204 | .assert()
205 | .failure();
206 |
207 | // non-existent file
208 | gradient()
209 | .arg("--file")
210 | .arg("gradients.svg")
211 | .assert()
212 | .failure()
213 | .stderr("gradients.svg: file not found.\n");
214 |
215 | // unsupported file formats
216 |
217 | gradient()
218 | .arg("--file")
219 | .arg("Cargo.toml")
220 | .assert()
221 | .failure()
222 | .stderr("Cargo.toml: file format not supported.\n");
223 |
224 | gradient()
225 | .arg("--file")
226 | .arg("Makefile")
227 | .assert()
228 | .failure()
229 | .stderr("Makefile: file format not supported.\n");
230 |
231 | // --cb-color need exactly 2 values
232 |
233 | gradient()
234 | .arg("--css")
235 | .arg("f00, f000")
236 | .arg("--cb-color")
237 | .assert()
238 | .failure();
239 |
240 | gradient()
241 | .arg("--css")
242 | .arg("f00, f000")
243 | .arg("--cb-color")
244 | .arg("black")
245 | .assert()
246 | .failure();
247 |
248 | gradient()
249 | .arg("--css")
250 | .arg("f00, f000")
251 | .arg("--cb-color")
252 | .arg("black")
253 | .arg("gold")
254 | .arg("lime")
255 | .assert()
256 | .failure();
257 | }
258 |
--------------------------------------------------------------------------------
/LICENSE-APACHE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2021 Nor Khasyatillah
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/Cargo.lock:
--------------------------------------------------------------------------------
1 | # This file is automatically @generated by Cargo.
2 | # It is not intended for manual editing.
3 | version = 4
4 |
5 | [[package]]
6 | name = "anstyle"
7 | version = "1.0.13"
8 | source = "registry+https://github.com/rust-lang/crates.io-index"
9 | checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
10 |
11 | [[package]]
12 | name = "assert_cmd"
13 | version = "2.1.1"
14 | source = "registry+https://github.com/rust-lang/crates.io-index"
15 | checksum = "bcbb6924530aa9e0432442af08bbcafdad182db80d2e560da42a6d442535bf85"
16 | dependencies = [
17 | "anstyle",
18 | "bstr",
19 | "libc",
20 | "predicates",
21 | "predicates-core",
22 | "predicates-tree",
23 | "wait-timeout",
24 | ]
25 |
26 | [[package]]
27 | name = "autocfg"
28 | version = "1.5.0"
29 | source = "registry+https://github.com/rust-lang/crates.io-index"
30 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
31 |
32 | [[package]]
33 | name = "bitflags"
34 | version = "2.10.0"
35 | source = "registry+https://github.com/rust-lang/crates.io-index"
36 | checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
37 |
38 | [[package]]
39 | name = "bstr"
40 | version = "1.12.1"
41 | source = "registry+https://github.com/rust-lang/crates.io-index"
42 | checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
43 | dependencies = [
44 | "memchr",
45 | "regex-automata",
46 | "serde",
47 | ]
48 |
49 | [[package]]
50 | name = "colorgrad"
51 | version = "0.8.0"
52 | source = "registry+https://github.com/rust-lang/crates.io-index"
53 | checksum = "aee94de557db6ddae3ca7b37b5fbe77ed6f159219f1815a8c2c1a9854c73087e"
54 | dependencies = [
55 | "csscolorparser",
56 | "libm",
57 | ]
58 |
59 | [[package]]
60 | name = "csscolorparser"
61 | version = "0.8.0"
62 | source = "registry+https://github.com/rust-lang/crates.io-index"
63 | checksum = "3b9e6b904cb9ee4cb0bb93ba2b1bf3dcd2d3fc58c25557934a4b56cbd2ad9f88"
64 | dependencies = [
65 | "num-traits",
66 | "phf",
67 | "uncased",
68 | ]
69 |
70 | [[package]]
71 | name = "difflib"
72 | version = "0.4.0"
73 | source = "registry+https://github.com/rust-lang/crates.io-index"
74 | checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
75 |
76 | [[package]]
77 | name = "errno"
78 | version = "0.3.14"
79 | source = "registry+https://github.com/rust-lang/crates.io-index"
80 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
81 | dependencies = [
82 | "libc",
83 | "windows-sys 0.61.2",
84 | ]
85 |
86 | [[package]]
87 | name = "fastrand"
88 | version = "2.3.0"
89 | source = "registry+https://github.com/rust-lang/crates.io-index"
90 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
91 |
92 | [[package]]
93 | name = "gradient"
94 | version = "0.4.1"
95 | dependencies = [
96 | "assert_cmd",
97 | "colorgrad",
98 | "csscolorparser",
99 | "lexopt",
100 | "svg",
101 | "terminal_size",
102 | ]
103 |
104 | [[package]]
105 | name = "lexopt"
106 | version = "0.3.1"
107 | source = "registry+https://github.com/rust-lang/crates.io-index"
108 | checksum = "9fa0e2a1fcbe2f6be6c42e342259976206b383122fc152e872795338b5a3f3a7"
109 |
110 | [[package]]
111 | name = "libc"
112 | version = "0.2.177"
113 | source = "registry+https://github.com/rust-lang/crates.io-index"
114 | checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
115 |
116 | [[package]]
117 | name = "libm"
118 | version = "0.2.15"
119 | source = "registry+https://github.com/rust-lang/crates.io-index"
120 | checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
121 |
122 | [[package]]
123 | name = "linux-raw-sys"
124 | version = "0.11.0"
125 | source = "registry+https://github.com/rust-lang/crates.io-index"
126 | checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
127 |
128 | [[package]]
129 | name = "memchr"
130 | version = "2.7.6"
131 | source = "registry+https://github.com/rust-lang/crates.io-index"
132 | checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
133 |
134 | [[package]]
135 | name = "num-traits"
136 | version = "0.2.19"
137 | source = "registry+https://github.com/rust-lang/crates.io-index"
138 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
139 | dependencies = [
140 | "autocfg",
141 | "libm",
142 | ]
143 |
144 | [[package]]
145 | name = "phf"
146 | version = "0.13.1"
147 | source = "registry+https://github.com/rust-lang/crates.io-index"
148 | checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
149 | dependencies = [
150 | "phf_macros",
151 | "phf_shared",
152 | "serde",
153 | ]
154 |
155 | [[package]]
156 | name = "phf_generator"
157 | version = "0.13.1"
158 | source = "registry+https://github.com/rust-lang/crates.io-index"
159 | checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
160 | dependencies = [
161 | "fastrand",
162 | "phf_shared",
163 | ]
164 |
165 | [[package]]
166 | name = "phf_macros"
167 | version = "0.13.1"
168 | source = "registry+https://github.com/rust-lang/crates.io-index"
169 | checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
170 | dependencies = [
171 | "phf_generator",
172 | "phf_shared",
173 | "proc-macro2",
174 | "quote",
175 | "syn",
176 | "uncased",
177 | ]
178 |
179 | [[package]]
180 | name = "phf_shared"
181 | version = "0.13.1"
182 | source = "registry+https://github.com/rust-lang/crates.io-index"
183 | checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
184 | dependencies = [
185 | "siphasher",
186 | "uncased",
187 | ]
188 |
189 | [[package]]
190 | name = "predicates"
191 | version = "3.1.3"
192 | source = "registry+https://github.com/rust-lang/crates.io-index"
193 | checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
194 | dependencies = [
195 | "anstyle",
196 | "difflib",
197 | "predicates-core",
198 | ]
199 |
200 | [[package]]
201 | name = "predicates-core"
202 | version = "1.0.9"
203 | source = "registry+https://github.com/rust-lang/crates.io-index"
204 | checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
205 |
206 | [[package]]
207 | name = "predicates-tree"
208 | version = "1.0.12"
209 | source = "registry+https://github.com/rust-lang/crates.io-index"
210 | checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
211 | dependencies = [
212 | "predicates-core",
213 | "termtree",
214 | ]
215 |
216 | [[package]]
217 | name = "proc-macro2"
218 | version = "1.0.103"
219 | source = "registry+https://github.com/rust-lang/crates.io-index"
220 | checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
221 | dependencies = [
222 | "unicode-ident",
223 | ]
224 |
225 | [[package]]
226 | name = "quote"
227 | version = "1.0.42"
228 | source = "registry+https://github.com/rust-lang/crates.io-index"
229 | checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
230 | dependencies = [
231 | "proc-macro2",
232 | ]
233 |
234 | [[package]]
235 | name = "regex-automata"
236 | version = "0.4.13"
237 | source = "registry+https://github.com/rust-lang/crates.io-index"
238 | checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
239 |
240 | [[package]]
241 | name = "rustix"
242 | version = "1.1.2"
243 | source = "registry+https://github.com/rust-lang/crates.io-index"
244 | checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
245 | dependencies = [
246 | "bitflags",
247 | "errno",
248 | "libc",
249 | "linux-raw-sys",
250 | "windows-sys 0.61.2",
251 | ]
252 |
253 | [[package]]
254 | name = "serde"
255 | version = "1.0.228"
256 | source = "registry+https://github.com/rust-lang/crates.io-index"
257 | checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
258 | dependencies = [
259 | "serde_core",
260 | ]
261 |
262 | [[package]]
263 | name = "serde_core"
264 | version = "1.0.228"
265 | source = "registry+https://github.com/rust-lang/crates.io-index"
266 | checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
267 | dependencies = [
268 | "serde_derive",
269 | ]
270 |
271 | [[package]]
272 | name = "serde_derive"
273 | version = "1.0.228"
274 | source = "registry+https://github.com/rust-lang/crates.io-index"
275 | checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
276 | dependencies = [
277 | "proc-macro2",
278 | "quote",
279 | "syn",
280 | ]
281 |
282 | [[package]]
283 | name = "siphasher"
284 | version = "1.0.1"
285 | source = "registry+https://github.com/rust-lang/crates.io-index"
286 | checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
287 |
288 | [[package]]
289 | name = "svg"
290 | version = "0.18.0"
291 | source = "registry+https://github.com/rust-lang/crates.io-index"
292 | checksum = "94afda9cd163c04f6bee8b4bf2501c91548deae308373c436f36aeff3cf3c4a3"
293 |
294 | [[package]]
295 | name = "syn"
296 | version = "2.0.110"
297 | source = "registry+https://github.com/rust-lang/crates.io-index"
298 | checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
299 | dependencies = [
300 | "proc-macro2",
301 | "quote",
302 | "unicode-ident",
303 | ]
304 |
305 | [[package]]
306 | name = "terminal_size"
307 | version = "0.4.3"
308 | source = "registry+https://github.com/rust-lang/crates.io-index"
309 | checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0"
310 | dependencies = [
311 | "rustix",
312 | "windows-sys 0.60.2",
313 | ]
314 |
315 | [[package]]
316 | name = "termtree"
317 | version = "0.5.1"
318 | source = "registry+https://github.com/rust-lang/crates.io-index"
319 | checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
320 |
321 | [[package]]
322 | name = "uncased"
323 | version = "0.9.10"
324 | source = "registry+https://github.com/rust-lang/crates.io-index"
325 | checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697"
326 | dependencies = [
327 | "version_check",
328 | ]
329 |
330 | [[package]]
331 | name = "unicode-ident"
332 | version = "1.0.22"
333 | source = "registry+https://github.com/rust-lang/crates.io-index"
334 | checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
335 |
336 | [[package]]
337 | name = "version_check"
338 | version = "0.9.5"
339 | source = "registry+https://github.com/rust-lang/crates.io-index"
340 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
341 |
342 | [[package]]
343 | name = "wait-timeout"
344 | version = "0.2.1"
345 | source = "registry+https://github.com/rust-lang/crates.io-index"
346 | checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
347 | dependencies = [
348 | "libc",
349 | ]
350 |
351 | [[package]]
352 | name = "windows-link"
353 | version = "0.2.1"
354 | source = "registry+https://github.com/rust-lang/crates.io-index"
355 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
356 |
357 | [[package]]
358 | name = "windows-sys"
359 | version = "0.60.2"
360 | source = "registry+https://github.com/rust-lang/crates.io-index"
361 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
362 | dependencies = [
363 | "windows-targets",
364 | ]
365 |
366 | [[package]]
367 | name = "windows-sys"
368 | version = "0.61.2"
369 | source = "registry+https://github.com/rust-lang/crates.io-index"
370 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
371 | dependencies = [
372 | "windows-link",
373 | ]
374 |
375 | [[package]]
376 | name = "windows-targets"
377 | version = "0.53.5"
378 | source = "registry+https://github.com/rust-lang/crates.io-index"
379 | checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
380 | dependencies = [
381 | "windows-link",
382 | "windows_aarch64_gnullvm",
383 | "windows_aarch64_msvc",
384 | "windows_i686_gnu",
385 | "windows_i686_gnullvm",
386 | "windows_i686_msvc",
387 | "windows_x86_64_gnu",
388 | "windows_x86_64_gnullvm",
389 | "windows_x86_64_msvc",
390 | ]
391 |
392 | [[package]]
393 | name = "windows_aarch64_gnullvm"
394 | version = "0.53.1"
395 | source = "registry+https://github.com/rust-lang/crates.io-index"
396 | checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
397 |
398 | [[package]]
399 | name = "windows_aarch64_msvc"
400 | version = "0.53.1"
401 | source = "registry+https://github.com/rust-lang/crates.io-index"
402 | checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
403 |
404 | [[package]]
405 | name = "windows_i686_gnu"
406 | version = "0.53.1"
407 | source = "registry+https://github.com/rust-lang/crates.io-index"
408 | checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
409 |
410 | [[package]]
411 | name = "windows_i686_gnullvm"
412 | version = "0.53.1"
413 | source = "registry+https://github.com/rust-lang/crates.io-index"
414 | checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
415 |
416 | [[package]]
417 | name = "windows_i686_msvc"
418 | version = "0.53.1"
419 | source = "registry+https://github.com/rust-lang/crates.io-index"
420 | checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
421 |
422 | [[package]]
423 | name = "windows_x86_64_gnu"
424 | version = "0.53.1"
425 | source = "registry+https://github.com/rust-lang/crates.io-index"
426 | checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
427 |
428 | [[package]]
429 | name = "windows_x86_64_gnullvm"
430 | version = "0.53.1"
431 | source = "registry+https://github.com/rust-lang/crates.io-index"
432 | checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
433 |
434 | [[package]]
435 | name = "windows_x86_64_msvc"
436 | version = "0.53.1"
437 | source = "registry+https://github.com/rust-lang/crates.io-index"
438 | checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
439 |
--------------------------------------------------------------------------------
/src/cli.rs:
--------------------------------------------------------------------------------
1 | use std::num::ParseFloatError;
2 | use std::path::PathBuf;
3 | use std::str::FromStr;
4 |
5 | use colorgrad::{Color, ParseColorError};
6 |
7 | #[derive(Clone)]
8 | pub enum BlendMode {
9 | Rgb,
10 | LinearRgb,
11 | Oklab,
12 | Lab,
13 | }
14 |
15 | impl FromStr for BlendMode {
16 | type Err = String;
17 |
18 | fn from_str(s: &str) -> Result {
19 | match s.to_lowercase().as_str() {
20 | "rgb" => Ok(Self::Rgb),
21 | "linear-rgb" => Ok(Self::LinearRgb),
22 | "oklab" => Ok(Self::Oklab),
23 | "lab" => Ok(Self::Lab),
24 | _ => Err(format!(
25 | "Invalid --blend-mode '{s}' [pick from: rgb, linear-rgb, oklab, lab]"
26 | )),
27 | }
28 | }
29 | }
30 |
31 | #[derive(Copy, Clone)]
32 | pub enum Interpolation {
33 | Linear,
34 | Basis,
35 | CatmullRom,
36 | }
37 |
38 | impl FromStr for Interpolation {
39 | type Err = String;
40 |
41 | fn from_str(s: &str) -> Result {
42 | match s.to_lowercase().as_str() {
43 | "linear" => Ok(Self::Linear),
44 | "basis" => Ok(Self::Basis),
45 | "catmull-rom" => Ok(Self::CatmullRom),
46 | _ => Err(format!(
47 | "Invalid --interpolation '{s}' [pick from: linear, basis, catmull-rom]"
48 | )),
49 | }
50 | }
51 | }
52 |
53 | #[derive(Copy, Clone, PartialEq)]
54 | pub enum OutputColor {
55 | Hex,
56 | Rgb,
57 | Hsl,
58 | Hsv,
59 | Hwb,
60 | Lab,
61 | Lch,
62 | Oklab,
63 | Oklch,
64 | }
65 |
66 | impl FromStr for OutputColor {
67 | type Err = String;
68 |
69 | fn from_str(s: &str) -> Result {
70 | match s.to_lowercase().as_str() {
71 | "hex" => Ok(Self::Hex),
72 | "rgb" => Ok(Self::Rgb),
73 | "hsl" => Ok(Self::Hsl),
74 | "hsv" => Ok(Self::Hsv),
75 | "hwb" => Ok(Self::Hwb),
76 | "lab" => Ok(Self::Lab),
77 | "lch" => Ok(Self::Lch),
78 | "oklab" => Ok(Self::Oklab),
79 | "oklch" => Ok(Self::Oklch),
80 | _ => Err(format!(
81 | "Invalid --format '{s}' [pick from: hex, rgb, hsl, hsv, hwb, lab, lch, oklab, oklch]"
82 | )),
83 | }
84 | }
85 | }
86 |
87 | pub const PRESET_NAMES: [&str; 38] = [
88 | "blues",
89 | "br-bg",
90 | "bu-gn",
91 | "bu-pu",
92 | "cividis",
93 | "cool",
94 | "cubehelix",
95 | "gn-bu",
96 | "greens",
97 | "greys",
98 | "inferno",
99 | "magma",
100 | "or-rd",
101 | "oranges",
102 | "pi-yg",
103 | "plasma",
104 | "pr-gn",
105 | "pu-bu",
106 | "pu-bu-gn",
107 | "pu-or",
108 | "pu-rd",
109 | "purples",
110 | "rainbow",
111 | "rd-bu",
112 | "rd-gy",
113 | "rd-pu",
114 | "rd-yl-bu",
115 | "rd-yl-gn",
116 | "reds",
117 | "sinebow",
118 | "spectral",
119 | "turbo",
120 | "viridis",
121 | "warm",
122 | "yl-gn",
123 | "yl-gn-bu",
124 | "yl-or-br",
125 | "yl-or-rd",
126 | ];
127 |
128 | const VERSION: &str = "\
129 | gradient v0.5.0
130 | ";
131 |
132 | const HELP: &str = "\
133 | A command line tool for playing with color gradients
134 |
135 | Usage: gradient [OPTIONS]
136 |
137 | Options:
138 | -W, --width Gradient display width
139 | -H, --height Gradient display height
140 | -b, --background Background color
141 | --cb-color Checkerboard color
142 | -t, --take Get N colors evenly spaced across gradient
143 | -s, --sample ... Get color(s) at specific position
144 | -o, --format Output color format. [default: hex]
145 | [hex, rgb, hsl, hsv, hwb, lab, lch, oklab, oklch]
146 | -a, --array Print colors from --take or --sample, as array
147 | --named-colors Lists all CSS named colors
148 | -h, --help Print help (see more with '--help')
149 | --version Print version
150 |
151 | PRESET GRADIENT:
152 | -l, --list-presets Lists all available preset gradient names
153 | -p, --preset Use the preset gradient
154 |
155 | CUSTOM GRADIENT:
156 | -c, --custom ... Create custom gradient with the specified colors
157 | -P, --position ... Custom gradient color position
158 | -C, --css Custom gradient using CSS gradient format
159 | -m, --blend-mode Custom gradient blending mode
160 | [values: rgb, linear-rgb, oklab, lab]
161 | -i, --interpolation Custom gradient interpolation mode
162 | [values: linear, basis, catmull-rom]
163 |
164 | GRADIENT FILE:
165 | --ggr-bg GGR background color [default: white]
166 | --ggr-fg GGR foreground color [default: black]
167 | --svg-id Pick SVG gradient by ID
168 | -f, --file ... Read gradient from SVG or GIMP gradient (ggr) file(s)
169 |
170 | \x1B[1mCOLOR\x1B[0m can be specified using CSS color format .
171 | ";
172 |
173 | const EXTRA_HELP: &str = "
174 | \x1B[1;4mUsage Examples:\x1B[0m
175 | Display preset gradient
176 |
177 | \x1B[1m$\x1B[0m gradient --preset rainbow
178 |
179 | Get 15 colors from preset gradient
180 |
181 | \x1B[1m$\x1B[0m gradient --preset spectral --take 15
182 |
183 | Create & display custom gradient
184 |
185 | \x1B[1m$\x1B[0m gradient --custom deeppink gold seagreen
186 |
187 | Create custom gradient & get 20 colors
188 |
189 | \x1B[1m$\x1B[0m gradient --custom ff00ff 'rgb(50,200,70)' 'hwb(195,0,0.5)' --take 20
190 |
191 | \x1B[1;4mRepository:\x1B[0m
192 | URL: https://github.com/mazznoer/gradient-rs
193 | ";
194 |
195 | #[derive(Default)]
196 | pub struct Opt {
197 | pub list_presets: bool,
198 | pub preset: Option,
199 | pub custom: Option>,
200 | pub position: Option>,
201 | pub css: Option,
202 | pub blend_mode: Option,
203 | pub interpolation: Option,
204 | pub ggr_bg: Option,
205 | pub ggr_fg: Option,
206 | pub svg_id: Option,
207 | pub file: Option>,
208 | pub width: Option,
209 | pub height: Option,
210 | pub background: Option,
211 | pub cb_color: Option<[Color; 2]>,
212 | pub take: Option,
213 | pub sample: Option>,
214 | pub format: Option,
215 | pub array: bool,
216 | pub named_colors: bool,
217 | }
218 |
219 | #[rustfmt::skip]
220 | pub fn parse_args() -> Result {
221 | use std::process::exit;
222 | use lexopt::prelude::*;
223 |
224 | let mut opt: Opt = Default::default();
225 |
226 | let mut parser = lexopt::Parser::from_env();
227 |
228 | while let Some(arg) = parser.next()? {
229 | match arg {
230 | Short('h') => {
231 | print!("{HELP}");
232 | exit(0);
233 | }
234 | Long("help") => {
235 | print!("{HELP}");
236 | print!("{EXTRA_HELP}");
237 | exit(0);
238 | }
239 | Long("version") => {
240 | print!("{VERSION}");
241 | exit(0);
242 | }
243 | Short('l') | Long("list-presets") => {
244 | opt.list_presets = true;
245 | }
246 | Short('p') | Long("preset") => {
247 | if opt.custom.is_some() || opt.css.is_some() {
248 | return Err("choose one: --preset, --custom, --css".into());
249 | }
250 | opt.preset = Some(parser.value()?.parse_with(parse_preset_name)?);
251 | }
252 | Short('c') | Long("custom") => {
253 | if opt.preset.is_some() || opt.css.is_some() {
254 | return Err("choose one: --preset, --custom, --css".into());
255 | }
256 | for s in parser.values()? {
257 | let v = s.parse_with(parse_colors)?;
258 | if let Some(ref mut colors) = opt.custom {
259 | colors.extend(v);
260 | } else {
261 | opt.custom = Some(v);
262 | }
263 | }
264 | }
265 | Short('P') | Long("position") => {
266 | for s in parser.values()? {
267 | let v = s.parse_with(parse_floats)?;
268 | if let Some(ref mut position) = opt.position {
269 | position.extend(v);
270 | } else {
271 | opt.position = Some(v);
272 | }
273 | }
274 | }
275 | Short('C') | Long("css") => {
276 | if opt.custom.is_some() || opt.preset.is_some() {
277 | return Err("choose one: --preset, --custom, --css".into());
278 | }
279 | opt.css = Some(parser.value()?.parse()?);
280 | }
281 | Short('m') | Long("blend-mode") => {
282 | opt.blend_mode = Some(parser.value()?.parse()?);
283 | }
284 | Short('i') | Long("interpolation") => {
285 | opt.interpolation = Some(parser.value()?.parse()?);
286 | }
287 | Long("ggr-bg") => {
288 | opt.ggr_bg = Some(parser.value()?.parse()?);
289 | }
290 | Long("ggr-fg") => {
291 | opt.ggr_fg = Some(parser.value()?.parse()?);
292 | }
293 | Long("svg-id") => {
294 | opt.svg_id = Some(parser.value()?.parse()?);
295 | }
296 | Short('f') | Long("file") => {
297 | let res: Result,_> = parser.values()?.map(|s|s.parse()).collect();
298 | opt.file = Some(res?);
299 | }
300 | Short('W') | Long("width") => {
301 | opt.width = Some(parser.value()?.parse()?);
302 | }
303 | Short('H') | Long("height") => {
304 | opt.height = Some(parser.value()?.parse()?);
305 | }
306 | Short('b') | Long("background") => {
307 | opt.background = Some(parser.value()?.parse()?);
308 | }
309 | Long("cb-color") => {
310 | opt.cb_color = Some([
311 | parser.value()?.parse()?,
312 | parser.value()?.parse()?,
313 | ]);
314 | }
315 | Short('t') | Long("take") => {
316 | if opt.sample.is_some() {
317 | return Err("--take cannot be used with --sample".into());
318 | }
319 | opt.take = Some(parser.value()?.parse()?);
320 | }
321 | Short('s') | Long("sample") => {
322 | if opt.take.is_some() {
323 | return Err("--take cannot be used with --sample".into());
324 | }
325 | for s in parser.values()? {
326 | let v = s.parse_with(parse_floats)?;
327 | if let Some(ref mut sample) = opt.sample {
328 | sample.extend(v);
329 | } else {
330 | opt.sample = Some(v);
331 | }
332 | }
333 | }
334 | Short('o') | Long("format") => {
335 | opt.format = Some(parser.value()?.parse()?);
336 | }
337 | Short('a') | Long("array") => {
338 | opt.array = true;
339 | }
340 | Long("named-colors") => {
341 | opt.named_colors = true;
342 | }
343 | _ => return Err(arg.unexpected()),
344 | }
345 | }
346 |
347 | Ok(opt)
348 | }
349 |
350 | fn parse_preset_name(s: &str) -> Result {
351 | let s = s.to_lowercase();
352 | if PRESET_NAMES.contains(&s.as_ref()) {
353 | return Ok(s);
354 | }
355 | Err("invalid preset name, try --list-presets".into())
356 | }
357 |
358 | fn parse_floats(s: &str) -> Result, ParseFloatError> {
359 | s.split(',').map(|s| s.trim().parse()).collect()
360 | }
361 |
362 | fn parse_colors(s: &str) -> Result, ParseColorError> {
363 | let mut colors = Vec::new();
364 | let mut start = 0;
365 | let mut inside = false;
366 |
367 | for (i, c) in s.char_indices() {
368 | if c == ',' && !inside {
369 | colors.push(s[start..i].parse()?);
370 | start = i + 1;
371 | } else if c == '(' {
372 | inside = true;
373 | } else if c == ')' {
374 | inside = false;
375 | }
376 | }
377 | colors.push(s[start..].parse()?);
378 | Ok(colors)
379 | }
380 |
381 | #[cfg(test)]
382 | mod tests {
383 | use super::*;
384 |
385 | #[test]
386 | fn preset_name_test() {
387 | assert_eq!(parse_preset_name("rainbow"), Ok("rainbow".into()));
388 | assert_eq!(parse_preset_name("Rainbow"), Ok("rainbow".into()));
389 | assert_eq!(parse_preset_name("PLASMA"), Ok("plasma".into()));
390 |
391 | assert!(parse_preset_name("sky").is_err());
392 | }
393 |
394 | #[test]
395 | fn parse_floats_test() {
396 | assert_eq!(parse_floats("90"), Ok(vec![90.0]));
397 | assert_eq!(parse_floats("0,0.5,1"), Ok(vec![0.0, 0.5, 1.0]));
398 | assert_eq!(
399 | parse_floats(" 12.7 , 0.56 ,-0.9"),
400 | Ok(vec![12.7, 0.56, -0.9])
401 | );
402 | assert_eq!(parse_floats("-2.3, 0.73 "), Ok(vec![-2.3, 0.73]));
403 |
404 | assert!(parse_floats("1,0.5,6p,8").is_err());
405 | assert!(parse_floats("").is_err());
406 | }
407 |
408 | #[test]
409 | fn parse_colors_test() {
410 | let res = parse_colors("rgb(0,255, 0), #ff0, rgba(100%, 0%, 0%, 100%), blue").unwrap();
411 |
412 | assert_eq!(res.len(), 4);
413 | assert_eq!(res[0].to_css_hex(), "#00ff00");
414 | assert_eq!(res[1].to_css_hex(), "#ffff00");
415 | assert_eq!(res[2].to_css_hex(), "#ff0000");
416 | assert_eq!(res[3].to_css_hex(), "#0000ff");
417 |
418 | assert!(parse_colors("red, rgb(90,20,)").is_err());
419 | }
420 | }
421 |
--------------------------------------------------------------------------------
/src/svg_gradient.rs:
--------------------------------------------------------------------------------
1 | use colorgrad::Color;
2 | use colorgrad::GradientBuilder;
3 | use svg::node::element::tag as svg_tag;
4 | use svg::parser::Event;
5 |
6 | fn parse_percent_or_float(s: &str) -> Option {
7 | if let Some(s) = s.strip_suffix('%') {
8 | if let Ok(t) = s.parse::() {
9 | return Some(t / 100.0);
10 | }
11 | return None;
12 | }
13 | s.parse::().ok()
14 | }
15 |
16 | // returns (color, opacity)
17 | fn parse_styles(s: &str) -> (Option<&str>, Option<&str>) {
18 | let mut val = (None, None);
19 |
20 | for x in s.split(';') {
21 | let d = x.split(':').collect::>();
22 |
23 | if d.len() == 2 {
24 | match d[0].trim().to_lowercase().as_ref() {
25 | "stop-color" => val.0 = Some(d[1]),
26 | "stop-opacity" => val.1 = Some(d[1]),
27 | _ => {}
28 | }
29 | }
30 | }
31 |
32 | val
33 | }
34 |
35 | #[derive(Debug)]
36 | pub struct SvgGradient {
37 | pub id: Option,
38 | pub colors: Vec,
39 | pub pos: Vec,
40 | pub valid: bool,
41 | }
42 |
43 | impl SvgGradient {
44 | pub fn gradient_builder(&mut self) -> Option {
45 | if !self.valid || self.colors.is_empty() {
46 | return None;
47 | }
48 | if self.pos[0] > 0.0 {
49 | self.pos.insert(0, 0.0);
50 | self.colors.insert(0, self.colors[0].clone());
51 | }
52 | let last = self.colors.len() - 1;
53 | if self.pos[last] < 1.0 {
54 | self.pos.push(1.0);
55 | self.colors.push(self.colors[last].clone());
56 | }
57 | let mut gb = GradientBuilder::new();
58 | gb.colors(&self.colors);
59 | gb.domain(&self.pos);
60 | Some(gb)
61 | }
62 | }
63 |
64 | pub fn parse_svg(s: &str, target_id: Option<&str>) -> Vec {
65 | let mut res = Vec::new();
66 | let mut index = 0;
67 | let mut prev_pos = f32::NEG_INFINITY;
68 | let mut inside = false;
69 | let mut skip = false;
70 |
71 | for event in svg::read(s).unwrap() {
72 | match event {
73 | Event::Tag(svg_tag::LinearGradient, t, attributes)
74 | | Event::Tag(svg_tag::RadialGradient, t, attributes) => match t {
75 | svg_tag::Type::Start => {
76 | let id = attributes.get("id").map(|v| v.to_string());
77 | skip = match (id.as_ref(), target_id) {
78 | (Some(a), Some(b)) => a != b,
79 | (None, Some(_)) => true,
80 | _ => false,
81 | };
82 | if skip {
83 | continue;
84 | }
85 | inside = true;
86 | res.push(SvgGradient {
87 | id,
88 | colors: Vec::new(),
89 | pos: Vec::new(),
90 | valid: true,
91 | });
92 | }
93 |
94 | svg_tag::Type::End => {
95 | if inside && !skip {
96 | index += 1;
97 | }
98 | inside = false;
99 | prev_pos = f32::NEG_INFINITY;
100 | }
101 |
102 | svg_tag::Type::Empty => {}
103 | },
104 | Event::Tag(svg_tag::Stop, _, attributes) => {
105 | if !inside || skip || res.is_empty() {
106 | continue;
107 | }
108 |
109 | let mut color: Option = None;
110 | let mut opacity: Option = None;
111 |
112 | if let Some(s) = attributes.get("stop-color") {
113 | let Ok(c) = s.parse::() else {
114 | res[index].valid = false;
115 | continue;
116 | };
117 | color = Some(c);
118 | }
119 |
120 | if let Some(s) = attributes.get("stop-opacity") {
121 | let Some(opc) = parse_percent_or_float(s) else {
122 | res[index].valid = false;
123 | continue;
124 | };
125 | opacity = Some(opc);
126 | }
127 |
128 | if let Some(styles) = attributes.get("style") {
129 | let (col, opac) = parse_styles(styles);
130 |
131 | if let Some(s) = col {
132 | let Ok(c) = s.parse::() else {
133 | res[index].valid = false;
134 | continue;
135 | };
136 | color = Some(c);
137 | }
138 |
139 | if let Some(s) = opac {
140 | let Some(opc) = parse_percent_or_float(s) else {
141 | res[index].valid = false;
142 | continue;
143 | };
144 | opacity = Some(opc);
145 | }
146 | }
147 |
148 | let offset = if let Some(pos) = attributes.get("offset") {
149 | let Some(of) = parse_percent_or_float(pos) else {
150 | res[index].valid = false;
151 | continue;
152 | };
153 | Some(of)
154 | } else {
155 | None
156 | };
157 |
158 | let color = color.unwrap_or(Color::new(0.0, 0.0, 0.0, 1.0));
159 |
160 | let offset = offset.unwrap_or(prev_pos);
161 |
162 | let color = if let Some(opacity) = opacity {
163 | Color::new(color.r, color.g, color.b, opacity.clamp(0.0, 1.0))
164 | } else {
165 | color
166 | };
167 |
168 | prev_pos = if offset.is_finite() {
169 | offset.max(prev_pos)
170 | } else {
171 | 0.0
172 | };
173 |
174 | res[index].colors.push(color);
175 | res[index].pos.push(prev_pos);
176 | }
177 | _ => {}
178 | }
179 | }
180 |
181 | res
182 | }
183 |
184 | #[cfg(test)]
185 | mod tests {
186 | use super::*;
187 |
188 | fn colors2hex(colors: &[Color]) -> Vec {
189 | colors.iter().map(|c| c.to_css_hex()).collect()
190 | }
191 |
192 | fn str_colors2hex(colors: &[&str]) -> Vec {
193 | colors
194 | .iter()
195 | .map(|s| s.parse::().unwrap().to_css_hex())
196 | .collect()
197 | }
198 |
199 | macro_rules! assert_gradient {
200 | ($sg:expr, $id:expr, $colors:expr, $pos:expr) => {
201 | assert_eq!($sg.id, Some($id.into()));
202 | assert_eq!(colors2hex(&$sg.colors), str_colors2hex($colors));
203 | assert_eq!(&$sg.pos, $pos);
204 | assert!($sg.valid);
205 | };
206 | }
207 |
208 | #[test]
209 | fn utils() {
210 | assert_eq!(parse_percent_or_float("50%"), Some(0.5));
211 | assert_eq!(parse_percent_or_float("100%"), Some(1.0));
212 | assert_eq!(parse_percent_or_float("1"), Some(1.0));
213 | assert_eq!(parse_percent_or_float("0.73"), Some(0.73));
214 |
215 | assert_eq!(parse_percent_or_float(""), None);
216 | assert_eq!(parse_percent_or_float("16g7"), None);
217 |
218 | assert_eq!(
219 | parse_styles("stop-color:#ff0; stop-opacity:0.5"),
220 | (Some("#ff0"), Some("0.5"))
221 | );
222 | assert_eq!(parse_styles("stop-color:skyblue"), (Some("skyblue"), None));
223 | assert_eq!(parse_styles("stop-opacity:50%"), (None, Some("50%")));
224 | assert_eq!(parse_styles(""), (None, None));
225 | }
226 |
227 | #[test]
228 | fn svg_parsing() {
229 | let result = parse_svg(
230 | r##"
231 |
232 |
233 |
234 |
235 |
236 | "##,
237 | None,
238 | );
239 | assert_eq!(result.len(), 1);
240 | assert_gradient!(
241 | result[0],
242 | "banana",
243 | &["#c41189", "#00bfff", "#ffd700"],
244 | &[0.0, 0.5, 1.0]
245 | );
246 |
247 | // Using percentage
248 | let result = parse_svg(
249 | r##"
250 |
251 |
252 |
253 |
254 |
255 | "##,
256 | None,
257 | );
258 | assert_eq!(result.len(), 1);
259 | assert_gradient!(
260 | result[0],
261 | "apple",
262 | &["deeppink", "gold", "seagreen"],
263 | &[0.0, 0.5, 1.0]
264 | );
265 |
266 | // radialGradient tag
267 | let result = parse_svg(
268 | r##"
269 |
270 |
271 |
272 |
273 |
274 | "##,
275 | None,
276 | );
277 | assert_eq!(result.len(), 1);
278 | assert_gradient!(
279 | result[0],
280 | "mango",
281 | &["deeppink", "gold", "seagreen"],
282 | &[0.0, 0.5, 1.0]
283 | );
284 |
285 | fn set_alpha(col: &str, alpha: f32) -> String {
286 | let c = col.parse::().unwrap();
287 | Color::new(c.r, c.g, c.b, alpha).to_css_hex()
288 | }
289 |
290 | // Using style attribute
291 | let result = parse_svg(
292 | r##"
293 |
294 |
295 |
296 |
297 |
298 | "##,
299 | None,
300 | );
301 | assert_eq!(result.len(), 1);
302 | assert_gradient!(
303 | result[0],
304 | "papaya",
305 | &["tomato", &set_alpha("gold", 0.9), "steelblue"],
306 | &[0.0, 0.5, 1.0]
307 | );
308 |
309 | // Multiple gradients
310 | let result = parse_svg(
311 | r##"
312 |
313 |
314 |
315 |
316 |
317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 | "##,
326 | None,
327 | );
328 | assert_eq!(result.len(), 2);
329 | assert_gradient!(
330 | result[0],
331 | "gradient-1",
332 | &["#c4114d", "#6268a6", "#57cf4f", "#ffe04d"],
333 | &[0.0, 0.5, 0.5, 1.0]
334 | );
335 | assert_gradient!(
336 | result[1],
337 | "gradient-2",
338 | &["#c4114d", "#6268a6", "#57cf4f", "#ffe04d"],
339 | &[0.0, 0.5, 0.5, 1.0]
340 | );
341 |
342 | // Incomplete stop attributes
343 | let result = parse_svg(
344 | r##"
345 |
346 |
347 |
348 |
349 |
350 | "##,
351 | None,
352 | );
353 | assert_eq!(result.len(), 1);
354 | assert_gradient!(
355 | result[0],
356 | "g4657",
357 | &["black", "gold", "steelblue"],
358 | &[0.0, 0.7, 0.7]
359 | );
360 | }
361 |
362 | #[test]
363 | fn filter_by_id() {
364 | let s = r##"
365 |
366 |
367 |
368 |
369 |
370 |
371 |
372 |
373 |
374 |
375 |
376 |
377 |
378 |
379 |
380 |
381 |
382 |
383 |
384 |
385 |
386 |
387 |
388 | "##;
389 |
390 | let result = parse_svg(s, None);
391 | assert_eq!(result.len(), 5);
392 |
393 | let result = parse_svg(s, Some("guava"));
394 | assert_eq!(result.len(), 2);
395 | assert_gradient!(
396 | result[0],
397 | "guava",
398 | &["#ff0000", "#00ff00", "#0000ff"],
399 | &[0.1, 0.5, 0.9]
400 | );
401 | assert_gradient!(result[1], "guava", &["#abc123"], &[0.35]);
402 |
403 | let result = parse_svg(s, Some("avocado"));
404 | assert_eq!(result.len(), 1);
405 | assert_gradient!(
406 | result[0],
407 | "avocado",
408 | &["#ffff00", "#ff00ff", "#00ffff"],
409 | &[0.2, 0.4, 0.8]
410 | );
411 |
412 | let result = parse_svg(s, Some(""));
413 | assert_eq!(result.len(), 1);
414 | assert_gradient!(result[0], "", &["#123456"], &[1.0]);
415 |
416 | let result = parse_svg(s, Some("pineapple"));
417 | assert!(result.is_empty());
418 | }
419 |
420 | #[test]
421 | fn malformed_gradients() {
422 | let result = parse_svg(
423 | r##"
424 |
425 |
426 |
427 |
428 |
429 |
430 |
431 |
432 |
433 |
434 |
435 |
436 |
437 |
438 |
439 |
440 |
441 |
442 |
443 |
444 |
445 |
446 |
447 |
448 |
449 |
450 |
451 |
452 | "##,
453 | None,
454 | );
455 | assert_eq!(result.len(), 4);
456 | assert_gradient!(result[0], "empty", &[], &[]);
457 | assert_gradient!(
458 | result[1],
459 | "empty-stops",
460 | &["black", "black", "black"],
461 | &[0.0, 0.0, 0.0]
462 | );
463 | assert_gradient!(
464 | result[2],
465 | "without-color",
466 | &["black", "black", "black"],
467 | &[0.0, 0.75, 1.0]
468 | );
469 | assert_gradient!(
470 | result[3],
471 | "without-offset",
472 | &["red", "lime", "blue"],
473 | &[0.0, 0.0, 0.0]
474 | );
475 | }
476 |
477 | #[test]
478 | fn invalid_gradients() {
479 | let result = parse_svg(
480 | r##"
481 |
482 |
483 |
484 |
485 |
486 |
487 |
488 |
489 |
490 |
491 |
492 |
493 |
494 |
495 |
496 |
497 |
498 |
499 |
500 |
501 |
502 |
503 |
504 |
505 |
506 |
507 |
508 |
509 |
510 |
511 |
512 | "##,
513 | None,
514 | );
515 | assert_eq!(result.len(), 6);
516 | for g in &result {
517 | assert_eq!(g.valid, false);
518 | assert_eq!(g.id, None);
519 | }
520 | }
521 | }
522 |
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
1 | use std::ffi::OsStr;
2 | use std::fs::File;
3 | use std::io::{self, BufReader, IsTerminal, Read, Write};
4 | use std::process::exit;
5 |
6 | use colorgrad::{Color, GimpGradient, Gradient};
7 | use csscolorparser::NAMED_COLORS;
8 |
9 | mod cli;
10 | use cli::{BlendMode, Interpolation, Opt, OutputColor, PRESET_NAMES};
11 |
12 | mod svg_gradient;
13 |
14 | mod util;
15 | use util::bold;
16 |
17 | #[derive(PartialEq)]
18 | enum OutputMode {
19 | Gradient,
20 | ColorsN,
21 | ColorsSample,
22 | }
23 |
24 | struct GradientApp<'a> {
25 | opt: Opt,
26 | stdout: io::BufWriter>,
27 | is_terminal: bool,
28 | output_mode: OutputMode,
29 | output_format: OutputColor,
30 | use_solid_bg: bool,
31 | background: Color,
32 | cb_color: [Color; 2],
33 | term_width: usize,
34 | width: usize,
35 | height: usize,
36 | }
37 |
38 | impl GradientApp<'_> {
39 | fn new(opt: Opt, stdout: io::Stdout) -> Self {
40 | let term_width = if let Some((terminal_size::Width(w), _)) = terminal_size::terminal_size()
41 | {
42 | Some(w as usize)
43 | } else {
44 | None
45 | };
46 |
47 | let background = if let Some(ref c) = opt.background {
48 | c.clone()
49 | } else {
50 | Color::new(0.0, 0.0, 0.0, 1.0)
51 | };
52 |
53 | let cb_color = if let Some(ref c) = opt.cb_color {
54 | c.clone()
55 | } else {
56 | [
57 | Color::new(0.05, 0.05, 0.05, 1.0),
58 | Color::new(0.20, 0.20, 0.20, 1.0),
59 | ]
60 | };
61 |
62 | let width = opt
63 | .width
64 | .unwrap_or_else(|| term_width.unwrap_or(80))
65 | .max(10)
66 | .min(term_width.unwrap_or(1000));
67 |
68 | let output_mode = if opt.take.is_some() {
69 | OutputMode::ColorsN
70 | } else if opt.sample.is_some() {
71 | OutputMode::ColorsSample
72 | } else {
73 | OutputMode::Gradient
74 | };
75 |
76 | let is_terminal = stdout.is_terminal();
77 |
78 | Self {
79 | output_mode,
80 | stdout: io::BufWriter::new(stdout.lock()),
81 | is_terminal,
82 | use_solid_bg: opt.background.is_some(),
83 | background,
84 | cb_color,
85 | term_width: term_width.unwrap_or(80),
86 | width,
87 | height: opt.height.unwrap_or(2).clamp(1, 50),
88 | output_format: opt.format.unwrap_or(OutputColor::Hex),
89 | opt,
90 | }
91 | }
92 |
93 | fn run(&mut self) -> io::Result {
94 | let result = if self.opt.list_presets {
95 | // List all preset gradients
96 | self.width = self.term_width.min(80);
97 | self.height = 2;
98 | for name in PRESET_NAMES {
99 | writeln!(self.stdout, "{name}")?;
100 | self.opt.preset = Some(name.into());
101 | self.preset_gradient()?;
102 | }
103 | Ok(0)
104 | } else if self.opt.named_colors {
105 | // List all CSS named colors
106 | for (&name, &[r, g, b]) in NAMED_COLORS.entries() {
107 | writeln!(
108 | self.stdout,
109 | "\x1B[48;2;{r};{g};{b}m \x1B[49;38;2;{r};{g};{b}m #{r:02x}{g:02x}{b:02x}\x1B[39m {name}"
110 | )?;
111 | }
112 | Ok(0)
113 | } else if self.opt.preset.is_some() {
114 | self.preset_gradient()
115 | } else if self.opt.custom.is_some() || self.opt.css.is_some() {
116 | self.custom_gradient()
117 | } else if self.opt.file.is_some() {
118 | self.file_gradient()
119 | } else {
120 | self.example_help()?;
121 | Ok(1)
122 | };
123 |
124 | self.stdout.flush()?;
125 | result
126 | }
127 |
128 | fn preset_gradient(&mut self) -> io::Result {
129 | use colorgrad::preset::*;
130 |
131 | fn cubehelix() -> CubehelixGradient {
132 | cubehelix_default()
133 | }
134 |
135 | macro_rules! presets {
136 | ($($name:ident),*) => {
137 | match self
138 | .opt
139 | .preset
140 | .as_ref()
141 | .unwrap()
142 | .to_lowercase()
143 | .replace('-', "_")
144 | .as_ref()
145 | {
146 | $(stringify!($name) => self.handle_output(&$name())?,)*
147 | _ => {
148 | writeln!(io::stderr(), "Error: Invalid preset gradient name. Use -l flag to list all preset gradient names.")?;
149 | return Ok(1);
150 | }
151 | }
152 | }
153 | }
154 |
155 | presets!(
156 | blues, br_bg, bu_gn, bu_pu, cividis, cool, cubehelix, gn_bu, greens, greys, inferno,
157 | magma, or_rd, oranges, pi_yg, plasma, pr_gn, pu_bu, pu_bu_gn, pu_or, pu_rd, purples,
158 | rainbow, rd_bu, rd_gy, rd_pu, rd_yl_bu, rd_yl_gn, reds, sinebow, spectral, turbo,
159 | viridis, warm, yl_gn, yl_gn_bu, yl_or_br, yl_or_rd
160 | );
161 |
162 | Ok(0)
163 | }
164 |
165 | fn custom_gradient(&mut self) -> io::Result {
166 | let mut gb = colorgrad::GradientBuilder::new();
167 |
168 | if let Some(ref css_gradient) = self.opt.css {
169 | gb.css(css_gradient);
170 | } else {
171 | gb.colors(self.opt.custom.as_ref().unwrap());
172 |
173 | if let Some(ref pos) = self.opt.position {
174 | gb.domain(pos);
175 | }
176 | }
177 |
178 | gb.mode(match self.opt.blend_mode {
179 | Some(BlendMode::Rgb) => colorgrad::BlendMode::Rgb,
180 | Some(BlendMode::LinearRgb) => colorgrad::BlendMode::LinearRgb,
181 | Some(BlendMode::Lab) => colorgrad::BlendMode::Lab,
182 | _ => colorgrad::BlendMode::Oklab,
183 | });
184 |
185 | match self.opt.interpolation {
186 | Some(Interpolation::Linear) => match gb.build::() {
187 | Ok(g) => self.handle_output(&g)?,
188 | Err(e) => {
189 | writeln!(io::stderr(), "Custom gradient error: {e}")?;
190 | return Ok(1);
191 | }
192 | },
193 | Some(Interpolation::Basis) => match gb.build::() {
194 | Ok(g) => self.handle_output(&g)?,
195 | Err(e) => {
196 | writeln!(io::stderr(), "Custom gradient error: {e}")?;
197 | return Ok(1);
198 | }
199 | },
200 | _ => match gb.build::() {
201 | Ok(g) => self.handle_output(&g)?,
202 | Err(e) => {
203 | writeln!(io::stderr(), "Custom gradient error: {e}")?;
204 | return Ok(1);
205 | }
206 | },
207 | };
208 |
209 | Ok(0)
210 | }
211 |
212 | fn file_gradient(&mut self) -> io::Result {
213 | use colorgrad::{BasisGradient, CatmullRomGradient, LinearGradient};
214 |
215 | let ggr_bg_color = if let Some(ref c) = self.opt.ggr_bg {
216 | c.clone()
217 | } else {
218 | Color::new(1.0, 1.0, 1.0, 1.0)
219 | };
220 |
221 | let ggr_fg_color = if let Some(ref c) = self.opt.ggr_fg {
222 | c.clone()
223 | } else {
224 | Color::new(0.0, 0.0, 0.0, 1.0)
225 | };
226 |
227 | let show_info = self.is_terminal || self.output_mode == OutputMode::Gradient;
228 | let mut status = 0;
229 |
230 | for path in self.opt.file.as_ref().unwrap().clone() {
231 | if !path.exists() {
232 | eprintln!("{}: file not found.", &path.display());
233 | status = 1;
234 | continue;
235 | }
236 |
237 | let Some(ext) = path.extension().and_then(OsStr::to_str) else {
238 | eprintln!("{}: file format not supported.", &path.display());
239 | status = 1;
240 | continue;
241 | };
242 |
243 | let ext = ext.to_lowercase();
244 |
245 | if &ext == "ggr" {
246 | match GimpGradient::new(
247 | BufReader::new(File::open(&path)?),
248 | &ggr_fg_color,
249 | &ggr_bg_color,
250 | ) {
251 | Ok(grad) => {
252 | if show_info {
253 | writeln!(self.stdout, "{} {}", &path.display(), bold(grad.name()))?;
254 | }
255 | self.handle_output(&grad)?;
256 | }
257 | Err(_) => {
258 | eprintln!("{} (invalid GIMP gradient)", &path.display());
259 | status = 1;
260 | continue;
261 | }
262 | }
263 | } else if &ext == "svg" {
264 | let mut file = File::open(&path)?;
265 | let mut content = String::new();
266 | file.read_to_string(&mut content)?;
267 | let svg_grads = svg_gradient::parse_svg(&content, self.opt.svg_id.as_deref());
268 |
269 | let cmode = match self.opt.blend_mode {
270 | Some(BlendMode::Rgb) => colorgrad::BlendMode::Rgb,
271 | Some(BlendMode::LinearRgb) => colorgrad::BlendMode::LinearRgb,
272 | Some(BlendMode::Lab) => colorgrad::BlendMode::Lab,
273 | _ => colorgrad::BlendMode::Oklab,
274 | };
275 | let mut valid = 0;
276 | let mut invalid = 0;
277 |
278 | for mut sg in svg_grads {
279 | assert_eq!(sg.colors.len(), sg.pos.len());
280 |
281 | let id = sg
282 | .id
283 | .as_ref()
284 | .map(|s| format!("#{s}"))
285 | .unwrap_or("[without id]".into());
286 |
287 | let Some(mut gb) = sg.gradient_builder() else {
288 | eprintln!("{} {} (invalid gradient)", &path.display(), bold(&id));
289 | status = 1;
290 | invalid += 1;
291 | continue;
292 | };
293 |
294 | if show_info {
295 | writeln!(self.stdout, "{} {}", &path.display(), bold(&id))?;
296 | }
297 |
298 | gb.mode(cmode);
299 |
300 | match self.opt.interpolation {
301 | Some(Interpolation::Linear) => {
302 | let g: LinearGradient = gb.build().unwrap();
303 | self.handle_output(&g)?;
304 | }
305 | Some(Interpolation::Basis) => {
306 | let g: BasisGradient = gb.build().unwrap();
307 | self.handle_output(&g)?;
308 | }
309 | _ => {
310 | let g: CatmullRomGradient = gb.build().unwrap();
311 | self.handle_output(&g)?;
312 | }
313 | }
314 | valid += 1;
315 | }
316 |
317 | if valid == 0 && invalid == 0 {
318 | if self.opt.svg_id.is_some() {
319 | eprintln!("{} -- (nothing matched)", &path.display(),);
320 | } else {
321 | eprintln!("{} -- (no gradients found)", &path.display());
322 | }
323 | status = 1;
324 | }
325 | } else {
326 | eprintln!("{}: file format not supported.", &path.display());
327 | status = 1;
328 | }
329 | }
330 |
331 | Ok(status)
332 | }
333 |
334 | fn handle_output(&mut self, grad: &dyn Gradient) -> io::Result {
335 | match self.output_mode {
336 | OutputMode::Gradient => self.display_gradient(grad),
337 |
338 | OutputMode::ColorsN => {
339 | let mut colors = grad.colors(self.opt.take.unwrap());
340 | if self.use_solid_bg {
341 | for col in &mut colors {
342 | util::blend_on(col, &self.background);
343 | }
344 | }
345 | self.display_colors(&colors)
346 | }
347 |
348 | OutputMode::ColorsSample => {
349 | let colors: Vec<_> = self
350 | .opt
351 | .sample
352 | .as_ref()
353 | .unwrap()
354 | .iter()
355 | .map(|t| {
356 | let mut c = grad.at(*t).clamp();
357 | if self.use_solid_bg {
358 | util::blend_on(&mut c, &self.background);
359 | }
360 | c
361 | })
362 | .collect();
363 | self.display_colors(&colors)
364 | }
365 | }
366 | }
367 |
368 | fn display_gradient(&mut self, grad: &dyn Gradient) -> io::Result {
369 | let colors = grad.colors(self.width * 2);
370 |
371 | for y in 0..self.height {
372 | for (x, cols) in colors.chunks_exact(2).enumerate() {
373 | let bg_color = if self.use_solid_bg {
374 | &self.background
375 | } else if ((x / 2) & 1) ^ (y & 1) == 0 {
376 | &self.cb_color[0]
377 | } else {
378 | &self.cb_color[1]
379 | };
380 |
381 | let [a, b, c, _] = util::blend_color(&cols[0], bg_color).to_rgba8();
382 | let [d, e, f, _] = util::blend_color(&cols[1], bg_color).to_rgba8();
383 |
384 | write!(
385 | self.stdout,
386 | "\x1B[38;2;{a};{b};{c};48;2;{d};{e};{f}m\u{258C}",
387 | )?;
388 | }
389 |
390 | writeln!(self.stdout, "\x1B[39;49m")?;
391 | }
392 |
393 | Ok(0)
394 | }
395 |
396 | fn display_colors(&mut self, colors: &[Color]) -> io::Result {
397 | if self.opt.array {
398 | let f = self.output_format;
399 | let cols: Vec<_> = colors.iter().map(|c| util::format_color(c, f)).collect();
400 | writeln!(self.stdout, "{cols:?}")?;
401 | return Ok(0);
402 | }
403 |
404 | if self.is_terminal {
405 | if self.output_format != OutputColor::Hex {
406 | for col in colors {
407 | writeln!(
408 | self.stdout,
409 | "{} {}",
410 | util::color_to_ansi(col, &self.cb_color, 7),
411 | util::format_color(col, self.output_format)
412 | )?;
413 | }
414 | return Ok(0);
415 | }
416 |
417 | let mut buff0 = "".to_string();
418 | let mut buff1 = "".to_string();
419 | let last = colors.len() - 1;
420 | let mut w = 0;
421 |
422 | for (i, col) in colors.iter().enumerate() {
423 | let hex = util::format_color(col, self.output_format);
424 | let wc = hex.len();
425 | buff0.push_str(&util::color_to_ansi(col, &self.cb_color, wc));
426 | buff1.push_str(&hex);
427 | w += wc;
428 | if w < self.term_width {
429 | buff0.push(' ');
430 | buff1.push(' ');
431 | w += 1;
432 | }
433 | let nwc = if i == last {
434 | 0
435 | } else {
436 | util::format_color(&colors[i + 1], self.output_format).len()
437 | };
438 |
439 | if w + nwc > self.term_width || i == last {
440 | writeln!(self.stdout, "{buff0}\n{buff1}")?;
441 | buff0.clear();
442 | buff1.clear();
443 | w = 0;
444 | }
445 | }
446 | return Ok(0);
447 | }
448 |
449 | for col in colors {
450 | writeln!(
451 | self.stdout,
452 | "{}",
453 | util::format_color(col, self.output_format)
454 | )?;
455 | }
456 |
457 | Ok(0)
458 | }
459 |
460 | fn example_help(&mut self) -> io::Result {
461 | fn parse_colors(colors: &[&str]) -> Vec {
462 | colors
463 | .iter()
464 | .map(|s| Color::from_html(s).unwrap())
465 | .collect()
466 | }
467 |
468 | let prompt = "\u{21AA} ";
469 | self.width = self.term_width.min(80);
470 | self.height = 2;
471 |
472 | writeln!(
473 | self.stdout,
474 | "{} Specify gradient using --preset, --custom, --css or --file\n",
475 | bold("INFO:")
476 | )?;
477 | writeln!(self.stdout, "{}", bold("EXAMPLES:"))?;
478 | writeln!(self.stdout, "{prompt} gradient --preset rainbow")?;
479 | self.opt.preset = Some("rainbow".into());
480 | self.preset_gradient()?;
481 |
482 | writeln!(
483 | self.stdout,
484 | "{prompt} gradient --custom C41189 'rgb(0,191,255)' gold 'hsv(91,88%,50%)'"
485 | )?;
486 | self.opt.preset = None;
487 | self.opt.custom = Some(parse_colors(&[
488 | "C41189",
489 | "rgb(0,191,255)",
490 | "gold",
491 | "hsv(91,88%,50%)",
492 | ]));
493 | self.custom_gradient()?;
494 |
495 | writeln!(self.stdout, "{prompt} gradient --css 'white, 25%, blue'")?;
496 | self.opt.custom = None;
497 | self.opt.css = Some("white, 25%, blue".into());
498 | self.custom_gradient()?;
499 |
500 | writeln!(
501 | self.stdout,
502 | "{prompt} gradient --file Test.svg Neon_Green.ggr"
503 | )?;
504 | writeln!(self.stdout, "Test.svg {}", bold("#purple-gradient"))?;
505 | self.opt.css = None;
506 | self.opt.custom = Some(parse_colors(&["4a1578", "c5a8de"]));
507 | self.custom_gradient()?;
508 |
509 | writeln!(self.stdout, "Neon_Green.ggr {}", bold("Neon Green"))?;
510 | const GGR_SAMPLE: &str = include_str!("../data/Neon_Green.ggr");
511 | let color = Color::default();
512 | let br = BufReader::new(GGR_SAMPLE.as_bytes());
513 | let grad = GimpGradient::new(br, &color, &color).unwrap();
514 | self.display_gradient(&grad)?;
515 |
516 | writeln!(self.stdout, "{prompt} gradient --preset viridis --take 10")?;
517 | self.opt.custom = None;
518 | self.opt.preset = Some("viridis".into());
519 | self.opt.take = Some(10);
520 | self.output_mode = OutputMode::ColorsN;
521 | self.preset_gradient()?;
522 |
523 | Ok(0)
524 | }
525 | }
526 |
527 | fn main() {
528 | let opt = match cli::parse_args() {
529 | Ok(opt) => opt,
530 | Err(err) => {
531 | eprintln!("Error: {err}.");
532 | exit(1);
533 | }
534 | };
535 |
536 | let mut ga = GradientApp::new(opt, io::stdout());
537 |
538 | match ga.run() {
539 | Ok(exit_code) => {
540 | exit(exit_code);
541 | }
542 |
543 | Err(err) if err.kind() == io::ErrorKind::BrokenPipe => {
544 | exit(0);
545 | }
546 |
547 | Err(err) => {
548 | eprintln!("{err}");
549 | exit(1);
550 | }
551 | }
552 | }
553 |
--------------------------------------------------------------------------------