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 push_history(&mut self, page: BrowsePage) {
57 let offset = self.view.scroll_offset;
58 self.history.push((page, offset));
59 }
60
61 pub fn run(&mut self) -> Result<TuiResult> {
62 terminal::enable_raw_mode()?;
63 let mut stdout = std::io::stdout();
64 execute!(stdout, EnterAlternateScreen)?;
65 let backend = CrosstermBackend::new(stdout);
66 let mut terminal = Terminal::new(backend)?;
67
68 let result = self.event_loop(&mut terminal);
69
70 terminal::disable_raw_mode()?;
71 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
72 terminal.show_cursor()?;
73
74 result
75 }
76
77 fn event_loop(
78 &mut self,
79 terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
80 ) -> Result<TuiResult> {
81 loop {
82 let viewport_height = terminal.size()?.height.saturating_sub(2) as usize;
83 terminal.draw(|f| self.render(f))?;
84
85 if let Event::Key(key) = event::read()? {
86 if let Some(result) = self.handle_key(key.code, key.modifiers, viewport_height) {
87 return Ok(result);
88 }
89 }
90 }
91 }
92
93 fn handle_key(
94 &mut self,
95 code: KeyCode,
96 modifiers: KeyModifiers,
97 viewport_height: usize,
98 ) -> Option<TuiResult> {
99 match &self.mode {
100 BrowserMode::Normal => self.handle_normal(code, modifiers, viewport_height),
101 BrowserMode::SearchInput(_) => self.handle_search_input(code),
102 BrowserMode::UrlInput(_) => self.handle_url_input(code),
103 }
104 }
105
106 fn handle_normal(
107 &mut self,
108 code: KeyCode,
109 modifiers: KeyModifiers,
110 viewport_height: usize,
111 ) -> Option<TuiResult> {
112 match code {
113 KeyCode::Char('q') | KeyCode::Esc => return Some(TuiResult::Quit),
115
116 KeyCode::Char('j') | KeyCode::Down => self.view.scroll_down(1, viewport_height),
118 KeyCode::Char('k') | KeyCode::Up => self.view.scroll_up(1),
119 KeyCode::Char('d') if modifiers == KeyModifiers::CONTROL => {
120 self.view.scroll_down(viewport_height / 2, viewport_height);
121 }
122 KeyCode::Char('u') if modifiers == KeyModifiers::CONTROL => {
123 self.view.scroll_up(viewport_height / 2);
124 }
125 KeyCode::PageDown => self.view.scroll_down(viewport_height, viewport_height),
126 KeyCode::PageUp => self.view.scroll_up(viewport_height),
127 KeyCode::Char('g') | KeyCode::Home => self.view.scroll_to_top(),
128 KeyCode::Char('G') | KeyCode::End => self.view.scroll_to_bottom(viewport_height),
129
130 KeyCode::Tab => self.view.next_link(),
132 KeyCode::BackTab => self.view.prev_link(),
133
134 KeyCode::Enter => {
136 if let Some(url) = self.view.selected_link_url(&self.page.links) {
137 return Some(TuiResult::Navigate(url.clone()));
138 }
139 }
140
141 KeyCode::Char('B') => {
143 if let Some((prev_page, prev_offset)) = self.history.pop() {
144 let mut prev_view = ContentView::from_document(&prev_page.doc, &prev_page.links);
145 prev_view.scroll_offset = prev_offset;
146 self.page = prev_page;
147 self.view = prev_view;
148 self.search_matches.clear();
149 }
150 }
151
152 KeyCode::Char('U') => {
154 self.mode = BrowserMode::UrlInput(String::new());
155 }
156
157 KeyCode::Char('/') => {
159 self.mode = BrowserMode::SearchInput(String::new());
160 }
161
162 KeyCode::Char('n') => {
164 if !self.search_matches.is_empty() {
165 self.search_cursor =
166 (self.search_cursor + 1) % self.search_matches.len();
167 self.view.scroll_offset = self.search_matches[self.search_cursor];
168 }
169 }
170
171 KeyCode::Char('d') => return Some(TuiResult::Dump),
173
174 _ => {}
175 }
176 None
177 }
178
179 fn handle_search_input(&mut self, code: KeyCode) -> Option<TuiResult> {
180 match code {
181 KeyCode::Esc => {
182 self.mode = BrowserMode::Normal;
183 }
184 KeyCode::Enter => {
185 let query = match &self.mode {
186 BrowserMode::SearchInput(q) => q.clone(),
187 _ => unreachable!(),
188 };
189 self.search_matches = self.view.search(&query);
190 self.search_cursor = 0;
191 if let Some(&line_idx) = self.search_matches.first() {
192 self.view.scroll_offset = line_idx;
193 }
194 self.mode = BrowserMode::Normal;
195 }
196 KeyCode::Backspace => {
197 if let BrowserMode::SearchInput(ref mut q) = self.mode {
198 q.pop();
199 }
200 }
201 KeyCode::Char(c) => {
202 if let BrowserMode::SearchInput(ref mut q) = self.mode {
203 q.push(c);
204 }
205 }
206 _ => {}
207 }
208 None
209 }
210
211 fn handle_url_input(&mut self, code: KeyCode) -> Option<TuiResult> {
212 match code {
213 KeyCode::Esc => {
214 self.mode = BrowserMode::Normal;
215 }
216 KeyCode::Enter => {
217 let input = match &self.mode {
218 BrowserMode::UrlInput(s) => s.clone(),
219 _ => unreachable!(),
220 };
221 self.mode = BrowserMode::Normal;
222 if let Ok(url) = input.parse::<Url>() {
223 return Some(TuiResult::Navigate(url));
224 }
225 if let Ok(url) = format!("https://{}", input).parse::<Url>() {
227 return Some(TuiResult::Navigate(url));
228 }
229 }
230 KeyCode::Backspace => {
231 if let BrowserMode::UrlInput(ref mut s) = self.mode {
232 s.pop();
233 }
234 }
235 KeyCode::Char(c) => {
236 if let BrowserMode::UrlInput(ref mut s) = self.mode {
237 s.push(c);
238 }
239 }
240 _ => {}
241 }
242 None
243 }
244
245 fn render(&self, f: &mut Frame) {
246 let chunks = Layout::default()
247 .direction(Direction::Vertical)
248 .constraints([
249 Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
253 .split(f.area());
254
255 render_status_bar(f, chunks[0], &self.page.title, self.page.url.as_str());
256
257 let viewport_height = chunks[1].height as usize;
258 let visible_lines: Vec<_> = self
259 .view
260 .lines
261 .iter()
262 .skip(self.view.scroll_offset)
263 .take(viewport_height)
264 .cloned()
265 .collect();
266
267 let content_widget = ratatui::widgets::Paragraph::new(visible_lines)
268 .style(ratatui::style::Style::default());
269 f.render_widget(content_widget, chunks[1]);
270
271 match &self.mode {
272 BrowserMode::Normal => {
273 render_hint_bar(
274 f,
275 chunks[2],
276 self.view.link_positions.len(),
277 self.view.selected_link,
278 );
279 }
280 BrowserMode::SearchInput(q) => {
281 render_input_bar(f, chunks[2], "/", q);
282 }
283 BrowserMode::UrlInput(s) => {
284 render_input_bar(f, chunks[2], "URL: ", s);
285 }
286 }
287 }
288}