Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Text Reflowing/Justification #220

Closed
wants to merge 31 commits into from
Closed

Text Reflowing/Justification #220

wants to merge 31 commits into from

Conversation

oldaccountdeadname
Copy link
Contributor

This fork introduces 'justification' functionality, where a line may be reformatted from

very long paragraph with no linebreaks, exceeding the eighty character line limit of most everywhere

to

very long paragraph with no linebreaks, exceeding the eighty character line
limit of most everywhere

The command will justify around comments, for instance

# very long paragraph with no linebreaks, exceeding the eighty character line limit of most everywhere

to

# very long paragraph with no linebreaks, exceeding the eighty character line
# limit of most everywhere

Line length is determined by the line_length_guide property in the preferences, or a hard-coded value of 80 if the guide is not present. Characters that count as a comment (referred to as a prefix in the justify_text function) are determined by a hard-coded regex, as the line_comment_prefix option is limited to only one style (and thus would break on, for instance, rustdoc comments).

This addresses #219.

Copy link
Contributor

@christoph-heiss christoph-heiss left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just reviewed it to help @jmacdonald a bit.
I made some small comments on things that can be improved, nothing major though.

One overall comment as well: It would be nice if you could squash and regroup all the commits into more clean bits. Maybe like this:

  • First one adding the comments in src/view/mod.rs and src/models/application/mod.rs.
  • Next one splitting out range_from() from delete() in src/commands/selection.rs.
  • After that, one adding the justify command itself + test.
  • Finally, one for adding the command to the default keymap.

But I can't quite figure out how exactly is supposed to work. More tests should be a must, testing all the possible combinations this command can be used in.
As example, if I select the full line, it works as it supposed to (other than inserting extra newlines at the end, that shouldn't happen). But if I select nothing (or just a few words), it completely messes up. My suggestion would be to either:

  • Only allow justify() to be called in select_line mode or
  • Always use the full line range in every allowed mode.

I made a small GIF so you can see what I mean.

Comment on lines -49 to +56
pub fn new(preferences: Rc<RefCell<Preferences>>, event_channel: Sender<Event>) -> Result<View> {
let terminal = build_terminal().chain_err(|| "Failed to initialize terminal")?;
/// Return a new view. This will initialize the terminal, load the theme,
/// and begin to listen for events.
pub fn new(
preferences: Rc<RefCell<Preferences>>,
event_channel: Sender<Event>
) -> Result<View> {
let terminal = build_terminal()
.chain_err(|| "Failed to initialize terminal")?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, completely unrelated change?
Not needed IMHO since this line just about tickles the 100 character barrier, which is reasonable to default to for todays standards.

The comment again is nice and can stay, but the reformatting is somewhat unneeded. Especially since there are more occurences of this sort in this file and elsewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah sorry, I was playing with the codebase a bit trying to understand it, and I think I accidentally commited this instead of starting from the original codebase when I tried to implement line wrapping.

Comment on lines -58 to +63
let preferences = initialize_preferences();
let preferences =
Rc::new(RefCell::new(
Preferences::load().unwrap_or_else(|_| Preferences::new(None)),
));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should stay as-is. This does not provide any benefits and not having all things crammed into one function is very good for readability and maintainability.

The comment is all well and can stay of course, such things are always nice to have.

@@ -226,6 +226,7 @@ select:
ctrl-a: selection::select_all
ctrl-z: application::suspend
ctrl-c: application::exit
a: selection::justify
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably should be added to the select_line mode as well, would be useful to have there too.

/// around it.
fn justify_string(text: &String, max_len: usize, potential_prefix: Regex) -> String {
let mut justified = String::new();
for paragraph in text.split("\n\n") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why split only on double newlines? A comment explaining how this works in general would be nice.

justified.push(' ');
}

justified += "\n\n"; // add the paragraph delim
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why add some additional newlines here? When justifying a paragraph I wouldn't expect it to suddenly add superfluous lines after the paragraph.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whitespace collapsing removes interprets paragraph breaks (\n\n) the same as line breaks and word breaks, so paragraph breaks would be removed without it. However, I can see how that would be very sub-optimal on single paragraphs. I suppose the solution to that is to only add the paragraph breaks if the current paragraph isn't the final. I'll ammend this so that it only runs if the current paragraph is not the last, and add relevant tests.

Thank you for the thorough review, and sorry my code is such a mess!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I understand! Yeah, that makes sense!
Don't worry, that's what review is for. Thanks for doing the work, it's something very useful to have! 👍

Comment on lines +9 to +11
if app.workspace.current_buffer().is_none() {
bail!(BUFFER_MISSING);
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that the buffer declaration is only used in a single branch of the match statement, let's remove this guard clause and inline the existence check; see below for the suggestion on that.

Suggested change
if app.workspace.current_buffer().is_none() {
bail!(BUFFER_MISSING);
}

match app.mode {
Mode::Select(_) | Mode::SelectLine(_) | Mode::Search(_) => {
let delete_range = range_from(app)?;
let buffer = app.workspace.current_buffer().unwrap();
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let buffer = app.workspace.current_buffer().unwrap();
let buffer = app.workspace.current_buffer().ok_or(BUFFER_MISSING)?;

buffer.insert(
&justify_string(
&text,
app.preferences.borrow().line_length_guide().unwrap_or(80),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already specify a default value in the preferences file itself. If someone has explicitly disabled the line length guide, let's raise an error here:

Suggested change
app.preferences.borrow().line_length_guide().unwrap_or(80),
app.preferences.borrow().line_length_guide().ok_or("Cannot justify without line length guide")

/// Wrap a string at a given maximum length (generally 80 characters). If the
/// line begins with a comment (matches potential_prefix), the text is wrapped
/// around it.
fn justify_string(text: &str, max_len: usize, potential_prefix: &str) -> String {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes more sense to move the default value handling for a prefix into this function, and the Option type makes the "potential" aspect of this argument self-describing.

Suggested change
fn justify_string(text: &str, max_len: usize, potential_prefix: &str) -> String {
fn justify_string(text: &str, max_len: usize, prefix: Option<&str>) -> String {

Copy link
Owner

@jmacdonald jmacdonald left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in agreement with @christoph-heiss's feedback, and I've added some of my own as well. That said, this is a feature I didn't know I wanted until this PR; I'd love to get this merged if you have the bandwidth to revisit these suggestions! 😁

Co-authored-by: Jordan MacDonald <[email protected]>
@oldaccountdeadname
Copy link
Contributor Author

I'm in agreement with @christoph-heiss's feedback, and I've added some of my own as well. That said, this is a feature I didn't know I wanted until this PR; I'd love to get this merged if you have the bandwidth to revisit these suggestions! grin

Yes - thanks so much for the feedback! I'm looking back at the code I wrote a few months ago, and just started to rewrite it because, as I'm sure you've noticed, it's pretty bad. I've got a justify function that works properly on strings, but I'm running into some issues when I try to get the contents of a selection.

I wrote a sel_to_range function that takes an &mut Application and attempts to get the selection Range, bail!ing if something goes wrong. However, buffer.read() returns None, implying that I'm getting an improper range. It looks like this:

fn sel_to_range(app: &mut Application) -> std::result::Result<Range, Error> {
    let buf = match app.workspace.current_buffer() {
	Some(b) => b,
	None => bail!(BUFFER_MISSING),
    };
    
    match app.mode {
	Mode::Select(ref sel) => Ok(Range::new(buf.cursor.position, sel.anchor)),
	Mode::SelectLine(ref sel) =>  Ok(sel.to_range(&*buf.cursor)),
	_ => bail!("Can't access a selection outside of select mode.")
    }
}

Then, when I call that with the below, there's a panic due to read(&sel) returning None.

let sel = sel_to_range(app)?;
let buf = app.workspace.current_buffer().unwrap(); // above call guaruntees existence of buffer
let txt = buf.read(&sel).unwrap();

If it's not too much trouble, do you think you'd be able to tell me what's going on here? I'd assume that sel_to_range is returning bad ranges, but I'm not quite sure how it's doing that.

In addition, I'm not quite sure how this should get at prefixes. There are two types of prefixes I'm identifying:

  1. 'stable' prefixes: these stay the same on every line, i.e., #, //, and ///.
  2. 'variable' prefixes: these are not necessarily the same throughout a paragraph, i.e., /* */ comments, where each line after the first begins with a * instead of a /* .

I believe that supporting just the first would require some modifications to the configuration API, as the line_comment_prefix only allows for one token, and would therefore disallow multiple tokens for a file, such as rustdoc comments.

Supporting the second would be tricky too, as the pattern parameter would have to have state. In a vacuum, I'd go about that by defining a trait Prefix requiring matches(impl AsRef<str>) -> bool and written_prefix() -> String function so that an object can be constructed to match a prefix and say what it should look like at the beginning of a line at any given time, but I'm not at all sure how to reconcile that with Amp's line_comment_prefix configuration API. Thoughts?

@jmacdonald
Copy link
Owner

If it's not too much trouble, do you think you'd be able to tell me what's going on here? I'd assume that sel_to_range is returning bad ranges, but I'm not quite sure how it's doing that.

That logic mirrors what's used for the commands::selection::delete method, which works fine. The range value depends on the text in the buffer, the select mode you're in, and the range you have selected. To start, I would suggest you debug the returned range so you can see if it matches your expectations. A simple solution would be to print the range to stderr, redirect stderr to a file when running amp, and then tailing that file. Then you can see the range values while playing with the selection in the app; you might have stumbled onto an edge case with that logic.

I believe that supporting just the first would require some modifications to the configuration API, as the line_comment_prefix only allows for one token, and would therefore disallow multiple tokens for a file, such as rustdoc comments.

What if we opted for a simple heuristic to start? Something like "if there is a leading symbol/non-alphanumeric character, use that as the prefix". That won't cover all use cases, but if it gets us 90% without needing to use lexical scoping rules and syntax definitions to determine the comment prefix, I think that could be sufficient. That also wouldn't support variable prefixes, but FWIW, I don't use those often enough to see that as a major shortcoming, personally.

@oldaccountdeadname
Copy link
Contributor Author

oldaccountdeadname commented Aug 11, 2021 via email

@jmacdonald
Copy link
Owner

I think I'd prefer to table multi-line comment support for now. I could only find one reflow plug-in for Sublime Text, and it too doesn't support multi-line comments.

The complexity that this functionality would add to Amp wouldn't be worth it, in my opinion. We'd be forced to add another per-type configuration with a complex syntax that users are unlikely to configure (or that we are forced to maintain). Working with multi-line comments is still possible, provided the opening and closing symbols are put on dedicated lines (making the comment content appear without prefixes). In my experience, that's what most best practices encourage, anyway. It does mean that single-line comments in HTML and CSS would need to be manually converted to multi-line beforebeing able to be reflowed, but I think it's okay if the implementation doesn't cover that case.

@oldaccountdeadname
Copy link
Contributor Author

Okay, sounds good! The code on gitlab is feature complete. I'll move that over to github, close this PR, and open one for that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants