Skip to main content

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::{ConcreteEffects, 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    /// Alternative character for `commit_merge` when the smartlog orientation
131    /// is reversed.
132    pub commit_merge_rev: &'static str,
133
134    /// Character used to point to the currently-checked-out branch.
135    pub branch_arrow: &'static str,
136
137    /// Bullet-point character for a list of newline-separated items.
138    pub bullet_point: &'static str,
139
140    /// Arrow character used when printing a commit cycle.
141    pub cycle_arrow: &'static str,
142
143    /// Horizontal line character used when printing a commit cycle.
144    pub cycle_horizontal_line: &'static str,
145
146    /// Vertical line character used when printing a commit cycle.
147    pub cycle_vertical_line: &'static str,
148
149    /// Corner at the upper left of the arrow used when printing a commit cycle.
150    pub cycle_upper_left_corner: &'static str,
151
152    /// Corner at the lower left of the arrow used when printing a commit cycle.
153    pub cycle_lower_left_corner: &'static str,
154}
155
156impl Glyphs {
157    /// Make the `Glyphs` object appropriate for `stdout`.
158    pub fn detect() -> Self {
159        let color_support = concolor::get(concolor::Stream::Stdout);
160        if color_support.color() {
161            Glyphs::pretty()
162        } else {
163            Glyphs::text()
164        }
165    }
166
167    /// Glyphs used for output to a text file or non-TTY.
168    pub fn text() -> Self {
169        Glyphs {
170            should_write_ansi_escape_codes: false,
171            line: "|",
172            line_with_offshoot: "|",
173            vertical_ellipsis: ":",
174            split: "\\",
175            merge: "/",
176            commit_visible: "o",
177            commit_visible_head: "@",
178            commit_obsolete: "x",
179            commit_obsolete_head: "%",
180            commit_main: "O",
181            commit_main_head: "@",
182            commit_main_obsolete: "X",
183            commit_main_obsolete_head: "%",
184            commit_omitted: "#",
185            commit_merge: "&",
186            commit_merge_rev: "&",
187            branch_arrow: ">",
188            bullet_point: "-",
189            cycle_arrow: ">",
190            cycle_horizontal_line: "-",
191            cycle_vertical_line: "|",
192            cycle_upper_left_corner: ",",
193            cycle_lower_left_corner: "`",
194        }
195    }
196
197    /// Glyphs used for output to a TTY.
198    pub fn pretty() -> Self {
199        Glyphs {
200            should_write_ansi_escape_codes: true,
201            line: "│",
202            line_with_offshoot: "├",
203            vertical_ellipsis: "⋮",
204            split: "─╮",
205            merge: "─╯",
206            commit_visible: "○",
207            commit_visible_head: "●",
208            commit_obsolete: "✕",
209            commit_obsolete_head: "⦻",
210            commit_omitted: "◌",
211            commit_merge: "↓",
212            commit_merge_rev: "↑",
213            commit_main: "◇",
214            commit_main_head: "◆",
215            commit_main_obsolete: "✕",
216            commit_main_obsolete_head: "❖",
217            branch_arrow: "ᐅ",
218            bullet_point: "•",
219            cycle_arrow: "ᐅ",
220            cycle_horizontal_line: "─",
221            cycle_vertical_line: "│",
222            cycle_upper_left_corner: "┌",
223            cycle_lower_left_corner: "└",
224        }
225    }
226
227    /// Return a `Glyphs` object suitable for rendering graphs in the reverse of
228    /// their usual order.
229    pub fn reverse_order(mut self, reverse: bool) -> Self {
230        if reverse {
231            std::mem::swap(&mut self.split, &mut self.merge);
232            std::mem::swap(&mut self.commit_merge, &mut self.commit_merge_rev);
233        }
234        self
235    }
236
237    /// Write the provided string to `out`, using ANSI escape codes as necessary to
238    /// style it.
239    ///
240    /// TODO: return something that implements `Display` instead of a `String`.
241    pub fn render(&self, string: StyledString) -> eyre::Result<String> {
242        let result = string
243            .spans()
244            .map(|span| {
245                let Span {
246                    content,
247                    attr,
248                    width: _,
249                } = span;
250                if self.should_write_ansi_escape_codes {
251                    Ok(render_style_as_ansi(content, *attr)?)
252                } else {
253                    Ok(content.to_string())
254                }
255            })
256            .collect::<eyre::Result<String>>()?;
257        Ok(result)
258    }
259}
260
261impl std::fmt::Debug for Glyphs {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        write!(
264            f,
265            "<Glyphs pretty={:?}>",
266            self.should_write_ansi_escape_codes
267        )
268    }
269}
270
271/// Helper to build `StyledString`s by combining multiple strings (both regular
272/// `String`s and `StyledString`s).
273pub struct StyledStringBuilder {
274    elements: Vec<StyledString>,
275}
276
277impl Default for StyledStringBuilder {
278    fn default() -> Self {
279        StyledStringBuilder::new()
280    }
281}
282
283impl StyledStringBuilder {
284    /// Constructor.
285    pub fn new() -> Self {
286        Self {
287            elements: Vec::new(),
288        }
289    }
290
291    fn append_plain_inner(mut self, text: &str) -> Self {
292        self.elements.push(StyledString::plain(text));
293        self
294    }
295
296    /// Append a plain-text string to the internal buffer.
297    pub fn append_plain(self, text: impl AsRef<str>) -> Self {
298        self.append_plain_inner(text.as_ref())
299    }
300
301    fn append_styled_inner(mut self, text: &str, style: Style) -> Self {
302        self.elements.push(StyledString::styled(text, style));
303        self
304    }
305
306    /// Style the provided `text` using `style`, then append it to the internal
307    /// buffer.
308    pub fn append_styled(self, text: impl AsRef<str>, style: impl Into<Style>) -> Self {
309        self.append_styled_inner(text.as_ref(), style.into())
310    }
311
312    fn append_inner(mut self, text: StyledString) -> Self {
313        self.elements.push(text);
314        self
315    }
316
317    /// Directly append the provided `StyledString` to the internal buffer.
318    pub fn append(self, text: impl Into<StyledString>) -> Self {
319        self.append_inner(text.into())
320    }
321
322    /// Create a new `StyledString` using all the components in the internal
323    /// buffer.
324    pub fn build(self) -> StyledString {
325        let mut result = StyledString::new();
326        for element in self.elements {
327            result.append(element);
328        }
329        result
330    }
331
332    /// Helper function to join a list of `StyledString`s into a single
333    /// `StyledString`s, using the provided `delimiter`.
334    pub fn join(delimiter: &str, strings: Vec<StyledString>) -> StyledString {
335        let mut result = Self::new();
336        let mut is_first = true;
337        for string in strings {
338            if is_first {
339                is_first = false;
340            } else {
341                result = result.append_plain(delimiter);
342            }
343            result = result.append(string);
344        }
345        result.into()
346    }
347
348    /// Helper function to turn a list of lines, each of which is a
349    /// `StyledString`, into a single `StyledString` with a newline at the end
350    /// of each line.
351    pub fn from_lines(lines: Vec<StyledString>) -> StyledString {
352        let mut result = Self::new();
353        for line in lines {
354            result = result.append(line);
355            result = result.append_plain("\n");
356        }
357        result.into()
358    }
359}
360
361/// Set the provided effect to all the internal spans of the styled string.
362pub fn set_effect(mut string: StyledString, effect: Effect) -> StyledString {
363    string.spans_raw_attr_mut().for_each(|span| {
364        span.attr.effects.insert(effect);
365    });
366    string
367}
368
369impl From<StyledStringBuilder> for StyledString {
370    fn from(builder: StyledStringBuilder) -> Self {
371        builder.build()
372    }
373}
374
375fn render_style_as_ansi(content: &str, style: Style) -> eyre::Result<String> {
376    let Style { effects, color } = style;
377    let output = {
378        use console::style;
379        use cursive::theme::{BaseColor, Color, ColorType};
380        let output = content.to_string();
381        match color.front {
382            ColorType::Palette(_) => {
383                eyre::bail!("Not implemented: using cursive palette colors")
384            }
385            ColorType::Color(Color::Rgb(..)) | ColorType::Color(Color::RgbLowRes(..)) => {
386                eyre::bail!("Not implemented: using raw RGB colors")
387            }
388            ColorType::InheritParent | ColorType::Color(Color::TerminalDefault) => style(output),
389            ColorType::Color(Color::Light(color)) => match color {
390                BaseColor::Black => style(output).black().bright(),
391                BaseColor::Red => style(output).red().bright(),
392                BaseColor::Green => style(output).green().bright(),
393                BaseColor::Yellow => style(output).yellow().bright(),
394                BaseColor::Blue => style(output).blue().bright(),
395                BaseColor::Magenta => style(output).magenta().bright(),
396                BaseColor::Cyan => style(output).cyan().bright(),
397                BaseColor::White => style(output).white().bright(),
398            },
399            ColorType::Color(Color::Dark(color)) => match color {
400                BaseColor::Black => style(output).black(),
401                BaseColor::Red => style(output).red(),
402                BaseColor::Green => style(output).green(),
403                BaseColor::Yellow => style(output).yellow(),
404                BaseColor::Blue => style(output).blue(),
405                BaseColor::Magenta => style(output).magenta(),
406                BaseColor::Cyan => style(output).cyan(),
407                BaseColor::White => style(output).white(),
408            },
409        }
410    };
411
412    let output = {
413        let mut output = output;
414        for effect in effects.resolve(ConcreteEffects::empty()) {
415            output = match effect {
416                Effect::Simple => output,
417                Effect::Dim => output.dim(),
418                Effect::Reverse => output.reverse(),
419                Effect::Bold => output.bold(),
420                Effect::Italic => output.italic(),
421                Effect::Strikethrough => eyre::bail!("Not implemented: Effect::Strikethrough"),
422                Effect::Underline => output.underlined(),
423                Effect::Blink => output.blink(),
424            };
425        }
426        output
427    };
428
429    // `StyledObject` will try to do its own detection of whether or not it
430    // should render ANSI escape codes. Disable that detection and use whatever
431    // we've determined, so that the user can force color on or off. (The caller
432    // will only call this function if the user wants color, so we pass `true`.)
433    // See https://github.com/arxanas/git-branchless/issues/506
434    let output = output.force_styling(true);
435
436    Ok(output.to_string())
437}