branchless/core/
formatting.rs

1//! Formatting and output helpers.
2//!
3//! We try to handle both textual output and interactive output (output to a
4//! "TTY"). In the case of interactive output, we render with prettier non-ASCII
5//! characters and with colors, using shell-specific escape codes.
6
7use std::fmt::Display;
8
9use cursive::theme::{Effect, Style};
10use cursive::utils::markup::StyledString;
11use cursive::utils::span::Span;
12
13/// Pluralize a quantity, as appropriate. Example:
14///
15/// ```
16/// # use branchless::core::formatting::Pluralize;
17/// let p = Pluralize {
18///     determiner: None,
19///     amount: 1,
20///     unit: ("thing", "things"),
21/// };
22/// assert_eq!(p.to_string(), "1 thing");
23///
24/// let p = Pluralize {
25///     determiner: Some(("this", "these")),
26///     amount: 2,
27///     unit: ("thing", "things")
28/// };
29/// assert_eq!(p.to_string(), "these 2 things");
30/// ```
31pub struct Pluralize<'a> {
32    /// The string to render before the amount if the amount is singular vs plural.
33    pub determiner: Option<(&'a str, &'a str)>,
34
35    /// The amount of the quantity.
36    pub amount: usize,
37
38    /// The string to render after the amount if the amount is singular vs plural.
39    pub unit: (&'a str, &'a str),
40}
41
42impl Display for Pluralize<'_> {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self {
46                amount: 1,
47                unit: (unit, _),
48                determiner: None,
49            } => write!(f, "{} {}", 1, unit),
50
51            Self {
52                amount,
53                unit: (_, unit),
54                determiner: None,
55            } => write!(f, "{amount} {unit}"),
56
57            Self {
58                amount: 1,
59                unit: (unit, _),
60                determiner: Some((determiner, _)),
61            } => write!(f, "{} {} {}", determiner, 1, unit),
62
63            Self {
64                amount,
65                unit: (_, unit),
66                determiner: Some((_, determiner)),
67            } => write!(f, "{determiner} {amount} {unit}"),
68        }
69    }
70}
71
72/// Glyphs to use for rendering the smartlog.
73#[derive(Clone)]
74pub struct Glyphs {
75    /// Whether or not ANSI escape codes should be emitted (e.g. to render
76    /// color).
77    pub should_write_ansi_escape_codes: bool,
78
79    /// Line connecting a parent commit to its single child commit.
80    pub line: &'static str,
81
82    /// Line connecting a parent commit with two or more child commits.
83    pub line_with_offshoot: &'static str,
84
85    /// Denotes an omitted sequence of commits.
86    pub vertical_ellipsis: &'static str,
87
88    /// Line used to connect a parent commit to its non-first child commit.
89    pub split: &'static str,
90
91    /// Line used to connect a child commit to its non-first parent commit.
92    pub merge: &'static str,
93
94    /// Cursor for a normal visible commit which is not currently checked out.
95    pub commit_visible: &'static str,
96
97    /// Cursor for the visible commit which is currently checked out.
98    pub commit_visible_head: &'static str,
99
100    /// Cursor for an obsolete commit.
101    pub commit_obsolete: &'static str,
102
103    /// Cursor for the obsolete commit which is currently checked out.
104    pub commit_obsolete_head: &'static str,
105
106    /// Cursor for a commit belonging to the main branch, which is not currently
107    /// checked out.
108    pub commit_main: &'static str,
109
110    /// Cursor for a commit belonging to the main branch, which is currently
111    /// checked out.
112    pub commit_main_head: &'static str,
113
114    /// Cursor for an obsolete commit belonging to the main branch. (This is an
115    /// unusual situation.)
116    pub commit_main_obsolete: &'static str,
117
118    /// Cursor for an obsolete commit belonging to the main branch, which is
119    /// currently checked out. (This is an unusual situation.)
120    pub commit_main_obsolete_head: &'static str,
121
122    /// Cursor indicating that some number of commits have been omitted from the
123    /// smartlog at this position.
124    pub commit_omitted: &'static str,
125
126    /// Cursor indicating that a commit was either merging into this child
127    /// commit or merged from this parent commit.
128    pub commit_merge: &'static str,
129
130    /// Character used to point to the currently-checked-out branch.
131    pub branch_arrow: &'static str,
132
133    /// Bullet-point character for a list of newline-separated items.
134    pub bullet_point: &'static str,
135
136    /// Arrow character used when printing a commit cycle.
137    pub cycle_arrow: &'static str,
138
139    /// Horizontal line character used when printing a commit cycle.
140    pub cycle_horizontal_line: &'static str,
141
142    /// Vertical line character used when printing a commit cycle.
143    pub cycle_vertical_line: &'static str,
144
145    /// Corner at the upper left of the arrow used when printing a commit cycle.
146    pub cycle_upper_left_corner: &'static str,
147
148    /// Corner at the lower left of the arrow used when printing a commit cycle.
149    pub cycle_lower_left_corner: &'static str,
150}
151
152impl Glyphs {
153    /// Make the `Glyphs` object appropriate for `stdout`.
154    pub fn detect() -> Self {
155        let color_support = concolor::get(concolor::Stream::Stdout);
156        if color_support.color() {
157            Glyphs::pretty()
158        } else {
159            Glyphs::text()
160        }
161    }
162
163    /// Glyphs used for output to a text file or non-TTY.
164    pub fn text() -> Self {
165        Glyphs {
166            should_write_ansi_escape_codes: false,
167            line: "|",
168            line_with_offshoot: "|",
169            vertical_ellipsis: ":",
170            split: "\\",
171            merge: "/",
172            commit_visible: "o",
173            commit_visible_head: "@",
174            commit_obsolete: "x",
175            commit_obsolete_head: "%",
176            commit_main: "O",
177            commit_main_head: "@",
178            commit_main_obsolete: "X",
179            commit_main_obsolete_head: "%",
180            commit_omitted: "#",
181            commit_merge: "&",
182            branch_arrow: ">",
183            bullet_point: "-",
184            cycle_arrow: ">",
185            cycle_horizontal_line: "-",
186            cycle_vertical_line: "|",
187            cycle_upper_left_corner: ",",
188            cycle_lower_left_corner: "`",
189        }
190    }
191
192    /// Glyphs used for output to a TTY.
193    pub fn pretty() -> Self {
194        Glyphs {
195            should_write_ansi_escape_codes: true,
196            line: "┃",
197            line_with_offshoot: "┣",
198            vertical_ellipsis: "⋮",
199            split: "━┓",
200            merge: "━┛",
201            commit_visible: "◯",
202            commit_visible_head: "●",
203            commit_obsolete: "✕",
204            commit_obsolete_head: "⦻",
205            commit_omitted: "◌",
206            commit_merge: "↓",
207            commit_main: "◇",
208            commit_main_head: "◆",
209            commit_main_obsolete: "✕",
210            commit_main_obsolete_head: "❖",
211            branch_arrow: "ᐅ",
212            bullet_point: "•",
213            cycle_arrow: "ᐅ",
214            cycle_horizontal_line: "─",
215            cycle_vertical_line: "│",
216            cycle_upper_left_corner: "┌",
217            cycle_lower_left_corner: "└",
218        }
219    }
220
221    /// Return a `Glyphs` object suitable for rendering graphs in the reverse of
222    /// their usual order.
223    pub fn reverse_order(mut self, reverse: bool) -> Self {
224        if reverse {
225            std::mem::swap(&mut self.split, &mut self.merge);
226        }
227        self
228    }
229
230    /// Write the provided string to `out`, using ANSI escape codes as necessary to
231    /// style it.
232    ///
233    /// TODO: return something that implements `Display` instead of a `String`.
234    pub fn render(&self, string: StyledString) -> eyre::Result<String> {
235        let result = string
236            .spans()
237            .map(|span| {
238                let Span {
239                    content,
240                    attr,
241                    width: _,
242                } = span;
243                if self.should_write_ansi_escape_codes {
244                    Ok(render_style_as_ansi(content, *attr)?)
245                } else {
246                    Ok(content.to_string())
247                }
248            })
249            .collect::<eyre::Result<String>>()?;
250        Ok(result)
251    }
252}
253
254impl std::fmt::Debug for Glyphs {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        write!(
257            f,
258            "<Glyphs pretty={:?}>",
259            self.should_write_ansi_escape_codes
260        )
261    }
262}
263
264/// Helper to build `StyledString`s by combining multiple strings (both regular
265/// `String`s and `StyledString`s).
266pub struct StyledStringBuilder {
267    elements: Vec<StyledString>,
268}
269
270impl Default for StyledStringBuilder {
271    fn default() -> Self {
272        StyledStringBuilder::new()
273    }
274}
275
276impl StyledStringBuilder {
277    /// Constructor.
278    pub fn new() -> Self {
279        Self {
280            elements: Vec::new(),
281        }
282    }
283
284    fn append_plain_inner(mut self, text: &str) -> Self {
285        self.elements.push(StyledString::plain(text));
286        self
287    }
288
289    /// Append a plain-text string to the internal buffer.
290    pub fn append_plain(self, text: impl AsRef<str>) -> Self {
291        self.append_plain_inner(text.as_ref())
292    }
293
294    fn append_styled_inner(mut self, text: &str, style: Style) -> Self {
295        self.elements.push(StyledString::styled(text, style));
296        self
297    }
298
299    /// Style the provided `text` using `style`, then append it to the internal
300    /// buffer.
301    pub fn append_styled(self, text: impl AsRef<str>, style: impl Into<Style>) -> Self {
302        self.append_styled_inner(text.as_ref(), style.into())
303    }
304
305    fn append_inner(mut self, text: StyledString) -> Self {
306        self.elements.push(text);
307        self
308    }
309
310    /// Directly append the provided `StyledString` to the internal buffer.
311    pub fn append(self, text: impl Into<StyledString>) -> Self {
312        self.append_inner(text.into())
313    }
314
315    /// Create a new `StyledString` using all the components in the internal
316    /// buffer.
317    pub fn build(self) -> StyledString {
318        let mut result = StyledString::new();
319        for element in self.elements {
320            result.append(element);
321        }
322        result
323    }
324
325    /// Helper function to join a list of `StyledString`s into a single
326    /// `StyledString`s, using the provided `delimiter`.
327    pub fn join(delimiter: &str, strings: Vec<StyledString>) -> StyledString {
328        let mut result = Self::new();
329        let mut is_first = true;
330        for string in strings {
331            if is_first {
332                is_first = false;
333            } else {
334                result = result.append_plain(delimiter);
335            }
336            result = result.append(string);
337        }
338        result.into()
339    }
340
341    /// Helper function to turn a list of lines, each of which is a
342    /// `StyledString`, into a single `StyledString` with a newline at the end
343    /// of each line.
344    pub fn from_lines(lines: Vec<StyledString>) -> StyledString {
345        let mut result = Self::new();
346        for line in lines {
347            result = result.append(line);
348            result = result.append_plain("\n");
349        }
350        result.into()
351    }
352}
353
354/// Set the provided effect to all the internal spans of the styled string.
355pub fn set_effect(mut string: StyledString, effect: Effect) -> StyledString {
356    string.spans_raw_attr_mut().for_each(|span| {
357        span.attr.effects.insert(effect);
358    });
359    string
360}
361
362impl From<StyledStringBuilder> for StyledString {
363    fn from(builder: StyledStringBuilder) -> Self {
364        builder.build()
365    }
366}
367
368fn render_style_as_ansi(content: &str, style: Style) -> eyre::Result<String> {
369    let Style { effects, color } = style;
370    let output = {
371        use console::style;
372        use cursive::theme::{BaseColor, Color, ColorType};
373        let output = content.to_string();
374        match color.front {
375            ColorType::Palette(_) => {
376                eyre::bail!("Not implemented: using cursive palette colors")
377            }
378            ColorType::Color(Color::Rgb(..)) | ColorType::Color(Color::RgbLowRes(..)) => {
379                eyre::bail!("Not implemented: using raw RGB colors")
380            }
381            ColorType::InheritParent | ColorType::Color(Color::TerminalDefault) => style(output),
382            ColorType::Color(Color::Light(color)) => match color {
383                BaseColor::Black => style(output).black().bright(),
384                BaseColor::Red => style(output).red().bright(),
385                BaseColor::Green => style(output).green().bright(),
386                BaseColor::Yellow => style(output).yellow().bright(),
387                BaseColor::Blue => style(output).blue().bright(),
388                BaseColor::Magenta => style(output).magenta().bright(),
389                BaseColor::Cyan => style(output).cyan().bright(),
390                BaseColor::White => style(output).white().bright(),
391            },
392            ColorType::Color(Color::Dark(color)) => match color {
393                BaseColor::Black => style(output).black(),
394                BaseColor::Red => style(output).red(),
395                BaseColor::Green => style(output).green(),
396                BaseColor::Yellow => style(output).yellow(),
397                BaseColor::Blue => style(output).blue(),
398                BaseColor::Magenta => style(output).magenta(),
399                BaseColor::Cyan => style(output).cyan(),
400                BaseColor::White => style(output).white(),
401            },
402        }
403    };
404
405    let output = {
406        let mut output = output;
407        for effect in effects.iter() {
408            output = match effect {
409                Effect::Simple => output,
410                Effect::Dim => output.dim(),
411                Effect::Reverse => output.reverse(),
412                Effect::Bold => output.bold(),
413                Effect::Italic => output.italic(),
414                Effect::Strikethrough => eyre::bail!("Not implemented: Effect::Strikethrough"),
415                Effect::Underline => output.underlined(),
416                Effect::Blink => output.blink(),
417            };
418        }
419        output
420    };
421
422    // `StyledObject` will try to do its own detection of whether or not it
423    // should render ANSI escape codes. Disable that detection and use whatever
424    // we've determined, so that the user can force color on or off. (The caller
425    // will only call this function if the user wants color, so we pass `true`.)
426    // See https://github.com/arxanas/git-branchless/issues/506
427    let output = output.force_styling(true);
428
429    Ok(output.to_string())
430}