Skip to main content

endbasic_std/console/
format.rs

1// EndBASIC
2// Copyright 2021 Julio Merino
3//
4// Licensed under the Apache License, Version 2.0 (the "License"); you may not
5// use this file except in compliance with the License.  You may obtain a copy
6// of the License at:
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
13// License for the specific language governing permissions and limitations
14// under the License.
15
16//! Utilities to format text.
17
18use super::Pager;
19use crate::console::Console;
20use std::io;
21
22/// Refills a paragraph to fit within a maximum width, returning the formatted lines.
23///
24/// This does not cut words half-way, which means that it may be impossible to fit certain words in
25/// the specified width.  If that happens, lines will overflow.
26fn refill(paragraph: &str, width: usize) -> Vec<String> {
27    if paragraph.is_empty() {
28        return vec!["".to_owned()];
29    }
30
31    let mut lines = vec![];
32
33    let mut line = String::new();
34    for word in paragraph.split_whitespace() {
35        if !line.is_empty() {
36            // Determine how many spaces to inject after a period.  We want 2 spaces to separate
37            // different sentences and 1 otherwise.  The heuristic here isn't great and it'd be
38            // better to respect the original spacing of the paragraph.
39            let spaces = if line.ends_with('.') {
40                let first = word.chars().next().expect("Words cannot be empty");
41                if first == first.to_ascii_uppercase() { 2 } else { 1 }
42            } else {
43                1
44            };
45
46            if (line.len() + word.len() + spaces) >= width {
47                lines.push(line);
48                line = String::new();
49            } else {
50                for _ in 0..spaces {
51                    line.push(' ');
52                }
53            }
54        }
55        line.push_str(word);
56    }
57    if !line.is_empty() {
58        lines.push(line);
59    }
60
61    lines
62}
63
64/// Refills a collection of paragraphs, prefixing them with an optional `indent`.
65fn refill_many<S: AsRef<str>, P: IntoIterator<Item = S>>(
66    paragraphs: P,
67    indent: &str,
68    width: usize,
69) -> Vec<String> {
70    let mut formatted = vec![];
71
72    let mut first = true;
73    for paragraph in paragraphs {
74        let paragraph = paragraph.as_ref();
75
76        if !first {
77            formatted.push(String::new());
78        }
79        first = false;
80
81        let mut extra_indent = String::new();
82        for ch in paragraph.chars() {
83            // TODO(jmmv): It'd be nice to recognize '*' prefixes so that continuation lines in
84            // lists look nicer.
85            if ch.is_whitespace() {
86                extra_indent.push(' ');
87            } else {
88                break;
89            }
90        }
91
92        let lines = refill(paragraph, width - 4 - indent.len() - extra_indent.len());
93        for line in lines {
94            if line.is_empty() {
95                formatted.push(String::new());
96            } else {
97                formatted.push(format!("{}{}{}", indent, extra_indent, line));
98            }
99        }
100    }
101
102    formatted
103}
104
105/// Same as `refill` but prints the lines of each paragraph to the console instead of returning
106/// them and prefixes them with an optional `indent`.
107///
108/// The width is automatically determined from the console's size.
109pub fn refill_and_print<S: AsRef<str>, P: IntoIterator<Item = S>>(
110    console: &mut dyn Console,
111    paragraphs: P,
112    indent: &str,
113) -> io::Result<()> {
114    // TODO(jmmv): This queries the size on every print, which is not very efficient.  Should
115    // reuse this across calls, maybe by having a wrapper over Console and using it throughout.
116    let size = console.size_chars()?;
117    for line in refill_many(paragraphs, indent, usize::from(size.x)) {
118        console.print(&line)?;
119    }
120    Ok(())
121}
122
123/// Same as `refill` but prints the lines of each paragraph to a pager instead of returning
124/// them and prefixes them with an optional `indent`.
125///
126/// The width is automatically determined from the console's size.
127pub(crate) async fn refill_and_page<S: AsRef<str>, P: IntoIterator<Item = S>>(
128    pager: &mut Pager<'_>,
129    paragraphs: P,
130    indent: &str,
131) -> io::Result<()> {
132    for line in refill_many(paragraphs, indent, usize::from(pager.columns())) {
133        pager.print(&line).await?;
134    }
135    Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::console::CharsXY;
142    use crate::testutils::{CapturedOut, MockConsole};
143
144    #[test]
145    fn test_refill_empty() {
146        assert_eq!(&[""], refill("", 0).as_slice());
147        assert_eq!(&[""], refill("", 10).as_slice());
148    }
149
150    #[test]
151    fn test_refill_nothing_fits() {
152        assert_eq!(&["this", "is", "some", "text"], refill("this is some text", 0).as_slice());
153        assert_eq!(&["this", "is", "some", "text"], refill("this is some text", 1).as_slice());
154    }
155
156    #[test]
157    fn test_refill_some_lines() {
158        assert_eq!(
159            &["this is a piece", "of text with", "a-fictitious-very-long-word", "within it"],
160            refill("this is a piece of text with a-fictitious-very-long-word within it", 16)
161                .as_slice()
162        );
163    }
164
165    #[test]
166    fn test_refill_reformats_periods() {
167        assert_eq!(&["foo. bar. baz."], refill("foo. bar.    baz.", 100).as_slice());
168        assert_eq!(&["foo.  Bar. baz."], refill("foo. Bar.    baz.", 100).as_slice());
169        assert_eq!(&["[some .. range]"], refill("[some .. range]", 100).as_slice());
170    }
171
172    #[test]
173    fn test_refill_and_print_empty() {
174        let mut console = MockConsole::default();
175        let paragraphs: &[&str] = &[];
176        refill_and_print(&mut console, paragraphs, "    ").unwrap();
177        assert!(console.captured_out().is_empty());
178    }
179
180    #[test]
181    fn test_refill_and_print_one() {
182        let mut console = MockConsole::default();
183        let paragraphs = &["First    paragraph"];
184        refill_and_print(&mut console, paragraphs, "    ").unwrap();
185        assert_eq!(&[CapturedOut::Print("    First paragraph".to_owned())], console.captured_out());
186    }
187
188    #[test]
189    fn test_refill_and_print_multiple() {
190        let mut console = MockConsole::default();
191        let paragraphs = &["First    paragraph", "Second", "Third. The. end."];
192        refill_and_print(&mut console, paragraphs, "    ").unwrap();
193        assert_eq!(
194            &[
195                CapturedOut::Print("    First paragraph".to_owned()),
196                CapturedOut::Print("".to_owned()),
197                CapturedOut::Print("    Second".to_owned()),
198                CapturedOut::Print("".to_owned()),
199                CapturedOut::Print("    Third.  The. end.".to_owned()),
200            ],
201            console.captured_out()
202        );
203    }
204
205    #[test]
206    fn test_refill_and_print_multiple_with_extra_indent() {
207        let mut console = MockConsole::default();
208        console.set_size_chars(CharsXY { x: 30, y: 30 });
209        let paragraphs = &[
210            "First    paragraph that is somewhat long",
211            "  The second paragraph contains an extra indent",
212            "Third. The. end.",
213        ];
214        refill_and_print(&mut console, paragraphs, "    ").unwrap();
215        assert_eq!(
216            &[
217                CapturedOut::Print("    First paragraph that".to_owned()),
218                CapturedOut::Print("    is somewhat long".to_owned()),
219                CapturedOut::Print("".to_owned()),
220                CapturedOut::Print("      The second".to_owned()),
221                CapturedOut::Print("      paragraph contains".to_owned()),
222                CapturedOut::Print("      an extra indent".to_owned()),
223                CapturedOut::Print("".to_owned()),
224                CapturedOut::Print("    Third.  The. end.".to_owned()),
225            ],
226            console.captured_out()
227        );
228    }
229}