1use 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
19pub type CrosstermTerm = Terminal<CrosstermBackend<Stdout>>;
21
22pub 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
35pub fn restore() -> io::Result<()> {
42 disable_raw_mode()?;
43 execute!(stdout(), LeaveAlternateScreen)?;
44 Ok(())
45}
46
47pub 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
169pub 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
189pub 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}