Skip to main content

git_tailor/views/
dialog.rs

1// Copyright 2026 Thomas Johannesson
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// Shared dialog rendering utilities for centered overlay dialogs.
16
17use ratatui::{
18    Frame,
19    layout::{Alignment, Rect},
20    style::{Color, Style},
21    text::Line,
22    widgets::{Block, Borders, Clear, Paragraph, Wrap},
23};
24
25/// Render a centered dialog overlay.
26///
27/// Computes a centered rectangle from `preferred_width` and the number of
28/// `lines`, clears the background, and draws a bordered paragraph.
29pub fn render_centered_dialog(
30    frame: &mut Frame,
31    title: &str,
32    border_color: Color,
33    preferred_width: u16,
34    lines: Vec<Line>,
35) {
36    let area = frame.area();
37    let dialog_width = preferred_width.min(area.width.saturating_sub(4));
38    let dialog_height = (lines.len() as u16 + 2).min(area.height.saturating_sub(2));
39    let dialog_x = area.x + (area.width.saturating_sub(dialog_width)) / 2;
40    let dialog_y = area.y + (area.height.saturating_sub(dialog_height)) / 2;
41    let dialog_area = Rect {
42        x: dialog_x,
43        y: dialog_y,
44        width: dialog_width,
45        height: dialog_height,
46    };
47
48    frame.render_widget(Clear, dialog_area);
49    frame.render_widget(
50        Paragraph::new(lines)
51            .block(
52                Block::default()
53                    .title(title)
54                    .borders(Borders::ALL)
55                    .border_style(Style::default().fg(border_color))
56                    .style(Style::default().bg(Color::Black)),
57            )
58            .alignment(Alignment::Left)
59            .wrap(Wrap { trim: false }),
60        dialog_area,
61    );
62}
63
64/// Compute the usable inner width for content inside a dialog.
65///
66/// Returns the number of character columns available between the borders,
67/// accounting for the terminal width constraint.
68pub fn inner_width(preferred_width: u16, area_width: u16) -> usize {
69    preferred_width
70        .min(area_width.saturating_sub(4))
71        .saturating_sub(2) as usize
72}
73
74/// Word-wrap `text` to at most `width` display columns per line.
75///
76/// Breaks at the last space within the allowed width; falls back to a hard
77/// break at `width` characters when no space is found. Always returns at
78/// least one element.
79pub fn wrap_text(text: &str, width: usize) -> Vec<String> {
80    if width == 0 || text.is_empty() {
81        return vec![text.to_string()];
82    }
83    let mut result = Vec::new();
84    let mut remaining = text;
85    while remaining.chars().count() > width {
86        let byte_limit = remaining
87            .char_indices()
88            .nth(width)
89            .map(|(i, _)| i)
90            .unwrap_or(remaining.len());
91        let break_at = remaining[..byte_limit]
92            .rfind(' ')
93            .filter(|&p| p > 0)
94            .unwrap_or(byte_limit);
95        result.push(remaining[..break_at].to_string());
96        remaining = remaining[break_at..].trim_start_matches(' ');
97    }
98    result.push(remaining.to_string());
99    result
100}
101
102/// Like [`wrap_text`], but preserves the leading whitespace of the first line
103/// as a hanging indent on all continuation lines so wrapped text stays
104/// visually grouped under its first line.
105pub fn wrap_text_indent(text: &str, width: usize) -> Vec<String> {
106    let indent: String = text.chars().take_while(|c| *c == ' ').collect();
107    let chunks = wrap_text(text, width);
108    if chunks.len() <= 1 || indent.is_empty() {
109        return chunks;
110    }
111    let mut result = vec![chunks[0].clone()];
112    for chunk in &chunks[1..] {
113        result.push(format!("{indent}{chunk}"));
114    }
115    result
116}