cyberpunk_mod_manager/ui/
ui.rs

1use std::path::PathBuf;
2use tui::{
3    layout::{Rect, Layout, Direction, Constraint, Alignment},
4    backend::Backend,
5    Frame,
6    text::{Spans, Span, Text},
7    widgets::{Paragraph, Block, Borders, Wrap, ListItem, List, ListState, Clear}
8};
9use tui_logger::TuiLoggerWidget;
10
11use crate::{
12    constants::{MIN_TERM_WIDTH, MIN_TERM_HEIGHT, ERROR_TEXT_STYLE,
13                APP_TITLE, FOCUS_STYLE, LOG_ERROR_STYLE,
14                LOG_DEBUG_STYLE, LOG_WARN_STYLE, LOG_TRACE_STYLE,
15                LOG_INFO_STYLE, MOD_FOLDER_INPUT_EMPTY_ERROR, NOT_A_DIRECTORY_ERROR,
16                CYBERPUNK_FOLDER_INPUT_EMPTY_ERROR, NOT_A_VALID_CYBERPUNK_FOLDER_ERROR,
17                CYBERPUNK_STYLE_YELLOW, CYBERPUNK_STYLE_PINK, CYBERPUNK_STYLE_CYAN,
18                CYBERPUNK_STYLE_YELLOW_DARK, CYBERPUNK_STYLE_PINK_DARK, CYBERPUNK_STYLE_CYAN_DARK
19    },
20    App, app::{state::{Focus, AppStatus}, utils::ModOptions},
21};
22
23/// Helper function to check terminal size
24pub fn check_size(rect: &Rect) -> String {
25    let mut msg = String::new();
26    if rect.width < MIN_TERM_WIDTH {
27        msg.push_str(&format!("For optimal viewing experience, Terminal width should be >= {}, (current {})",MIN_TERM_WIDTH, rect.width));
28    }
29    else if rect.height < MIN_TERM_HEIGHT {
30        msg.push_str(&format!("For optimal viewing experience, Terminal height should be >= {}, (current {})",MIN_TERM_HEIGHT, rect.height));
31    }
32    else {
33        msg.push_str("Size OK");
34    }
35    msg
36}
37
38/// Draws size error screen if the terminal is too small
39pub fn draw_size_error<B>(rect: &mut Frame<B>, size: &Rect, msg: String)
40where
41    B: Backend,
42{
43    let chunks = Layout::default()
44        .direction(Direction::Vertical)
45        .constraints([Constraint::Length(3), Constraint::Min(10)].as_ref())
46        .split(*size);
47
48    let title = draw_title(false);
49    rect.render_widget(title, chunks[0]);
50
51    let mut text = vec![Spans::from(Span::styled(&msg, ERROR_TEXT_STYLE))];
52    text.append(&mut vec![Spans::from(Span::raw("Resize the window to continue, or press 'q' to quit."))]);
53    let body = Paragraph::new(text)
54    .block(Block::default().borders(Borders::ALL))
55    .alignment(Alignment::Center);
56    rect.render_widget(body, chunks[1]);
57}
58
59/// Draws the title bar
60pub fn draw_title<'a>(dark_mode: bool) -> Paragraph<'a> {
61    
62    let title_style = if dark_mode {
63        CYBERPUNK_STYLE_YELLOW_DARK
64    } else {
65        CYBERPUNK_STYLE_YELLOW
66    };
67
68    Paragraph::new(APP_TITLE)
69        .alignment(Alignment::Center)
70        .block(
71            Block::default()
72                .borders(Borders::ALL)
73                .style(title_style)
74        )
75}
76
77pub fn draw_select_folder<B: Backend>(f: &mut Frame<B>, app: &App) {
78
79    let submit_style = if app.state.focus == Focus::Submit {
80        FOCUS_STYLE
81    } else {
82        CYBERPUNK_STYLE_CYAN
83    };
84
85    let chunks = Layout::default()
86        .direction(Direction::Vertical)
87        .constraints([
88            Constraint::Percentage(10),
89            Constraint::Percentage(40),
90            Constraint::Percentage(40),
91            Constraint::Percentage(10)
92            ].as_ref())
93        .split(f.size());
94
95    let title = Paragraph::new(Text::styled("Select Folder, Press <i> to edit, <Tab> to change focus and <Enter> to submit", CYBERPUNK_STYLE_CYAN))
96        .block(Block::default().borders(Borders::ALL))
97        .style(CYBERPUNK_STYLE_CYAN)
98        .wrap(Wrap { trim: true });
99
100    let mod_folder_text = app.state.select_folder_form[0].clone();
101    let mod_folder_input_style = if app.state.focus == Focus::ModFolderInput {
102        if app.state.status == AppStatus::UserInput {
103            CYBERPUNK_STYLE_PINK
104        } else {
105            FOCUS_STYLE
106        }
107    } else if mod_folder_text.contains(MOD_FOLDER_INPUT_EMPTY_ERROR) || mod_folder_text.contains(NOT_A_DIRECTORY_ERROR){
108        ERROR_TEXT_STYLE
109    } else {
110        CYBERPUNK_STYLE_CYAN
111    };
112    let mod_folder = Paragraph::new(Text::raw(mod_folder_text))
113        .block(Block::default().borders(Borders::ALL).title("Mods Folder"))
114        .style(mod_folder_input_style)
115        .wrap(Wrap { trim: true });
116
117    let cyberpunk_folder_text = app.state.select_folder_form[1].clone();
118    let cyberpunk_folder_input_style = if app.state.focus == Focus::CyberpunkFolderInput {
119        if app.state.status == AppStatus::UserInput {
120            CYBERPUNK_STYLE_PINK
121        } else {
122            FOCUS_STYLE
123        }
124    } else if cyberpunk_folder_text.contains(CYBERPUNK_FOLDER_INPUT_EMPTY_ERROR)
125        || cyberpunk_folder_text.contains(NOT_A_DIRECTORY_ERROR)
126        || cyberpunk_folder_text.contains(NOT_A_VALID_CYBERPUNK_FOLDER_ERROR)
127        {
128        ERROR_TEXT_STYLE
129    } else {
130        CYBERPUNK_STYLE_CYAN
131    };
132    let cyberpunk_folder = Paragraph::new(Text::raw(cyberpunk_folder_text))
133        .block(Block::default().borders(Borders::ALL).title("Cyberpunk Folder"))
134        .style(cyberpunk_folder_input_style)
135        .wrap(Wrap { trim: true });
136
137    let submit_button = Paragraph::new("Submit")
138        .block(Block::default().borders(Borders::ALL))
139        .style(submit_style)
140        .wrap(Wrap { trim: true });
141
142    // check if input mode is active, if so, show cursor
143    if app.state.status == AppStatus::UserInput && app.state.focus == Focus::ModFolderInput {
144        f.set_cursor(
145            chunks[1].x + app.state.cursor_position.unwrap_or_else(||0) as u16 + 1,
146            chunks[1].y + 1,
147        );
148    } else if app.state.status == AppStatus::UserInput && app.state.focus == Focus::CyberpunkFolderInput {
149        f.set_cursor(
150            chunks[2].x + app.state.cursor_position.unwrap_or_else(||0) as u16 + 1,
151            chunks[2].y + 1,
152        );
153    }
154
155    f.render_widget(title, chunks[0]);
156    f.render_widget(mod_folder, chunks[1]);
157    f.render_widget(cyberpunk_folder, chunks[2]);
158    f.render_widget(submit_button, chunks[3]);
159}
160
161pub fn draw_explore<B: Backend>(f: &mut Frame<B>, app: &App, file_list_state: &mut ListState) {
162    // Create two chunks with equal horizontal screen space
163    let main_chunks = Layout::default()
164        .direction(Direction::Vertical)
165        .constraints([
166            Constraint::Percentage(10),
167            Constraint::Percentage(70),
168            Constraint::Percentage(10),
169            Constraint::Percentage(10)
170            ].as_ref())
171        .split(f.size());
172    
173    let chunks = Layout::default()
174        .direction(Direction::Horizontal)
175        .constraints([
176            Constraint::Percentage(70),
177            Constraint::Percentage(30)
178            ].as_ref())
179        .split(main_chunks[1]);
180
181    let title_widget = draw_title(app.mod_popup.is_some());
182    
183    let current_folder = app.mod_folder.clone().unwrap_or_else(|| PathBuf::new());
184    // check if current folder is a directory if not set it to No folder selected
185    let current_folder_string = if current_folder.is_dir() {
186        current_folder.to_string_lossy().to_string()
187    } else {
188        "No folder selected".to_string()
189    };
190    let current_folder_widget_style = if app.mod_popup.is_some() {
191        CYBERPUNK_STYLE_PINK_DARK
192    } else {
193        CYBERPUNK_STYLE_PINK
194    };
195    let current_folder_widget = Paragraph::new(Text::raw(current_folder_string))
196        .block(Block::default().borders(Borders::ALL).title("Mod Folder"))
197        .style(current_folder_widget_style)
198        .wrap(Wrap { trim: true });
199
200    let cyberpunk_folder = app.cyberpunk_folder.clone().unwrap_or_else(|| PathBuf::new());
201    // check if current folder is a directory if not set it to No folder selected
202    let cyberpunk_folder_string = if cyberpunk_folder.is_dir() {
203        cyberpunk_folder.to_string_lossy().to_string()
204    } else {
205        "No folder selected".to_string()
206    };
207    let cyberpunk_folder_widget_style = if app.mod_popup.is_some() {
208        CYBERPUNK_STYLE_YELLOW_DARK
209    } else {
210        CYBERPUNK_STYLE_YELLOW
211    };
212    let cyberpunk_folder_widget = Paragraph::new(Text::raw(cyberpunk_folder_string))
213        .block(Block::default().borders(Borders::ALL).title("Cyberpunk Folder"))
214        .style(cyberpunk_folder_widget_style)
215        .wrap(Wrap { trim: true });
216
217    // Create a list of ListItems from the list of files
218    let items: Vec<ListItem> = app
219        .state.file_list
220        .items
221        .iter()
222        .map(|(name, _size)| {
223            ListItem::new(Text::from(name.clone()))
224        })
225        .collect();
226
227    let item_list_style = if app.mod_popup.is_some() {
228        CYBERPUNK_STYLE_CYAN_DARK
229    } else {
230        CYBERPUNK_STYLE_CYAN
231    };
232
233    // Create a List from all list items and highlight the currently selected one
234    let items_list = List::new(items)
235        .block(Block::default().borders(Borders::ALL).title("Available files"))
236        .highlight_style(current_folder_widget_style)
237        .highlight_symbol(">> ")
238        .style(item_list_style);
239
240    let log_widget = TuiLoggerWidget::default()
241        .style_error(LOG_ERROR_STYLE)
242        .style_debug(LOG_DEBUG_STYLE)
243        .style_warn(LOG_WARN_STYLE)
244        .style_trace(LOG_TRACE_STYLE)
245        .style_info(LOG_INFO_STYLE)
246        .block(
247            Block::default()
248                .title("Logs")
249                .border_style(item_list_style)
250                .borders(Borders::ALL),
251        )
252        .output_timestamp(None)
253        .output_target(false)
254        .output_level(None);
255    
256    f.render_widget(title_widget, main_chunks[0]);
257    f.render_stateful_widget(items_list, chunks[0], file_list_state);
258    f.render_widget(log_widget, chunks[1]);
259    f.render_widget(current_folder_widget, main_chunks[2]);
260    f.render_widget(cyberpunk_folder_widget, main_chunks[3]);
261}
262
263// helper function to create a centered rect using up certain percentage of the available rect `r`
264pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
265    let popup_layout = Layout::default()
266        .direction(Direction::Vertical)
267        .constraints(
268            [
269                Constraint::Percentage((100 - percent_y) / 2),
270                Constraint::Percentage(percent_y),
271                Constraint::Percentage((100 - percent_y) / 2),
272            ]
273            .as_ref(),
274        )
275        .split(r);
276    
277    Layout::default()
278        .direction(Direction::Horizontal)
279        .constraints(
280            [
281                Constraint::Percentage((100 - percent_x) / 2),
282                Constraint::Percentage(percent_x),
283                Constraint::Percentage((100 - percent_x) / 2),
284            ]
285            .as_ref(),
286        )
287        .split(popup_layout[1])[1]
288    }
289
290pub fn draw_mod_popup<B: Backend>(f: &mut Frame<B>, app: &App, mod_options_state: &mut ListState) {
291    let clear_area = centered_rect(90, 90, f.size());
292    let popup_area = centered_rect(80, 80, f.size());
293    // clear the popup area
294    f.render_widget(Clear, clear_area);
295    f.render_widget(Block::default()
296        .borders(Borders::ALL)    
297        .border_style(CYBERPUNK_STYLE_YELLOW)
298        .title("What do you want to do?"), clear_area);
299    
300    let chunks = Layout::default()
301        .direction(Direction::Vertical)
302        .constraints(
303            [
304                Constraint::Length(3),
305                Constraint::Percentage(75),
306                Constraint::Length(3),
307            ]
308            .as_ref(),
309        )
310        .split(popup_area);
311        
312    let mod_name = app.mod_popup.as_ref().unwrap().get_mod_name();
313    let mod_name_widget = Paragraph::new(Text::raw(mod_name))
314        .block(Block::default().borders(Borders::ALL).title("Mod Name"))
315        .style(CYBERPUNK_STYLE_YELLOW)
316        .wrap(Wrap { trim: true });
317
318    let items: Vec<ListItem> = ModOptions::get_all_options()
319        .iter()
320        .map(|mod_option| {
321            ListItem::new(Text::from(mod_option.to_string()))
322        })
323        .collect();
324    let items_list = List::new(items)
325        .block(Block::default().borders(Borders::ALL).title("Available files"))
326        .highlight_style(CYBERPUNK_STYLE_PINK)
327        .highlight_symbol(">> ")
328        .style(CYBERPUNK_STYLE_CYAN);
329
330    let mod_install_status_bool = app.mod_popup.as_ref().unwrap().get_mod_install_status();
331    let mod_install_status = if mod_install_status_bool.is_none() {
332        "Checking...".to_string()
333    } else {
334        if mod_install_status_bool.unwrap() {
335            "Installed".to_string()
336        } else {
337            "Not Installed".to_string()
338        }
339    };
340    let mod_install_status_widget = Paragraph::new(Text::raw(mod_install_status))
341        .block(Block::default().borders(Borders::ALL).title("Mod Install Status"))
342        .style(CYBERPUNK_STYLE_YELLOW)
343        .wrap(Wrap { trim: true });
344
345    f.render_widget(mod_name_widget, chunks[0]);
346    f.render_stateful_widget(items_list, chunks[1], mod_options_state);
347    f.render_widget(mod_install_status_widget, chunks[2]);
348}