mgeisler / textwrap

An efficient and powerful Rust library for word wrapping text.
MIT License
446 stars 44 forks source link

Option to preserve trailing whitespace #503

Open heiskane opened 1 year ago

heiskane commented 1 year ago

Currently working on something that requires me to keep track of a cursor in a message and wrap the message in a textbox with the cursor in the right place. This currently is not possible as wrapping trims the trailing whitespace making wrapped text different length from the original message misaligning any position i might track in the message.

Trimming trailing whitespace could be disabled in the options?

wanted behavior.

let text = "as     a hello";
let wrapped = textwrap::wrap(text, 5);
// ["as   ", "  a ", "hello"]
mgeisler commented 1 year ago

Hi @heiskane,

Currently working on something that requires me to keep track of a cursor in a message and wrap the message in a textbox with the cursor in the right place.

Interesting! Since you're doing something custom, have you considered using the Word struct directly? You might even want to implement Fragment for your own custom type.

All the wrapping logic operates on fragments: the "upper layer" simply splits the text into fragments using different options.

I think your use case could be handled by

Today, the string "as a hello" is turned into words and whitespace like this:

[("as", "     "), ("a", " "), ("hello". "")]

I'm suggesting instead turning it into

[("as", ""), (" ", ""), (" ", ""), (" ", ""), (" ", ""), (" ", ""), ("a", ""), (" ", ""), ("hello", "")]

That should do the trick. You should be able to do this with the WordSeparator::Custom setting.

heiskane commented 1 year ago

Thanks for the reply. I experimented a bit with what you suggested but i am still having some issues.

The issue with this is that WordSeparator::Custom takes a closure that returns iterator over Words and Word::from() strips whitespace so you can't really do something like Word::from(" "). I can't construct Word manually either because it needs width which is a private field. Not sure how i can make this happen with these restrictions.

If WordSeparator::Custom took a closure that returns an iterator over any type that implements Fragment i feel like this would be simpler to solve. Although i do feel like it would be nice if the library offered a NoTrim option directly.

Update: I implemented a simple method in textwrap to create Word without trimming the whitespace and that did allow me to do what i described in the issue.

the method:

impl<'a> Word<'a> {
    // snip....
    pub fn no_trim(word: &str) -> Word<'_> {
        Word {
            word,
            width: display_width(word),
            whitespace: "",
            penalty: "",
        }
    }
}

my (quick and dirty) solution to the issue:

use textwrap::{core::Word, Options, WordSeparator};

fn main() {
    let text = "as     a hello";

    let opts = Options::new(5).word_separator(WordSeparator::Custom(|line| {
        let mut start = 0;
        let mut prev_char = ' ';
        let mut char_indices = line.char_indices();

        println!("line: {:?}", line);
        Box::new(std::iter::from_fn(move || {
            for (idx, ch) in char_indices.by_ref() {
                if prev_char == ' ' && start != 0 {
                    let word = Word::no_trim(" ");
                    println!("word0: {:?}", word);
                    prev_char = ch;
                    start = idx;
                    return Some(word);
                }
                if prev_char != ' ' && ch == ' ' {
                    let word = Word::from(&line[start..idx]);
                    println!("word1: {:?}", word);
                    start = idx;
                    prev_char = ch;
                    return Some(word);
                }
                prev_char = ch;
            }

            if start < line.len() {
                let word = Word::no_trim(&line[start..]);
                println!("word2: {:?}", word);
                start = line.len();
                return Some(word);
            }

            None
        }))
    }));
    let wrapped = textwrap::wrap(text, opts);
    println!("{wrapped:?}");
}

prints:

line: "as     a hello"
word1: Word { word: "as", whitespace: "", penalty: "", width: 2 }
word0: Word { word: " ", whitespace: "", penalty: "", width: 1 }
word0: Word { word: " ", whitespace: "", penalty: "", width: 1 }
word0: Word { word: " ", whitespace: "", penalty: "", width: 1 }
word0: Word { word: " ", whitespace: "", penalty: "", width: 1 }
word0: Word { word: " ", whitespace: "", penalty: "", width: 1 }
word1: Word { word: "a", whitespace: "", penalty: "", width: 1 }
word0: Word { word: " ", whitespace: "", penalty: "", width: 1 }
word2: Word { word: "hello", whitespace: "", penalty: "", width: 5 }
["as  ", "   a ", "hello"]

Could this method (or something similar) be added to the crate so that wrapping without trimming would be possible? I can submit a PR if the method i created is fine (and maybe create a new WordSeparator type for this?). Let me know how you want to move forward with this :+1: