Skip to main content

stillo_renderer/widgets/
content_view.rs

1use std::mem::take;
2use ratatui::{
3    style::{Color, Modifier, Style},
4    text::{Line, Span},
5};
6use stillo_core::{Block, Document, Inline, document::{ExtractedContent, ExtractedLink}};
7use url::Url;
8
9const WRAP_WIDTH: usize = 80;
10const CODE_WIDTH: usize = 76;
11
12/// HTML コンテンツを意味的 AST 経由で ratatui Lines に変換し、
13/// スクロールとリンク選択を管理する。
14pub struct ContentView {
15    pub lines: Vec<Line<'static>>,
16    pub scroll_offset: usize,
17    pub selected_link: Option<usize>,
18    /// (line_idx, link_idx_in_content_links) — navigate 用
19    pub link_positions: Vec<(usize, usize)>,
20    /// (line_idx, span_start, span_end) — ハイライト再適用用
21    link_span_ranges: Vec<(usize, usize, usize)>,
22}
23
24impl ContentView {
25    pub fn from_content(content: &ExtractedContent) -> Self {
26        let doc = stillo_core::parse_html_to_ast(&content.body_html, &content.url);
27        let mut renderer = DocRenderer::new(&content.links);
28        renderer.render(&doc);
29        Self {
30            lines: renderer.lines,
31            link_positions: renderer.link_positions,
32            link_span_ranges: renderer.link_span_ranges,
33            scroll_offset: 0,
34            selected_link: None,
35        }
36    }
37
38    pub fn total_lines(&self) -> usize {
39        self.lines.len()
40    }
41
42    pub fn scroll_down(&mut self, n: usize, viewport_height: usize) {
43        let max = self.lines.len().saturating_sub(viewport_height);
44        self.scroll_offset = (self.scroll_offset + n).min(max);
45    }
46
47    pub fn scroll_up(&mut self, n: usize) {
48        self.scroll_offset = self.scroll_offset.saturating_sub(n);
49    }
50
51    pub fn scroll_to_top(&mut self) {
52        self.scroll_offset = 0;
53    }
54
55    pub fn scroll_to_bottom(&mut self, viewport_height: usize) {
56        self.scroll_offset = self.lines.len().saturating_sub(viewport_height);
57    }
58
59    pub fn next_link(&mut self) {
60        if self.link_positions.is_empty() {
61            return;
62        }
63        self.selected_link = Some(match self.selected_link {
64            None => 0,
65            Some(i) => (i + 1).min(self.link_positions.len() - 1),
66        });
67        self.scroll_to_selected_link();
68        self.rebuild_link_highlights();
69    }
70
71    pub fn prev_link(&mut self) {
72        if self.link_positions.is_empty() {
73            return;
74        }
75        self.selected_link = Some(match self.selected_link {
76            None => 0,
77            Some(i) => i.saturating_sub(1),
78        });
79        self.scroll_to_selected_link();
80        self.rebuild_link_highlights();
81    }
82
83    pub fn selected_link_url<'a>(&self, links: &'a [ExtractedLink]) -> Option<&'a Url> {
84        let sel = self.selected_link?;
85        let (_, link_idx) = self.link_positions.get(sel)?;
86        links.get(*link_idx).map(|l| &l.href)
87    }
88
89    /// 選択中のリンクが表示領域内に入るようスクロールする
90    fn scroll_to_selected_link(&mut self) {
91        if let Some(sel) = self.selected_link {
92            if let Some(&(line_idx, _)) = self.link_positions.get(sel) {
93                if line_idx < self.scroll_offset {
94                    self.scroll_offset = line_idx;
95                }
96            }
97        }
98    }
99
100    /// 選択状態変化後に span 単位でハイライトを更新する。
101    /// 行全体を上書きするのではなく span 範囲だけを変えることで、
102    /// 通常テキストのスタイルを保持できる。
103    fn rebuild_link_highlights(&mut self) {
104        for (pos_idx, (&(line_idx, _), &(_, span_start, span_end))) in
105            self.link_positions.iter().zip(self.link_span_ranges.iter()).enumerate()
106        {
107            let is_selected = self.selected_link == Some(pos_idx);
108            let style = if is_selected {
109                Style::default().fg(Color::Black).bg(Color::Cyan)
110            } else {
111                Style::default().fg(Color::Cyan)
112            };
113            if let Some(line) = self.lines.get_mut(line_idx) {
114                for span in line.spans[span_start..span_end].iter_mut() {
115                    span.style = style;
116                }
117            }
118        }
119    }
120
121    /// 検索クエリにマッチする行インデックスを返す
122    pub fn search(&self, query: &str) -> Vec<usize> {
123        if query.is_empty() {
124            return vec![];
125        }
126        let q = query.to_lowercase();
127        self.lines
128            .iter()
129            .enumerate()
130            .filter(|(_, line)| {
131                let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
132                text.to_lowercase().contains(&q)
133            })
134            .map(|(i, _)| i)
135            .collect()
136    }
137}
138
139// ---------------------------------------------------------------------------
140// DocRenderer
141// ---------------------------------------------------------------------------
142
143/// Document → ratatui Lines の変換器
144struct DocRenderer<'a> {
145    links: &'a [ExtractedLink],
146    pub lines: Vec<Line<'static>>,
147    pub link_positions: Vec<(usize, usize)>,
148    pub link_span_ranges: Vec<(usize, usize, usize)>,
149    link_counter: usize,
150    current_spans: Vec<Span<'static>>,
151    current_len: usize,
152    /// フラッシュ待ちリンク: (link_idx_in_content, span_start, span_end)
153    pending_links: Vec<(usize, usize, usize)>,
154}
155
156impl<'a> DocRenderer<'a> {
157    fn new(links: &'a [ExtractedLink]) -> Self {
158        Self {
159            links,
160            lines: Vec::new(),
161            link_positions: Vec::new(),
162            link_span_ranges: Vec::new(),
163            link_counter: 0,
164            current_spans: Vec::new(),
165            current_len: 0,
166            pending_links: Vec::new(),
167        }
168    }
169
170    fn render(&mut self, doc: &Document) {
171        for block in &doc.blocks {
172            match block {
173                Block::Heading { level, inlines } => self.render_heading(*level, inlines),
174                Block::Paragraph(inlines) => self.render_paragraph(inlines),
175                Block::ListItem { depth, ordered, number, inlines } => {
176                    self.render_list_item(*depth, *ordered, *number, inlines);
177                }
178                Block::CodeBlock { lang, content } => self.render_code_block(lang.as_deref(), content),
179                Block::Blockquote(inlines) => self.render_blockquote(inlines),
180                Block::Rule => self.render_rule(),
181            }
182        }
183        // 末尾に未フラッシュのスパンがあれば出力する
184        self.flush_line();
185    }
186
187    fn render_heading(&mut self, level: u8, inlines: &[Inline]) {
188        let text = inlines_to_text(inlines);
189        self.push_empty_line();
190        match level {
191            1 => {
192                // タイトル行
193                let title_line = Line::from(Span::styled(
194                    text.clone(),
195                    Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
196                ));
197                self.lines.push(title_line);
198                // アンダーライン
199                let underline = "═".repeat(text.chars().count());
200                self.lines.push(Line::from(Span::styled(
201                    underline,
202                    Style::default().fg(Color::Yellow),
203                )));
204            }
205            2 => {
206                // "── テキスト ─────..." (合計60文字)
207                let prefix = "── ";
208                let suffix = " ";
209                let inner = format!("{}{}{}", prefix, text, suffix);
210                let pad_count = 60usize.saturating_sub(inner.chars().count());
211                let pad = "─".repeat(pad_count);
212                let full = format!("{}{}", inner, pad);
213                self.lines.push(Line::from(Span::styled(
214                    full,
215                    Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
216                )));
217            }
218            3 => {
219                let full = format!("▸ {}", text);
220                self.lines.push(Line::from(Span::styled(
221                    full,
222                    Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
223                )));
224            }
225            _ => {
226                let full = format!("  § {}", text);
227                self.lines.push(Line::from(Span::styled(
228                    full,
229                    Style::default().add_modifier(Modifier::BOLD),
230                )));
231            }
232        }
233        self.push_empty_line();
234    }
235
236    fn render_paragraph(&mut self, inlines: &[Inline]) {
237        self.push_empty_line();
238        self.render_inlines(inlines, Style::default(), WRAP_WIDTH);
239        self.flush_line();
240    }
241
242    fn render_list_item(&mut self, depth: usize, ordered: bool, number: usize, inlines: &[Inline]) {
243        self.flush_line();
244        let prefix = if ordered {
245            format!("{}{number}. ", "  ".repeat(depth))
246        } else {
247            format!("{}• ", "  ".repeat(depth.saturating_sub(1)))
248        };
249        let prefix_len = prefix.chars().count();
250        self.current_spans.push(Span::styled(
251            prefix,
252            Style::default().fg(Color::DarkGray),
253        ));
254        self.current_len += prefix_len;
255        let remaining = WRAP_WIDTH.saturating_sub(prefix_len);
256        self.render_inlines(inlines, Style::default(), remaining);
257        self.flush_line();
258    }
259
260    fn render_code_block(&mut self, lang: Option<&str>, content: &str) {
261        self.flush_line();
262        self.push_empty_line();
263
264        // 上境界線: ╭─ {lang} ─────╮
265        let lang_label = lang.map(|l| format!(" {} ", l)).unwrap_or_else(|| " ".to_owned());
266        let border_inner_len = CODE_WIDTH + 2; // "─ " + content + " ─"
267        let lang_pad = border_inner_len.saturating_sub(lang_label.chars().count() + 1);
268        let top = format!("╭─{}{}╮", lang_label, "─".repeat(lang_pad));
269        self.lines.push(Line::from(Span::styled(top, Style::default().fg(Color::DarkGray))));
270
271        // コンテンツ行
272        for line in content.lines() {
273            let line_len = line.chars().count();
274            let pad = CODE_WIDTH.saturating_sub(line_len);
275            let padded = format!("{}{}", line, " ".repeat(pad));
276            self.lines.push(Line::from(vec![
277                Span::styled("│ ", Style::default().fg(Color::DarkGray)),
278                Span::styled(padded, Style::default().fg(Color::Yellow)),
279                Span::styled(" │", Style::default().fg(Color::DarkGray)),
280            ]));
281        }
282
283        // 下境界線: ╰──────────╯
284        let bottom = format!("╰{}╯", "─".repeat(CODE_WIDTH + 2));
285        self.lines.push(Line::from(Span::styled(bottom, Style::default().fg(Color::DarkGray))));
286
287        self.push_empty_line();
288    }
289
290    fn render_blockquote(&mut self, inlines: &[Inline]) {
291        self.push_empty_line();
292        // 先頭に引用プレフィックスを追加してからインライン展開する
293        self.current_spans.push(Span::styled("▎ ", Style::default().fg(Color::Cyan)));
294        self.current_len += 2;
295        let italic_style = Style::default().add_modifier(Modifier::ITALIC);
296        self.render_inlines(inlines, italic_style, WRAP_WIDTH.saturating_sub(2));
297        self.flush_line();
298        self.push_empty_line();
299    }
300
301    fn render_rule(&mut self) {
302        self.flush_line();
303        self.lines.push(Line::from(Span::styled(
304            "─".repeat(60),
305            Style::default().fg(Color::DarkGray),
306        )));
307        self.push_empty_line();
308    }
309
310    /// インライン要素列をワードラップしながら current_spans に追加する
311    fn render_inlines(&mut self, inlines: &[Inline], base_style: Style, wrap_width: usize) {
312        for inline in inlines {
313            match inline {
314                Inline::Text(t) => self.push_words(t, base_style, wrap_width),
315                Inline::Bold(t) => {
316                    self.push_words(t, base_style.add_modifier(Modifier::BOLD), wrap_width);
317                }
318                Inline::Italic(t) => {
319                    self.push_words(t, base_style.add_modifier(Modifier::ITALIC), wrap_width);
320                }
321                Inline::BoldItalic(t) => {
322                    self.push_words(
323                        t,
324                        base_style
325                            .add_modifier(Modifier::BOLD)
326                            .add_modifier(Modifier::ITALIC),
327                        wrap_width,
328                    );
329                }
330                Inline::Code(t) => {
331                    let display = format!("`{}`", t);
332                    let display_len = display.chars().count();
333                    let need_space = self.current_len > 0;
334                    let total = display_len + if need_space { 1 } else { 0 };
335                    if self.current_len > 0 && self.current_len + total > wrap_width {
336                        self.flush_line();
337                    } else if need_space {
338                        self.current_spans.push(Span::raw(" "));
339                        self.current_len += 1;
340                    }
341                    self.current_spans.push(Span::styled(
342                        display,
343                        Style::default().fg(Color::Yellow),
344                    ));
345                    self.current_len += display_len;
346                }
347                Inline::Link { text, href } => self.push_link(text, href, wrap_width),
348                Inline::SoftBreak => self.flush_line(),
349            }
350        }
351    }
352
353    /// テキストをワード単位でラップしながら追加する
354    fn push_words(&mut self, text: &str, style: Style, wrap_width: usize) {
355        for word in text.split_whitespace() {
356            let need_space = self.current_len > 0;
357            let word_len = word.chars().count();
358            let total = word_len + if need_space { 1 } else { 0 };
359            if self.current_len > 0 && self.current_len + total > wrap_width {
360                self.flush_line();
361                self.current_spans.push(Span::styled(word.to_owned(), style));
362                self.current_len = word_len;
363            } else {
364                if need_space {
365                    self.current_spans.push(Span::raw(" "));
366                    self.current_len += 1;
367                }
368                self.current_spans.push(Span::styled(word.to_owned(), style));
369                self.current_len += word_len;
370            }
371        }
372    }
373
374    /// リンクを出力する。content.links に一致するものがあれば link_positions に記録する。
375    fn push_link(&mut self, text: &str, href: &str, wrap_width: usize) {
376        // content.links との照合(末尾スラッシュを無視して比較)
377        let link_idx = self.links.iter().position(|l| {
378            l.href.as_str() == href
379                || l.href.as_str().trim_end_matches('/') == href.trim_end_matches('/')
380        });
381
382        let display_num = self.link_counter + 1;
383        self.link_counter += 1;
384
385        let label = format!("[{}]", display_num);
386        let trimmed_text = text.trim();
387        let display_text = if trimmed_text.is_empty() {
388            label.clone()
389        } else {
390            format!("{} {}", label, trimmed_text)
391        };
392        let display_len = display_text.chars().count();
393        let need_space = self.current_len > 0;
394        let total = display_len + if need_space { 1 } else { 0 };
395
396        if self.current_len > 0 && self.current_len + total > wrap_width {
397            self.flush_line();
398        } else if need_space {
399            self.current_spans.push(Span::raw(" "));
400            self.current_len += 1;
401        }
402
403        let span_start = self.current_spans.len();
404        self.current_spans.push(Span::styled(
405            label,
406            Style::default().fg(Color::Cyan),
407        ));
408        if !trimmed_text.is_empty() {
409            self.current_spans.push(Span::raw(" "));
410            self.current_spans.push(Span::styled(
411                trimmed_text.to_owned(),
412                Style::default().fg(Color::Cyan),
413            ));
414        }
415        let span_end = self.current_spans.len();
416        self.current_len += display_len;
417
418        // navigate できるリンクのみ link_positions に登録する
419        if let Some(idx) = link_idx {
420            self.pending_links.push((idx, span_start, span_end));
421        }
422    }
423
424    /// current_spans を1行として確定し、pending_links を記録する
425    fn flush_line(&mut self) {
426        let line_idx = self.lines.len();
427        for (link_idx, span_start, span_end) in self.pending_links.drain(..) {
428            self.link_positions.push((line_idx, link_idx));
429            self.link_span_ranges.push((line_idx, span_start, span_end));
430        }
431        if !self.current_spans.is_empty() {
432            self.lines.push(Line::from(take(&mut self.current_spans)));
433        }
434        self.current_len = 0;
435    }
436
437    /// 未フラッシュのスパンを先に出力してから空行を挿入する
438    fn push_empty_line(&mut self) {
439        self.flush_line();
440        self.lines.push(Line::from(""));
441    }
442}
443
444/// Inline のテキスト部分を連結してプレーン文字列にする(ヘッダー描画用)
445fn inlines_to_text(inlines: &[Inline]) -> String {
446    inlines
447        .iter()
448        .map(|i| match i {
449            Inline::Text(s)
450            | Inline::Bold(s)
451            | Inline::Italic(s)
452            | Inline::BoldItalic(s)
453            | Inline::Code(s) => s.as_str(),
454            Inline::Link { text, .. } => text.as_str(),
455            Inline::SoftBreak => " ",
456        })
457        .collect()
458}