Skip to main content

stillo_renderer/widgets/
content_view.rs

1use ratatui::{
2    style::{Color, Modifier, Style},
3    text::{Line, Span},
4};
5use stillo_core::document::{ExtractedContent, ExtractedLink};
6use url::Url;
7
8/// HTML コンテンツを ratatui の Lines に変換して保持し、スクロールとリンク選択を管理する。
9pub struct ContentView {
10    pub lines: Vec<Line<'static>>,
11    /// (line_index, link_index) のペア。リンクが存在する行とそのリンク番号の対応
12    pub link_positions: Vec<(usize, usize)>,
13    pub scroll_offset: usize,
14    pub selected_link: Option<usize>,
15}
16
17impl ContentView {
18    pub fn from_content(content: &ExtractedContent) -> Self {
19        let mut converter = HtmlToLines::new(&content.links);
20        converter.convert(&content.body_html);
21
22        Self {
23            lines: converter.lines,
24            link_positions: converter.link_positions,
25            scroll_offset: 0,
26            selected_link: None,
27        }
28    }
29
30    pub fn total_lines(&self) -> usize {
31        self.lines.len()
32    }
33
34    pub fn scroll_down(&mut self, n: usize, viewport_height: usize) {
35        let max = self.lines.len().saturating_sub(viewport_height);
36        self.scroll_offset = (self.scroll_offset + n).min(max);
37    }
38
39    pub fn scroll_up(&mut self, n: usize) {
40        self.scroll_offset = self.scroll_offset.saturating_sub(n);
41    }
42
43    pub fn scroll_to_top(&mut self) {
44        self.scroll_offset = 0;
45    }
46
47    pub fn scroll_to_bottom(&mut self, viewport_height: usize) {
48        self.scroll_offset = self.lines.len().saturating_sub(viewport_height);
49    }
50
51    pub fn next_link(&mut self) {
52        if self.link_positions.is_empty() {
53            return;
54        }
55        self.selected_link = Some(match self.selected_link {
56            None => 0,
57            Some(i) => (i + 1).min(self.link_positions.len() - 1),
58        });
59        self.scroll_to_selected_link();
60        self.rebuild_link_highlights();
61    }
62
63    pub fn prev_link(&mut self) {
64        if self.link_positions.is_empty() {
65            return;
66        }
67        self.selected_link = Some(match self.selected_link {
68            None => 0,
69            Some(i) => i.saturating_sub(1),
70        });
71        self.scroll_to_selected_link();
72        self.rebuild_link_highlights();
73    }
74
75    pub fn selected_link_url<'a>(&self, links: &'a [ExtractedLink]) -> Option<&'a Url> {
76        let sel = self.selected_link?;
77        let (_, link_idx) = self.link_positions.get(sel)?;
78        links.get(*link_idx).map(|l| &l.href)
79    }
80
81    /// 選択中のリンクが表示領域内に入るようスクロールする
82    fn scroll_to_selected_link(&mut self) {
83        if let Some(sel) = self.selected_link {
84            if let Some(&(line_idx, _)) = self.link_positions.get(sel) {
85                if line_idx < self.scroll_offset {
86                    self.scroll_offset = line_idx;
87                }
88            }
89        }
90    }
91
92    /// 選択状態変化後に該当行のハイライトを更新する
93    fn rebuild_link_highlights(&mut self) {
94        // 全リンク行を走査して selected/unselected スタイルを再適用
95        for (pos_idx, &(line_idx, _)) in self.link_positions.iter().enumerate() {
96            let is_selected = self.selected_link == Some(pos_idx);
97            if let Some(line) = self.lines.get_mut(line_idx) {
98                let style = if is_selected {
99                    Style::default().fg(Color::Black).bg(Color::Cyan)
100                } else {
101                    Style::default().fg(Color::Cyan)
102                };
103                // 行全体のスタイルを更新
104                *line = Line::styled(
105                    line.spans
106                        .iter()
107                        .map(|s| s.content.as_ref().to_owned())
108                        .collect::<Vec<_>>()
109                        .join(""),
110                    style,
111                );
112            }
113        }
114    }
115
116    /// 検索クエリにマッチする行インデックスを返す
117    pub fn search(&self, query: &str) -> Vec<usize> {
118        if query.is_empty() {
119            return vec![];
120        }
121        let q = query.to_lowercase();
122        self.lines
123            .iter()
124            .enumerate()
125            .filter(|(_, line)| {
126                let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
127                text.to_lowercase().contains(&q)
128            })
129            .map(|(i, _)| i)
130            .collect()
131    }
132}
133
134/// body_html を ratatui の Line リストに変換する状態機械
135struct HtmlToLines<'a> {
136    links: &'a [ExtractedLink],
137    pub lines: Vec<Line<'static>>,
138    pub link_positions: Vec<(usize, usize)>,
139    current_spans: Vec<Span<'static>>,
140    bold: bool,
141    italic: bool,
142    /// リンク処理中: (link_index, accumulated_text)
143    link_stack: Option<(usize, String)>,
144    list_depth: usize,
145    link_counter: usize,
146}
147
148impl<'a> HtmlToLines<'a> {
149    fn new(links: &'a [ExtractedLink]) -> Self {
150        Self {
151            links,
152            lines: Vec::new(),
153            link_positions: Vec::new(),
154            current_spans: Vec::new(),
155            bold: false,
156            italic: false,
157            link_stack: None,
158            list_depth: 0,
159            link_counter: 0,
160        }
161    }
162
163    fn convert(&mut self, html: &str) {
164        let mut pos = 0;
165        let bytes = html.as_bytes();
166
167        while pos < html.len() {
168            if bytes[pos] == b'<' {
169                if let Some(close) = html[pos..].find('>') {
170                    let inner = &html[pos + 1..pos + close];
171                    let (tag, attrs, is_closing, _) = parse_tag(inner);
172                    self.handle_tag(&tag, attrs, is_closing);
173                    pos += close + 1;
174                    continue;
175                }
176            }
177            let next = html[pos..].find('<').map(|i| pos + i).unwrap_or(html.len());
178            let text = html_decode(&html[pos..next]);
179            if !text.is_empty() {
180                self.push_text(&text);
181            }
182            pos = next;
183        }
184
185        // 残りのスパンをフラッシュ
186        self.flush_line();
187    }
188
189    fn handle_tag(&mut self, tag: &str, attrs: &str, is_closing: bool) {
190        match (tag, is_closing) {
191            ("h1", false) => { self.flush_line(); self.push_text("# "); self.bold = true; }
192            ("h2", false) => { self.flush_line(); self.push_text("## "); self.bold = true; }
193            ("h3", false) => { self.flush_line(); self.push_text("### "); self.bold = true; }
194            ("h4" | "h5" | "h6", false) => { self.flush_line(); self.bold = true; }
195            ("h1" | "h2" | "h3" | "h4" | "h5" | "h6", true) => {
196                self.bold = false;
197                self.flush_line();
198                self.push_empty_line();
199            }
200            ("p", false) => { self.flush_line(); }
201            ("p", true) => { self.flush_line(); self.push_empty_line(); }
202            ("br", _) => { self.flush_line(); }
203            ("hr", _) => {
204                self.flush_line();
205                self.lines.push(Line::from(Span::styled(
206                    "─".repeat(60),
207                    Style::default().fg(Color::DarkGray),
208                )));
209            }
210            ("strong" | "b", false) => { self.bold = true; }
211            ("strong" | "b", true) => { self.bold = false; }
212            ("em" | "i", false) => { self.italic = true; }
213            ("em" | "i", true) => { self.italic = false; }
214            ("a", false) => {
215                // リンクインデックスを attrs の href から探す
216                let href = extract_attr(attrs, "href").unwrap_or_default();
217                let link_idx = self.links.iter().position(|l| l.href.as_str() == href
218                    || l.href.as_str().trim_end_matches('/') == href.trim_end_matches('/'));
219                let idx = link_idx.unwrap_or(self.link_counter);
220                self.link_stack = Some((idx, String::new()));
221                self.link_counter += 1;
222            }
223            ("a", true) => {
224                if let Some((link_idx, text)) = self.link_stack.take() {
225                    let display = format!("[{}] {}", link_idx + 1, text.trim());
226                    let line_idx = self.lines.len();
227                    self.link_positions.push((line_idx, link_idx));
228                    self.current_spans.push(Span::styled(
229                        display,
230                        Style::default().fg(Color::Cyan),
231                    ));
232                    self.flush_line();
233                }
234            }
235            ("li", false) => {
236                self.flush_line();
237                let indent = "  ".repeat(self.list_depth.saturating_sub(1));
238                self.push_text(&format!("{}• ", indent));
239            }
240            ("ul" | "ol", false) => { self.list_depth += 1; }
241            ("ul" | "ol", true) => {
242                self.list_depth = self.list_depth.saturating_sub(1);
243                self.flush_line();
244            }
245            ("pre", false) => {
246                self.flush_line();
247                self.lines.push(Line::from(Span::styled(
248                    "```",
249                    Style::default().fg(Color::DarkGray),
250                )));
251            }
252            ("pre", true) => {
253                self.flush_line();
254                self.lines.push(Line::from(Span::styled(
255                    "```",
256                    Style::default().fg(Color::DarkGray),
257                )));
258            }
259            ("script" | "style" | "noscript" | "iframe", _) => {}
260            _ => {}
261        }
262    }
263
264    fn current_style(&self) -> Style {
265        let mut style = Style::default();
266        if self.bold {
267            style = style.add_modifier(Modifier::BOLD);
268        }
269        if self.italic {
270            style = style.add_modifier(Modifier::ITALIC);
271        }
272        style
273    }
274
275    fn push_text(&mut self, text: &str) {
276        if let Some((_, ref mut link_text)) = self.link_stack {
277            link_text.push_str(text);
278        } else if !text.is_empty() {
279            let style = self.current_style();
280            self.current_spans.push(Span::styled(text.to_owned(), style));
281        }
282    }
283
284    fn flush_line(&mut self) {
285        if !self.current_spans.is_empty() {
286            self.lines.push(Line::from(std::mem::take(&mut self.current_spans)));
287        }
288    }
289
290    fn push_empty_line(&mut self) {
291        self.lines.push(Line::from(""));
292    }
293}
294
295fn parse_tag(inner: &str) -> (String, &str, bool, bool) {
296    let is_self_closing = inner.ends_with('/');
297    let trimmed = if is_self_closing { &inner[..inner.len() - 1] } else { inner };
298    let is_closing = trimmed.starts_with('/');
299    let body = if is_closing { &trimmed[1..] } else { trimmed }.trim();
300    let (tag_name, attrs) = body
301        .split_once(|c: char| c.is_whitespace())
302        .unwrap_or((body, ""));
303    (tag_name.to_lowercase(), attrs.trim(), is_closing, is_self_closing)
304}
305
306fn extract_attr(attrs: &str, name: &str) -> Option<String> {
307    for quote in &['"', '\''] {
308        let search = format!("{}={}", name, quote);
309        if let Some(start_idx) = attrs.find(&search) {
310            let value_start = start_idx + search.len();
311            if let Some(end_offset) = attrs[value_start..].find(*quote) {
312                return Some(attrs[value_start..value_start + end_offset].to_owned());
313            }
314        }
315    }
316    None
317}
318
319fn html_decode(s: &str) -> String {
320    s.replace("&amp;", "&")
321        .replace("&lt;", "<")
322        .replace("&gt;", ">")
323        .replace("&quot;", "\"")
324        .replace("&#39;", "'")
325        .replace("&nbsp;", " ")
326}