Skip to main content

stillo_renderer/
tui.rs

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    /// 現在ページを履歴に積んで新ページへ遷移する。CLIのナビゲーションループから呼ぶ。
56    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            // 終了
123            KeyCode::Char('q') | KeyCode::Esc => return Some(TuiResult::Quit),
124
125            // スクロール
126            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            // リンクナビゲーション
140            KeyCode::Tab => self.view.next_link(),
141            KeyCode::BackTab => self.view.prev_link(),
142
143            // リンクフォロー
144            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            // 戻る
151            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            // URL入力モード
162            KeyCode::Char('U') => {
163                self.mode = BrowserMode::UrlInput(String::new());
164            }
165
166            // 検索モード
167            KeyCode::Char('/') => {
168                self.mode = BrowserMode::SearchInput(String::new());
169            }
170
171            // 次の検索マッチ
172            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            // Markdown dump
181            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                // httpスキームを補完して再試行
235                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), // ステータスバー
259                Constraint::Min(0),    // コンテンツ
260                Constraint::Length(1), // ヒントバー / 入力バー
261            ])
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}