commit b91098d3d416117cda37d824f2c6ee05d0fd8ac4 Author: Thorsten Sommer Date: Wed Nov 8 19:04:59 2023 +0100 Init diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..24a8e87 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2073fc0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,81 @@ +/target +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Rust template +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + + + +# Added by cargo +# +# already existing elements were commented out + +#/target +/Cargo.lock diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/borderline.iml b/.idea/borderline.iml new file mode 100644 index 0000000..cf84ae4 --- /dev/null +++ b/.idea/borderline.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..f86ff4b --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3aafa53 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "borderline" +version = "1.0.0" +edition = "2021" +authors = ["Thorsten Sommer"] +description = "A simple crate to use borders in your terminal." +license = "MIT" +homepage = "https://devops.tsommer.org/open-source/rust/crates/borderline" +repository = "https://devops.tsommer.org/open-source/rust/crates/borderline" +readme = "README.md" +keywords = ["border", "terminal", "cli", "progressbar"] +categories = ["command-line-interface"] + +[dependencies] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a7cac1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023 Thorsten Sommer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2065902 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Borderline + +Borderline is a Crate to render borders to the terminal. You can place text or progress bars inside the borders. The usage is as simple as: + +```rust +use borderline::LineBuilder; + +// Create a line builder: +let mut lb = LineBuilder::new(); + +// Print the header first: +borderline::print_header("Borderline Test-Drive"); + +// Now, we are able to attach as many "line" as we want. +// Every line must be started with a call to `line_begin` +// and ended with a call to `line_end`: +lb.line_begin("- Loading..."); + +// perform other actions here... + +// When ending a line, you can specify the second part +// of the line, which will be printed on the right side: +lb.line_end("done."); + +// You can also print a progress bar: +lb.line_begin("- Loading many files:"); + +// Set up the progress bar using the total number of steps: +lb.progress_bar_setup(1_000); + +// Do some work and update the progress bar: +for i in 0..1_000 { + sleep(std::time::Duration::from_millis(50)); + lb.progress_bar_update(i); +} + +// End the progress bar: +lb.progressbar_end(); + +// End the line and print the second text part: +lb.line_end("done."); + +// At the end, we can print the closing border: +borderline::print_border(); +``` + +While the progress bar is active: +![progressbar.png](images/progressbar.png) + +When the program is done: +![done.png](images/done.png) + +In case that the given text is too long, it gets wrapped at word boundaries. When this is not possible, the text is wrapped at the given width. \ No newline at end of file diff --git a/images/done.png b/images/done.png new file mode 100644 index 0000000..fa9d332 --- /dev/null +++ b/images/done.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be059e45366f73cec011ddec8f45bd420ae94dda41377d95511a7c115cc2afe1 +size 14756 diff --git a/images/progressbar.png b/images/progressbar.png new file mode 100644 index 0000000..ec0255a --- /dev/null +++ b/images/progressbar.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a1cf8b8d594b1b4cf8b5074217a2ed394ceba7ced84a4a31a34e13039b46289 +size 14994 diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8dedef1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,261 @@ +use std::io; +use std::io::Write; + +// How big should the fence be? +const LEN: usize = 66; + +// How much should the text be indented? Applies to lines, not the header. +const INNER_INDENT: usize = 1; + +/// Prints a header. +pub fn print_header(text: &str) { + + /// Left-aligns a string with spaces. + fn left_aligned_string(s: &str, total_len: usize) -> String { + let text_len = s.chars().count(); + if text_len >= total_len { + return s.to_string(); + } + + let padding = total_len - text_len; + format!("{}{}", s, " ".repeat(padding)) + } + + // Top border: + print_border(); + + // + // Text processing: + // + let mut remaining = text.to_string(); + while !remaining.is_empty() { + + // Fits the remaining text on one line? + if remaining.chars().count() <= LEN - 4 { + // Yes, print it: + println!("| {} |", left_aligned_string(&remaining, LEN - 4)); + break; + } else { + // No, we need to split the text into multiple lines. + // We want to split at word boundaries, though. + let mut line = String::from(&remaining[..LEN-4]); + if let Some(last_space) = line.rfind(' ') { + line.truncate(last_space); + remaining = remaining[last_space..].trim_start().to_string(); + } else { + line = String::from(&remaining[..LEN-4]); + remaining = remaining[LEN-4..].trim_start().to_string(); + } + + println!("| {} |", left_aligned_string(&line, LEN - 4)); + } + } + + // Bottom border: + print_border(); +} + +/// A helper struct to build lines according to the fence. +pub struct LineBuilder { + /// The current line. + current_line: String, + + /// The current line prompt, when rendering a progress bar. + current_line_prompt: String, + + /// The total number of steps in the progress bar. + pb_num_steps: usize, + + /// The number of characters available for the progress bar. + pb_chars_available: usize, + + /// The number of steps per character in the progress bar. + pb_steps_per_char: f64, +} + +/// Default implementation for LineBuilder. +impl Default for LineBuilder { + fn default() -> Self { + Self::new() + } +} + +/// Implementation for LineBuilder. +impl LineBuilder { + /// Creates a new LineBuilder. + #[allow(clippy::repeat_once)] + pub fn new() -> Self { + Self { + current_line: " ".repeat(INNER_INDENT), + current_line_prompt: " ".repeat(INNER_INDENT), + pb_num_steps: 0, + pb_steps_per_char: 0.0, + pb_chars_available: 0, + } + } + + /// Starts a new line. When showing a progress bar, the text will be used as a prompt. + #[allow(clippy::repeat_once)] + pub fn line_begin(&mut self, text: &str) { + // Reset the state: + self.current_line.clear(); + self.current_line_prompt.clear(); + + let inner_indent = " ".repeat(INNER_INDENT); + self.current_line.push_str(&inner_indent); + self.current_line_prompt.push_str(&inner_indent); + + self.current_line.push_str(text); + self.current_line_prompt.push_str(text); + self.print_and_clear(false); + io::stdout().flush().unwrap(); + } + + /// Ends the current line. After calling this, a progress bar cannot be shown anymore. + pub fn line_end(&mut self, text: &str) { + print!("\r"); // Go back to the beginning of the line + self.current_line.push(' '); + self.current_line.push_str(text); + self.print_and_clear(true); + } + + /// Starts a progress bar with the given number of total steps. You must start a new line + /// before calling this. + pub fn progress_bar_setup(&mut self, num_steps: usize) { + print!("\r"); // Go back to the beginning of the line + + // Determine how many characters we have left for the progress bar: + self.pb_chars_available = LEN - 4 - 4 - self.current_line_prompt.chars().count(); + self.pb_num_steps = num_steps; + + // Determine how many steps we can fit into one character: + if self.pb_chars_available >= self.pb_num_steps { + self.pb_steps_per_char = self.pb_chars_available as f64 / num_steps as f64; + } else { + self.pb_steps_per_char = num_steps as f64 / self.pb_chars_available as f64; + } + + // Print the prompt: + self.current_line.clear(); + self.current_line.push_str(self.current_line_prompt.as_str()); + + // Print the progress bar: + self.current_line.push(' '); + self.current_line.push('>'); + self.current_line.push_str(&".".repeat(self.pb_chars_available)); + + self.print_and_clear(false); + io::stdout().flush().unwrap(); + } + + /// Updates the progress bar with the given current step. + pub fn progress_bar_update(&mut self, current_step: usize) { + + // Ensure we don't go over the number of steps: + let current_step = if current_step > self.pb_num_steps { + self.pb_num_steps + } else { + current_step + }; + + // Determine how many characters we need to use to show the passed steps: + let num_filled = if self.pb_chars_available >= self.pb_num_steps { + self.pb_steps_per_char.floor() as usize * current_step + } else { + current_step / self.pb_steps_per_char as usize + }; + + // Ensure we don't go over the number of available characters: + let len_bar = if num_filled >= self.pb_chars_available { + self.pb_chars_available - 1 + } else { + num_filled + }; + + // Build the progress bar: + print!("\r"); // Go back to the beginning of the line + self.current_line.clear(); + + // Print the prompt: + self.current_line.push_str(self.current_line_prompt.as_str()); + + // Print the progress bar: + self.current_line.push(' '); + self.current_line.push_str(&"#".repeat(len_bar)); + self.current_line.push('>'); + + // Print the remaining characters for the future progress area: + if len_bar < self.pb_chars_available { + self.current_line.push_str(&".".repeat(self.pb_chars_available - len_bar)); + } + + // Finish the line: + self.current_line.push(' '); + self.print_and_clear(false); + io::stdout().flush().unwrap(); + } + + /// Ends the progress bar. After this call, you cannot update the progress bar anymore. + /// You must end the current line, though. + pub fn progressbar_end(&mut self) { + print!("\r"); // Go back to the beginning of the line + self.current_line.clear(); + self.current_line.push_str(self.current_line_prompt.as_str()); + self.print_and_clear(false); + io::stdout().flush().unwrap(); + } + + /// Prints the current line and clears it. + fn print_and_clear(&mut self, finalize: bool) { + let mut line_break_occurred = false; // Track if we had a line break + + while !self.current_line.is_empty() { + if self.current_line.chars().count() <= LEN - 4 { + if finalize { + println!("| {} |", format_line(&self.current_line, LEN - 4)); + self.current_line.clear(); + self.current_line_prompt.clear(); + } else { + print!("| {} |", format_line(&self.current_line, LEN - 4)); + } + + break; // Break when not finalizing + + } else { + + let mut line = String::from(&self.current_line[..LEN-4]); + let original_line = self.current_line.clone(); + + if let Some(last_space) = line.rfind(' ') { + line.truncate(last_space); + self.current_line = format!("{}{}", " ".repeat(INNER_INDENT), &self.current_line[last_space..].trim_start()); + } else { + line = String::from(&self.current_line[..LEN-4]); + self.current_line = format!("{}{}", " ".repeat(INNER_INDENT), &self.current_line[LEN-4..].trim_start()); + } + + if self.current_line == original_line { + self.current_line.clear(); // Prevent infinite loop + } + + println!("| {} |", format_line(&line, LEN - 4)); + line_break_occurred = true; // Set flag since we had a line break + } + } + + if line_break_occurred { + println!("| {} |", " ".repeat(LEN - 4)); // Print an empty line if we had a line break + } + } +} + +/// Formats a line with spaces. +fn format_line(s: &str, total_len: usize) -> String { + let text_len = s.chars().count(); + format!("{}{}", s, " ".repeat(total_len - text_len)) +} + +/// Prints a border. +pub fn print_border() { + println!("+{}+", "-".repeat(LEN - 2)); +} \ No newline at end of file