├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── assets ├── screenshot.gif └── screenshot.png ├── examples └── cooking.rs └── src ├── lib.rs ├── spinner.rs ├── state.rs └── term.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: CI 4 | 5 | jobs: 6 | check: 7 | name: Check 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout sources 11 | uses: actions/checkout@v2 12 | 13 | - name: Install stable toolchain 14 | uses: actions-rs/toolchain@v1 15 | with: 16 | profile: minimal 17 | toolchain: stable 18 | override: true 19 | 20 | - name: Run cargo check 21 | uses: actions-rs/cargo@v1 22 | with: 23 | command: check 24 | 25 | tests: 26 | name: Tests 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout sources 30 | uses: actions/checkout@v2 31 | 32 | - name: Install stable toolchain 33 | uses: actions-rs/toolchain@v1 34 | with: 35 | profile: minimal 36 | toolchain: stable 37 | override: true 38 | 39 | - name: Run cargo test 40 | uses: actions-rs/cargo@v1 41 | with: 42 | command: test 43 | 44 | clippy: 45 | name: Clippy and fmt 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Checkout sources 49 | uses: actions/checkout@v2 50 | 51 | - name: Install stable toolchain 52 | uses: actions-rs/toolchain@v1 53 | with: 54 | profile: minimal 55 | toolchain: stable 56 | override: true 57 | components: rustfmt, clippy 58 | 59 | - name: Run cargo fmt 60 | uses: actions-rs/cargo@v1 61 | with: 62 | command: fmt 63 | args: --all -- --check 64 | 65 | - name: Run cargo clippy 66 | uses: actions-rs/cargo@v1 67 | with: 68 | command: clippy 69 | args: -- -D warnings 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editors 2 | .vscode 3 | 4 | old 5 | 6 | # Generated by Cargo 7 | # will have compiled files and executables 8 | debug/ 9 | target/ 10 | 11 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 12 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 13 | Cargo.lock 14 | 15 | # These are backup files generated by rustfmt 16 | **/*.rs.bk 17 | 18 | # MSVC Windows builds of rustc generate these, which store debugging information 19 | *.pdb 20 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "spinach" 3 | version = "3.1.0" 4 | authors = ["Etienne Napoleone "] 5 | edition = "2021" 6 | description = "Practical spinner for Rust" 7 | documentation = "https://docs.rs/spinach" 8 | readme = "README.md" 9 | repository = "https://github.com/etienne-napoleone/spinach" 10 | license = "MIT" 11 | keywords = ["spinner", "terminal-ui", "loader", "cli", "terminal"] 12 | categories = ["command-line-interface"] 13 | exclude = ["assets/"] 14 | 15 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 16 | 17 | [dependencies] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🥬 spinach 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/spinach)](https://crates.io/crates/spinach) 4 | [![Docs.rs](https://img.shields.io/docsrs/spinach)](https://docs.rs/spinach) 5 | [![License](https://img.shields.io/crates/l/spinach)](LICENSE) 6 | [![CI](https://github.com/etienne-napoleone/spinach/actions/workflows/ci.yml/badge.svg)](https://github.com/etienne-napoleone/spinach/actions/workflows/ci.yml) 7 | 8 | > Practical spinner for Rust — `v3` now with method chaining 9 | 10 |

11 | 12 |

13 | 14 | ## Install 15 | 16 | Add as a dependency to your `Cargo.toml`. 17 | 18 | ```toml 19 | [dependencies] 20 | spinach = "3" 21 | ``` 22 | 23 | ## Usage 24 | 25 | Basic example. 26 | 27 | ```rust 28 | use spinach::Spinner; 29 | 30 | fn main() { 31 | let s = Spinner::new("Cutting spinaches...").start(); 32 | // Cut spinaches 33 | s.text("Cutting tomatoes...").update(); 34 | // Cut tomatoes 35 | s.text("Vegetables cut").symbol("🔪").stop(); 36 | } 37 | ``` 38 | 39 | ### Starting 40 | 41 | ```rust 42 | use spinach::{Color, Spinner}; 43 | 44 | // With custom text 45 | let s = Spinner::new("workin'...").start(); 46 | 47 | // With custom text, spinner, spinner speed and spinner color 48 | let symbols = vec!["▮","▯"]; 49 | let s = Spinner::new("blip... blop...") 50 | .color(Color::Red) 51 | .symbols(symbols) 52 | .frames_duration(80) 53 | .start(); 54 | ``` 55 | 56 | ### Updating 57 | 58 | ```rust 59 | use spinach::{Color, Spinner}; 60 | 61 | let s = Spinner::new("workin'...").start(); 62 | 63 | // Updating text 64 | s.text("new text").update(); 65 | 66 | // Updating color 67 | s.color(Color::White).update(); 68 | 69 | // Updating spinner symbols 70 | s.symbols(vec!["◐", "◓", "◑", "◒"]).update(); 71 | 72 | // Updating spinner speed 73 | s.frames_duration(80).update(); 74 | 75 | // Updating multiple at once 76 | s.text("new text").color(Color::Red); 77 | ``` 78 | 79 | ### Stopping 80 | 81 | ```rust 82 | use spinach::{Color, Spinner}; 83 | 84 | let s = Spinner::new("workin'...").start(); 85 | 86 | // Stop with final `✔` frame and green color. 87 | s.text("gg!").success(); 88 | 89 | // Stop with final `✖` frame and red color. 90 | s.text("ups").failure(); 91 | 92 | // Stop with final `⚠` frame and yellow color. 93 | s.text("something may have happened?").warn(); 94 | 95 | // Stop with final `ℹ` frame and blue color. 96 | s.text("notice").stop(); 97 | 98 | // Stop current spinner (sends update at the same time) 99 | s.stop(); // freeze 100 | s.text("spinach'd").symbol("🥬").stop(); // stop with the text "spinach'd" and a vegetable as the spinner 101 | ``` 102 | 103 | ## FAQ 104 | 105 | ### How to avoid leaving terminal without prompt on interupt (ctrl^c)? 106 | 107 | You can use a library like [`ctrlc`](https://crates.io/crates/ctrlc) to handle interupts. 108 | 109 | The most basic way to handle it would be in conjuction with this lib QoL `show_cursor` function like this: 110 | 111 | ```rust 112 | use spinach::{show_cursor, Spinner}; 113 | 114 | fn main() { 115 | ctrlc::set_handler(|| { 116 | show_cursor(); 117 | std::process::exit(0); 118 | }) 119 | .expect("Error setting Ctrl-C handler"); 120 | 121 | let s = Spinner::new("workin'...").start(); 122 | // ... 123 | ``` 124 | 125 | ## Related 126 | 127 | Inspired by: 128 | 129 | - [ora](https://github.com/sindresorhus/ora) 130 | - [spinners](https://github.com/FGRibreau/spinners) 131 | -------------------------------------------------------------------------------- /assets/screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etienne-napoleone/spinach/5d690c974297db1405502d56d9347ebd53ca827e/assets/screenshot.gif -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etienne-napoleone/spinach/5d690c974297db1405502d56d9347ebd53ca827e/assets/screenshot.png -------------------------------------------------------------------------------- /examples/cooking.rs: -------------------------------------------------------------------------------- 1 | use std::thread::sleep; 2 | use std::time::Duration; 3 | 4 | use spinach::Color::Green; 5 | use spinach::Spinner; 6 | 7 | fn main() { 8 | let spinner = Spinner::new("Cutting spinaches") 9 | .color(Green) 10 | .frames_duration(30) 11 | .start(); 12 | 13 | sleep(Duration::from_secs(1)); 14 | spinner.text("Cutting tomatoes").update(); 15 | 16 | sleep(Duration::from_secs(1)); 17 | spinner.text("Vegetables cut").symbol("🔪").stop(); 18 | 19 | let spinner = Spinner::new("Cooking vegetables") 20 | .color(Green) 21 | .frames_duration(30) 22 | .start(); 23 | 24 | sleep(Duration::from_secs(1)); 25 | spinner.text("Vegetables cooked").symbol("🍲").stop(); 26 | 27 | sleep(Duration::from_secs(1)); 28 | } 29 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | mod spinner; 2 | mod state; 3 | mod term; 4 | 5 | pub use spinner::{RunningSpinner, Spinner, StoppedSpinner}; 6 | pub use term::{show_cursor, Color}; 7 | -------------------------------------------------------------------------------- /src/spinner.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | use std::rc::Rc; 3 | use std::sync::mpsc::{channel, Sender, TryRecvError}; 4 | use std::thread::{sleep, spawn, JoinHandle}; 5 | use std::time::Duration; 6 | 7 | use crate::state::{State, Update}; 8 | use crate::term; 9 | 10 | /// A Spinach spinner 11 | /// 12 | /// Represents a spinner that can be used to show progress or activity. 13 | /// 14 | /// # Examples 15 | /// 16 | /// ``` 17 | /// use spinach::Spinner; 18 | /// 19 | /// let spinner = Spinner::new("Loading...").start(); 20 | /// // Perform some tasks 21 | /// spinner.text("gg!").success(); 22 | /// ``` 23 | #[derive(Debug, Default, Clone)] 24 | pub struct Spinner { 25 | update: RefCell, 26 | state: S, 27 | } 28 | 29 | /// Represents the stopped state of a spinner. 30 | #[derive(Clone, Debug)] 31 | pub struct Stopped; 32 | 33 | /// Represents the running state of a spinner. 34 | #[derive(Clone, Debug)] 35 | pub struct Running { 36 | sender: Sender, 37 | handle: Rc>>>, 38 | } 39 | 40 | /// Represents a spinner that is currently running. 41 | pub type RunningSpinner = Spinner; 42 | 43 | /// Represents a spinner that is currently stopped. 44 | pub type StoppedSpinner = Spinner; 45 | 46 | impl Spinner { 47 | /// Sets the color of the spinner. 48 | /// 49 | /// # Examples 50 | /// 51 | /// ``` 52 | /// use spinach::{Spinner, Color}; 53 | /// 54 | /// let spinner = Spinner::new("workin'...").color(Color::Blue).start(); 55 | /// ``` 56 | pub fn color(&self, color: term::Color) -> &Self { 57 | self.update.borrow_mut().color = Some(color); 58 | self 59 | } 60 | 61 | /// Sets the text displayed alongside the spinner. 62 | /// 63 | /// # Examples 64 | /// 65 | /// ``` 66 | /// use spinach::Spinner; 67 | /// 68 | /// let spinner = Spinner::new("workin'...").start(); 69 | /// ``` 70 | pub fn text(&self, text: &str) -> &Self { 71 | self.update.borrow_mut().text = Some(text.to_string()); 72 | self 73 | } 74 | 75 | /// Sets the symbols used for the spinner animation. 76 | /// 77 | /// # Examples 78 | /// 79 | /// ``` 80 | /// use spinach::Spinner; 81 | /// 82 | /// let spinner = Spinner::new("workin'...").symbols(vec!["◐", "◓", "◑", "◒"]).start(); 83 | /// ``` 84 | pub fn symbols(&self, symbols: Vec<&'static str>) -> &Self { 85 | self.update.borrow_mut().symbols = Some(symbols); 86 | self 87 | } 88 | 89 | /// Sets a single symbol for the spinner animation. 90 | /// This is useful when you want to set a final symbol, for example. 91 | /// 92 | /// # Examples 93 | /// 94 | /// ``` 95 | /// use spinach::Spinner; 96 | /// 97 | /// let spinner = Spinner::new("workin'...").start().text("done!").symbol("✔").stop(); 98 | /// ``` 99 | pub fn symbol(&self, symbol: &'static str) -> &Self { 100 | self.update.borrow_mut().symbols = Some(vec![symbol]); 101 | self 102 | } 103 | 104 | /// Sets the duration of each frame in the spinner animation. 105 | /// 106 | /// # Examples 107 | /// 108 | /// ``` 109 | /// use spinach::Spinner; 110 | /// 111 | /// let spinner = Spinner::new("workin'...").frames_duration(40).start(); 112 | /// ``` 113 | pub fn frames_duration(&self, ms: u64) -> &Self { 114 | self.update.borrow_mut().frames_duration_ms = Some(ms); 115 | self 116 | } 117 | } 118 | 119 | impl Spinner { 120 | /// Creates a new spinner. 121 | /// 122 | /// # Examples 123 | /// 124 | /// ``` 125 | /// use spinach::Spinner; 126 | /// 127 | /// let spinner = Spinner::new("let's go...").start(); 128 | /// ``` 129 | #[must_use] 130 | pub fn new(text: &str) -> Self { 131 | Spinner { 132 | update: RefCell::new(Update::new(text)), 133 | state: Stopped, 134 | } 135 | } 136 | 137 | /// Starts the spinner. 138 | /// 139 | /// # Examples 140 | /// 141 | /// ``` 142 | /// use spinach::Spinner; 143 | /// 144 | /// let spinner = Spinner::new("let's go...").start(); 145 | /// ``` 146 | pub fn start(&self) -> Spinner { 147 | term::hide_cursor(); 148 | let (sender, receiver) = channel::(); 149 | let mut state = State::default(); 150 | state.update(self.update.take()); 151 | let handle = RefCell::new(Some(spawn(move || { 152 | let mut iteration = 0; 153 | loop { 154 | match receiver.try_recv() { 155 | Ok(update) if update.stop => { 156 | state.update(update); 157 | if iteration >= state.symbols.len() { 158 | iteration = 0; 159 | } 160 | state.render(iteration); 161 | break; 162 | } 163 | Ok(update) => state.update(update), 164 | Err(TryRecvError::Disconnected) => break, 165 | Err(TryRecvError::Empty) => (), 166 | } 167 | if iteration >= state.symbols.len() { 168 | iteration = 0; 169 | } 170 | state.render(iteration); 171 | iteration += 1; 172 | sleep(Duration::from_millis(state.frames_duration_ms)); 173 | } 174 | term::new_line(); 175 | term::show_cursor(); 176 | }))); 177 | let handle = Rc::new(handle); 178 | Spinner { 179 | update: RefCell::new(Update::default()), 180 | state: Running { sender, handle }, 181 | } 182 | } 183 | } 184 | 185 | impl Spinner { 186 | /// Joins the spinner thread, stopping it. 187 | fn join(&self) { 188 | if let Some(handle) = self.state.handle.borrow_mut().take() { 189 | _ = handle.join(); 190 | } 191 | } 192 | 193 | /// Updates the spinner with the current update state. 194 | /// 195 | /// # Examples 196 | /// 197 | /// ``` 198 | /// use spinach::Spinner; 199 | /// 200 | /// let spinner = Spinner::new("Doing something...").start(); 201 | /// // Perform some tasks 202 | /// spinner.text("Doing something else...").update(); 203 | /// ``` 204 | pub fn update(&self) -> &Self { 205 | _ = self.state.sender.send(self.update.borrow().clone()); 206 | self 207 | } 208 | 209 | /// Stops the spinner. 210 | /// 211 | /// # Examples 212 | /// 213 | /// ``` 214 | /// use spinach::Spinner; 215 | /// 216 | /// let spinner = Spinner::new("Doing something...").start(); 217 | /// // Perform some tasks 218 | /// spinner.text("done!").stop(); 219 | /// ``` 220 | pub fn stop(&self) { 221 | self.update.borrow_mut().stop = true; 222 | self.update(); 223 | self.join(); 224 | } 225 | 226 | /// Stops the spinner with a pre-configured success indication. 227 | /// Sets the symbol and color. 228 | /// 229 | /// # Examples 230 | /// 231 | /// ``` 232 | /// use spinach::Spinner; 233 | /// 234 | /// let spinner = Spinner::new("Doing something...").start(); 235 | /// // Perform some task that succeeds 236 | /// spinner.text("done!").success(); 237 | /// ``` 238 | pub fn success(&self) { 239 | self.update.borrow_mut().color = Some(term::Color::Green); 240 | self.update.borrow_mut().symbols = Some(vec!["✔"]); 241 | self.stop(); 242 | } 243 | 244 | /// Stops the spinner with a pre-configured failure indication. 245 | /// Sets the symbol and color. 246 | /// 247 | /// # Examples 248 | /// 249 | /// ``` 250 | /// use spinach::Spinner; 251 | /// 252 | /// let spinner = Spinner::new("Doing something...").start(); 253 | /// // Perform some task that fails 254 | /// spinner.text("oops").failure(); 255 | /// ``` 256 | pub fn failure(&self) { 257 | self.update.borrow_mut().color = Some(term::Color::Red); 258 | self.update.borrow_mut().symbols = Some(vec!["✖"]); 259 | self.stop(); 260 | } 261 | 262 | /// Stops the spinner with a pre-configured warning indication. 263 | /// Sets the symbol and color. 264 | /// 265 | /// # Examples 266 | /// 267 | /// ``` 268 | /// use spinach::Spinner; 269 | /// 270 | /// let spinner = Spinner::new("Doing something...").start(); 271 | /// // Perform some task with unexpected results 272 | /// spinner.text("wait, what?").warn(); 273 | /// ``` 274 | pub fn warn(&self) { 275 | self.update.borrow_mut().color = Some(term::Color::Yellow); 276 | self.update.borrow_mut().symbols = Some(vec!["⚠"]); 277 | self.stop(); 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | use crate::term; 2 | 3 | /// Represents the state of a spinner or progress indicator. 4 | pub struct State { 5 | /// The text to display alongside the spinner. 6 | pub text: String, 7 | /// The color of the spinner. 8 | pub color: term::Color, 9 | /// The symbols used for animation frames. 10 | pub symbols: Vec<&'static str>, 11 | /// The duration of each frame in milliseconds. 12 | pub frames_duration_ms: u64, 13 | } 14 | 15 | impl State { 16 | /// Updates the state with new values from an Update struct. 17 | pub fn update(&mut self, update: Update) { 18 | if let Some(text) = update.text { 19 | self.text = text; 20 | } 21 | if let Some(color) = update.color { 22 | self.color = color; 23 | } 24 | if let Some(symbols) = update.symbols { 25 | self.symbols = symbols; 26 | } 27 | if let Some(frames_duration_ms) = update.frames_duration_ms { 28 | self.frames_duration_ms = frames_duration_ms; 29 | } 30 | } 31 | 32 | /// Renders the current state of the spinner. 33 | pub fn render(&self, iteration: usize) { 34 | let color = term::color(&self.color); 35 | let frame = self.symbols.clone()[iteration]; 36 | let color_reset = term::color(&term::Color::Reset); 37 | let text = &self.text; 38 | term::delete_line(); 39 | print!("\r{color}{frame}{color_reset} {text}"); 40 | term::flush(); 41 | } 42 | } 43 | 44 | impl Default for State { 45 | /// Default State with predefined spinner. 46 | fn default() -> Self { 47 | Self { 48 | text: String::new(), 49 | color: term::Color::Cyan, 50 | symbols: vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], 51 | frames_duration_ms: 65, 52 | } 53 | } 54 | } 55 | 56 | /// Represents an update to be applied to a State. 57 | #[derive(Debug, Default, Clone)] 58 | pub struct Update { 59 | /// Indicates whether to stop the spinner. 60 | pub stop: bool, 61 | /// Optional new text for the spinner. 62 | pub text: Option, 63 | /// Optional new color for the spinner. 64 | pub color: Option, 65 | /// Optional new symbols for the spinner animation. 66 | pub symbols: Option>, 67 | /// Optional new frame duration in milliseconds. 68 | pub frames_duration_ms: Option, 69 | } 70 | 71 | impl Update { 72 | pub fn new(text: &str) -> Self { 73 | Self { 74 | text: Some(text.to_owned()), 75 | ..Self::default() 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/term.rs: -------------------------------------------------------------------------------- 1 | use std::io::{stdout, Write}; 2 | 3 | /// Spinach supported color enum. 4 | #[derive(Clone, Debug)] 5 | pub enum Color { 6 | Ignore, 7 | Reset, 8 | Black, 9 | Red, 10 | Green, 11 | Yellow, 12 | Blue, 13 | Magenta, 14 | Cyan, 15 | White, 16 | } 17 | 18 | impl Default for Color { 19 | fn default() -> Self { 20 | Self::Cyan 21 | } 22 | } 23 | 24 | pub(crate) fn flush() { 25 | stdout().flush().unwrap(); 26 | } 27 | 28 | pub(crate) fn delete_line() { 29 | print!("\x1b[2K"); 30 | } 31 | 32 | pub(crate) fn hide_cursor() { 33 | print!("\x1b[?25l"); 34 | } 35 | 36 | /// Print show cursor ANSI escape code 37 | /// 38 | /// Can be used when managing ctrl^c/SIGINT to show the cursor back 39 | /// 40 | /// # Examples 41 | /// 42 | /// ``` 43 | /// use spinach::{Spinner, show_cursor}; 44 | /// 45 | /// let spinner = Spinner::new("Loading...").start(); 46 | /// // Somehow `spinner` is dropped 47 | /// show_cursor(); 48 | /// ``` 49 | pub fn show_cursor() { 50 | print!("\x1b[?25h"); 51 | } 52 | 53 | pub(crate) fn new_line() { 54 | println!(); 55 | } 56 | 57 | pub(crate) fn color(color: &Color) -> String { 58 | match color { 59 | Color::Ignore => String::new(), 60 | Color::Reset => ansi_color(0), 61 | Color::Black => ansi_color(30), 62 | Color::Red => ansi_color(31), 63 | Color::Green => ansi_color(32), 64 | Color::Yellow => ansi_color(33), 65 | Color::Blue => ansi_color(34), 66 | Color::Magenta => ansi_color(35), 67 | Color::Cyan => ansi_color(36), 68 | Color::White => ansi_color(37), 69 | } 70 | } 71 | 72 | fn ansi_color(code: u64) -> String { 73 | format!("\x1b[{code}m") 74 | } 75 | --------------------------------------------------------------------------------