Skip to main content

vissue_tui/
view.rs

1//! Draw the board. No catalog or mutation logic.
2
3use std::io::{self, Stdout, stdout};
4
5use ratatui::backend::{CrosstermBackend, TestBackend};
6use ratatui::crossterm::execute;
7use ratatui::crossterm::terminal::{
8    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
9};
10use ratatui::layout::{Constraint, Layout, Rect};
11use ratatui::style::{Modifier, Style};
12use ratatui::text::{Line, Span};
13use ratatui::widgets::{Block, Borders, Clear, Paragraph, Tabs, Wrap};
14use ratatui::{Frame, Terminal};
15
16use crate::app::App;
17use crate::keys::Pane;
18
19/// Crossterm terminal used by [`install`].
20pub type CrosstermTerm = Terminal<CrosstermBackend<Stdout>>;
21
22/// Enter raw mode and the alternate screen.
23///
24/// # Errors
25///
26/// Returns an error if the terminal cannot enter raw mode, cannot switch to
27/// the alternate screen, or cannot be wrapped as a ratatui backend.
28pub fn install() -> io::Result<CrosstermTerm> {
29    enable_raw_mode()?;
30    let mut out = stdout();
31    execute!(out, EnterAlternateScreen)?;
32    Terminal::new(CrosstermBackend::new(out))
33}
34
35/// Leave raw mode and the alternate screen.
36///
37/// # Errors
38///
39/// Returns an error if the terminal cannot leave raw mode or the alternate
40/// screen.
41pub fn restore() -> io::Result<()> {
42    disable_raw_mode()?;
43    execute!(stdout(), LeaveAlternateScreen)?;
44    Ok(())
45}
46
47/// Draw tabs, list, detail, status, and any prompt or help overlay.
48pub fn draw(frame: &mut Frame, app: &App) {
49    let area = frame.area();
50    let [tabs, body, status] = Layout::vertical([
51        Constraint::Length(3),
52        Constraint::Min(3),
53        Constraint::Length(1),
54    ])
55    .areas(area);
56
57    draw_tabs(frame, tabs, app);
58    let [list, detail] =
59        Layout::horizontal([Constraint::Percentage(55), Constraint::Percentage(45)]).areas(body);
60    draw_list(frame, list, app);
61    draw_detail(frame, detail, app);
62    draw_status(frame, status, app);
63
64    if app.help {
65        draw_overlay(frame, area, app.help_text());
66    } else if let Some(line) = app.prompt_line() {
67        draw_prompt(frame, area, &line);
68    } else if let Some(line) = app.confirm_line() {
69        draw_prompt(frame, area, &line);
70    }
71}
72
73fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
74    let titles: Vec<Line> = Pane::ALL.iter().map(|p| Line::from(p.title())).collect();
75    let tabs = Tabs::new(titles)
76        .select(app.pane.index())
77        .block(Block::bordered().title("vissue"));
78    frame.render_widget(tabs, area);
79}
80
81fn draw_list(frame: &mut Frame, area: Rect, app: &App) {
82    let mut lines = Vec::new();
83    if app.rows.is_empty() {
84        lines.push(Line::from("(empty)"));
85    } else {
86        for (i, row) in app.rows.iter().enumerate() {
87            let marker = if i == app.selected { ">" } else { " " };
88            let text = if row.extra.is_empty() {
89                format!(
90                    "{marker} {} [{:>8}] [#{}] {}  {}",
91                    row.id, row.state, row.priority, row.title, row.project
92                )
93            } else {
94                format!(
95                    "{marker} {} [{:>8}] [#{}] {}  {}  {}",
96                    row.id, row.state, row.priority, row.title, row.project, row.extra
97                )
98            };
99            let style = if i == app.selected {
100                Style::default().add_modifier(Modifier::REVERSED)
101            } else {
102                Style::default()
103            };
104            lines.push(Line::from(Span::styled(text, style)));
105        }
106    }
107    let block = Block::bordered().title(app.pane.title());
108    frame.render_widget(Paragraph::new(lines).block(block), area);
109}
110
111fn draw_detail(frame: &mut Frame, area: Rect, app: &App) {
112    let title = format!("detail: {}", app.detail_tab.title());
113    let id = app.selected_id().unwrap_or("-");
114    let mut text = id.to_string();
115    text.push('\n');
116    text.push_str(&app.detail_body);
117    let block = Block::bordered().title(title);
118    frame.render_widget(
119        Paragraph::new(text).block(block).wrap(Wrap { trim: false }),
120        area,
121    );
122}
123
124fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
125    frame.render_widget(Paragraph::new(app.status_line()), area);
126}
127
128fn draw_prompt(frame: &mut Frame, area: Rect, line: &str) {
129    let box_area = prompt_area(area, 3);
130    frame.render_widget(Clear, box_area);
131    frame.render_widget(
132        Paragraph::new(line).block(Block::bordered().title("input")),
133        box_area,
134    );
135}
136
137fn draw_overlay(frame: &mut Frame, area: Rect, text: &str) {
138    let box_area = centered(area, 70, 80);
139    frame.render_widget(Clear, box_area);
140    frame.render_widget(
141        Paragraph::new(text)
142            .block(Block::bordered().title("help").borders(Borders::ALL))
143            .wrap(Wrap { trim: false }),
144        box_area,
145    );
146}
147
148fn prompt_area(area: Rect, height: u16) -> Rect {
149    let y = area.y + area.height.saturating_sub(height + 1);
150    Rect {
151        x: area.x + 2,
152        y,
153        width: area.width.saturating_sub(4),
154        height,
155    }
156}
157
158fn centered(area: Rect, pct_x: u16, pct_y: u16) -> Rect {
159    let width = area.width.saturating_mul(pct_x) / 100;
160    let height = area.height.saturating_mul(pct_y) / 100;
161    Rect {
162        x: area.x + (area.width.saturating_sub(width)) / 2,
163        y: area.y + (area.height.saturating_sub(height)) / 2,
164        width,
165        height,
166    }
167}
168
169/// Render `app` on a [`TestBackend`] and return the buffer as plain text.
170///
171/// # Errors
172///
173/// Returns an error if the test terminal cannot be created or drawn.
174pub fn render_plain(
175    app: &App,
176    width: u16,
177    height: u16,
178) -> Result<String, vissue_core::error::Error> {
179    let backend = TestBackend::new(width, height);
180    let mut terminal = Terminal::new(backend).map_err(io_to_core)?;
181    terminal.draw(|f| draw(f, app)).map_err(io_to_core)?;
182    Ok(buffer_plain(terminal.backend()))
183}
184
185fn io_to_core<E: std::fmt::Display>(err: E) -> vissue_core::error::Error {
186    vissue_core::error::Error::Other(anyhow::anyhow!("{err}"))
187}
188
189/// Flatten a test buffer to trimmed lines joined by `\n`.
190pub fn buffer_plain(backend: &TestBackend) -> String {
191    let buf = backend.buffer();
192    let area = buf.area();
193    let mut out = String::new();
194    for y in area.top()..area.bottom() {
195        let mut line = String::new();
196        for x in area.left()..area.right() {
197            line.push_str(buf[(x, y)].symbol());
198        }
199        out.push_str(line.trim_end());
200        out.push('\n');
201    }
202    out
203}