1use anyhow::Result;
2use crossterm::{
3 event::{self, Event, KeyCode, KeyModifiers},
4 execute,
5 terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
6};
7use ratatui::{
8 backend::CrosstermBackend,
9 layout::{Constraint, Direction, Layout},
10 Frame, Terminal,
11};
12use stillo_core::document::BrowsePage;
13use url::Url;
14
15use crate::widgets::{
16 content_view::ContentView,
17 link_bar::{render_hint_bar, render_input_bar},
18 status_bar::render_status_bar,
19};
20
21pub enum TuiResult {
22 Navigate(Url),
23 Dump,
24 Quit,
25}
26
27enum BrowserMode {
28 Normal,
29 SearchInput(String),
30 UrlInput(String),
31}
32
33pub struct TuiBrowser {
34 page: BrowsePage,
35 view: ContentView,
36 mode: BrowserMode,
37 search_matches: Vec<usize>,
38 search_cursor: usize,
39 history: Vec<(BrowsePage, usize)>,
40}
41
42impl TuiBrowser {
43 pub fn new(page: BrowsePage) -> Self {
44 let view = ContentView::from_document(&page.doc, &page.links);
45 Self {
46 page,
47 view,
48 mode: BrowserMode::Normal,
49 search_matches: Vec::new(),
50 search_cursor: 0,
51 history: Vec::new(),
52 }
53 }
54
55 pub fn load_page(&mut self, page: BrowsePage) {
57 let offset = self.view.scroll_offset;
58 let old_page = std::mem::replace(&mut self.page, page);
59 self.history.push((old_page, offset));
60 self.view = ContentView::from_document(&self.page.doc, &self.page.links);
61 self.mode = BrowserMode::Normal;
62 self.search_matches.clear();
63 self.search_cursor = 0;
64 }
65
66 pub fn markdown(&self) -> &str {
67 &self.page.markdown
68 }
69
70 pub fn run(&mut self) -> Result<TuiResult> {
71 terminal::enable_raw_mode()?;
72 let mut stdout = std::io::stdout();
73 execute!(stdout, EnterAlternateScreen)?;
74 let backend = CrosstermBackend::new(stdout);
75 let mut terminal = Terminal::new(backend)?;
76
77 let result = self.event_loop(&mut terminal);
78
79 terminal::disable_raw_mode()?;
80 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
81 terminal.show_cursor()?;
82
83 result
84 }
85
86 fn event_loop(
87 &mut self,
88 terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
89 ) -> Result<TuiResult> {
90 loop {
91 let viewport_height = terminal.size()?.height.saturating_sub(2) as usize;
92 terminal.draw(|f| self.render(f))?;
93
94 if let Event::Key(key) = event::read()? {
95 if let Some(result) = self.handle_key(key.code, key.modifiers, viewport_height) {
96 return Ok(result);
97 }
98 }
99 }
100 }
101
102 fn handle_key(
103 &mut self,
104 code: KeyCode,
105 modifiers: KeyModifiers,
106 viewport_height: usize,
107 ) -> Option<TuiResult> {
108 match &self.mode {
109 BrowserMode::Normal => self.handle_normal(code, modifiers, viewport_height),
110 BrowserMode::SearchInput(_) => self.handle_search_input(code),
111 BrowserMode::UrlInput(_) => self.handle_url_input(code),
112 }
113 }
114
115 fn handle_normal(
116 &mut self,
117 code: KeyCode,
118 modifiers: KeyModifiers,
119 viewport_height: usize,
120 ) -> Option<TuiResult> {
121 match code {
122 KeyCode::Char('q') | KeyCode::Esc => return Some(TuiResult::Quit),
124
125 KeyCode::Char('j') | KeyCode::Down => self.view.scroll_down(1, viewport_height),
127 KeyCode::Char('k') | KeyCode::Up => self.view.scroll_up(1),
128 KeyCode::Char('d') if modifiers == KeyModifiers::CONTROL => {
129 self.view.scroll_down(viewport_height / 2, viewport_height);
130 }
131 KeyCode::Char('u') if modifiers == KeyModifiers::CONTROL => {
132 self.view.scroll_up(viewport_height / 2);
133 }
134 KeyCode::PageDown => self.view.scroll_down(viewport_height, viewport_height),
135 KeyCode::PageUp => self.view.scroll_up(viewport_height),
136 KeyCode::Char('g') | KeyCode::Home => self.view.scroll_to_top(),
137 KeyCode::Char('G') | KeyCode::End => self.view.scroll_to_bottom(viewport_height),
138
139 KeyCode::Tab => self.view.next_link(),
141 KeyCode::BackTab => self.view.prev_link(),
142
143 KeyCode::Enter => {
145 if let Some(url) = self.view.selected_link_url(&self.page.links) {
146 return Some(TuiResult::Navigate(url.clone()));
147 }
148 }
149
150 KeyCode::Char('B') => {
152 if let Some((prev_page, prev_offset)) = self.history.pop() {
153 let mut prev_view = ContentView::from_document(&prev_page.doc, &prev_page.links);
154 prev_view.scroll_offset = prev_offset;
155 self.page = prev_page;
156 self.view = prev_view;
157 self.search_matches.clear();
158 }
159 }
160
161 KeyCode::Char('U') => {
163 self.mode = BrowserMode::UrlInput(String::new());
164 }
165
166 KeyCode::Char('/') => {
168 self.mode = BrowserMode::SearchInput(String::new());
169 }
170
171 KeyCode::Char('n') => {
173 if !self.search_matches.is_empty() {
174 self.search_cursor =
175 (self.search_cursor + 1) % self.search_matches.len();
176 self.view.scroll_offset = self.search_matches[self.search_cursor];
177 }
178 }
179
180 KeyCode::Char('d') => return Some(TuiResult::Dump),
182
183 _ => {}
184 }
185 None
186 }
187
188 fn handle_search_input(&mut self, code: KeyCode) -> Option<TuiResult> {
189 match code {
190 KeyCode::Esc => {
191 self.mode = BrowserMode::Normal;
192 }
193 KeyCode::Enter => {
194 let query = match &self.mode {
195 BrowserMode::SearchInput(q) => q.clone(),
196 _ => unreachable!(),
197 };
198 self.search_matches = self.view.search(&query);
199 self.search_cursor = 0;
200 if let Some(&line_idx) = self.search_matches.first() {
201 self.view.scroll_offset = line_idx;
202 }
203 self.mode = BrowserMode::Normal;
204 }
205 KeyCode::Backspace => {
206 if let BrowserMode::SearchInput(ref mut q) = self.mode {
207 q.pop();
208 }
209 }
210 KeyCode::Char(c) => {
211 if let BrowserMode::SearchInput(ref mut q) = self.mode {
212 q.push(c);
213 }
214 }
215 _ => {}
216 }
217 None
218 }
219
220 fn handle_url_input(&mut self, code: KeyCode) -> Option<TuiResult> {
221 match code {
222 KeyCode::Esc => {
223 self.mode = BrowserMode::Normal;
224 }
225 KeyCode::Enter => {
226 let input = match &self.mode {
227 BrowserMode::UrlInput(s) => s.clone(),
228 _ => unreachable!(),
229 };
230 self.mode = BrowserMode::Normal;
231 if let Ok(url) = input.parse::<Url>() {
232 return Some(TuiResult::Navigate(url));
233 }
234 if let Ok(url) = format!("https://{}", input).parse::<Url>() {
236 return Some(TuiResult::Navigate(url));
237 }
238 }
239 KeyCode::Backspace => {
240 if let BrowserMode::UrlInput(ref mut s) = self.mode {
241 s.pop();
242 }
243 }
244 KeyCode::Char(c) => {
245 if let BrowserMode::UrlInput(ref mut s) = self.mode {
246 s.push(c);
247 }
248 }
249 _ => {}
250 }
251 None
252 }
253
254 fn render(&self, f: &mut Frame) {
255 let chunks = Layout::default()
256 .direction(Direction::Vertical)
257 .constraints([
258 Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
262 .split(f.area());
263
264 render_status_bar(f, chunks[0], &self.page.title, self.page.url.as_str());
265
266 let viewport_height = chunks[1].height as usize;
267 let visible_lines: Vec<_> = self
268 .view
269 .lines
270 .iter()
271 .skip(self.view.scroll_offset)
272 .take(viewport_height)
273 .cloned()
274 .collect();
275
276 let content_widget = ratatui::widgets::Paragraph::new(visible_lines)
277 .style(ratatui::style::Style::default());
278 f.render_widget(content_widget, chunks[1]);
279
280 match &self.mode {
281 BrowserMode::Normal => {
282 render_hint_bar(
283 f,
284 chunks[2],
285 self.view.link_positions.len(),
286 self.view.selected_link,
287 );
288 }
289 BrowserMode::SearchInput(q) => {
290 render_input_bar(f, chunks[2], "/", q);
291 }
292 BrowserMode::UrlInput(s) => {
293 render_input_bar(f, chunks[2], "URL: ", s);
294 }
295 }
296 }
297}