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::ExtractedContent;
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    content: ExtractedContent,
35    view: ContentView,
36    mode: BrowserMode,
37    search_matches: Vec<usize>,
38    search_cursor: usize,
39    history: Vec<(ExtractedContent, usize)>,
40}
41
42impl TuiBrowser {
43    pub fn new(content: ExtractedContent) -> Self {
44        let view = ContentView::from_content(&content);
45        Self {
46            content,
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 push_history(&mut self, content: ExtractedContent) {
57        let offset = self.view.scroll_offset;
58        self.history.push((content, 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            // 終了
114            KeyCode::Char('q') | KeyCode::Esc => return Some(TuiResult::Quit),
115
116            // スクロール
117            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            // リンクナビゲーション
131            KeyCode::Tab => self.view.next_link(),
132            KeyCode::BackTab => self.view.prev_link(),
133
134            // リンクフォロー
135            KeyCode::Enter => {
136                if let Some(url) = self.view.selected_link_url(&self.content.links) {
137                    return Some(TuiResult::Navigate(url.clone()));
138                }
139            }
140
141            // 戻る
142            KeyCode::Char('B') => {
143                if let Some((prev_content, prev_offset)) = self.history.pop() {
144                    let mut prev_view = ContentView::from_content(&prev_content);
145                    prev_view.scroll_offset = prev_offset;
146                    self.content = prev_content;
147                    self.view = prev_view;
148                    self.search_matches.clear();
149                }
150            }
151
152            // URL入力モード
153            KeyCode::Char('U') => {
154                self.mode = BrowserMode::UrlInput(String::new());
155            }
156
157            // 検索モード
158            KeyCode::Char('/') => {
159                self.mode = BrowserMode::SearchInput(String::new());
160            }
161
162            // 次の検索マッチ
163            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            // Markdown dump
172            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                // httpスキームを補完して再試行
226                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), // ステータスバー
250                Constraint::Min(0),    // コンテンツ
251                Constraint::Length(1), // ヒントバー / 入力バー
252            ])
253            .split(f.area());
254
255        render_status_bar(f, chunks[0], &self.content.title, self.content.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}