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    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
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
19pub type CrosstermTerm = Terminal<CrosstermBackend<Stdout>>;
20
21pub fn install() -> io::Result<CrosstermTerm> {
22    enable_raw_mode()?;
23    let mut out = stdout();
24    execute!(out, EnterAlternateScreen)?;
25    Terminal::new(CrosstermBackend::new(out))
26}
27
28pub fn restore() -> io::Result<()> {
29    disable_raw_mode()?;
30    execute!(stdout(), LeaveAlternateScreen)?;
31    Ok(())
32}
33
34pub fn draw(frame: &mut Frame, app: &App) {
35    let area = frame.area();
36    let [tabs, body, status] = Layout::vertical([
37        Constraint::Length(3),
38        Constraint::Min(3),
39        Constraint::Length(1),
40    ])
41    .areas(area);
42
43    draw_tabs(frame, tabs, app);
44    let [list, detail] =
45        Layout::horizontal([Constraint::Percentage(55), Constraint::Percentage(45)]).areas(body);
46    draw_list(frame, list, app);
47    draw_detail(frame, detail, app);
48    draw_status(frame, status, app);
49
50    if app.help {
51        draw_overlay(frame, area, app.help_text());
52    } else if let Some(line) = app.prompt_line() {
53        draw_prompt(frame, area, &line);
54    } else if let Some(line) = app.confirm_line() {
55        draw_prompt(frame, area, &line);
56    }
57}
58
59fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
60    let titles: Vec<Line> = Pane::ALL.iter().map(|p| Line::from(p.title())).collect();
61    let tabs = Tabs::new(titles)
62        .select(app.pane.index())
63        .block(Block::bordered().title("vissue"));
64    frame.render_widget(tabs, area);
65}
66
67fn draw_list(frame: &mut Frame, area: Rect, app: &App) {
68    let mut lines = Vec::new();
69    if app.rows.is_empty() {
70        lines.push(Line::from("(empty)"));
71    } else {
72        for (i, row) in app.rows.iter().enumerate() {
73            let marker = if i == app.selected { ">" } else { " " };
74            let text = if row.extra.is_empty() {
75                format!(
76                    "{marker} {} [{:>8}] [#{}] {}  {}",
77                    row.id, row.state, row.priority, row.title, row.project
78                )
79            } else {
80                format!(
81                    "{marker} {} [{:>8}] [#{}] {}  {}  {}",
82                    row.id, row.state, row.priority, row.title, row.project, row.extra
83                )
84            };
85            let style = if i == app.selected {
86                Style::default().add_modifier(Modifier::REVERSED)
87            } else {
88                Style::default()
89            };
90            lines.push(Line::from(Span::styled(text, style)));
91        }
92    }
93    let block = Block::bordered().title(app.pane.title());
94    frame.render_widget(Paragraph::new(lines).block(block), area);
95}
96
97fn draw_detail(frame: &mut Frame, area: Rect, app: &App) {
98    let title = format!("detail: {}", app.detail_tab.title());
99    let id = app.selected_id().unwrap_or("-");
100    let mut text = id.to_string();
101    text.push('\n');
102    text.push_str(&app.detail_body);
103    let block = Block::bordered().title(title);
104    frame.render_widget(
105        Paragraph::new(text).block(block).wrap(Wrap { trim: false }),
106        area,
107    );
108}
109
110fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
111    frame.render_widget(Paragraph::new(app.status_line()), area);
112}
113
114fn draw_prompt(frame: &mut Frame, area: Rect, line: &str) {
115    let box_area = prompt_area(area, 3);
116    frame.render_widget(Clear, box_area);
117    frame.render_widget(
118        Paragraph::new(line).block(Block::bordered().title("input")),
119        box_area,
120    );
121}
122
123fn draw_overlay(frame: &mut Frame, area: Rect, text: &str) {
124    let box_area = centered(area, 70, 80);
125    frame.render_widget(Clear, box_area);
126    frame.render_widget(
127        Paragraph::new(text)
128            .block(Block::bordered().title("help").borders(Borders::ALL))
129            .wrap(Wrap { trim: false }),
130        box_area,
131    );
132}
133
134fn prompt_area(area: Rect, height: u16) -> Rect {
135    let y = area.y + area.height.saturating_sub(height + 1);
136    Rect {
137        x: area.x + 2,
138        y,
139        width: area.width.saturating_sub(4),
140        height,
141    }
142}
143
144fn centered(area: Rect, pct_x: u16, pct_y: u16) -> Rect {
145    let width = area.width.saturating_mul(pct_x) / 100;
146    let height = area.height.saturating_mul(pct_y) / 100;
147    Rect {
148        x: area.x + (area.width.saturating_sub(width)) / 2,
149        y: area.y + (area.height.saturating_sub(height)) / 2,
150        width,
151        height,
152    }
153}
154
155/// Render `app` on a [`TestBackend`] and return the buffer as plain text.
156pub fn render_plain(app: &App, width: u16, height: u16) -> anyhow::Result<String> {
157    let backend = TestBackend::new(width, height);
158    let mut terminal = Terminal::new(backend)?;
159    terminal.draw(|f| draw(f, app))?;
160    Ok(buffer_plain(terminal.backend()))
161}
162
163pub fn buffer_plain(backend: &TestBackend) -> String {
164    let buf = backend.buffer();
165    let area = buf.area();
166    let mut out = String::new();
167    for y in area.top()..area.bottom() {
168        let mut line = String::new();
169        for x in area.left()..area.right() {
170            line.push_str(buf[(x, y)].symbol());
171        }
172        out.push_str(line.trim_end());
173        out.push('\n');
174    }
175    out
176}