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